From 43707c935b55d62656417314659fc57c268d2f5d Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Fri, 13 Feb 2026 13:47:12 +0200 Subject: [PATCH 01/20] feat: add RedemptionVaultWithAave contract and integrate Aave V3 for liquidity management --- config/constants/addresses.ts | 3 +- contracts/RedemptionVaultWithAave.sol | 202 ++ contracts/interfaces/aave/IAaveV3Pool.sol | 59 + contracts/mocks/AaveV3PoolMock.sol | 91 + contracts/mocks/ERC20Mock.sol | 4 + .../testers/RedemptionVaultWithAaveTest.sol | 12 + helpers/contracts.ts | 4 + scripts/deploy/codegen/common/index.ts | 2 + .../deploy/codegen/common/templates/index.ts | 1 + .../common/templates/rv-aave.template.ts | 46 + .../codegen/common/ui/deployment-config.ts | 31 +- .../codegen/common/ui/deployment-contracts.ts | 5 + scripts/deploy/common/rv.ts | 20 +- scripts/deploy/common/types.ts | 2 + scripts/deploy/deploy_RVAave.ts | 13 + test/common/fixtures.ts | 47 + test/common/manageable-vault.helpers.ts | 2 + test/common/redemption-vault-aave.helpers.ts | 112 + test/common/redemption-vault.helpers.ts | 5 + .../RedemptionVaultWithAave.test.ts | 318 +++ test/integration/fixtures/aave.fixture.ts | 208 ++ test/integration/helpers/mainnet-addresses.ts | 5 + test/unit/RedemptionVaultWithAave.test.ts | 2109 +++++++++++++++++ 23 files changed, 3296 insertions(+), 5 deletions(-) create mode 100644 contracts/RedemptionVaultWithAave.sol create mode 100644 contracts/interfaces/aave/IAaveV3Pool.sol create mode 100644 contracts/mocks/AaveV3PoolMock.sol create mode 100644 contracts/testers/RedemptionVaultWithAaveTest.sol create mode 100644 scripts/deploy/codegen/common/templates/rv-aave.template.ts create mode 100644 scripts/deploy/deploy_RVAave.ts create mode 100644 test/common/redemption-vault-aave.helpers.ts create mode 100644 test/integration/RedemptionVaultWithAave.test.ts create mode 100644 test/integration/fixtures/aave.fixture.ts create mode 100644 test/unit/RedemptionVaultWithAave.test.ts diff --git a/config/constants/addresses.ts b/config/constants/addresses.ts index 9a1d0bcb..fa881eaa 100644 --- a/config/constants/addresses.ts +++ b/config/constants/addresses.ts @@ -7,7 +7,8 @@ export type RedemptionVaultType = | 'redemptionVault' | 'redemptionVaultBuidl' | 'redemptionVaultSwapper' - | 'redemptionVaultUstb'; + | 'redemptionVaultUstb' + | 'redemptionVaultAave'; export type DepositVaultType = 'depositVault' | 'depositVaultUstb'; diff --git a/contracts/RedemptionVaultWithAave.sol b/contracts/RedemptionVaultWithAave.sol new file mode 100644 index 00000000..2bf4aed3 --- /dev/null +++ b/contracts/RedemptionVaultWithAave.sol @@ -0,0 +1,202 @@ +// 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 Aave V3 Pool contract used for withdrawals + */ + IAaveV3Pool public aavePool; + + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @notice Emitted when the Aave Pool address is updated + * @param caller address of the caller + * @param newPool new Aave Pool address + */ + event SetAavePool(address indexed caller, address indexed newPool); + + /** + * @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 _aavePool Aave V3 Pool contract address + */ + 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 _aavePool + ) external initializer { + __RedemptionVault_init( + _ac, + _mTokenInitParams, + _receiversInitParams, + _instantInitParams, + _sanctionsList, + _variationTolerance, + _minAmount, + _fiatRedemptionInitParams, + _requestRedeemer + ); + _validateAddress(_aavePool, false); + aavePool = IAaveV3Pool(_aavePool); + } + + /** + * @notice Sets the Aave V3 Pool address + * @param _aavePool new Aave V3 Pool address + */ + function setAavePool(address _aavePool) external onlyVaultAdmin { + _validateAddress(_aavePool, false); + aavePool = IAaveV3Pool(_aavePool); + emit SetAavePool(msg.sender, _aavePool); + } + + /** + * @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; + + uint256 missingAmount = amountTokenOut - contractBalanceTokenOut; + + address aToken = aavePool.getReserveData(tokenOut).aTokenAddress; + require(aToken != address(0), "RVA: token not in Aave pool"); + + uint256 aTokenBalance = IERC20(aToken).balanceOf(address(this)); + require( + aTokenBalance >= missingAmount, + "RVA: insufficient aToken balance" + ); + + aavePool.withdraw(tokenOut, missingAmount, address(this)); + } +} diff --git a/contracts/interfaces/aave/IAaveV3Pool.sol b/contracts/interfaces/aave/IAaveV3Pool.sol new file mode 100644 index 00000000..9f0978e4 --- /dev/null +++ b/contracts/interfaces/aave/IAaveV3Pool.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +/** + * @title IAaveV3Pool + * @notice Minimal interface for the Aave V3 Pool + * @dev Full interface: https://github.com/aave/aave-v3-core/blob/master/contracts/interfaces/IPool.sol + * Full DataTypes: https://github.com/aave/aave-v3-core/blob/master/contracts/protocol/libraries/types/DataTypes.sol + */ +interface IAaveV3Pool { + struct ReserveConfigurationMap { + uint256 data; + } + + struct ReserveData { + ReserveConfigurationMap configuration; + uint128 liquidityIndex; + uint128 currentLiquidityRate; + uint128 variableBorrowIndex; + uint128 currentVariableBorrowRate; + uint128 currentStableBorrowRate; + uint40 lastUpdateTimestamp; + uint16 id; + address aTokenAddress; + address stableDebtTokenAddress; + address variableDebtTokenAddress; + address interestRateStrategyAddress; + uint128 accruedToTreasury; + uint128 unbacked; + uint128 isolationModeTotalDebt; + } + + /** + * @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 Returns the state and configuration of the reserve + * @param asset The address of the underlying asset of the reserve + * @return The state and configuration data of the reserve + */ + function getReserveData(address asset) + external + view + returns (ReserveData memory); +} diff --git a/contracts/mocks/AaveV3PoolMock.sol b/contracts/mocks/AaveV3PoolMock.sol new file mode 100644 index 00000000..db6d34c3 --- /dev/null +++ b/contracts/mocks/AaveV3PoolMock.sol @@ -0,0 +1,91 @@ +// 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 "../interfaces/aave/IAaveV3Pool.sol"; +import "./ERC20Mock.sol"; + +/** + * @title AaveV3PoolMock + * @notice Mock contract simulating Aave V3 Pool behavior for testing + * @dev Implements withdraw() and getReserveData() from IAaveV3Pool. + * The mock burns aTokens from the caller (vault) and transfers underlying to `to`. + */ +contract AaveV3PoolMock { + using SafeERC20 for IERC20; + + /// @notice Mapping from underlying asset to its aToken address + mapping(address => address) public reserveATokens; + + // ──────────────────────────── Admin helpers ──────────────────────────── + + /** + * @notice Register an aToken for an underlying asset + * @param asset The underlying asset address (e.g. USDC) + * @param aToken The aToken address (e.g. aUSDC) + */ + function setReserveAToken(address asset, address aToken) external { + reserveATokens[asset] = aToken; + } + + // ──────────────────────────── IAaveV3Pool ────────────────────────────── + + /** + * @notice Simulates Aave V3 Pool withdraw: burns aTokens from caller, transfers underlying to `to` + * @param asset The underlying asset to withdraw + * @param amount The amount to withdraw (in asset decimals) + * @param to The recipient of the underlying tokens + * @return The actual amount withdrawn + */ + function withdraw( + address asset, + uint256 amount, + address to + ) external returns (uint256) { + address aToken = reserveATokens[asset]; + require(aToken != address(0), "AaveV3PoolMock: NoReserve"); + + // Check pool has enough underlying liquidity + uint256 poolBalance = IERC20(asset).balanceOf(address(this)); + require(poolBalance >= amount, "AaveV3PoolMock: InsufficientLiquidity"); + + // Burn aTokens from the caller (simulates Aave burning aTokens from the vault) + ERC20Mock(aToken).burn(msg.sender, amount); + + // Transfer underlying to recipient + IERC20(asset).safeTransfer(to, amount); + + return amount; + } + + /** + * @notice Returns reserve data for an asset (only aTokenAddress is populated) + * @param asset The underlying asset address + * @return data The ReserveData struct + */ + function getReserveData(address asset) + external + view + returns (IAaveV3Pool.ReserveData memory data) + { + data.aTokenAddress = reserveATokens[asset]; + // All other fields default to zero + return data; + } + + /** + * @notice Withdraw any token from the mock (admin utility for tests) + * @param token The token to withdraw + * @param to The recipient + * @param amount The amount to withdraw + */ + function withdrawAdmin( + address token, + address to, + uint256 amount + ) external { + IERC20(token).safeTransfer(to, amount); + } +} 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/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/helpers/contracts.ts b/helpers/contracts.ts index 1bb69f51..05ff42a2 100644 --- a/helpers/contracts.ts +++ b/helpers/contracts.ts @@ -8,6 +8,7 @@ export type TokenContractNames = { rvSwapper: string; rvBuidl: string; rvUstb: string; + rvAave: string; dataFeed?: string; dataFeedComposite?: string; dataFeedMultiply?: string; @@ -34,6 +35,7 @@ const vaultTypeToContractNameMap: Record = { depositVault: 'dv', depositVaultUstb: 'dvUstb', redemptionVaultBuidl: 'rvBuidl', + redemptionVaultAave: 'rvAave', }; export const vaultTypeToContractName = ( @@ -125,6 +127,7 @@ export const getCommonContractNames = (): CommonContractNames => { rvSwapper: 'RedemptionVaultWithSwapper', rvBuidl: 'RedemptionVaultWIthBUIDL', rvUstb: 'RedemptionVaultWithUSTB', + rvAave: 'RedemptionVaultWithAave', dataFeed: 'DataFeed', customAggregator: 'CustomAggregatorV3CompatibleFeed', customAggregatorGrowth: 'CustomAggregatorV3CompatibleFeedGrowth', @@ -156,6 +159,7 @@ export const getTokenContractNames = ( rvSwapper: `${tokenPrefix}${commonContractNames.rvSwapper}`, rvBuidl: `${tokenPrefix}${commonContractNames.rvBuidl}`, rvUstb: `${tokenPrefix}${commonContractNames.rvUstb}`, + rvAave: `${tokenPrefix}${commonContractNames.rvAave}`, 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 1794dac8..d12da4dc 100644 --- a/scripts/deploy/codegen/common/index.ts +++ b/scripts/deploy/codegen/common/index.ts @@ -17,6 +17,7 @@ import { getCustomAggregatorGrowthContractFromTemplate, getDataFeedContractFromTemplate, getDvContractFromTemplate, + getRvAaveContractFromTemplate, getRvContractFromTemplate, getRvSwapperContractFromTemplate, getRvUstbContractFromTemplate, @@ -65,6 +66,7 @@ const generatorPerContract: Partial< rv: getRvContractFromTemplate, rvSwapper: getRvSwapperContractFromTemplate, rvUstb: getRvUstbContractFromTemplate, + rvAave: getRvAaveContractFromTemplate, dataFeed: getDataFeedContractFromTemplate, customAggregator: getCustomAggregatorContractFromTemplate, customAggregatorGrowth: getCustomAggregatorGrowthContractFromTemplate, diff --git a/scripts/deploy/codegen/common/templates/index.ts b/scripts/deploy/codegen/common/templates/index.ts index 5a46816e..0df60024 100644 --- a/scripts/deploy/codegen/common/templates/index.ts +++ b/scripts/deploy/codegen/common/templates/index.ts @@ -3,6 +3,7 @@ export * from './data-feed.template'; export * from './dv.template'; export * from './mtoken.template'; export * from './rv-swapper.template'; +export * from './rv-aave.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..441b7dfe --- /dev/null +++ b/scripts/deploy/codegen/common/templates/rv-aave.template.ts @@ -0,0 +1,46 @@ +import { MTokenName } from '../../../../../config'; +import { importWithoutCache } from '../../../../../helpers/utils'; + +export const getRvAaveContractFromTemplate = async (mToken: MTokenName) => { + 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}; + } + }`, + }; +}; diff --git a/scripts/deploy/codegen/common/ui/deployment-config.ts b/scripts/deploy/codegen/common/ui/deployment-config.ts index 1c893627..144428ef 100644 --- a/scripts/deploy/codegen/common/ui/deployment-config.ts +++ b/scripts/deploy/codegen/common/ui/deployment-config.ts @@ -25,6 +25,7 @@ export const configsPerNetworkConfig = { dv: getDvConfigFromUser, rv: getRvConfigFromUser, rvSwapper: getRvSwapperConfigFromUser, + rvAave: getRvAaveConfigFromUser, genericConfig: getGenericConfigFromUser, postDeploy: { grantRoles: getPostDeployGrantRolesConfigFromUser, @@ -183,6 +184,27 @@ async function getRvConfigFromUser( }; } +async function getRvAaveConfigFromUser(hre: HardhatRuntimeEnvironment) { + const config = await getRvConfigFromUser( + hre, + { + aavePool: () => + text({ + message: 'Aave V3 Pool Address', + validate: validateAddress, + }) + .then(requireNotCancelled) + .then(requireAddress), + }, + 'Redemption Vault With Aave', + ); + + return { + ...config, + type: 'AAVE' as const, + }; +} + const getVaultForSwapper = ( hre: HardhatRuntimeEnvironment, mToken: MTokenName, @@ -194,6 +216,8 @@ const getVaultForSwapper = ( return 'redemptionVaultUstb'; } else if (addresses?.[mToken]?.redemptionVaultBuidl) { return 'redemptionVaultBuidl'; + } else if (addresses?.[mToken]?.redemptionVaultAave) { + return 'redemptionVaultAave'; } else if (addresses?.[mToken]?.redemptionVault) { return 'redemptionVault'; } @@ -390,7 +414,7 @@ export async function getDeploymentConfigFromUser( multiselect< keyof Pick< DeploymentConfig['networkConfigs'][number], - 'rv' | 'rvSwapper' | 'dv' + 'rv' | 'rvSwapper' | 'rvAave' | 'dv' > >({ message: @@ -411,6 +435,11 @@ export async function getDeploymentConfigFromUser( label: 'Redemption Vault With Swapper', hint: 'Redemption Vault With Swapper contract', }, + { + value: 'rvAave', + label: 'Redemption Vault With Aave', + hint: 'Redemption Vault With Aave V3 contract', + }, ], initialValues: ['dv', 'rvSwapper'], required: true, diff --git a/scripts/deploy/codegen/common/ui/deployment-contracts.ts b/scripts/deploy/codegen/common/ui/deployment-contracts.ts index 321d6ce4..acce7565 100644 --- a/scripts/deploy/codegen/common/ui/deployment-contracts.ts +++ b/scripts/deploy/codegen/common/ui/deployment-contracts.ts @@ -93,6 +93,11 @@ export const getContractsToGenerateFromUser = async () => { 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: 'dataFeed', label: 'Data Feed', hint: 'Data Feed contract' }, { value: 'customAggregator', diff --git a/scripts/deploy/common/rv.ts b/scripts/deploy/common/rv.ts index f22d58ea..e1c91010 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, + RedemptionVaultWithAave, RedemptionVaultWIthBUIDL, } from '../../../typechain-types'; @@ -67,17 +68,23 @@ export type DeployRvSwapperConfig = { liquidityProvider?: `0x${string}` | 'dummy'; } & DeployRvConfigCommon; +export type DeployRvAaveConfig = { + type: 'AAVE'; + aavePool: string; +} & DeployRvConfigCommon; + export type DeployRvConfig = | DeployRvRegularConfig | DeployRvBuidlConfig - | DeployRvSwapperConfig; + | DeployRvSwapperConfig + | DeployRvAaveConfig; const DUMMY_ADDRESS = '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; export const deployRedemptionVault = async ( hre: HardhatRuntimeEnvironment, token: MTokenName, - type: 'rv' | 'rvBuidl' | 'rvSwapper', + type: 'rv' | 'rvBuidl' | 'rvSwapper' | 'rvAave', ) => { const addresses = getCurrentAddresses(hre); const deployer = await getDeployer(hre); @@ -97,7 +104,9 @@ export const deployRedemptionVault = async ( const extraParams: unknown[] = []; - if (networkConfig.type === 'BUIDL') { + if (networkConfig.type === 'AAVE') { + extraParams.push(networkConfig.aavePool); + } else if (networkConfig.type === 'BUIDL') { extraParams.push(networkConfig.buidlRedemption); extraParams.push(networkConfig.minBuidlToRedeem); extraParams.push(networkConfig.minBuidlBalance); @@ -183,6 +192,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< + RedemptionVaultWithAave['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)'] >; await deployAndVerifyProxy(hre, contractName, params, undefined, { @@ -191,6 +203,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 === 'AAVE' + ? '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..55f81fbf 100644 --- a/scripts/deploy/common/types.ts +++ b/scripts/deploy/common/types.ts @@ -14,6 +14,7 @@ import { GrantDefaultAdminRoleToAcAdminConfig, } from './roles'; import { + DeployRvAaveConfig, DeployRvBuidlConfig, DeployRvRegularConfig, DeployRvSwapperConfig, @@ -96,6 +97,7 @@ export type DeploymentConfig = { rv?: DeployRvRegularConfig; rvBuidl?: DeployRvBuidlConfig; rvSwapper?: DeployRvSwapperConfig; + rvAave?: DeployRvAaveConfig; postDeploy?: PostDeployConfig; } >; 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/test/common/fixtures.ts b/test/common/fixtures.ts index b8bb899d..2d11ead9 100644 --- a/test/common/fixtures.ts +++ b/test/common/fixtures.ts @@ -38,6 +38,8 @@ import { RedemptionVaultWithBUIDLTest__factory, RedemptionVaultWithUSTBTest__factory, RedemptionVaultWithSwapperTest__factory, + RedemptionVaultWithAaveTest__factory, + AaveV3PoolMock__factory, CustomAggregatorV3CompatibleFeedDiscountedTester__factory, DepositVaultWithUSTBTest__factory, USTBMock__factory, @@ -375,6 +377,48 @@ 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(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' + ]( + 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, + aavePoolMock.address, + ); + await accessControl.grantRole( + mTBILL.M_TBILL_BURN_OPERATOR_ROLE(), + redemptionVaultWithAave.address, + ); + /* Redemption Vault With Swapper */ const redemptionVaultWithSwapper = @@ -595,6 +639,9 @@ export const defaultDeploy = async () => { buidlRedemption, redemptionVaultWithBUIDL, redemptionVaultWithUSTB, + redemptionVaultWithAave, + aavePoolMock, + aUSDC, liquidityProvider, otherCoins, ustbToken, diff --git a/test/common/manageable-vault.helpers.ts b/test/common/manageable-vault.helpers.ts index b8009e87..e0f2f0b1 100644 --- a/test/common/manageable-vault.helpers.ts +++ b/test/common/manageable-vault.helpers.ts @@ -14,6 +14,7 @@ import { IERC20, RedemptionVault, RedemptionVaultWIthBUIDL, + RedemptionVaultWithAave, RedemptionVaultWithSwapper, RedemptionVaultWithUSTB, } from '../../typechain-types'; @@ -24,6 +25,7 @@ type CommonParamsChangePaymentToken = { | DepositVaultWithUSTB | RedemptionVault | RedemptionVaultWIthBUIDL + | RedemptionVaultWithAave | RedemptionVaultWithSwapper | RedemptionVaultWithUSTB; owner: SignerWithAddress; diff --git a/test/common/redemption-vault-aave.helpers.ts b/test/common/redemption-vault-aave.helpers.ts new file mode 100644 index 00000000..0127533b --- /dev/null +++ b/test/common/redemption-vault-aave.helpers.ts @@ -0,0 +1,112 @@ +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 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 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.helpers.ts b/test/common/redemption-vault.helpers.ts index 91142b24..67716f6d 100644 --- a/test/common/redemption-vault.helpers.ts +++ b/test/common/redemption-vault.helpers.ts @@ -20,6 +20,7 @@ import { MToken, RedemptionVault, RedemptionVaultWIthBUIDL, + RedemptionVaultWithAave, RedemptionVaultWithSwapper, RedemptionVaultWithUSTB, } from '../../typechain-types'; @@ -33,6 +34,7 @@ type CommonParamsRedeem = { redemptionVault: | RedemptionVault | RedemptionVaultWIthBUIDL + | RedemptionVaultWithAave | RedemptionVaultWithUSTB | RedemptionVaultWithSwapper; }; @@ -41,6 +43,7 @@ type CommonParams = Pick>, 'owner'> & { redemptionVault: | RedemptionVault | RedemptionVaultWIthBUIDL + | RedemptionVaultWithAave | RedemptionVaultWithUSTB | RedemptionVaultWithSwapper; }; @@ -931,6 +934,7 @@ export const getFeePercent = async ( redemptionVault: | RedemptionVault | RedemptionVaultWIthBUIDL + | RedemptionVaultWithAave | RedemptionVaultWithSwapper | RedemptionVaultWithUSTB, isInstant: boolean, @@ -955,6 +959,7 @@ export const calcExpectedTokenOutAmount = async ( redemptionVault: | RedemptionVault | RedemptionVaultWIthBUIDL + | RedemptionVaultWithAave | RedemptionVaultWithSwapper | RedemptionVaultWithUSTB, mTokenRate: BigNumber, diff --git a/test/integration/RedemptionVaultWithAave.test.ts b/test/integration/RedemptionVaultWithAave.test.ts new file mode 100644 index 00000000..feaf877a --- /dev/null +++ b/test/integration/RedemptionVaultWithAave.test.ts @@ -0,0 +1,318 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { constants } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; + +import { aaveRedemptionVaultFixture } 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(aaveRedemptionVaultFixture); + + 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(aaveRedemptionVaultFixture); + + 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(aaveRedemptionVaultFixture); + + 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(aaveRedemptionVaultFixture); + + 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(aaveRedemptionVaultFixture); + + // Deploy a fake token that isn't registered in Aave + const fakeTokenFactory = await ( + await import('hardhat') + ).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 getReserveData returns address(0) for aTokenAddress + await redeemInstantWithAaveTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc: fakeToken, + aToken: aUsdc, + }, + mTBILLAmount, + { + from: testUser, + revertMessage: 'RVA: token not in Aave pool', + }, + ); + }); + }); +}); diff --git a/test/integration/fixtures/aave.fixture.ts b/test/integration/fixtures/aave.fixture.ts new file mode 100644 index 00000000..a1034e52 --- /dev/null +++ b/test/integration/fixtures/aave.fixture.ts @@ -0,0 +1,208 @@ +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 { rpcUrls } from '../../../config'; +import { getAllRoles } from '../../../helpers/roles'; +import { + MidasAccessControlTest, + MTBILLTest, + RedemptionVaultWithAaveTest, + DataFeedTest, + AggregatorV3Mock, +} from '../../../typechain-types'; +import { deployProxyContract } from '../../common/deploy.helpers'; +import { MAINNET_ADDRESSES } from '../helpers/mainnet-addresses'; + +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); +} + +export const FORK_BLOCK_NUMBER = 24441000; + +const USDC_WHALE_ADDRESS = '0x28C6c06298d514Db089934071355E5743bf21d60'; + +export async function aaveRedemptionVaultFixture() { + await network.provider.request({ + method: 'hardhat_reset', + params: [ + { + forking: { + jsonRpcUrl: rpcUrls.main, + blockNumber: FORK_BLOCK_NUMBER, + }, + }, + ], + }); + await network.provider.send('evm_setAutomine', [true]); + + 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.redemptionVaultAdmin, + allRoles.common.greenlistedOperator, + ]; + + for (const role of rolesArray) { + await accessControl.grantRole(role, owner.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 mtbillDataFeed = await deployProxyContract( + 'DataFeedTest', + [ + accessControl.address, + mtbillAggregator.address, + 3 * 24 * 3600, + parseUnits('0.1', await mtbillAggregator.decimals()), + parseUnits('10000', await mtbillAggregator.decimals()), + ], + ); + + // Deploy RedemptionVaultWithAave + const redemptionVaultWithAave = + await deployProxyContract( + 'RedemptionVaultWithAaveTest', + [ + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mtbillDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: 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), + }, + requestRedeemer.address, + MAINNET_ADDRESSES.AAVE_V3_POOL, + ], + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)', + ); + + // Grant BURN_ROLE to vault + await accessControl.grantRole( + allRoles.tokenRoles.mTBILL.burner, + redemptionVaultWithAave.address, + ); + + // Get mainnet contracts + const usdc = await ethers.getContractAt( + 'IERC20Metadata', + MAINNET_ADDRESSES.USDC, + ); + const aUsdc = await ethers.getContractAt('IERC20', MAINNET_ADDRESSES.AUSDC); + const aavePool = await ethers.getContractAt( + 'IAaveV3Pool', + MAINNET_ADDRESSES.AAVE_V3_POOL, + ); + + // Impersonate whales + const usdcWhale = await impersonateAndFundAccount(USDC_WHALE_ADDRESS); + const aUsdcWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.AUSDC_WHALE, + ); + + // Setup payment token + await redemptionVaultWithAave.connect(owner).addPaymentToken( + usdc.address, + usdcDataFeed.address, + 0, // no fee + ethers.constants.MaxUint256, + true, // is stable + ); + + return { + accessControl, + mTBILL, + dataFeed: usdcDataFeed, + mTokenToUsdDataFeed: mtbillDataFeed, + mockedAggregator: usdcAggregator, + mockedAggregatorMToken: mtbillAggregator, + redemptionVaultWithAave, + usdc, + aUsdc, + aavePool, + owner, + tokensReceiver, + feeReceiver, + requestRedeemer, + vaultAdmin, + testUser, + usdcWhale, + aUsdcWhale, + roles: allRoles, + }; +} + +export type AaveDeployedContracts = Awaited< + ReturnType +>; diff --git a/test/integration/helpers/mainnet-addresses.ts b/test/integration/helpers/mainnet-addresses.ts index 058e9f2c..805c8ba4 100644 --- a/test/integration/helpers/mainnet-addresses.ts +++ b/test/integration/helpers/mainnet-addresses.ts @@ -3,6 +3,10 @@ export const MAINNET_ADDRESSES = { REDEMPTION_IDLE_PROXY: '0x4c21B7577C8FE8b0B0669165ee7C8f67fa1454Cf', SUPERSTATE_TOKEN_PROXY: '0x43415eB6ff9DB7E26A15b704e7A3eDCe97d31C4e', + // Aave V3 contracts (Ethereum mainnet) + AAVE_V3_POOL: '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2', + AUSDC: '0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c', // aEthUSDC + // Tokens USDC: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', USDT: '0xdAC17F958D2ee523a2206206994597C13D831ec7', @@ -10,4 +14,5 @@ export const MAINNET_ADDRESSES = { // Whale addresses USDC_WHALE: '0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503', USTB_WHALE: '0x5138D77d51dC57983e5A653CeA6e1C1aa9750A39', + AUSDC_WHALE: '0x7055b17A1b911b6b971172C01FF0Cc27881aeA94', }; diff --git a/test/unit/RedemptionVaultWithAave.test.ts b/test/unit/RedemptionVaultWithAave.test.ts new file mode 100644 index 00000000..32ac4b1c --- /dev/null +++ b/test/unit/RedemptionVaultWithAave.test.ts @@ -0,0 +1,2109 @@ +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 } 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, + 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, + 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.aavePool()).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(address,(address,address),(address,address),(uint256,uint256),address,uint256,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'), + { + 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(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, + 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'); + }); + + it('should fail: when aavePool address zero', async () => { + const { + owner, + accessControl, + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + mockedSanctionsList, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + const redemptionVaultWithAave = + await new RedemptionVaultWithAaveTest__factory(owner).deploy(); + + await expect( + redemptionVaultWithAave[ + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' + ]( + 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, + constants.AddressZero, + ), + ).revertedWith('zero address'); + }); + }); + + describe('setAavePool()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithAave, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + redemptionVaultWithAave + .connect(regularAccounts[0]) + .setAavePool(regularAccounts[1].address), + ).to.be.revertedWith('WMAC: hasnt role'); + }); + + it('should fail: zero address', async () => { + const { redemptionVaultWithAave } = await loadFixture(defaultDeploy); + await expect( + redemptionVaultWithAave.setAavePool(constants.AddressZero), + ).to.be.revertedWith('zero address'); + }); + + it('should succeed and emit SetAavePool event', async () => { + const { redemptionVaultWithAave, owner, regularAccounts } = + await loadFixture(defaultDeploy); + + const newPool = regularAccounts[0].address; + + await expect(redemptionVaultWithAave.setAavePool(newPool)) + .to.emit(redemptionVaultWithAave, 'SetAavePool') + .withArgs(owner.address, newPool); + + expect(await redemptionVaultWithAave.aavePool()).eq(newPool); + }); + }); + + 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 token not in Aave pool', async () => { + const { redemptionVaultWithAave, stableCoins } = await loadFixture( + defaultDeploy, + ); + + // DAI is not registered in Aave pool mock + await expect( + redemptionVaultWithAave.checkAndRedeemAave( + stableCoins.dai.address, + parseUnits('1000', 9), + ), + ).to.be.revertedWith('RVA: token not in Aave pool'); + }); + + 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'); + }); + }); + + 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'); + }); + + // ── 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], + }, + ); + }); + }); + + 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()', () => { + it('should fail: when there is no request', async () => { + const { + owner, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await approveRedeemRequestTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + +new Date(), + parseUnits('1'), + { + revertMessage: 'RV: request not exist', + }, + ); + }); + + it('approve request: happy path', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithAave, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVaultWithAave, + 100000, + ); + + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await approveRedeemRequestTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + +requestId, + parseUnits('1'), + ); + }); + }); + + describe('rejectRequest()', () => { + it('reject request: happy path', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + 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.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await rejectRedeemRequestTest( + { + redemptionVault: redemptionVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + +requestId, + ); + }); + }); +}); From d05d40dccb9d108490ce232085466e688985adb3 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Mon, 16 Feb 2026 15:28:18 +0200 Subject: [PATCH 02/20] feat: add RedemptionVaultWithMorpho contract and integrate Morpho Vault for liquidity management --- config/constants/addresses.ts | 3 +- contracts/RedemptionVaultWithMorpho.sol | 202 ++ contracts/interfaces/morpho/IERC4626Vault.sol | 84 + contracts/mocks/AaveV3PoolMock.sol | 39 - contracts/mocks/MorphoVaultMock.sol | 96 + .../testers/RedemptionVaultWithMorphoTest.sol | 12 + helpers/contracts.ts | 4 + scripts/deploy/codegen/common/index.ts | 2 + .../deploy/codegen/common/templates/index.ts | 1 + .../common/templates/rv-morpho.template.ts | 46 + .../codegen/common/ui/deployment-config.ts | 31 +- .../codegen/common/ui/deployment-contracts.ts | 5 + scripts/deploy/common/rv.ts | 18 +- scripts/deploy/common/types.ts | 2 + scripts/deploy/deploy_RVMorpho.ts | 13 + test/common/deposit-vault-ustb.helpers.ts | 38 +- test/common/fixtures.ts | 46 + test/common/manageable-vault.helpers.ts | 2 + .../common/redemption-vault-morpho.helpers.ts | 112 + test/common/redemption-vault.helpers.ts | 5 + test/integration/DepositVaultWithUSTB.test.ts | 3 +- .../RedemptionVaultWithMorpho.test.ts | 318 +++ test/integration/fixtures/aave.fixture.ts | 6 +- test/integration/fixtures/morpho.fixture.ts | 207 ++ test/integration/helpers/mainnet-addresses.ts | 7 +- test/unit/RedemptionVaultWithMorpho.test.ts | 2214 +++++++++++++++++ 26 files changed, 3440 insertions(+), 76 deletions(-) create mode 100644 contracts/RedemptionVaultWithMorpho.sol create mode 100644 contracts/interfaces/morpho/IERC4626Vault.sol create mode 100644 contracts/mocks/MorphoVaultMock.sol create mode 100644 contracts/testers/RedemptionVaultWithMorphoTest.sol create mode 100644 scripts/deploy/codegen/common/templates/rv-morpho.template.ts create mode 100644 scripts/deploy/deploy_RVMorpho.ts create mode 100644 test/common/redemption-vault-morpho.helpers.ts create mode 100644 test/integration/RedemptionVaultWithMorpho.test.ts create mode 100644 test/integration/fixtures/morpho.fixture.ts create mode 100644 test/unit/RedemptionVaultWithMorpho.test.ts diff --git a/config/constants/addresses.ts b/config/constants/addresses.ts index fa881eaa..da856e37 100644 --- a/config/constants/addresses.ts +++ b/config/constants/addresses.ts @@ -8,7 +8,8 @@ export type RedemptionVaultType = | 'redemptionVaultBuidl' | 'redemptionVaultSwapper' | 'redemptionVaultUstb' - | 'redemptionVaultAave'; + | 'redemptionVaultAave' + | 'redemptionVaultMorpho'; export type DepositVaultType = 'depositVault' | 'depositVaultUstb'; diff --git a/contracts/RedemptionVaultWithMorpho.sol b/contracts/RedemptionVaultWithMorpho.sol new file mode 100644 index 00000000..5ad83d36 --- /dev/null +++ b/contracts/RedemptionVaultWithMorpho.sol @@ -0,0 +1,202 @@ +// 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/morpho/IERC4626Vault.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 Morpho Vault contract used for withdrawals + */ + IERC4626Vault public morphoVault; + + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @notice Emitted when the Morpho Vault address is updated + * @param caller address of the caller + * @param newVault new Morpho Vault address + */ + event SetMorphoVault(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 _morphoVault Morpho Vault (ERC-4626) contract address + */ + 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 _morphoVault + ) external initializer { + __RedemptionVault_init( + _ac, + _mTokenInitParams, + _receiversInitParams, + _instantInitParams, + _sanctionsList, + _variationTolerance, + _minAmount, + _fiatRedemptionInitParams, + _requestRedeemer + ); + _validateAddress(_morphoVault, false); + morphoVault = IERC4626Vault(_morphoVault); + } + + /** + * @notice Sets the Morpho Vault address + * @param _morphoVault new Morpho Vault address + */ + function setMorphoVault(address _morphoVault) external onlyVaultAdmin { + _validateAddress(_morphoVault, false); + morphoVault = IERC4626Vault(_morphoVault); + emit SetMorphoVault(msg.sender, _morphoVault); + } + + /** + * @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; + + uint256 missingAmount = amountTokenOut - contractBalanceTokenOut; + + require(morphoVault.asset() == tokenOut, "RVM: token not vault asset"); + + uint256 sharesNeeded = morphoVault.previewWithdraw(missingAmount); + require( + morphoVault.balanceOf(address(this)) >= sharesNeeded, + "RVM: insufficient shares" + ); + + morphoVault.withdraw(missingAmount, address(this), address(this)); + } +} diff --git a/contracts/interfaces/morpho/IERC4626Vault.sol b/contracts/interfaces/morpho/IERC4626Vault.sol new file mode 100644 index 00000000..ab3175fd --- /dev/null +++ b/contracts/interfaces/morpho/IERC4626Vault.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.9; + +/** + * @title IERC4626Vault + * @notice Minimal ERC-4626 vault interface for Morpho Vault integration + * @dev Full standard: https://eips.ethereum.org/EIPS/eip-4626 + * 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 IERC4626Vault { + /** + * @dev Returns the address of the underlying token used for the Vault for accounting, depositing, and withdrawing. + * + * - MUST be an ERC-20 token contract. + * - MUST NOT revert. + */ + function asset() external view returns (address assetTokenAddress); + + /** + * @dev Burns shares from owner and sends exactly assets of underlying tokens to receiver. + * + * - MUST emit the Withdraw event. + * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the + * withdraw execution, and are accounted for during withdraw. + * - MUST revert if all of assets cannot be withdrawn (due to withdrawal limit being reached, slippage, the owner + * not having enough shares, etc). + * + * Note that some implementations will require pre-requesting to the Vault before a withdrawal may be performed. + * Those methods should be performed separately. + */ + function withdraw( + uint256 assets, + address receiver, + address owner + ) external returns (uint256 shares); + + /** + * @dev Allows an on-chain or off-chain user to simulate the effects of their withdrawal at the current block, + * given current on-chain conditions. + * + * - MUST return as close to and no fewer than the exact amount of Vault shares that would be burned in a withdraw + * call in the same transaction. I.e. withdraw should return the same or fewer shares as previewWithdraw if + * called + * in the same transaction. + * - MUST NOT account for withdrawal limits like those returned from maxWithdraw and should always act as though + * the withdrawal would be accepted, regardless if the user has enough shares, etc. + * - MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of withdrawal fees. + * - MUST NOT revert. + * + * NOTE: any unfavorable discrepancy between convertToShares and previewWithdraw SHOULD be considered slippage in + * share price or some other type of condition, meaning the depositor will lose assets by depositing. + */ + function previewWithdraw(uint256 assets) + external + view + returns (uint256 shares); + + /** + * @dev Returns the amount of assets that the Vault would exchange for the amount of shares provided, in an ideal + * scenario where all the conditions are met. + * + * - MUST NOT be inclusive of any fees that are charged against assets in the Vault. + * - MUST NOT show any variations depending on the caller. + * - MUST NOT reflect slippage or other on-chain conditions, when performing the actual exchange. + * - MUST NOT revert. + * + * NOTE: This calculation MAY NOT reflect the “per-user” price-per-share, and instead should reflect the + * “average-user’s” price-per-share, meaning what the average user should expect to see when exchanging to and + * from. + */ + function convertToAssets(uint256 shares) + external + view + returns (uint256 assets); + + /** + * @notice Returns the amount of vault shares owned by `account` + * @param account The address to query + * @return The share balance + */ + function balanceOf(address account) external view returns (uint256); +} diff --git a/contracts/mocks/AaveV3PoolMock.sol b/contracts/mocks/AaveV3PoolMock.sol index db6d34c3..3f08436d 100644 --- a/contracts/mocks/AaveV3PoolMock.sol +++ b/contracts/mocks/AaveV3PoolMock.sol @@ -7,38 +7,15 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "../interfaces/aave/IAaveV3Pool.sol"; import "./ERC20Mock.sol"; -/** - * @title AaveV3PoolMock - * @notice Mock contract simulating Aave V3 Pool behavior for testing - * @dev Implements withdraw() and getReserveData() from IAaveV3Pool. - * The mock burns aTokens from the caller (vault) and transfers underlying to `to`. - */ contract AaveV3PoolMock { using SafeERC20 for IERC20; - /// @notice Mapping from underlying asset to its aToken address mapping(address => address) public reserveATokens; - // ──────────────────────────── Admin helpers ──────────────────────────── - - /** - * @notice Register an aToken for an underlying asset - * @param asset The underlying asset address (e.g. USDC) - * @param aToken The aToken address (e.g. aUSDC) - */ function setReserveAToken(address asset, address aToken) external { reserveATokens[asset] = aToken; } - // ──────────────────────────── IAaveV3Pool ────────────────────────────── - - /** - * @notice Simulates Aave V3 Pool withdraw: burns aTokens from caller, transfers underlying to `to` - * @param asset The underlying asset to withdraw - * @param amount The amount to withdraw (in asset decimals) - * @param to The recipient of the underlying tokens - * @return The actual amount withdrawn - */ function withdraw( address asset, uint256 amount, @@ -47,40 +24,24 @@ contract AaveV3PoolMock { address aToken = reserveATokens[asset]; require(aToken != address(0), "AaveV3PoolMock: NoReserve"); - // Check pool has enough underlying liquidity uint256 poolBalance = IERC20(asset).balanceOf(address(this)); require(poolBalance >= amount, "AaveV3PoolMock: InsufficientLiquidity"); - // Burn aTokens from the caller (simulates Aave burning aTokens from the vault) ERC20Mock(aToken).burn(msg.sender, amount); - - // Transfer underlying to recipient IERC20(asset).safeTransfer(to, amount); return amount; } - /** - * @notice Returns reserve data for an asset (only aTokenAddress is populated) - * @param asset The underlying asset address - * @return data The ReserveData struct - */ function getReserveData(address asset) external view returns (IAaveV3Pool.ReserveData memory data) { data.aTokenAddress = reserveATokens[asset]; - // All other fields default to zero return data; } - /** - * @notice Withdraw any token from the mock (admin utility for tests) - * @param token The token to withdraw - * @param to The recipient - * @param amount The amount to withdraw - */ function withdrawAdmin( address token, address to, diff --git a/contracts/mocks/MorphoVaultMock.sol b/contracts/mocks/MorphoVaultMock.sol new file mode 100644 index 00000000..2e3f26a2 --- /dev/null +++ b/contracts/mocks/MorphoVaultMock.sol @@ -0,0 +1,96 @@ +// 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"; +import "../interfaces/morpho/IERC4626Vault.sol"; + +contract MorphoVaultMock is ERC20, IERC4626Vault { + using SafeERC20 for IERC20; + + address public immutable underlyingAsset; + + uint256 public exchangeRateNumerator; + uint256 public constant RATE_PRECISION = 1e18; + + 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 withdrawAdmin( + address token, + address to, + uint256 amount + ) external { + IERC20(token).safeTransfer(to, amount); + } + + function balanceOf(address account) + public + view + override(ERC20, IERC4626Vault) + returns (uint256) + { + return super.balanceOf(account); + } + + function asset() external view override returns (address) { + return underlyingAsset; + } + + function withdraw( + uint256 assets, + address receiver, + address owner + ) external override 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 + override + returns (uint256 shares) + { + // round up + shares = + (assets * RATE_PRECISION + exchangeRateNumerator - 1) / + exchangeRateNumerator; + } + + function convertToAssets(uint256 shares) + external + view + override + returns (uint256 assets) + { + assets = (shares * exchangeRateNumerator) / RATE_PRECISION; + } +} 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 05ff42a2..89809488 100644 --- a/helpers/contracts.ts +++ b/helpers/contracts.ts @@ -9,6 +9,7 @@ export type TokenContractNames = { rvBuidl: string; rvUstb: string; rvAave: string; + rvMorpho: string; dataFeed?: string; dataFeedComposite?: string; dataFeedMultiply?: string; @@ -36,6 +37,7 @@ const vaultTypeToContractNameMap: Record = { depositVaultUstb: 'dvUstb', redemptionVaultBuidl: 'rvBuidl', redemptionVaultAave: 'rvAave', + redemptionVaultMorpho: 'rvMorpho', }; export const vaultTypeToContractName = ( @@ -128,6 +130,7 @@ export const getCommonContractNames = (): CommonContractNames => { rvBuidl: 'RedemptionVaultWIthBUIDL', rvUstb: 'RedemptionVaultWithUSTB', rvAave: 'RedemptionVaultWithAave', + rvMorpho: 'RedemptionVaultWithMorpho', dataFeed: 'DataFeed', customAggregator: 'CustomAggregatorV3CompatibleFeed', customAggregatorGrowth: 'CustomAggregatorV3CompatibleFeedGrowth', @@ -160,6 +163,7 @@ export const getTokenContractNames = ( 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 d12da4dc..53c97dbe 100644 --- a/scripts/deploy/codegen/common/index.ts +++ b/scripts/deploy/codegen/common/index.ts @@ -19,6 +19,7 @@ import { getDvContractFromTemplate, getRvAaveContractFromTemplate, getRvContractFromTemplate, + getRvMorphoContractFromTemplate, getRvSwapperContractFromTemplate, getRvUstbContractFromTemplate, getTokenContractFromTemplate, @@ -67,6 +68,7 @@ const generatorPerContract: Partial< rvSwapper: getRvSwapperContractFromTemplate, rvUstb: getRvUstbContractFromTemplate, rvAave: getRvAaveContractFromTemplate, + rvMorpho: getRvMorphoContractFromTemplate, dataFeed: getDataFeedContractFromTemplate, customAggregator: getCustomAggregatorContractFromTemplate, customAggregatorGrowth: getCustomAggregatorGrowthContractFromTemplate, diff --git a/scripts/deploy/codegen/common/templates/index.ts b/scripts/deploy/codegen/common/templates/index.ts index 0df60024..e85bf50c 100644 --- a/scripts/deploy/codegen/common/templates/index.ts +++ b/scripts/deploy/codegen/common/templates/index.ts @@ -4,6 +4,7 @@ export * from './dv.template'; export * from './mtoken.template'; export * from './rv-swapper.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-morpho.template.ts b/scripts/deploy/codegen/common/templates/rv-morpho.template.ts new file mode 100644 index 00000000..9d474a3f --- /dev/null +++ b/scripts/deploy/codegen/common/templates/rv-morpho.template.ts @@ -0,0 +1,46 @@ +import { MTokenName } from '../../../../../config'; +import { importWithoutCache } from '../../../../../helpers/utils'; + +export const getRvMorphoContractFromTemplate = async (mToken: MTokenName) => { + 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}; + } + }`, + }; +}; diff --git a/scripts/deploy/codegen/common/ui/deployment-config.ts b/scripts/deploy/codegen/common/ui/deployment-config.ts index 144428ef..a92e4256 100644 --- a/scripts/deploy/codegen/common/ui/deployment-config.ts +++ b/scripts/deploy/codegen/common/ui/deployment-config.ts @@ -26,6 +26,7 @@ export const configsPerNetworkConfig = { rv: getRvConfigFromUser, rvSwapper: getRvSwapperConfigFromUser, rvAave: getRvAaveConfigFromUser, + rvMorpho: getRvMorphoConfigFromUser, genericConfig: getGenericConfigFromUser, postDeploy: { grantRoles: getPostDeployGrantRolesConfigFromUser, @@ -205,6 +206,27 @@ async function getRvAaveConfigFromUser(hre: HardhatRuntimeEnvironment) { }; } +async function getRvMorphoConfigFromUser(hre: HardhatRuntimeEnvironment) { + const config = await getRvConfigFromUser( + hre, + { + morphoVault: () => + text({ + message: 'Morpho Vault Address (ERC-4626)', + validate: validateAddress, + }) + .then(requireNotCancelled) + .then(requireAddress), + }, + 'Redemption Vault With Morpho', + ); + + return { + ...config, + type: 'MORPHO' as const, + }; +} + const getVaultForSwapper = ( hre: HardhatRuntimeEnvironment, mToken: MTokenName, @@ -218,6 +240,8 @@ const getVaultForSwapper = ( return 'redemptionVaultBuidl'; } else if (addresses?.[mToken]?.redemptionVaultAave) { return 'redemptionVaultAave'; + } else if (addresses?.[mToken]?.redemptionVaultMorpho) { + return 'redemptionVaultMorpho'; } else if (addresses?.[mToken]?.redemptionVault) { return 'redemptionVault'; } @@ -414,7 +438,7 @@ export async function getDeploymentConfigFromUser( multiselect< keyof Pick< DeploymentConfig['networkConfigs'][number], - 'rv' | 'rvSwapper' | 'rvAave' | 'dv' + 'rv' | 'rvSwapper' | 'rvAave' | 'rvMorpho' | 'dv' > >({ message: @@ -440,6 +464,11 @@ export async function getDeploymentConfigFromUser( 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, diff --git a/scripts/deploy/codegen/common/ui/deployment-contracts.ts b/scripts/deploy/codegen/common/ui/deployment-contracts.ts index acce7565..2893bcda 100644 --- a/scripts/deploy/codegen/common/ui/deployment-contracts.ts +++ b/scripts/deploy/codegen/common/ui/deployment-contracts.ts @@ -98,6 +98,11 @@ export const getContractsToGenerateFromUser = async () => { 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: 'dataFeed', label: 'Data Feed', hint: 'Data Feed contract' }, { value: 'customAggregator', diff --git a/scripts/deploy/common/rv.ts b/scripts/deploy/common/rv.ts index e1c91010..59f12ea9 100644 --- a/scripts/deploy/common/rv.ts +++ b/scripts/deploy/common/rv.ts @@ -15,6 +15,7 @@ import { MBasisRedemptionVaultWithSwapper, RedemptionVault, RedemptionVaultWithAave, + RedemptionVaultWithMorpho, RedemptionVaultWIthBUIDL, } from '../../../typechain-types'; @@ -73,18 +74,24 @@ export type DeployRvAaveConfig = { aavePool: string; } & DeployRvConfigCommon; +export type DeployRvMorphoConfig = { + type: 'MORPHO'; + morphoVault: string; +} & DeployRvConfigCommon; + export type DeployRvConfig = | DeployRvRegularConfig | DeployRvBuidlConfig | DeployRvSwapperConfig - | DeployRvAaveConfig; + | DeployRvAaveConfig + | DeployRvMorphoConfig; const DUMMY_ADDRESS = '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; export const deployRedemptionVault = async ( hre: HardhatRuntimeEnvironment, token: MTokenName, - type: 'rv' | 'rvBuidl' | 'rvSwapper' | 'rvAave', + type: 'rv' | 'rvBuidl' | 'rvSwapper' | 'rvAave' | 'rvMorpho', ) => { const addresses = getCurrentAddresses(hre); const deployer = await getDeployer(hre); @@ -106,6 +113,8 @@ export const deployRedemptionVault = async ( if (networkConfig.type === 'AAVE') { extraParams.push(networkConfig.aavePool); + } else if (networkConfig.type === 'MORPHO') { + extraParams.push(networkConfig.morphoVault); } else if (networkConfig.type === 'BUIDL') { extraParams.push(networkConfig.buidlRedemption); extraParams.push(networkConfig.minBuidlToRedeem); @@ -195,6 +204,9 @@ export const deployRedemptionVault = async ( > | Parameters< RedemptionVaultWithAave['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)'] + > + | Parameters< + RedemptionVaultWithMorpho['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)'] >; await deployAndVerifyProxy(hre, contractName, params, undefined, { @@ -203,7 +215,7 @@ 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 === 'AAVE' + : networkConfig.type === 'AAVE' || networkConfig.type === 'MORPHO' ? '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 55f81fbf..86f5fb1d 100644 --- a/scripts/deploy/common/types.ts +++ b/scripts/deploy/common/types.ts @@ -16,6 +16,7 @@ import { import { DeployRvAaveConfig, DeployRvBuidlConfig, + DeployRvMorphoConfig, DeployRvRegularConfig, DeployRvSwapperConfig, } from './rv'; @@ -98,6 +99,7 @@ export type DeploymentConfig = { rvBuidl?: DeployRvBuidlConfig; rvSwapper?: DeployRvSwapperConfig; rvAave?: DeployRvAaveConfig; + rvMorpho?: DeployRvMorphoConfig; postDeploy?: PostDeployConfig; } >; 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/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/fixtures.ts b/test/common/fixtures.ts index 2d11ead9..72f9ce85 100644 --- a/test/common/fixtures.ts +++ b/test/common/fixtures.ts @@ -39,7 +39,9 @@ import { RedemptionVaultWithUSTBTest__factory, RedemptionVaultWithSwapperTest__factory, RedemptionVaultWithAaveTest__factory, + RedemptionVaultWithMorphoTest__factory, AaveV3PoolMock__factory, + MorphoVaultMock__factory, CustomAggregatorV3CompatibleFeedDiscountedTester__factory, DepositVaultWithUSTBTest__factory, USTBMock__factory, @@ -419,6 +421,48 @@ export const defaultDeploy = async () => { 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(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' + ]( + 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, + morphoVaultMock.address, + ); + await accessControl.grantRole( + mTBILL.M_TBILL_BURN_OPERATOR_ROLE(), + redemptionVaultWithMorpho.address, + ); + /* Redemption Vault With Swapper */ const redemptionVaultWithSwapper = @@ -642,6 +686,8 @@ export const defaultDeploy = async () => { redemptionVaultWithAave, aavePoolMock, aUSDC, + redemptionVaultWithMorpho, + morphoVaultMock, liquidityProvider, otherCoins, ustbToken, diff --git a/test/common/manageable-vault.helpers.ts b/test/common/manageable-vault.helpers.ts index e0f2f0b1..c210e345 100644 --- a/test/common/manageable-vault.helpers.ts +++ b/test/common/manageable-vault.helpers.ts @@ -15,6 +15,7 @@ import { RedemptionVault, RedemptionVaultWIthBUIDL, RedemptionVaultWithAave, + RedemptionVaultWithMorpho, RedemptionVaultWithSwapper, RedemptionVaultWithUSTB, } from '../../typechain-types'; @@ -26,6 +27,7 @@ type CommonParamsChangePaymentToken = { | RedemptionVault | RedemptionVaultWIthBUIDL | RedemptionVaultWithAave + | RedemptionVaultWithMorpho | RedemptionVaultWithSwapper | RedemptionVaultWithUSTB; owner: SignerWithAddress; diff --git a/test/common/redemption-vault-morpho.helpers.ts b/test/common/redemption-vault-morpho.helpers.ts new file mode 100644 index 00000000..1a45b699 --- /dev/null +++ b/test/common/redemption-vault-morpho.helpers.ts @@ -0,0 +1,112 @@ +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 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 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.helpers.ts b/test/common/redemption-vault.helpers.ts index 67716f6d..ab214164 100644 --- a/test/common/redemption-vault.helpers.ts +++ b/test/common/redemption-vault.helpers.ts @@ -21,6 +21,7 @@ import { RedemptionVault, RedemptionVaultWIthBUIDL, RedemptionVaultWithAave, + RedemptionVaultWithMorpho, RedemptionVaultWithSwapper, RedemptionVaultWithUSTB, } from '../../typechain-types'; @@ -35,6 +36,7 @@ type CommonParamsRedeem = { | RedemptionVault | RedemptionVaultWIthBUIDL | RedemptionVaultWithAave + | RedemptionVaultWithMorpho | RedemptionVaultWithUSTB | RedemptionVaultWithSwapper; }; @@ -44,6 +46,7 @@ type CommonParams = Pick>, 'owner'> & { | RedemptionVault | RedemptionVaultWIthBUIDL | RedemptionVaultWithAave + | RedemptionVaultWithMorpho | RedemptionVaultWithUSTB | RedemptionVaultWithSwapper; }; @@ -935,6 +938,7 @@ export const getFeePercent = async ( | RedemptionVault | RedemptionVaultWIthBUIDL | RedemptionVaultWithAave + | RedemptionVaultWithMorpho | RedemptionVaultWithSwapper | RedemptionVaultWithUSTB, isInstant: boolean, @@ -960,6 +964,7 @@ export const calcExpectedTokenOutAmount = async ( | RedemptionVault | RedemptionVaultWIthBUIDL | RedemptionVaultWithAave + | RedemptionVaultWithMorpho | RedemptionVaultWithSwapper | RedemptionVaultWithUSTB, mTokenRate: BigNumber, diff --git a/test/integration/DepositVaultWithUSTB.test.ts b/test/integration/DepositVaultWithUSTB.test.ts index 2017ba64..1a35002f 100644 --- a/test/integration/DepositVaultWithUSTB.test.ts +++ b/test/integration/DepositVaultWithUSTB.test.ts @@ -1,5 +1,5 @@ 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'; @@ -51,7 +51,6 @@ describe('DepositVaultWithUSTB - Mainnet Fork Integration Tests', function () { mTokenToUsdDataFeed, ustbToken, expectedUstbDeposited: true, - expectedUstbMinted: BigNumber.from(9264844), }, usdc, usdcAmount, diff --git a/test/integration/RedemptionVaultWithMorpho.test.ts b/test/integration/RedemptionVaultWithMorpho.test.ts new file mode 100644 index 00000000..5039055a --- /dev/null +++ b/test/integration/RedemptionVaultWithMorpho.test.ts @@ -0,0 +1,318 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { constants } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; + +import { morphoRedemptionVaultFixture } from './fixtures/morpho.fixture'; + +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(morphoRedemptionVaultFixture); + + 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(morphoRedemptionVaultFixture); + + 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(morphoRedemptionVaultFixture); + + 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(morphoRedemptionVaultFixture); + + 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(morphoRedemptionVaultFixture); + + // Deploy a fake token that isn't the Morpho vault's underlying asset + const fakeTokenFactory = await ( + await import('hardhat') + ).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 with "RVM: token not vault asset" + await redeemInstantWithMorphoTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + usdc: fakeToken, + morphoVault, + }, + mTBILLAmount, + { + from: testUser, + revertMessage: 'RVM: token not vault asset', + }, + ); + }); + }); +}); diff --git a/test/integration/fixtures/aave.fixture.ts b/test/integration/fixtures/aave.fixture.ts index a1034e52..3d575fcd 100644 --- a/test/integration/fixtures/aave.fixture.ts +++ b/test/integration/fixtures/aave.fixture.ts @@ -28,8 +28,6 @@ async function impersonateAndFundAccount( export const FORK_BLOCK_NUMBER = 24441000; -const USDC_WHALE_ADDRESS = '0x28C6c06298d514Db089934071355E5743bf21d60'; - export async function aaveRedemptionVaultFixture() { await network.provider.request({ method: 'hardhat_reset', @@ -166,7 +164,9 @@ export async function aaveRedemptionVaultFixture() { ); // Impersonate whales - const usdcWhale = await impersonateAndFundAccount(USDC_WHALE_ADDRESS); + const usdcWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.USDC_WHALE_BINANCE, + ); const aUsdcWhale = await impersonateAndFundAccount( MAINNET_ADDRESSES.AUSDC_WHALE, ); diff --git a/test/integration/fixtures/morpho.fixture.ts b/test/integration/fixtures/morpho.fixture.ts new file mode 100644 index 00000000..4b0da740 --- /dev/null +++ b/test/integration/fixtures/morpho.fixture.ts @@ -0,0 +1,207 @@ +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 { rpcUrls } from '../../../config'; +import { getAllRoles } from '../../../helpers/roles'; +import { + MidasAccessControlTest, + MTBILLTest, + RedemptionVaultWithMorphoTest, + DataFeedTest, + AggregatorV3Mock, +} from '../../../typechain-types'; +import { deployProxyContract } from '../../common/deploy.helpers'; +import { MAINNET_ADDRESSES } from '../helpers/mainnet-addresses'; + +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); +} + +// Block where Steakhouse USDC Morpho vault is active and has liquidity +export const FORK_BLOCK_NUMBER = 24441000; + +export async function morphoRedemptionVaultFixture() { + await network.provider.request({ + method: 'hardhat_reset', + params: [ + { + forking: { + jsonRpcUrl: rpcUrls.main, + blockNumber: FORK_BLOCK_NUMBER, + }, + }, + ], + }); + await network.provider.send('evm_setAutomine', [true]); + + 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.redemptionVaultAdmin, + allRoles.common.greenlistedOperator, + ]; + + for (const role of rolesArray) { + await accessControl.grantRole(role, owner.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 mtbillDataFeed = await deployProxyContract( + 'DataFeedTest', + [ + accessControl.address, + mtbillAggregator.address, + 3 * 24 * 3600, + parseUnits('0.1', await mtbillAggregator.decimals()), + parseUnits('10000', await mtbillAggregator.decimals()), + ], + ); + + // Deploy RedemptionVaultWithMorpho + const redemptionVaultWithMorpho = + await deployProxyContract( + 'RedemptionVaultWithMorphoTest', + [ + accessControl.address, + { + mToken: mTBILL.address, + mTokenDataFeed: mtbillDataFeed.address, + }, + { + feeReceiver: feeReceiver.address, + tokensReceiver: 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), + }, + requestRedeemer.address, + MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_VAULT, + ], + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)', + ); + + // Grant BURN_ROLE to vault + await accessControl.grantRole( + allRoles.tokenRoles.mTBILL.burner, + redemptionVaultWithMorpho.address, + ); + + // Get mainnet contracts + const usdc = await ethers.getContractAt( + 'IERC20Metadata', + MAINNET_ADDRESSES.USDC, + ); + const morphoVault = await ethers.getContractAt( + 'IERC20', + MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_VAULT, + ); + + // Impersonate whales + const usdcWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.USDC_WHALE_BINANCE, + ); + const morphoShareWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_WHALE, + ); + + // Setup payment token + await redemptionVaultWithMorpho.connect(owner).addPaymentToken( + usdc.address, + usdcDataFeed.address, + 0, // no fee + ethers.constants.MaxUint256, + true, // is stable + ); + + return { + accessControl, + mTBILL, + dataFeed: usdcDataFeed, + mTokenToUsdDataFeed: mtbillDataFeed, + mockedAggregator: usdcAggregator, + mockedAggregatorMToken: mtbillAggregator, + redemptionVaultWithMorpho, + usdc, + morphoVault, + owner, + tokensReceiver, + feeReceiver, + requestRedeemer, + vaultAdmin, + testUser, + usdcWhale, + morphoShareWhale, + roles: allRoles, + }; +} + +export type MorphoDeployedContracts = Awaited< + ReturnType +>; diff --git a/test/integration/helpers/mainnet-addresses.ts b/test/integration/helpers/mainnet-addresses.ts index 805c8ba4..cadad33c 100644 --- a/test/integration/helpers/mainnet-addresses.ts +++ b/test/integration/helpers/mainnet-addresses.ts @@ -3,16 +3,21 @@ export const MAINNET_ADDRESSES = { REDEMPTION_IDLE_PROXY: '0x4c21B7577C8FE8b0B0669165ee7C8f67fa1454Cf', SUPERSTATE_TOKEN_PROXY: '0x43415eB6ff9DB7E26A15b704e7A3eDCe97d31C4e', - // Aave V3 contracts (Ethereum mainnet) + // Aave V3 contracts AAVE_V3_POOL: '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2', AUSDC: '0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c', // aEthUSDC + // Morpho Vault contracts + MORPHO_STEAKHOUSE_USDC_VAULT: '0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB', // Steakhouse USDC (steakUSDC) + // Tokens USDC: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', USDT: '0xdAC17F958D2ee523a2206206994597C13D831ec7', // Whale addresses USDC_WHALE: '0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503', + USDC_WHALE_BINANCE: '0xe1940f578743367F38D3f25c2D2d32D6636929B6', // Binance 91 USTB_WHALE: '0x5138D77d51dC57983e5A653CeA6e1C1aa9750A39', AUSDC_WHALE: '0x7055b17A1b911b6b971172C01FF0Cc27881aeA94', + MORPHO_STEAKHOUSE_USDC_WHALE: '0xdcee3ae4f82bd085ff147b87a754517d8caaff3b', }; diff --git a/test/unit/RedemptionVaultWithMorpho.test.ts b/test/unit/RedemptionVaultWithMorpho.test.ts new file mode 100644 index 00000000..1ebb3d92 --- /dev/null +++ b/test/unit/RedemptionVaultWithMorpho.test.ts @@ -0,0 +1,2214 @@ +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 } 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, + setFiatAdditionalFeeTest, + setMinFiatRedeemAmountTest, +} from '../common/redemption-vault.helpers'; +import { sanctionUser } from '../common/with-sanctions-list.helpers'; + +describe('RedemptionVaultWithMorpho', function () { + it('deployment', async () => { + const { + redemptionVaultWithMorpho, + morphoVaultMock, + 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.morphoVault()).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(address,(address,address),(address,address),(uint256,uint256),address,uint256,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'), + { + 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(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, + 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'); + }); + + it('should fail: when morphoVault address zero', async () => { + const { + owner, + accessControl, + mTBILL, + tokensReceiver, + feeReceiver, + mTokenToUsdDataFeed, + mockedSanctionsList, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + const redemptionVaultWithMorpho = + await new RedemptionVaultWithMorphoTest__factory(owner).deploy(); + + await expect( + redemptionVaultWithMorpho[ + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' + ]( + 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, + constants.AddressZero, + ), + ).revertedWith('zero address'); + }); + }); + + describe('setMorphoVault()', () => { + it('should fail: call from address without vault admin role', async () => { + const { redemptionVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + redemptionVaultWithMorpho + .connect(regularAccounts[0]) + .setMorphoVault(regularAccounts[1].address), + ).revertedWith('WMAC: hasnt role'); + }); + + it('should fail: zero address', async () => { + const { redemptionVaultWithMorpho } = await loadFixture(defaultDeploy); + await expect( + redemptionVaultWithMorpho.setMorphoVault(constants.AddressZero), + ).revertedWith('zero address'); + }); + + it('should succeed and emit SetMorphoVault event', async () => { + const { redemptionVaultWithMorpho, regularAccounts } = await loadFixture( + defaultDeploy, + ); + await expect( + redemptionVaultWithMorpho.setMorphoVault(regularAccounts[1].address), + ) + .to.emit(redemptionVaultWithMorpho, 'SetMorphoVault') + .withArgs( + ( + await ethers.getSigners() + )[0].address, + regularAccounts[1].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: token not vault asset'); + }); + + 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], + }, + ); + }); + }); + + 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()', () => { + it('should fail: when there is no request', async () => { + const { + owner, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + } = await loadFixture(defaultDeploy); + + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, + true, + ); + + await approveRedeemRequestTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + +new Date(), + parseUnits('1'), + { + revertMessage: 'RV: request not exist', + }, + ); + }); + + it('approve request: happy path', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + redemptionVaultWithMorpho, + stableCoins, + mTBILL, + dataFeed, + mTokenToUsdDataFeed, + requestRedeemer, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVaultWithMorpho, + 100000, + ); + + await mintToken(mTBILL, owner, 100); + await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithMorpho, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await approveRedeemRequestTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + +requestId, + parseUnits('1'), + ); + }); + }); + + describe('rejectRequest()', () => { + it('reject request: happy path', async () => { + const { + owner, + mockedAggregator, + mockedAggregatorMToken, + 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.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + const requestId = 0; + + await rejectRedeemRequestTest( + { + redemptionVault: redemptionVaultWithMorpho, + owner, + mTBILL, + mTokenToUsdDataFeed, + }, + +requestId, + ); + }); + }); +}); From 462e01efff26f5418727ffb8718317dfeddc3f4e Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Mon, 16 Feb 2026 16:22:06 +0200 Subject: [PATCH 03/20] refactor: update Aave V3 interface to use getReserveAToken --- contracts/RedemptionVaultWithAave.sol | 2 +- contracts/interfaces/aave/IAaveV3Pool.sol | 36 +++---------------- contracts/mocks/AaveV3PoolMock.sol | 10 ++---- .../RedemptionVaultWithAave.test.ts | 2 +- 4 files changed, 9 insertions(+), 41 deletions(-) diff --git a/contracts/RedemptionVaultWithAave.sol b/contracts/RedemptionVaultWithAave.sol index 2bf4aed3..e1495bbb 100644 --- a/contracts/RedemptionVaultWithAave.sol +++ b/contracts/RedemptionVaultWithAave.sol @@ -188,7 +188,7 @@ contract RedemptionVaultWithAave is RedemptionVault { uint256 missingAmount = amountTokenOut - contractBalanceTokenOut; - address aToken = aavePool.getReserveData(tokenOut).aTokenAddress; + address aToken = aavePool.getReserveAToken(tokenOut); require(aToken != address(0), "RVA: token not in Aave pool"); uint256 aTokenBalance = IERC20(aToken).balanceOf(address(this)); diff --git a/contracts/interfaces/aave/IAaveV3Pool.sol b/contracts/interfaces/aave/IAaveV3Pool.sol index 9f0978e4..0a0edd9d 100644 --- a/contracts/interfaces/aave/IAaveV3Pool.sol +++ b/contracts/interfaces/aave/IAaveV3Pool.sol @@ -3,33 +3,10 @@ pragma solidity 0.8.9; /** * @title IAaveV3Pool - * @notice Minimal interface for the Aave V3 Pool - * @dev Full interface: https://github.com/aave/aave-v3-core/blob/master/contracts/interfaces/IPool.sol - * Full DataTypes: https://github.com/aave/aave-v3-core/blob/master/contracts/protocol/libraries/types/DataTypes.sol + * @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 { - struct ReserveConfigurationMap { - uint256 data; - } - - struct ReserveData { - ReserveConfigurationMap configuration; - uint128 liquidityIndex; - uint128 currentLiquidityRate; - uint128 variableBorrowIndex; - uint128 currentVariableBorrowRate; - uint128 currentStableBorrowRate; - uint40 lastUpdateTimestamp; - uint16 id; - address aTokenAddress; - address stableDebtTokenAddress; - address variableDebtTokenAddress; - address interestRateStrategyAddress; - uint128 accruedToTreasury; - uint128 unbacked; - uint128 isolationModeTotalDebt; - } - /** * @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 @@ -48,12 +25,9 @@ interface IAaveV3Pool { ) external returns (uint256); /** - * @notice Returns the state and configuration of the reserve + * @notice Returns the aToken address of a reserve * @param asset The address of the underlying asset of the reserve - * @return The state and configuration data of the reserve + * @return The aToken address of the reserve */ - function getReserveData(address asset) - external - view - returns (ReserveData memory); + function getReserveAToken(address asset) external view returns (address); } diff --git a/contracts/mocks/AaveV3PoolMock.sol b/contracts/mocks/AaveV3PoolMock.sol index 3f08436d..40d93eaf 100644 --- a/contracts/mocks/AaveV3PoolMock.sol +++ b/contracts/mocks/AaveV3PoolMock.sol @@ -4,7 +4,6 @@ pragma solidity 0.8.9; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import "../interfaces/aave/IAaveV3Pool.sol"; import "./ERC20Mock.sol"; contract AaveV3PoolMock { @@ -33,13 +32,8 @@ contract AaveV3PoolMock { return amount; } - function getReserveData(address asset) - external - view - returns (IAaveV3Pool.ReserveData memory data) - { - data.aTokenAddress = reserveATokens[asset]; - return data; + function getReserveAToken(address asset) external view returns (address) { + return reserveATokens[asset]; } function withdrawAdmin( diff --git a/test/integration/RedemptionVaultWithAave.test.ts b/test/integration/RedemptionVaultWithAave.test.ts index feaf877a..874210fe 100644 --- a/test/integration/RedemptionVaultWithAave.test.ts +++ b/test/integration/RedemptionVaultWithAave.test.ts @@ -297,7 +297,7 @@ describe('RedemptionVaultWithAave - Mainnet Fork Integration Tests', function () // Should revert because fakeToken is not in Aave pool // The vault has no fakeToken balance, so it tries Aave withdrawal - // Aave's getReserveData returns address(0) for aTokenAddress + // Aave's getReserveAToken returns address(0) await redeemInstantWithAaveTest( { redemptionVault: redemptionVaultWithAave, From e76018cae33f6df466cc769d607672768e68df88 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Mon, 16 Feb 2026 21:20:35 +0200 Subject: [PATCH 04/20] fix: withdrawal amount check in RedemptionVaultWithAave --- contracts/RedemptionVaultWithAave.sol | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/contracts/RedemptionVaultWithAave.sol b/contracts/RedemptionVaultWithAave.sol index e1495bbb..c1afd44f 100644 --- a/contracts/RedemptionVaultWithAave.sol +++ b/contracts/RedemptionVaultWithAave.sol @@ -197,6 +197,14 @@ contract RedemptionVaultWithAave is RedemptionVault { "RVA: insufficient aToken balance" ); - aavePool.withdraw(tokenOut, missingAmount, address(this)); + uint256 withdrawnAmount = aavePool.withdraw( + tokenOut, + missingAmount, + address(this) + ); + require( + withdrawnAmount >= missingAmount, + "RVA: insufficient withdrawal amount" + ); } } From db24ac10390a42398521a0654344054b841626f7 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Tue, 17 Feb 2026 13:05:55 +0200 Subject: [PATCH 05/20] refactor: replace IERC4626Vault with IMorphoVault in RedemptionVaultWithMorpho --- contracts/RedemptionVaultWithMorpho.sol | 12 +-- contracts/interfaces/morpho/IERC4626Vault.sol | 84 ------------------- contracts/interfaces/morpho/IMorphoVault.sol | 15 ++++ contracts/mocks/MorphoVaultMock.sol | 18 +--- test/integration/fixtures/aave.fixture.ts | 29 +------ test/integration/fixtures/morpho.fixture.ts | 29 +------ test/integration/fixtures/upgrades.fixture.ts | 34 +------- test/integration/fixtures/ustb.fixture.ts | 29 +------ test/integration/helpers/fork.helpers.ts | 23 +++++ 9 files changed, 60 insertions(+), 213 deletions(-) delete mode 100644 contracts/interfaces/morpho/IERC4626Vault.sol create mode 100644 contracts/interfaces/morpho/IMorphoVault.sol create mode 100644 test/integration/helpers/fork.helpers.ts diff --git a/contracts/RedemptionVaultWithMorpho.sol b/contracts/RedemptionVaultWithMorpho.sol index 5ad83d36..227c50f5 100644 --- a/contracts/RedemptionVaultWithMorpho.sol +++ b/contracts/RedemptionVaultWithMorpho.sol @@ -5,7 +5,7 @@ import {IERC20Upgradeable as IERC20} from "@openzeppelin/contracts-upgradeable/t import "./RedemptionVault.sol"; -import "./interfaces/morpho/IERC4626Vault.sol"; +import {IMorphoVault} from "./interfaces/morpho/IMorphoVault.sol"; import "./libraries/DecimalsCorrectionLibrary.sol"; /** @@ -22,7 +22,7 @@ contract RedemptionVaultWithMorpho is RedemptionVault { /** * @notice Morpho Vault contract used for withdrawals */ - IERC4626Vault public morphoVault; + IMorphoVault public morphoVault; /** * @dev leaving a storage gap for futures updates @@ -73,7 +73,7 @@ contract RedemptionVaultWithMorpho is RedemptionVault { _requestRedeemer ); _validateAddress(_morphoVault, false); - morphoVault = IERC4626Vault(_morphoVault); + morphoVault = IMorphoVault(_morphoVault); } /** @@ -82,7 +82,7 @@ contract RedemptionVaultWithMorpho is RedemptionVault { */ function setMorphoVault(address _morphoVault) external onlyVaultAdmin { _validateAddress(_morphoVault, false); - morphoVault = IERC4626Vault(_morphoVault); + morphoVault = IMorphoVault(_morphoVault); emit SetMorphoVault(msg.sender, _morphoVault); } @@ -187,10 +187,10 @@ contract RedemptionVaultWithMorpho is RedemptionVault { ); if (contractBalanceTokenOut >= amountTokenOut) return; - uint256 missingAmount = amountTokenOut - contractBalanceTokenOut; - require(morphoVault.asset() == tokenOut, "RVM: token not vault asset"); + uint256 missingAmount = amountTokenOut - contractBalanceTokenOut; + uint256 sharesNeeded = morphoVault.previewWithdraw(missingAmount); require( morphoVault.balanceOf(address(this)) >= sharesNeeded, diff --git a/contracts/interfaces/morpho/IERC4626Vault.sol b/contracts/interfaces/morpho/IERC4626Vault.sol deleted file mode 100644 index ab3175fd..00000000 --- a/contracts/interfaces/morpho/IERC4626Vault.sol +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.9; - -/** - * @title IERC4626Vault - * @notice Minimal ERC-4626 vault interface for Morpho Vault integration - * @dev Full standard: https://eips.ethereum.org/EIPS/eip-4626 - * 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 IERC4626Vault { - /** - * @dev Returns the address of the underlying token used for the Vault for accounting, depositing, and withdrawing. - * - * - MUST be an ERC-20 token contract. - * - MUST NOT revert. - */ - function asset() external view returns (address assetTokenAddress); - - /** - * @dev Burns shares from owner and sends exactly assets of underlying tokens to receiver. - * - * - MUST emit the Withdraw event. - * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the - * withdraw execution, and are accounted for during withdraw. - * - MUST revert if all of assets cannot be withdrawn (due to withdrawal limit being reached, slippage, the owner - * not having enough shares, etc). - * - * Note that some implementations will require pre-requesting to the Vault before a withdrawal may be performed. - * Those methods should be performed separately. - */ - function withdraw( - uint256 assets, - address receiver, - address owner - ) external returns (uint256 shares); - - /** - * @dev Allows an on-chain or off-chain user to simulate the effects of their withdrawal at the current block, - * given current on-chain conditions. - * - * - MUST return as close to and no fewer than the exact amount of Vault shares that would be burned in a withdraw - * call in the same transaction. I.e. withdraw should return the same or fewer shares as previewWithdraw if - * called - * in the same transaction. - * - MUST NOT account for withdrawal limits like those returned from maxWithdraw and should always act as though - * the withdrawal would be accepted, regardless if the user has enough shares, etc. - * - MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of withdrawal fees. - * - MUST NOT revert. - * - * NOTE: any unfavorable discrepancy between convertToShares and previewWithdraw SHOULD be considered slippage in - * share price or some other type of condition, meaning the depositor will lose assets by depositing. - */ - function previewWithdraw(uint256 assets) - external - view - returns (uint256 shares); - - /** - * @dev Returns the amount of assets that the Vault would exchange for the amount of shares provided, in an ideal - * scenario where all the conditions are met. - * - * - MUST NOT be inclusive of any fees that are charged against assets in the Vault. - * - MUST NOT show any variations depending on the caller. - * - MUST NOT reflect slippage or other on-chain conditions, when performing the actual exchange. - * - MUST NOT revert. - * - * NOTE: This calculation MAY NOT reflect the “per-user” price-per-share, and instead should reflect the - * “average-user’s” price-per-share, meaning what the average user should expect to see when exchanging to and - * from. - */ - function convertToAssets(uint256 shares) - external - view - returns (uint256 assets); - - /** - * @notice Returns the amount of vault shares owned by `account` - * @param account The address to query - * @return The share balance - */ - function balanceOf(address account) external view returns (uint256); -} 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/MorphoVaultMock.sol b/contracts/mocks/MorphoVaultMock.sol index 2e3f26a2..02dd1f5b 100644 --- a/contracts/mocks/MorphoVaultMock.sol +++ b/contracts/mocks/MorphoVaultMock.sol @@ -5,9 +5,8 @@ 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"; -import "../interfaces/morpho/IERC4626Vault.sol"; -contract MorphoVaultMock is ERC20, IERC4626Vault { +contract MorphoVaultMock is ERC20 { using SafeERC20 for IERC20; address public immutable underlyingAsset; @@ -36,16 +35,7 @@ contract MorphoVaultMock is ERC20, IERC4626Vault { IERC20(token).safeTransfer(to, amount); } - function balanceOf(address account) - public - view - override(ERC20, IERC4626Vault) - returns (uint256) - { - return super.balanceOf(account); - } - - function asset() external view override returns (address) { + function asset() external view returns (address) { return underlyingAsset; } @@ -53,7 +43,7 @@ contract MorphoVaultMock is ERC20, IERC4626Vault { uint256 assets, address receiver, address owner - ) external override returns (uint256 shares) { + ) external returns (uint256 shares) { shares = previewWithdraw(assets); require( @@ -76,7 +66,6 @@ contract MorphoVaultMock is ERC20, IERC4626Vault { function previewWithdraw(uint256 assets) public view - override returns (uint256 shares) { // round up @@ -88,7 +77,6 @@ contract MorphoVaultMock is ERC20, IERC4626Vault { function convertToAssets(uint256 shares) external view - override returns (uint256 assets) { assets = (shares * exchangeRateNumerator) / RATE_PRECISION; diff --git a/test/integration/fixtures/aave.fixture.ts b/test/integration/fixtures/aave.fixture.ts index 3d575fcd..d1147fb7 100644 --- a/test/integration/fixtures/aave.fixture.ts +++ b/test/integration/fixtures/aave.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'; @@ -13,34 +11,13 @@ import { AggregatorV3Mock, } from '../../../typechain-types'; import { deployProxyContract } from '../../common/deploy.helpers'; +import { impersonateAndFundAccount, resetFork } from '../helpers/fork.helpers'; import { MAINNET_ADDRESSES } from '../helpers/mainnet-addresses'; -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); -} - export const FORK_BLOCK_NUMBER = 24441000; export async function aaveRedemptionVaultFixture() { - await network.provider.request({ - method: 'hardhat_reset', - params: [ - { - forking: { - jsonRpcUrl: rpcUrls.main, - blockNumber: FORK_BLOCK_NUMBER, - }, - }, - ], - }); - await network.provider.send('evm_setAutomine', [true]); + await resetFork(rpcUrls.main, FORK_BLOCK_NUMBER); const [ owner, diff --git a/test/integration/fixtures/morpho.fixture.ts b/test/integration/fixtures/morpho.fixture.ts index 4b0da740..054c80c2 100644 --- a/test/integration/fixtures/morpho.fixture.ts +++ b/test/integration/fixtures/morpho.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'; @@ -13,35 +11,14 @@ import { AggregatorV3Mock, } from '../../../typechain-types'; import { deployProxyContract } from '../../common/deploy.helpers'; +import { impersonateAndFundAccount, resetFork } from '../helpers/fork.helpers'; import { MAINNET_ADDRESSES } from '../helpers/mainnet-addresses'; -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); -} - // Block where Steakhouse USDC Morpho vault is active and has liquidity export const FORK_BLOCK_NUMBER = 24441000; export async function morphoRedemptionVaultFixture() { - await network.provider.request({ - method: 'hardhat_reset', - params: [ - { - forking: { - jsonRpcUrl: rpcUrls.main, - blockNumber: FORK_BLOCK_NUMBER, - }, - }, - ], - }); - await network.provider.send('evm_setAutomine', [true]); + await resetFork(rpcUrls.main, FORK_BLOCK_NUMBER); const [ owner, 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..67257f62 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]); + await resetFork(rpcUrls.main, FORK_BLOCK_NUMBER); const [ owner, 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); +} From 74b10e99382fe3c316b650bf7003a6132aa30e79 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Mon, 2 Mar 2026 14:28:05 +0200 Subject: [PATCH 06/20] feat: add RedemptionVaultWithMToken contract --- config/constants/addresses.ts | 1 + contracts/RedemptionVaultWithMToken.sol | 247 +++ .../mFONE/MFOneRedemptionVaultWithMToken.sol | 29 + .../mSL/MSlRedemptionVaultWithMToken.sol | 29 + .../testers/RedemptionVaultWithMTokenTest.sol | 16 + helpers/contracts.ts | 4 + scripts/deploy/codegen/common/index.ts | 2 + .../deploy/codegen/common/templates/index.ts | 1 + .../common/templates/rv-mtoken.template.ts | 48 + .../codegen/common/ui/deployment-config.ts | 33 +- .../codegen/common/ui/deployment-contracts.ts | 5 + scripts/deploy/common/rv.ts | 20 +- scripts/deploy/common/types.ts | 5 +- scripts/deploy/deploy_RVMToken.ts | 13 + .../upgrades/upgrade_RedemptionVaultMToken.ts | 54 + test/common/fixtures.ts | 66 + test/common/manageable-vault.helpers.ts | 2 + .../common/redemption-vault-mtoken.helpers.ts | 162 ++ test/common/redemption-vault.helpers.ts | 5 + test/unit/RedemptionVaultWithMToken.test.ts | 1895 +++++++++++++++++ 20 files changed, 2631 insertions(+), 6 deletions(-) create mode 100644 contracts/RedemptionVaultWithMToken.sol create mode 100644 contracts/products/mFONE/MFOneRedemptionVaultWithMToken.sol create mode 100644 contracts/products/mSL/MSlRedemptionVaultWithMToken.sol create mode 100644 contracts/testers/RedemptionVaultWithMTokenTest.sol create mode 100644 scripts/deploy/codegen/common/templates/rv-mtoken.template.ts create mode 100644 scripts/deploy/deploy_RVMToken.ts create mode 100644 scripts/upgrades/upgrade_RedemptionVaultMToken.ts create mode 100644 test/common/redemption-vault-mtoken.helpers.ts create mode 100644 test/unit/RedemptionVaultWithMToken.test.ts diff --git a/config/constants/addresses.ts b/config/constants/addresses.ts index da856e37..d7dd1c7b 100644 --- a/config/constants/addresses.ts +++ b/config/constants/addresses.ts @@ -7,6 +7,7 @@ export type RedemptionVaultType = | 'redemptionVault' | 'redemptionVaultBuidl' | 'redemptionVaultSwapper' + | 'redemptionVaultMToken' | 'redemptionVaultUstb' | 'redemptionVaultAave' | 'redemptionVaultMorpho'; diff --git a/contracts/RedemptionVaultWithMToken.sol b/contracts/RedemptionVaultWithMToken.sol new file mode 100644 index 00000000..4542d982 --- /dev/null +++ b/contracts/RedemptionVaultWithMToken.sol @@ -0,0 +1,247 @@ +// 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 "./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 + */ + IRedemptionVault public redemptionVault; + + /** + * @dev DEPRECATED storage slot kept for layout compatibility + */ + // 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 contractBalanceTokenOut = IERC20(tokenOut).balanceOf( + address(this) + ); + if (contractBalanceTokenOut >= amountTokenOut) return; + + uint256 missingAmount = amountTokenOut - contractBalanceTokenOut; + uint256 tokenDecimals = _tokenDecimals(tokenOut); + + uint256 missingAmountBase18 = missingAmount.convertToBase18( + tokenDecimals + ); + uint256 mTokenARate = redemptionVault + .mTokenDataFeed() + .getDataInBase18(); + + uint256 mTokenAAmount = (missingAmountBase18 * tokenOutRate) / + mTokenARate + + 1; + + address mTokenA = address(redemptionVault.mToken()); + + require( + IERC20(mTokenA).balanceOf(address(this)) >= mTokenAAmount, + "RVMT: insufficient mToken balance" + ); + + IERC20(mTokenA).safeIncreaseAllowance( + address(redemptionVault), + mTokenAAmount + ); + + redemptionVault.redeemInstant( + tokenOut, + mTokenAAmount, + missingAmountBase18 + ); + } +} 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/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/helpers/contracts.ts b/helpers/contracts.ts index 89809488..7d3abeb9 100644 --- a/helpers/contracts.ts +++ b/helpers/contracts.ts @@ -6,6 +6,7 @@ export type TokenContractNames = { dvUstb: string; rv: string; rvSwapper: string; + rvMToken: string; rvBuidl: string; rvUstb: string; rvAave: string; @@ -32,6 +33,7 @@ type CommonContractNames = Omit & { const vaultTypeToContractNameMap: Record = { redemptionVault: 'rv', redemptionVaultSwapper: 'rvSwapper', + redemptionVaultMToken: 'rvMToken', redemptionVaultUstb: 'rvUstb', depositVault: 'dv', depositVaultUstb: 'dvUstb', @@ -127,6 +129,7 @@ export const getCommonContractNames = (): CommonContractNames => { dvUstb: 'DepositVaultWithUSTB', rv: 'RedemptionVault', rvSwapper: 'RedemptionVaultWithSwapper', + rvMToken: 'RedemptionVaultWithMToken', rvBuidl: 'RedemptionVaultWIthBUIDL', rvUstb: 'RedemptionVaultWithUSTB', rvAave: 'RedemptionVaultWithAave', @@ -160,6 +163,7 @@ export const getTokenContractNames = ( dvUstb: `${tokenPrefix}${commonContractNames.dvUstb}`, rv: `${tokenPrefix}${commonContractNames.rv}`, rvSwapper: `${tokenPrefix}${commonContractNames.rvSwapper}`, + rvMToken: `${tokenPrefix}${commonContractNames.rvMToken}`, rvBuidl: `${tokenPrefix}${commonContractNames.rvBuidl}`, rvUstb: `${tokenPrefix}${commonContractNames.rvUstb}`, rvAave: `${tokenPrefix}${commonContractNames.rvAave}`, diff --git a/scripts/deploy/codegen/common/index.ts b/scripts/deploy/codegen/common/index.ts index 53c97dbe..45c88e4d 100644 --- a/scripts/deploy/codegen/common/index.ts +++ b/scripts/deploy/codegen/common/index.ts @@ -20,6 +20,7 @@ import { getRvAaveContractFromTemplate, getRvContractFromTemplate, getRvMorphoContractFromTemplate, + getRvMTokenContractFromTemplate, getRvSwapperContractFromTemplate, getRvUstbContractFromTemplate, getTokenContractFromTemplate, @@ -66,6 +67,7 @@ const generatorPerContract: Partial< dv: getDvContractFromTemplate, rv: getRvContractFromTemplate, rvSwapper: getRvSwapperContractFromTemplate, + rvMToken: getRvMTokenContractFromTemplate, rvUstb: getRvUstbContractFromTemplate, rvAave: getRvAaveContractFromTemplate, rvMorpho: getRvMorphoContractFromTemplate, diff --git a/scripts/deploy/codegen/common/templates/index.ts b/scripts/deploy/codegen/common/templates/index.ts index e85bf50c..e5e69a6e 100644 --- a/scripts/deploy/codegen/common/templates/index.ts +++ b/scripts/deploy/codegen/common/templates/index.ts @@ -3,6 +3,7 @@ export * from './data-feed.template'; export * from './dv.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'; 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..3a7d0e03 --- /dev/null +++ b/scripts/deploy/codegen/common/templates/rv-mtoken.template.ts @@ -0,0 +1,48 @@ +import { MTokenName } from '../../../../../config'; +import { importWithoutCache } from '../../../../../helpers/utils'; + +export const getRvMTokenContractFromTemplate = async (mToken: MTokenName) => { + 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}; + } + }`, + }; +}; diff --git a/scripts/deploy/codegen/common/ui/deployment-config.ts b/scripts/deploy/codegen/common/ui/deployment-config.ts index a92e4256..02cdf0de 100644 --- a/scripts/deploy/codegen/common/ui/deployment-config.ts +++ b/scripts/deploy/codegen/common/ui/deployment-config.ts @@ -25,6 +25,7 @@ export const configsPerNetworkConfig = { dv: getDvConfigFromUser, rv: getRvConfigFromUser, rvSwapper: getRvSwapperConfigFromUser, + rvMToken: getRvMTokenConfigFromUser, rvAave: getRvAaveConfigFromUser, rvMorpho: getRvMorphoConfigFromUser, genericConfig: getGenericConfigFromUser, @@ -227,12 +228,35 @@ async function getRvMorphoConfigFromUser(hre: HardhatRuntimeEnvironment) { }; } +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'; @@ -438,7 +462,7 @@ export async function getDeploymentConfigFromUser( multiselect< keyof Pick< DeploymentConfig['networkConfigs'][number], - 'rv' | 'rvSwapper' | 'rvAave' | 'rvMorpho' | 'dv' + 'rv' | 'rvSwapper' | 'rvMToken' | 'rvAave' | 'rvMorpho' | 'dv' > >({ message: @@ -459,6 +483,11 @@ 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', diff --git a/scripts/deploy/codegen/common/ui/deployment-contracts.ts b/scripts/deploy/codegen/common/ui/deployment-contracts.ts index 2893bcda..881af9e9 100644 --- a/scripts/deploy/codegen/common/ui/deployment-contracts.ts +++ b/scripts/deploy/codegen/common/ui/deployment-contracts.ts @@ -88,6 +88,11 @@ 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', diff --git a/scripts/deploy/common/rv.ts b/scripts/deploy/common/rv.ts index 59f12ea9..bf2dcdf9 100644 --- a/scripts/deploy/common/rv.ts +++ b/scripts/deploy/common/rv.ts @@ -16,6 +16,7 @@ import { RedemptionVault, RedemptionVaultWithAave, RedemptionVaultWithMorpho, + RedemptionVaultWithMToken, RedemptionVaultWIthBUIDL, } from '../../../typechain-types'; @@ -79,19 +80,25 @@ export type DeployRvMorphoConfig = { morphoVault: string; } & DeployRvConfigCommon; +export type DeployRvMTokenConfig = { + type: 'MTOKEN'; + redemptionVault: string; +} & DeployRvConfigCommon; + export type DeployRvConfig = | DeployRvRegularConfig | DeployRvBuidlConfig | DeployRvSwapperConfig | DeployRvAaveConfig - | DeployRvMorphoConfig; + | DeployRvMorphoConfig + | DeployRvMTokenConfig; const DUMMY_ADDRESS = '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; export const deployRedemptionVault = async ( hre: HardhatRuntimeEnvironment, token: MTokenName, - type: 'rv' | 'rvBuidl' | 'rvSwapper' | 'rvAave' | 'rvMorpho', + type: 'rv' | 'rvBuidl' | 'rvSwapper' | 'rvAave' | 'rvMorpho' | 'rvMToken', ) => { const addresses = getCurrentAddresses(hre); const deployer = await getDeployer(hre); @@ -115,6 +122,8 @@ export const deployRedemptionVault = async ( extraParams.push(networkConfig.aavePool); } else if (networkConfig.type === 'MORPHO') { extraParams.push(networkConfig.morphoVault); + } else if (networkConfig.type === 'MTOKEN') { + extraParams.push(networkConfig.redemptionVault); } else if (networkConfig.type === 'BUIDL') { extraParams.push(networkConfig.buidlRedemption); extraParams.push(networkConfig.minBuidlToRedeem); @@ -207,6 +216,9 @@ export const deployRedemptionVault = async ( > | Parameters< RedemptionVaultWithMorpho['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),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, { @@ -215,7 +227,9 @@ 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 === 'AAVE' || networkConfig.type === 'MORPHO' + : networkConfig.type === 'AAVE' || + networkConfig.type === 'MORPHO' || + 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 86f5fb1d..5c7eafb3 100644 --- a/scripts/deploy/common/types.ts +++ b/scripts/deploy/common/types.ts @@ -17,6 +17,7 @@ import { DeployRvAaveConfig, DeployRvBuidlConfig, DeployRvMorphoConfig, + DeployRvMTokenConfig, DeployRvRegularConfig, DeployRvSwapperConfig, } from './rv'; @@ -100,6 +101,7 @@ export type DeploymentConfig = { rvSwapper?: DeployRvSwapperConfig; rvAave?: DeployRvAaveConfig; rvMorpho?: DeployRvMorphoConfig; + rvMToken?: DeployRvMTokenConfig; postDeploy?: PostDeployConfig; } >; @@ -139,6 +141,7 @@ export type NetworkDeploymentConfig = Record< export type RvType = | 'redemptionVault' | 'redemptionVaultBuidl' - | 'redemptionVaultSwapper'; + | 'redemptionVaultSwapper' + | 'redemptionVaultMToken'; export type DeployFunction = (hre: HardhatRuntimeEnvironment) => Promise; 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/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/fixtures.ts b/test/common/fixtures.ts index 72f9ce85..188a5956 100644 --- a/test/common/fixtures.ts +++ b/test/common/fixtures.ts @@ -40,6 +40,7 @@ import { RedemptionVaultWithSwapperTest__factory, RedemptionVaultWithAaveTest__factory, RedemptionVaultWithMorphoTest__factory, + RedemptionVaultWithMTokenTest__factory, AaveV3PoolMock__factory, MorphoVaultMock__factory, CustomAggregatorV3CompatibleFeedDiscountedTester__factory, @@ -503,6 +504,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(); @@ -689,6 +751,10 @@ export const defaultDeploy = async () => { redemptionVaultWithMorpho, morphoVaultMock, liquidityProvider, + mFONE, + mockedAggregatorMFone, + mFoneToUsdDataFeed, + redemptionVaultWithMToken, otherCoins, ustbToken, ustbRedemption, diff --git a/test/common/manageable-vault.helpers.ts b/test/common/manageable-vault.helpers.ts index c210e345..f8d58f09 100644 --- a/test/common/manageable-vault.helpers.ts +++ b/test/common/manageable-vault.helpers.ts @@ -16,6 +16,7 @@ import { RedemptionVaultWIthBUIDL, RedemptionVaultWithAave, RedemptionVaultWithMorpho, + RedemptionVaultWithMToken, RedemptionVaultWithSwapper, RedemptionVaultWithUSTB, } from '../../typechain-types'; @@ -28,6 +29,7 @@ type CommonParamsChangePaymentToken = { | RedemptionVaultWIthBUIDL | RedemptionVaultWithAave | RedemptionVaultWithMorpho + | RedemptionVaultWithMToken | RedemptionVaultWithSwapper | RedemptionVaultWithUSTB; owner: SignerWithAddress; 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 ab214164..083d8aa1 100644 --- a/test/common/redemption-vault.helpers.ts +++ b/test/common/redemption-vault.helpers.ts @@ -22,6 +22,7 @@ import { RedemptionVaultWIthBUIDL, RedemptionVaultWithAave, RedemptionVaultWithMorpho, + RedemptionVaultWithMToken, RedemptionVaultWithSwapper, RedemptionVaultWithUSTB, } from '../../typechain-types'; @@ -37,6 +38,7 @@ type CommonParamsRedeem = { | RedemptionVaultWIthBUIDL | RedemptionVaultWithAave | RedemptionVaultWithMorpho + | RedemptionVaultWithMToken | RedemptionVaultWithUSTB | RedemptionVaultWithSwapper; }; @@ -47,6 +49,7 @@ type CommonParams = Pick>, 'owner'> & { | RedemptionVaultWIthBUIDL | RedemptionVaultWithAave | RedemptionVaultWithMorpho + | RedemptionVaultWithMToken | RedemptionVaultWithUSTB | RedemptionVaultWithSwapper; }; @@ -939,6 +942,7 @@ export const getFeePercent = async ( | RedemptionVaultWIthBUIDL | RedemptionVaultWithAave | RedemptionVaultWithMorpho + | RedemptionVaultWithMToken | RedemptionVaultWithSwapper | RedemptionVaultWithUSTB, isInstant: boolean, @@ -965,6 +969,7 @@ export const calcExpectedTokenOutAmount = async ( | RedemptionVaultWIthBUIDL | RedemptionVaultWithAave | RedemptionVaultWithMorpho + | RedemptionVaultWithMToken | RedemptionVaultWithSwapper | RedemptionVaultWithUSTB, mTokenRate: BigNumber, diff --git a/test/unit/RedemptionVaultWithMToken.test.ts b/test/unit/RedemptionVaultWithMToken.test.ts new file mode 100644 index 00000000..38d368ce --- /dev/null +++ b/test/unit/RedemptionVaultWithMToken.test.ts @@ -0,0 +1,1895 @@ +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, + RedemptionVaultWithMTokenTest__factory, +} from '../../typechain-types'; +import { acErrors, blackList } 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, + 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('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('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, + greenListableTester, + accessControl, + } = 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: 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: insufficient mToken balance', + }, + ); + }); + + 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 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], + }, + ); + }); + + 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()', () => { + it('approve request', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + requestRedeemer, + mockedAggregator, + mockedAggregatorMFone, + } = await loadFixture(defaultDeploy); + + await mintToken(stableCoins.dai, requestRedeemer, 100000); + await approveBase18( + requestRedeemer, + stableCoins.dai, + redemptionVaultWithMToken, + 100000, + ); + + await mintToken(mFONE, owner, 100); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 5); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + + const requestId = 0; + + await approveRedeemRequestTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + parseUnits('1'), + ); + }); + }); + + describe('rejectRequest()', () => { + it('reject request', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mFONE, + mFoneToUsdDataFeed, + dataFeed, + mockedAggregator, + mockedAggregatorMFone, + } = await loadFixture(defaultDeploy); + + await mintToken(mFONE, owner, 100); + await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100); + await addPaymentTokenTest( + { vault: redemptionVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, + true, + ); + await setRoundData({ mockedAggregator }, 1.03); + await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 5); + + await redeemRequestTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + stableCoins.dai, + 100, + ); + + const requestId = 0; + + await rejectRedeemRequestTest( + { + redemptionVault: redemptionVaultWithMToken, + owner, + mTBILL: mFONE, + mTokenToUsdDataFeed: mFoneToUsdDataFeed, + }, + +requestId, + ); + }); + }); +}); From 64618d2960743863508bd5709dd70a9546ebd8bd Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Tue, 3 Mar 2026 12:34:59 +0200 Subject: [PATCH 07/20] feat: add DepositVaultWithAave and DepositVaultWithMorpho contracts for Aave and Morpho integration --- config/constants/addresses.ts | 10 +- contracts/DepositVaultWithAave.sol | 146 ++ contracts/DepositVaultWithMorpho.sol | 143 ++ contracts/interfaces/aave/IAaveV3Pool.sol | 18 + contracts/mocks/AaveV3PoolMock.sol | 28 +- contracts/mocks/MorphoVaultMock.sol | 24 + .../testers/DepositVaultWithAaveTest.sol | 74 + .../testers/DepositVaultWithMorphoTest.sol | 74 + helpers/contracts.ts | 8 + scripts/deploy/codegen/common/index.ts | 4 + .../common/templates/dv-aave.template.ts | 46 + .../common/templates/dv-morpho.template.ts | 46 + .../deploy/codegen/common/templates/index.ts | 2 + .../codegen/common/ui/deployment-config.ts | 177 +- .../codegen/common/ui/deployment-contracts.ts | 10 + scripts/deploy/common/dv.ts | 36 +- scripts/deploy/common/roles.ts | 19 +- scripts/deploy/common/types.ts | 15 +- scripts/deploy/common/vault-resolver.ts | 44 + scripts/deploy/deploy_DVAave.ts | 13 + scripts/deploy/deploy_DVMorpho.ts | 13 + .../deploy/misc/acre/deploy_AcreAdapter.ts | 18 + .../deploy/misc/axelar/deploy_Executable.ts | 28 +- .../deploy/misc/layerzero/deploy_Composer.ts | 28 +- .../deploy/post-deploy/set_SanctionsList.ts | 23 +- test/common/deposit-vault-aave.helpers.ts | 169 ++ test/common/deposit-vault-morpho.helpers.ts | 204 ++ test/common/deposit-vault.helpers.ts | 23 +- test/common/fixtures.ts | 71 + test/common/manageable-vault.helpers.ts | 10 +- test/integration/DepositVaultWithAave.test.ts | 120 ++ .../DepositVaultWithMorpho.test.ts | 164 ++ test/integration/DepositVaultWithUSTB.test.ts | 10 +- .../RedemptionVaultWithAave.test.ts | 17 +- .../RedemptionVaultWithMorpho.test.ts | 17 +- .../RedemptionVaultWithUSTB.test.ts | 12 +- test/integration/fixtures/aave.fixture.ts | 169 +- test/integration/fixtures/morpho.fixture.ts | 171 +- test/integration/fixtures/ustb.fixture.ts | 187 +- test/integration/helpers/deposit.helpers.ts | 153 ++ test/unit/DepositVaultWithAave.test.ts | 1522 +++++++++++++++ test/unit/DepositVaultWithMorpho.test.ts | 1643 +++++++++++++++++ test/unit/RedemptionVaultWithAave.test.ts | 61 + 43 files changed, 5519 insertions(+), 251 deletions(-) create mode 100644 contracts/DepositVaultWithAave.sol create mode 100644 contracts/DepositVaultWithMorpho.sol create mode 100644 contracts/testers/DepositVaultWithAaveTest.sol create mode 100644 contracts/testers/DepositVaultWithMorphoTest.sol create mode 100644 scripts/deploy/codegen/common/templates/dv-aave.template.ts create mode 100644 scripts/deploy/codegen/common/templates/dv-morpho.template.ts create mode 100644 scripts/deploy/common/vault-resolver.ts create mode 100644 scripts/deploy/deploy_DVAave.ts create mode 100644 scripts/deploy/deploy_DVMorpho.ts create mode 100644 test/common/deposit-vault-aave.helpers.ts create mode 100644 test/common/deposit-vault-morpho.helpers.ts create mode 100644 test/integration/DepositVaultWithAave.test.ts create mode 100644 test/integration/DepositVaultWithMorpho.test.ts create mode 100644 test/integration/helpers/deposit.helpers.ts create mode 100644 test/unit/DepositVaultWithAave.test.ts create mode 100644 test/unit/DepositVaultWithMorpho.test.ts diff --git a/config/constants/addresses.ts b/config/constants/addresses.ts index d7dd1c7b..6446916e 100644 --- a/config/constants/addresses.ts +++ b/config/constants/addresses.ts @@ -12,7 +12,11 @@ export type RedemptionVaultType = | 'redemptionVaultAave' | 'redemptionVaultMorpho'; -export type DepositVaultType = 'depositVault' | 'depositVaultUstb'; +export type DepositVaultType = + | 'depositVault' + | 'depositVaultUstb' + | 'depositVaultAave' + | 'depositVaultMorpho'; export type LayerZeroTokenAddresses = { oft?: string; @@ -29,11 +33,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/DepositVaultWithAave.sol b/contracts/DepositVaultWithAave.sol new file mode 100644 index 00000000..472ca324 --- /dev/null +++ b/contracts/DepositVaultWithAave.sol @@ -0,0 +1,146 @@ +// 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 Aave V3 Pool contract address + */ + IAaveV3Pool public aavePool; + + /** + * @notice Whether Aave auto-invest deposits are enabled + * @dev if false, regular deposit flow will be used + */ + bool public aaveDepositsEnabled; + + /** + * @dev leaving a storage gap for futures updates + */ + uint256[50] private __gap; + + /** + * @notice Emitted when the Aave Pool address is updated + * @param caller address of the caller + * @param newPool new Aave Pool address + */ + event SetAavePool(address indexed caller, address indexed newPool); + + /** + * @notice Emitted when `aaveDepositsEnabled` flag is updated + * @param enabled Whether Aave deposits are enabled + */ + event SetAaveDepositsEnabled(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 _aavePool Aave V3 Pool contract 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 _aavePool + ) external { + initialize( + _ac, + _mTokenInitParams, + _receiversInitParams, + _instantInitParams, + _sanctionsList, + _variationTolerance, + _minAmount, + _minMTokenAmountForFirstDeposit, + _maxSupplyCap + ); + + _validateAddress(_aavePool, false); + aavePool = IAaveV3Pool(_aavePool); + } + + /** + * @notice Sets the Aave V3 Pool address + * @param _aavePool new Aave V3 Pool address + */ + function setAavePool(address _aavePool) external onlyVaultAdmin { + _validateAddress(_aavePool, false); + aavePool = IAaveV3Pool(_aavePool); + emit SetAavePool(msg.sender, _aavePool); + } + + /** + * @notice Updates `aaveDepositsEnabled` value + * @param enabled whether Aave auto-invest deposits are enabled + */ + function setAaveDepositsEnabled(bool enabled) external onlyVaultAdmin { + aaveDepositsEnabled = enabled; + emit SetAaveDepositsEnabled(enabled); + } + + /** + * @dev overrides original transfer to tokens receiver function + * in case of Aave deposits are disabled, it will act as the original transfer + * otherwise it will take payment tokens from user, supply them to Aave V3 Pool + * and aTokens will be minted to tokens receiver + * @param tokenIn token address + * @param amountToken amount of tokens to transfer in base18 + * @param tokensDecimals decimals of tokens + */ + function _instantTransferTokensToTokensReceiver( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) internal override { + if (!aaveDepositsEnabled) { + return + super._instantTransferTokensToTokensReceiver( + tokenIn, + amountToken, + tokensDecimals + ); + } + + uint256 transferredAmount = _tokenTransferFromUser( + tokenIn, + address(this), + amountToken, + tokensDecimals + ); + + IERC20(tokenIn).safeIncreaseAllowance( + address(aavePool), + transferredAmount + ); + aavePool.supply(tokenIn, transferredAmount, tokensReceiver, 0); + } +} diff --git a/contracts/DepositVaultWithMorpho.sol b/contracts/DepositVaultWithMorpho.sol new file mode 100644 index 00000000..00dc7038 --- /dev/null +++ b/contracts/DepositVaultWithMorpho.sol @@ -0,0 +1,143 @@ +// 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; + + /** + * @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 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, false); + _validateAddress(_morphoVault, false); + 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); + } + + /** + * @dev overrides original transfer to tokens receiver function + * in case of Morpho deposits are disabled, it will act as the original transfer + * otherwise it will take payment tokens from user, deposit them into Morpho Vault + * and vault shares will be minted to tokens receiver + * @param tokenIn token address + * @param amountToken amount of tokens to transfer in base18 + * @param tokensDecimals decimals of tokens + */ + function _instantTransferTokensToTokensReceiver( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) internal override { + if (!morphoDepositsEnabled) { + return + super._instantTransferTokensToTokensReceiver( + tokenIn, + amountToken, + tokensDecimals + ); + } + + IMorphoVault vault = morphoVaults[tokenIn]; + require(address(vault) != address(0), "DVM: no vault for token"); + + uint256 transferredAmount = _tokenTransferFromUser( + tokenIn, + address(this), + amountToken, + tokensDecimals + ); + + IERC20(tokenIn).safeIncreaseAllowance( + address(vault), + transferredAmount + ); + uint256 shares = vault.deposit(transferredAmount, tokensReceiver); + require(shares > 0, "DVM: zero shares"); + } +} diff --git a/contracts/interfaces/aave/IAaveV3Pool.sol b/contracts/interfaces/aave/IAaveV3Pool.sol index 0a0edd9d..c239da0f 100644 --- a/contracts/interfaces/aave/IAaveV3Pool.sol +++ b/contracts/interfaces/aave/IAaveV3Pool.sol @@ -24,6 +24,24 @@ interface IAaveV3Pool { 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 diff --git a/contracts/mocks/AaveV3PoolMock.sol b/contracts/mocks/AaveV3PoolMock.sol index 40d93eaf..ba440474 100644 --- a/contracts/mocks/AaveV3PoolMock.sol +++ b/contracts/mocks/AaveV3PoolMock.sol @@ -10,11 +10,17 @@ contract AaveV3PoolMock { using SafeERC20 for IERC20; mapping(address => address) public reserveATokens; + uint256 public withdrawReturnBps = 10_000; 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, @@ -26,14 +32,24 @@ contract AaveV3PoolMock { 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, amount); + IERC20(asset).safeTransfer(to, returnedAmount); - return amount; + return returnedAmount; } - function getReserveAToken(address asset) external view returns (address) { - return reserveATokens[asset]; + function supply( + address asset, + uint256 amount, + address onBehalfOf, + uint16 /* referralCode */ + ) external { + 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( @@ -43,4 +59,8 @@ contract AaveV3PoolMock { ) external { IERC20(token).safeTransfer(to, amount); } + + function getReserveAToken(address asset) external view returns (address) { + return reserveATokens[asset]; + } } diff --git a/contracts/mocks/MorphoVaultMock.sol b/contracts/mocks/MorphoVaultMock.sol index 02dd1f5b..6d933246 100644 --- a/contracts/mocks/MorphoVaultMock.sol +++ b/contracts/mocks/MorphoVaultMock.sol @@ -39,6 +39,30 @@ contract MorphoVaultMock is ERC20 { return underlyingAsset; } + function deposit(uint256 assets, address receiver) + external + returns (uint256 shares) + { + 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, 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/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/helpers/contracts.ts b/helpers/contracts.ts index 7d3abeb9..9517259c 100644 --- a/helpers/contracts.ts +++ b/helpers/contracts.ts @@ -4,6 +4,8 @@ import { VaultType } from '../config/constants/addresses'; export type TokenContractNames = { dv: string; dvUstb: string; + dvAave: string; + dvMorpho: string; rv: string; rvSwapper: string; rvMToken: string; @@ -37,6 +39,8 @@ const vaultTypeToContractNameMap: Record = { redemptionVaultUstb: 'rvUstb', depositVault: 'dv', depositVaultUstb: 'dvUstb', + depositVaultAave: 'dvAave', + depositVaultMorpho: 'dvMorpho', redemptionVaultBuidl: 'rvBuidl', redemptionVaultAave: 'rvAave', redemptionVaultMorpho: 'rvMorpho', @@ -127,6 +131,8 @@ export const getCommonContractNames = (): CommonContractNames => { ac: 'MidasAccessControl', dv: 'DepositVault', dvUstb: 'DepositVaultWithUSTB', + dvAave: 'DepositVaultWithAave', + dvMorpho: 'DepositVaultWithMorpho', rv: 'RedemptionVault', rvSwapper: 'RedemptionVaultWithSwapper', rvMToken: 'RedemptionVaultWithMToken', @@ -161,6 +167,8 @@ export const getTokenContractNames = ( return { dv: `${tokenPrefix}${commonContractNames.dv}`, dvUstb: `${tokenPrefix}${commonContractNames.dvUstb}`, + dvAave: `${tokenPrefix}${commonContractNames.dvAave}`, + dvMorpho: `${tokenPrefix}${commonContractNames.dvMorpho}`, rv: `${tokenPrefix}${commonContractNames.rv}`, rvSwapper: `${tokenPrefix}${commonContractNames.rvSwapper}`, rvMToken: `${tokenPrefix}${commonContractNames.rvMToken}`, diff --git a/scripts/deploy/codegen/common/index.ts b/scripts/deploy/codegen/common/index.ts index 45c88e4d..5f13c8e1 100644 --- a/scripts/deploy/codegen/common/index.ts +++ b/scripts/deploy/codegen/common/index.ts @@ -16,7 +16,9 @@ import { getCustomAggregatorContractFromTemplate, getCustomAggregatorGrowthContractFromTemplate, getDataFeedContractFromTemplate, + getDvAaveContractFromTemplate, getDvContractFromTemplate, + getDvMorphoContractFromTemplate, getRvAaveContractFromTemplate, getRvContractFromTemplate, getRvMorphoContractFromTemplate, @@ -65,6 +67,8 @@ const generatorPerContract: Partial< > = { token: getTokenContractFromTemplate, dv: getDvContractFromTemplate, + dvAave: getDvAaveContractFromTemplate, + dvMorpho: getDvMorphoContractFromTemplate, rv: getRvContractFromTemplate, rvSwapper: getRvSwapperContractFromTemplate, rvMToken: getRvMTokenContractFromTemplate, 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..34f81bb4 --- /dev/null +++ b/scripts/deploy/codegen/common/templates/dv-aave.template.ts @@ -0,0 +1,46 @@ +import { MTokenName } from '../../../../../config'; +import { importWithoutCache } from '../../../../../helpers/utils'; + +export const getDvAaveContractFromTemplate = async (mToken: MTokenName) => { + 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}; + } + }`, + }; +}; 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..08eda6dc --- /dev/null +++ b/scripts/deploy/codegen/common/templates/dv-morpho.template.ts @@ -0,0 +1,46 @@ +import { MTokenName } from '../../../../../config'; +import { importWithoutCache } from '../../../../../helpers/utils'; + +export const getDvMorphoContractFromTemplate = async (mToken: MTokenName) => { + 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}; + } + }`, + }; +}; diff --git a/scripts/deploy/codegen/common/templates/index.ts b/scripts/deploy/codegen/common/templates/index.ts index e5e69a6e..0444cd2c 100644 --- a/scripts/deploy/codegen/common/templates/index.ts +++ b/scripts/deploy/codegen/common/templates/index.ts @@ -1,6 +1,8 @@ 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 './mtoken.template'; export * from './rv-swapper.template'; export * from './rv-mtoken.template'; diff --git a/scripts/deploy/codegen/common/ui/deployment-config.ts b/scripts/deploy/codegen/common/ui/deployment-config.ts index 02cdf0de..71099d57 100644 --- a/scripts/deploy/codegen/common/ui/deployment-config.ts +++ b/scripts/deploy/codegen/common/ui/deployment-config.ts @@ -23,6 +23,8 @@ import { DeploymentConfig, PostDeployConfig } from '../../../common/types'; export const configsPerNetworkConfig = { dv: getDvConfigFromUser, + dvAave: getDvAaveConfigFromUser, + dvMorpho: getDvMorphoConfigFromUser, rv: getRvConfigFromUser, rvSwapper: getRvSwapperConfigFromUser, rvMToken: getRvMTokenConfigFromUser, @@ -128,6 +130,158 @@ 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), + aavePool: () => + text({ message: 'Aave V3 Pool 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: '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 getRvConfigFromUser( hre: HardhatRuntimeEnvironment, extendGroup?: PromptGroup, @@ -462,7 +616,14 @@ export async function getDeploymentConfigFromUser( multiselect< keyof Pick< DeploymentConfig['networkConfigs'][number], - 'rv' | 'rvSwapper' | 'rvMToken' | 'rvAave' | 'rvMorpho' | 'dv' + | 'rv' + | 'rvSwapper' + | 'rvMToken' + | 'rvAave' + | 'rvMorpho' + | 'dv' + | 'dvAave' + | 'dvMorpho' > >({ message: @@ -473,6 +634,16 @@ 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: 'rv', label: 'Redemption Vault', @@ -604,7 +775,7 @@ const validateBase18 = (value: string) => { try { parseUnits(value, 18); return undefined; - } catch (_) { + } catch { return new Error('Invalid value'); } }; @@ -618,7 +789,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 881af9e9..92cf7a0a 100644 --- a/scripts/deploy/codegen/common/ui/deployment-contracts.ts +++ b/scripts/deploy/codegen/common/ui/deployment-contracts.ts @@ -108,6 +108,16 @@ export const getContractsToGenerateFromUser = async () => { 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: '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..a733a48d 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, + DepositVaultWithAave, + 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,20 @@ export type DeployDvUstbConfig = DeployDvConfigCommon & { type: 'USTB'; }; -export type DeployDvConfig = DeployDvRegularConfig | DeployDvUstbConfig; +export type DeployDvAaveConfig = DeployDvConfigCommon & { + type: 'AAVE'; + aavePool: string; +}; + +export type DeployDvMorphoConfig = DeployDvConfigCommon & { + type: 'MORPHO'; +}; + +export type DeployDvConfig = + | DeployDvRegularConfig + | DeployDvUstbConfig + | DeployDvAaveConfig + | DeployDvMorphoConfig; const isAddress = (value: string): value is `0x${string}` => { return value.startsWith('0x'); @@ -51,7 +68,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', ) => { const addresses = getCurrentAddresses(hre); const deployer = await getDeployer(hre); @@ -116,6 +133,8 @@ export const deployDepositVault = async ( } extraParams.push(ustbContract); + } else if (networkConfig.type === 'AAVE') { + extraParams.push(networkConfig.aavePool); } const params = [ @@ -142,12 +161,15 @@ export const deployDepositVault = async ( | Parameters | Parameters< DepositVaultWithUSTB['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)'] + > + | Parameters< + DepositVaultWithAave['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 === 'AAVE' + ? '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 9a0d8d70..249381f1 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/types.ts b/scripts/deploy/common/types.ts index 5c7eafb3..055d932f 100644 --- a/scripts/deploy/common/types.ts +++ b/scripts/deploy/common/types.ts @@ -8,7 +8,12 @@ import { DeployDataFeedConfig, SetRoundDataConfig, } from './data-feed'; -import { DeployDvRegularConfig, DeployDvUstbConfig } from './dv'; +import { + DeployDvAaveConfig, + DeployDvMorphoConfig, + DeployDvRegularConfig, + DeployDvUstbConfig, +} from './dv'; import { GrantAllTokenRolesConfig, GrantDefaultAdminRoleToAcAdminConfig, @@ -96,6 +101,8 @@ export type DeploymentConfig = { { dv?: DeployDvRegularConfig; dvUstb?: DeployDvUstbConfig; + dvAave?: DeployDvAaveConfig; + dvMorpho?: DeployDvMorphoConfig; rv?: DeployRvRegularConfig; rvBuidl?: DeployRvBuidlConfig; rvSwapper?: DeployRvSwapperConfig; @@ -138,10 +145,4 @@ export type NetworkDeploymentConfig = Record< } >; -export type RvType = - | 'redemptionVault' - | 'redemptionVaultBuidl' - | 'redemptionVaultSwapper' - | 'redemptionVaultMToken'; - 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..f735ecb9 --- /dev/null +++ b/scripts/deploy/common/vault-resolver.ts @@ -0,0 +1,44 @@ +import { + DepositVaultType, + RedemptionVaultType, + TokenAddresses, +} from '../../../config/constants/addresses'; + +export const defaultDepositVaultPriority: DepositVaultType[] = [ + 'depositVault', + 'depositVaultUstb', + 'depositVaultAave', + 'depositVaultMorpho', +]; + +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_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/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/test/common/deposit-vault-aave.helpers.ts b/test/common/deposit-vault-aave.helpers.ts new file mode 100644 index 00000000..e95efe1d --- /dev/null +++ b/test/common/deposit-vault-aave.helpers.ts @@ -0,0 +1,169 @@ +import { expect } from 'chai'; +import { BigNumberish } from 'ethers'; + +import { + AccountOrContract, + OptionalCommonParams, + balanceOfBase18, + getAccount, +} from './common.helpers'; +import { depositInstantTest } 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, + newPool: string, + opt?: OptionalCommonParams, +) => { + if (opt?.revertMessage) { + await expect( + depositVaultWithAave.connect(opt?.from ?? owner).setAavePool(newPool), + ).revertedWith(opt?.revertMessage); + return; + } + + await expect( + depositVaultWithAave.connect(opt?.from ?? owner).setAavePool(newPool), + ).to.emit( + depositVaultWithAave, + depositVaultWithAave.interface.events['SetAavePool(address,address)'].name, + ).to.not.reverted; + + const poolAfter = await depositVaultWithAave.aavePool(); + expect(poolAfter).eq(newPool); +}; + +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); + } +}; diff --git a/test/common/deposit-vault-morpho.helpers.ts b/test/common/deposit-vault-morpho.helpers.ts new file mode 100644 index 00000000..e3a997b9 --- /dev/null +++ b/test/common/deposit-vault-morpho.helpers.ts @@ -0,0 +1,204 @@ +import { expect } from 'chai'; +import { BigNumberish } from 'ethers'; + +import { + AccountOrContract, + OptionalCommonParams, + balanceOfBase18, + getAccount, +} from './common.helpers'; +import { depositInstantTest } 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); + } +}; diff --git a/test/common/deposit-vault.helpers.ts b/test/common/deposit-vault.helpers.ts index de0dbc49..35a8f921 100644 --- a/test/common/deposit-vault.helpers.ts +++ b/test/common/deposit-vault.helpers.ts @@ -15,6 +15,8 @@ import { DataFeedTest__factory, DepositVault, DepositVaultTest, + DepositVaultWithAaveTest, + DepositVaultWithMorphoTest, DepositVaultWithUSTBTest, ERC20, ERC20__factory, @@ -22,7 +24,12 @@ import { } from '../../typechain-types'; type CommonParamsDeposit = { - depositVault: DepositVault | DepositVaultTest | DepositVaultWithUSTBTest; + depositVault: + | DepositVault + | DepositVaultTest + | DepositVaultWithAaveTest + | DepositVaultWithMorphoTest + | DepositVaultWithUSTBTest; mTBILL: MToken; } & Pick< Awaited>, @@ -714,7 +721,12 @@ export const setMaxSupplyCapTest = async ( export const getFeePercent = async ( sender: string, token: string, - depositVault: DepositVault | DepositVaultTest | DepositVaultWithUSTBTest, + depositVault: + | DepositVault + | DepositVaultTest + | DepositVaultWithAaveTest + | DepositVaultWithMorphoTest + | DepositVaultWithUSTBTest, isInstant: boolean, ) => { const tokenConfig = await depositVault.tokensConfig(token); @@ -733,7 +745,12 @@ export const getFeePercent = async ( export const calcExpectedMintAmount = async ( sender: SignerWithAddress, token: string, - depositVault: DepositVault | DepositVaultTest | DepositVaultWithUSTBTest, + depositVault: + | DepositVault + | DepositVaultTest + | DepositVaultWithAaveTest + | DepositVaultWithMorphoTest + | DepositVaultWithUSTBTest, mTokenRate: BigNumber, amountIn: BigNumber, isInstant: boolean, diff --git a/test/common/fixtures.ts b/test/common/fixtures.ts index 188a5956..50cbda6a 100644 --- a/test/common/fixtures.ts +++ b/test/common/fixtures.ts @@ -44,6 +44,8 @@ import { AaveV3PoolMock__factory, MorphoVaultMock__factory, CustomAggregatorV3CompatibleFeedDiscountedTester__factory, + DepositVaultWithAaveTest__factory, + DepositVaultWithMorphoTest__factory, DepositVaultWithUSTBTest__factory, USTBMock__factory, CustomAggregatorV3CompatibleFeedGrowthTester__factory, @@ -464,6 +466,73 @@ export const defaultDeploy = async () => { redemptionVaultWithMorpho.address, ); + /* Deposit Vault With Aave */ + + const depositVaultWithAave = await new DepositVaultWithAaveTest__factory( + owner, + ).deploy(); + + await depositVaultWithAave[ + '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, + 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, + ); + /* Redemption Vault With Swapper */ const redemptionVaultWithSwapper = @@ -760,6 +829,8 @@ export const defaultDeploy = async () => { ustbRedemption, customRecipient, depositVaultWithUSTB, + depositVaultWithAave, + depositVaultWithMorpho, dataFeedGrowth, compositeDataFeed, }; diff --git a/test/common/manageable-vault.helpers.ts b/test/common/manageable-vault.helpers.ts index f8d58f09..839c5e25 100644 --- a/test/common/manageable-vault.helpers.ts +++ b/test/common/manageable-vault.helpers.ts @@ -8,6 +8,8 @@ import { defaultDeploy } from './fixtures'; import { DepositVault, + DepositVaultWithAave, + DepositVaultWithMorpho, DepositVaultWithUSTB, ERC20, ERC20__factory, @@ -24,6 +26,8 @@ import { type CommonParamsChangePaymentToken = { vault: | DepositVault + | DepositVaultWithAave + | DepositVaultWithMorpho | DepositVaultWithUSTB | RedemptionVault | RedemptionVaultWIthBUIDL @@ -35,7 +39,11 @@ type CommonParamsChangePaymentToken = { owner: SignerWithAddress; }; type CommonParams = { - depositVault: DepositVault | DepositVaultWithUSTB; + depositVault: + | DepositVault + | DepositVaultWithAave + | DepositVaultWithMorpho + | DepositVaultWithUSTB; } & Pick>, 'owner'>; export const setInstantFeeTest = async ( diff --git a/test/integration/DepositVaultWithAave.test.ts b/test/integration/DepositVaultWithAave.test.ts new file mode 100644 index 00000000..2a65c40f --- /dev/null +++ b/test/integration/DepositVaultWithAave.test.ts @@ -0,0 +1,120 @@ +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, + usdc, + aUsdc, + mToken: mTBILL, + 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, + mTBILL, + depositVaultWithAave, + usdc, + aUsdc, + usdcWhale, + } = await loadFixture(aaveDepositFixture); + + const result = await depositInstantAave({ + depositVault: depositVaultWithAave, + user: testUser, + usdc, + aUsdc, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + 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, + usdc, + aUsdc, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(result1); + + await depositVaultWithAave + .connect(vaultAdmin) + .setAaveDepositsEnabled(false); + + const result2 = await depositInstantAave({ + depositVault: depositVaultWithAave, + user: testUser, + usdc, + aUsdc, + mToken: mTBILL, + 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..a812d113 --- /dev/null +++ b/test/integration/DepositVaultWithMorpho.test.ts @@ -0,0 +1,164 @@ +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { constants } from 'ethers'; +import { parseUnits } from 'ethers/lib/utils'; + +import { morphoDepositFixture } from './fixtures/morpho.fixture'; +import { + assertAutoInvestDisabled, + assertAutoInvestEnabled, + depositInstantMorpho, +} from './helpers/deposit.helpers'; + +import { approveBase18 } from '../common/common.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, + usdc, + morphoVault, + mToken: mTBILL, + 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, + mTBILL, + depositVaultWithMorpho, + usdc, + morphoVault, + usdcWhale, + } = await loadFixture(morphoDepositFixture); + + const result = await depositInstantMorpho({ + depositVault: depositVaultWithMorpho, + user: testUser, + usdc, + morphoVault, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + 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, + usdc, + morphoVault, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestEnabled(result1); + + await depositVaultWithMorpho + .connect(vaultAdmin) + .setMorphoDepositsEnabled(false); + + const result2 = await depositInstantMorpho({ + depositVault: depositVaultWithMorpho, + user: testUser, + usdc, + morphoVault, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + usdcWhale, + amountUsd: 100, + }); + + assertAutoInvestDisabled(result2); + }); + }); + + describe('Error case: No vault configured for token', function () { + it('should revert with DVM: no vault for token', async function () { + const { vaultAdmin, testUser, depositVaultWithMorpho, usdc, usdcWhale } = + await loadFixture(morphoDepositFixture); + + await depositVaultWithMorpho + .connect(vaultAdmin) + .setMorphoDepositsEnabled(true); + + await depositVaultWithMorpho + .connect(vaultAdmin) + .removeMorphoVault(usdc.address); + + const usdcAmountUsd = 100; + + await usdc + .connect(usdcWhale) + .transfer(testUser.address, parseUnits('100', 6)); + + await approveBase18( + testUser, + usdc, + depositVaultWithMorpho, + usdcAmountUsd, + ); + + await expect( + depositVaultWithMorpho + .connect(testUser) + ['depositInstant(address,uint256,uint256,bytes32)']( + usdc.address, + parseUnits(String(usdcAmountUsd)), + constants.Zero, + constants.HashZero, + ), + ).to.be.revertedWith('DVM: no vault for token'); + }); + }); +}); diff --git a/test/integration/DepositVaultWithUSTB.test.ts b/test/integration/DepositVaultWithUSTB.test.ts index 1a35002f..cfa043e1 100644 --- a/test/integration/DepositVaultWithUSTB.test.ts +++ b/test/integration/DepositVaultWithUSTB.test.ts @@ -2,7 +2,7 @@ import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; 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; @@ -72,7 +72,7 @@ describe('DepositVaultWithUSTB - Mainnet Fork Integration Tests', function () { ustbTokenOwner, usdcWhale, mTokenToUsdDataFeed, - } = await loadFixture(ustbRedemptionVaultFixture); + } = await loadFixture(ustbDepositFixture); const usdcAmount = 100; @@ -128,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 index 874210fe..6bfa4239 100644 --- a/test/integration/RedemptionVaultWithAave.test.ts +++ b/test/integration/RedemptionVaultWithAave.test.ts @@ -2,8 +2,9 @@ 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 { aaveRedemptionVaultFixture } from './fixtures/aave.fixture'; +import { aaveRedemptionFixture } from './fixtures/aave.fixture'; import { mintToken, approveBase18 } from '../common/common.helpers'; import { redeemInstantWithAaveTest } from '../common/redemption-vault-aave.helpers'; @@ -22,7 +23,7 @@ describe('RedemptionVaultWithAave - Mainnet Fork Integration Tests', function () aUsdc, usdcWhale, mTokenToUsdDataFeed, - } = await loadFixture(aaveRedemptionVaultFixture); + } = await loadFixture(aaveRedemptionFixture); const mTBILLAmount = 1000; @@ -87,7 +88,7 @@ describe('RedemptionVaultWithAave - Mainnet Fork Integration Tests', function () aUsdc, aUsdcWhale, mTokenToUsdDataFeed, - } = await loadFixture(aaveRedemptionVaultFixture); + } = await loadFixture(aaveRedemptionFixture); const mTBILLAmount = 1000; @@ -152,7 +153,7 @@ describe('RedemptionVaultWithAave - Mainnet Fork Integration Tests', function () usdcWhale, aUsdcWhale, mTokenToUsdDataFeed, - } = await loadFixture(aaveRedemptionVaultFixture); + } = await loadFixture(aaveRedemptionFixture); const mTBILLAmount = 1000; const partialUSDC = parseUnits('500', 6); // 500 USDC in vault @@ -221,7 +222,7 @@ describe('RedemptionVaultWithAave - Mainnet Fork Integration Tests', function () usdc, aUsdc, mTokenToUsdDataFeed, - } = await loadFixture(aaveRedemptionVaultFixture); + } = await loadFixture(aaveRedemptionFixture); const mTBILLAmount = 100000; // 100k mTBILL - vault has no USDC and no aTokens @@ -262,12 +263,10 @@ describe('RedemptionVaultWithAave - Mainnet Fork Integration Tests', function () redemptionVaultWithAave, aUsdc, mTokenToUsdDataFeed, - } = await loadFixture(aaveRedemptionVaultFixture); + } = await loadFixture(aaveRedemptionFixture); // Deploy a fake token that isn't registered in Aave - const fakeTokenFactory = await ( - await import('hardhat') - ).ethers.getContractFactory('ERC20Mock'); + const fakeTokenFactory = await ethers.getContractFactory('ERC20Mock'); const fakeToken = await fakeTokenFactory.deploy(6); await fakeToken.deployed(); diff --git a/test/integration/RedemptionVaultWithMorpho.test.ts b/test/integration/RedemptionVaultWithMorpho.test.ts index 5039055a..5a5782ff 100644 --- a/test/integration/RedemptionVaultWithMorpho.test.ts +++ b/test/integration/RedemptionVaultWithMorpho.test.ts @@ -2,8 +2,9 @@ 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 { morphoRedemptionVaultFixture } from './fixtures/morpho.fixture'; +import { morphoRedemptionFixture } from './fixtures/morpho.fixture'; import { mintToken, approveBase18 } from '../common/common.helpers'; import { redeemInstantWithMorphoTest } from '../common/redemption-vault-morpho.helpers'; @@ -22,7 +23,7 @@ describe('RedemptionVaultWithMorpho - Mainnet Fork Integration Tests', function morphoVault, usdcWhale, mTokenToUsdDataFeed, - } = await loadFixture(morphoRedemptionVaultFixture); + } = await loadFixture(morphoRedemptionFixture); const mTBILLAmount = 1000; @@ -87,7 +88,7 @@ describe('RedemptionVaultWithMorpho - Mainnet Fork Integration Tests', function morphoVault, morphoShareWhale, mTokenToUsdDataFeed, - } = await loadFixture(morphoRedemptionVaultFixture); + } = await loadFixture(morphoRedemptionFixture); const mTBILLAmount = 1000; @@ -155,7 +156,7 @@ describe('RedemptionVaultWithMorpho - Mainnet Fork Integration Tests', function usdcWhale, morphoShareWhale, mTokenToUsdDataFeed, - } = await loadFixture(morphoRedemptionVaultFixture); + } = await loadFixture(morphoRedemptionFixture); const mTBILLAmount = 1000; const partialUSDC = parseUnits('500', 6); // 500 USDC in vault @@ -221,7 +222,7 @@ describe('RedemptionVaultWithMorpho - Mainnet Fork Integration Tests', function usdc, morphoVault, mTokenToUsdDataFeed, - } = await loadFixture(morphoRedemptionVaultFixture); + } = await loadFixture(morphoRedemptionFixture); const mTBILLAmount = 100000; // 100k mTBILL - vault has no USDC and no shares @@ -262,12 +263,10 @@ describe('RedemptionVaultWithMorpho - Mainnet Fork Integration Tests', function redemptionVaultWithMorpho, morphoVault, mTokenToUsdDataFeed, - } = await loadFixture(morphoRedemptionVaultFixture); + } = await loadFixture(morphoRedemptionFixture); // Deploy a fake token that isn't the Morpho vault's underlying asset - const fakeTokenFactory = await ( - await import('hardhat') - ).ethers.getContractFactory('ERC20Mock'); + const fakeTokenFactory = await ethers.getContractFactory('ERC20Mock'); const fakeToken = await fakeTokenFactory.deploy(6); await fakeToken.deployed(); 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 index d1147fb7..c23bad7a 100644 --- a/test/integration/fixtures/aave.fixture.ts +++ b/test/integration/fixtures/aave.fixture.ts @@ -6,6 +6,7 @@ import { getAllRoles } from '../../../helpers/roles'; import { MidasAccessControlTest, MTBILLTest, + DepositVaultWithAaveTest, RedemptionVaultWithAaveTest, DataFeedTest, AggregatorV3Mock, @@ -14,9 +15,9 @@ import { deployProxyContract } from '../../common/deploy.helpers'; import { impersonateAndFundAccount, resetFork } from '../helpers/fork.helpers'; import { MAINNET_ADDRESSES } from '../helpers/mainnet-addresses'; -export const FORK_BLOCK_NUMBER = 24441000; +const FORK_BLOCK_NUMBER = 24441000; -export async function aaveRedemptionVaultFixture() { +async function setupAaveBase() { await resetFork(rpcUrls.main, FORK_BLOCK_NUMBER); const [ @@ -43,6 +44,7 @@ export async function aaveRedemptionVaultFixture() { allRoles.tokenRoles.mTBILL.minter, allRoles.tokenRoles.mTBILL.burner, allRoles.tokenRoles.mTBILL.pauser, + allRoles.tokenRoles.mTBILL.depositVaultAdmin, allRoles.tokenRoles.mTBILL.redemptionVaultAdmin, allRoles.common.greenlistedOperator, ]; @@ -51,6 +53,11 @@ export async function aaveRedemptionVaultFixture() { await accessControl.grantRole(role, owner.address); } + await accessControl.grantRole( + allRoles.tokenRoles.mTBILL.depositVaultAdmin, + vaultAdmin.address, + ); + await accessControl.grantRole( allRoles.tokenRoles.mTBILL.redemptionVaultAdmin, vaultAdmin.address, @@ -91,6 +98,104 @@ export async function aaveRedemptionVaultFixture() { ], ); + // Get mainnet contracts + const usdc = await ethers.getContractAt( + 'IERC20Metadata', + MAINNET_ADDRESSES.USDC, + ); + const aUsdc = await ethers.getContractAt('IERC20', MAINNET_ADDRESSES.AUSDC); + 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, + ); + + return { + accessControl, + mTBILL, + dataFeed: usdcDataFeed, + mTokenToUsdDataFeed: mtbillDataFeed, + mockedAggregator: usdcAggregator, + mockedAggregatorMToken: mtbillAggregator, + usdc, + aUsdc, + aavePool, + owner, + tokensReceiver, + feeReceiver, + requestRedeemer, + vaultAdmin, + testUser, + usdcWhale, + aUsdcWhale, + 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, + MAINNET_ADDRESSES.AAVE_V3_POOL, + ], + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)', + ); + + // 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 + ); + + return { + ...base, + depositVaultWithAave, + }; +} + +export async function aaveRedemptionFixture() { + const base = await setupAaveBase(); + const { accessControl, mTBILL, owner, roles, usdc } = base; + // Deploy RedemptionVaultWithAave const redemptionVaultWithAave = await deployProxyContract( @@ -99,11 +204,11 @@ export async function aaveRedemptionVaultFixture() { 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% @@ -117,69 +222,37 @@ export async function aaveRedemptionVaultFixture() { fiatAdditionalFee: 100, fiatFlatFee: parseUnits('10', 18), }, - requestRedeemer.address, + base.requestRedeemer.address, MAINNET_ADDRESSES.AAVE_V3_POOL, ], 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)', ); - // Grant BURN_ROLE to vault + // Grant BURN_ROLE to redemption vault await accessControl.grantRole( - allRoles.tokenRoles.mTBILL.burner, + roles.tokenRoles.mTBILL.burner, redemptionVaultWithAave.address, ); - // Get mainnet contracts - const usdc = await ethers.getContractAt( - 'IERC20Metadata', - MAINNET_ADDRESSES.USDC, - ); - const aUsdc = await ethers.getContractAt('IERC20', MAINNET_ADDRESSES.AUSDC); - 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, - ); - // Setup payment token await redemptionVaultWithAave.connect(owner).addPaymentToken( usdc.address, - usdcDataFeed.address, + base.dataFeed.address, 0, // no fee ethers.constants.MaxUint256, true, // is stable ); return { - accessControl, - mTBILL, - dataFeed: usdcDataFeed, - mTokenToUsdDataFeed: mtbillDataFeed, - mockedAggregator: usdcAggregator, - mockedAggregatorMToken: mtbillAggregator, + ...base, redemptionVaultWithAave, - usdc, - aUsdc, - aavePool, - owner, - tokensReceiver, - feeReceiver, - requestRedeemer, - vaultAdmin, - testUser, - usdcWhale, - aUsdcWhale, - roles: allRoles, }; } -export type AaveDeployedContracts = Awaited< - ReturnType +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 index 054c80c2..b6216a66 100644 --- a/test/integration/fixtures/morpho.fixture.ts +++ b/test/integration/fixtures/morpho.fixture.ts @@ -6,6 +6,7 @@ import { getAllRoles } from '../../../helpers/roles'; import { MidasAccessControlTest, MTBILLTest, + DepositVaultWithMorphoTest, RedemptionVaultWithMorphoTest, DataFeedTest, AggregatorV3Mock, @@ -15,9 +16,9 @@ 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 -export const FORK_BLOCK_NUMBER = 24441000; +const FORK_BLOCK_NUMBER = 24441000; -export async function morphoRedemptionVaultFixture() { +async function setupMorphoBase() { await resetFork(rpcUrls.main, FORK_BLOCK_NUMBER); const [ @@ -44,6 +45,7 @@ export async function morphoRedemptionVaultFixture() { allRoles.tokenRoles.mTBILL.minter, allRoles.tokenRoles.mTBILL.burner, allRoles.tokenRoles.mTBILL.pauser, + allRoles.tokenRoles.mTBILL.depositVaultAdmin, allRoles.tokenRoles.mTBILL.redemptionVaultAdmin, allRoles.common.greenlistedOperator, ]; @@ -52,6 +54,11 @@ export async function morphoRedemptionVaultFixture() { await accessControl.grantRole(role, owner.address); } + await accessControl.grantRole( + allRoles.tokenRoles.mTBILL.depositVaultAdmin, + vaultAdmin.address, + ); + await accessControl.grantRole( allRoles.tokenRoles.mTBILL.redemptionVaultAdmin, vaultAdmin.address, @@ -92,6 +99,108 @@ export async function morphoRedemptionVaultFixture() { ], ); + // Get mainnet contracts + const usdc = await ethers.getContractAt( + 'IERC20Metadata', + MAINNET_ADDRESSES.USDC, + ); + const morphoVault = await ethers.getContractAt( + 'IERC20', + MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_VAULT, + ); + + // Impersonate whales + const usdcWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.USDC_WHALE_BINANCE, + ); + const morphoShareWhale = await impersonateAndFundAccount( + MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_WHALE, + ); + + return { + accessControl, + mTBILL, + dataFeed: usdcDataFeed, + mTokenToUsdDataFeed: mtbillDataFeed, + mockedAggregator: usdcAggregator, + mockedAggregatorMToken: mtbillAggregator, + usdc, + morphoVault, + owner, + tokensReceiver, + feeReceiver, + requestRedeemer, + vaultAdmin, + testUser, + usdcWhale, + morphoShareWhale, + 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, + ); + + return { + ...base, + depositVaultWithMorpho, + }; +} + +export async function morphoRedemptionFixture() { + const base = await setupMorphoBase(); + const { accessControl, mTBILL, owner, roles, usdc } = base; + // Deploy RedemptionVaultWithMorpho const redemptionVaultWithMorpho = await deployProxyContract( @@ -100,11 +209,11 @@ export async function morphoRedemptionVaultFixture() { 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% @@ -118,67 +227,37 @@ export async function morphoRedemptionVaultFixture() { fiatAdditionalFee: 100, fiatFlatFee: parseUnits('10', 18), }, - requestRedeemer.address, + base.requestRedeemer.address, MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_VAULT, ], 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)', ); - // Grant BURN_ROLE to vault + // Grant BURN_ROLE to redemption vault await accessControl.grantRole( - allRoles.tokenRoles.mTBILL.burner, + roles.tokenRoles.mTBILL.burner, redemptionVaultWithMorpho.address, ); - // Get mainnet contracts - const usdc = await ethers.getContractAt( - 'IERC20Metadata', - MAINNET_ADDRESSES.USDC, - ); - const morphoVault = await ethers.getContractAt( - 'IERC20', - MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_VAULT, - ); - - // Impersonate whales - const usdcWhale = await impersonateAndFundAccount( - MAINNET_ADDRESSES.USDC_WHALE_BINANCE, - ); - const morphoShareWhale = await impersonateAndFundAccount( - MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_WHALE, - ); - // Setup payment token await redemptionVaultWithMorpho.connect(owner).addPaymentToken( usdc.address, - usdcDataFeed.address, + base.dataFeed.address, 0, // no fee ethers.constants.MaxUint256, true, // is stable ); return { - accessControl, - mTBILL, - dataFeed: usdcDataFeed, - mTokenToUsdDataFeed: mtbillDataFeed, - mockedAggregator: usdcAggregator, - mockedAggregatorMToken: mtbillAggregator, + ...base, redemptionVaultWithMorpho, - usdc, - morphoVault, - owner, - tokensReceiver, - feeReceiver, - requestRedeemer, - vaultAdmin, - testUser, - usdcWhale, - morphoShareWhale, - roles: allRoles, }; } -export type MorphoDeployedContracts = Awaited< - ReturnType +export type MorphoDepositContracts = Awaited< + ReturnType +>; + +export type MorphoRedemptionContracts = Awaited< + ReturnType >; diff --git a/test/integration/fixtures/ustb.fixture.ts b/test/integration/fixtures/ustb.fixture.ts index 67257f62..3ee3b96f 100644 --- a/test/integration/fixtures/ustb.fixture.ts +++ b/test/integration/fixtures/ustb.fixture.ts @@ -17,9 +17,9 @@ import { MAINNET_ADDRESSES } from '../helpers/mainnet-addresses'; import { setupUSTBAllowlist } from '../helpers/ustb-helpers'; // Fork block number where we know all fixture related addresses have funds -export const FORK_BLOCK_NUMBER = 22540000; +const FORK_BLOCK_NUMBER = 22540000; -export async function ustbRedemptionVaultFixture() { +async function setupUstbBase() { await resetFork(rpcUrls.main, FORK_BLOCK_NUMBER); const [ @@ -100,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', @@ -108,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, @@ -128,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( @@ -136,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% @@ -154,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..d1680c92 --- /dev/null +++ b/test/integration/helpers/deposit.helpers.ts @@ -0,0 +1,153 @@ +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, + IERC20, + IERC20Metadata, + IMToken, +} from '../../../typechain-types'; +import { approveBase18 } from '../../common/common.helpers'; + +type DepositInstantAaveParams = { + depositVault: DepositVaultWithAaveTest; + user: SignerWithAddress; + usdc: IERC20Metadata; + aUsdc: IERC20; + mToken: IMToken; + tokensReceiverAddress: string; + usdcWhale: SignerWithAddress; + amountUsd: number; +}; + +type DepositInstantMorphoParams = { + depositVault: DepositVaultWithMorphoTest; + user: SignerWithAddress; + usdc: IERC20Metadata; + morphoVault: IERC20; + mToken: IMToken; + tokensReceiverAddress: string; + usdcWhale: SignerWithAddress; + amountUsd: number; +}; + +type DepositResult = { + userMTokenReceived: BigNumber; + receiverReceiptTokenReceived: BigNumber; + receiverUsdcReceived: BigNumber; +}; + +export async function depositInstantAave({ + depositVault, + user, + usdc, + aUsdc, + mToken, + tokensReceiverAddress, + usdcWhale, + amountUsd, +}: DepositInstantAaveParams): 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 receiverAUsdcBefore = await aUsdc.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 receiverAUsdcAfter = await aUsdc.balanceOf(tokensReceiverAddress); + const userMTokenAfter = await mToken.balanceOf(user.address); + + return { + userMTokenReceived: userMTokenAfter.sub(userMTokenBefore), + receiverReceiptTokenReceived: receiverAUsdcAfter.sub(receiverAUsdcBefore), + receiverUsdcReceived: receiverUsdcAfter.sub(receiverUsdcBefore), + }; +} + +export async function depositInstantMorpho({ + depositVault, + user, + usdc, + morphoVault, + mToken, + tokensReceiverAddress, + usdcWhale, + amountUsd, +}: DepositInstantMorphoParams): 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 receiverSharesBefore = await morphoVault.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 receiverSharesAfter = await morphoVault.balanceOf( + tokensReceiverAddress, + ); + const userMTokenAfter = await mToken.balanceOf(user.address); + + return { + userMTokenReceived: userMTokenAfter.sub(userMTokenBefore), + receiverReceiptTokenReceived: receiverSharesAfter.sub(receiverSharesBefore), + receiverUsdcReceived: receiverUsdcAfter.sub(receiverUsdcBefore), + }; +} + +export function assertAutoInvestEnabled(result: DepositResult) { + expect(result.receiverReceiptTokenReceived).to.be.gt( + 0, + 'tokensReceiver should have received receipt tokens', + ); + expect(result.receiverUsdcReceived).to.equal( + 0, + 'tokensReceiver raw USDC 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.receiverUsdcReceived).to.be.gt( + 0, + 'tokensReceiver should have received USDC', + ); + 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/unit/DepositVaultWithAave.test.ts b/test/unit/DepositVaultWithAave.test.ts new file mode 100644 index 00000000..f92f1dbb --- /dev/null +++ b/test/unit/DepositVaultWithAave.test.ts @@ -0,0 +1,1522 @@ +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 { acErrors, blackList, greenList } from '../common/ac.helpers'; +import { approveBase18, mintToken, pauseVault } from '../common/common.helpers'; +import { + depositInstantWithAaveTest, + setAaveDepositsEnabledTest, + setAavePoolTest, +} 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, + } = 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.aavePool()).eq(aavePoolMock.address); + expect(await depositVaultWithAave.aaveDepositsEnabled()).eq(false); + }); + + describe('setAavePool()', () => { + it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner, regularAccounts, aavePoolMock } = + await loadFixture(defaultDeploy); + + await setAavePoolTest( + { depositVaultWithAave, owner }, + aavePoolMock.address, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); + + it('should fail: zero address', async () => { + const { depositVaultWithAave, owner } = await loadFixture(defaultDeploy); + + await setAavePoolTest( + { depositVaultWithAave, owner }, + ethers.constants.AddressZero, + { + revertMessage: 'zero address', + }, + ); + }); + + it('call from address with DEPOSIT_VAULT_ADMIN_ROLE role', async () => { + const { depositVaultWithAave, owner, regularAccounts } = + await loadFixture(defaultDeploy); + + await setAavePoolTest( + { depositVaultWithAave, owner }, + regularAccounts[1].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('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('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('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 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('should fail: Aave deposit enabled with unconfigured reserve token', async () => { + const { + owner, + depositVaultWithAave, + stableCoins, + mTBILL, + mTokenToUsdDataFeed, + dataFeed, + aavePoolMock, + } = await loadFixture(defaultDeploy); + + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); + await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + + await mintToken(stableCoins.dai, owner, 100); + await approveBase18(owner, stableCoins.dai, depositVaultWithAave, 100); + await addPaymentTokenTest( + { vault: depositVaultWithAave, owner }, + stableCoins.dai, + dataFeed.address, + 0, + false, + ); + + await depositInstantWithAaveTest( + { + depositVaultWithAave, + owner, + mTBILL, + mTokenToUsdDataFeed, + aavePoolMock, + }, + stableCoins.dai, + 100, + { + revertMessage: 'AaveV3PoolMock: NoReserve', + }, + ); + }); + }); + + 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], + }, + ); + }); + }); + + 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/DepositVaultWithMorpho.test.ts b/test/unit/DepositVaultWithMorpho.test.ts new file mode 100644 index 00000000..db3003f8 --- /dev/null +++ b/test/unit/DepositVaultWithMorpho.test.ts @@ -0,0 +1,1643 @@ +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 { MorphoVaultMock__factory } from '../../typechain-types'; +import { acErrors, blackList, greenList } from '../common/ac.helpers'; +import { approveBase18, mintToken, pauseVault } from '../common/common.helpers'; +import { + depositInstantWithMorphoTest, + removeMorphoVaultTest, + 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, + setMinAmountTest, + setVariabilityToleranceTest, + withdrawTest, +} from '../common/manageable-vault.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('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('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('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: morphoDepositsEnabled but no vault for token', 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], + revertMessage: 'DVM: no vault for token', + }, + ); + }); + + 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], + }, + ); + }); + }); + + 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], + }, + ); + }); + }); + + 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 index 32ac4b1c..dd507850 100644 --- a/test/unit/RedemptionVaultWithAave.test.ts +++ b/test/unit/RedemptionVaultWithAave.test.ts @@ -829,6 +829,30 @@ describe('RedemptionVaultWithAave', function () { ), ).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: insufficient withdrawal amount'); + }); }); describe('redeemInstant()', () => { @@ -1611,6 +1635,43 @@ describe('RedemptionVaultWithAave', function () { ).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: insufficient withdrawal amount'); + }); + // ── Custom recipient tests ─────────────────────────────────────────── it('redeem 100 mTBILL (custom recipient overload)', async () => { From d78beb8721576b58a7dacb7df66f51c157efb2b3 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Tue, 3 Mar 2026 17:42:23 +0200 Subject: [PATCH 08/20] feat: add DepositVaultWithMToken contract --- config/constants/addresses.ts | 3 +- contracts/DepositVaultWithMToken.sol | 166 ++ .../testers/DepositVaultWithMTokenTest.sol | 74 + helpers/contracts.ts | 4 + scripts/deploy/codegen/common/index.ts | 2 + .../common/templates/dv-mtoken.template.ts | 46 + .../deploy/codegen/common/templates/index.ts | 1 + .../codegen/common/ui/deployment-config.ts | 88 + .../codegen/common/ui/deployment-contracts.ts | 5 + scripts/deploy/common/dv.ts | 20 +- scripts/deploy/common/types.ts | 2 + scripts/deploy/common/vault-resolver.ts | 1 + scripts/deploy/deploy_DVMToken.ts | 13 + test/common/deposit-vault-mtoken.helpers.ts | 182 ++ test/common/deposit-vault.helpers.ts | 2 + test/common/fixtures.ts | 39 + test/common/manageable-vault.helpers.ts | 3 + .../DepositVaultWithMToken.test.ts | 120 ++ .../RedemptionVaultWithMToken.test.ts | 258 +++ test/integration/fixtures/mtoken.fixture.ts | 403 +++++ test/integration/helpers/deposit.helpers.ts | 55 + test/unit/DepositVaultWithMToken.test.ts | 1600 +++++++++++++++++ test/unit/RedemptionVaultWithMToken.test.ts | 253 ++- 23 files changed, 3249 insertions(+), 91 deletions(-) create mode 100644 contracts/DepositVaultWithMToken.sol create mode 100644 contracts/testers/DepositVaultWithMTokenTest.sol create mode 100644 scripts/deploy/codegen/common/templates/dv-mtoken.template.ts create mode 100644 scripts/deploy/deploy_DVMToken.ts create mode 100644 test/common/deposit-vault-mtoken.helpers.ts create mode 100644 test/integration/DepositVaultWithMToken.test.ts create mode 100644 test/integration/RedemptionVaultWithMToken.test.ts create mode 100644 test/integration/fixtures/mtoken.fixture.ts create mode 100644 test/unit/DepositVaultWithMToken.test.ts diff --git a/config/constants/addresses.ts b/config/constants/addresses.ts index 6446916e..801716b0 100644 --- a/config/constants/addresses.ts +++ b/config/constants/addresses.ts @@ -16,7 +16,8 @@ export type DepositVaultType = | 'depositVault' | 'depositVaultUstb' | 'depositVaultAave' - | 'depositVaultMorpho'; + | 'depositVaultMorpho' + | 'depositVaultMToken'; export type LayerZeroTokenAddresses = { oft?: string; diff --git a/contracts/DepositVaultWithMToken.sol b/contracts/DepositVaultWithMToken.sol new file mode 100644 index 00000000..01dc7a90 --- /dev/null +++ b/contracts/DepositVaultWithMToken.sol @@ -0,0 +1,166 @@ +// 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; + + /** + * @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 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, false); + 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, false); + 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); + } + + /** + * @dev overrides original transfer to tokens receiver function + * in case of mToken deposits are disabled, it will act as the original transfer + * otherwise it will take payment tokens from user, deposit them into the target + * mToken DepositVault and forward received mTokens to tokens receiver + * @param tokenIn token address + * @param amountToken amount of tokens to transfer in base18 + * @param tokensDecimals decimals of tokens + */ + function _instantTransferTokensToTokensReceiver( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) internal override { + if (!mTokenDepositsEnabled) { + return + super._instantTransferTokensToTokensReceiver( + tokenIn, + amountToken, + tokensDecimals + ); + } + + 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)); + + 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); + } +} 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/helpers/contracts.ts b/helpers/contracts.ts index 9517259c..26e794d3 100644 --- a/helpers/contracts.ts +++ b/helpers/contracts.ts @@ -6,6 +6,7 @@ export type TokenContractNames = { dvUstb: string; dvAave: string; dvMorpho: string; + dvMToken: string; rv: string; rvSwapper: string; rvMToken: string; @@ -41,6 +42,7 @@ const vaultTypeToContractNameMap: Record = { depositVaultUstb: 'dvUstb', depositVaultAave: 'dvAave', depositVaultMorpho: 'dvMorpho', + depositVaultMToken: 'dvMToken', redemptionVaultBuidl: 'rvBuidl', redemptionVaultAave: 'rvAave', redemptionVaultMorpho: 'rvMorpho', @@ -133,6 +135,7 @@ export const getCommonContractNames = (): CommonContractNames => { dvUstb: 'DepositVaultWithUSTB', dvAave: 'DepositVaultWithAave', dvMorpho: 'DepositVaultWithMorpho', + dvMToken: 'DepositVaultWithMToken', rv: 'RedemptionVault', rvSwapper: 'RedemptionVaultWithSwapper', rvMToken: 'RedemptionVaultWithMToken', @@ -169,6 +172,7 @@ export const getTokenContractNames = ( 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}`, diff --git a/scripts/deploy/codegen/common/index.ts b/scripts/deploy/codegen/common/index.ts index 5f13c8e1..25c04edc 100644 --- a/scripts/deploy/codegen/common/index.ts +++ b/scripts/deploy/codegen/common/index.ts @@ -19,6 +19,7 @@ import { getDvAaveContractFromTemplate, getDvContractFromTemplate, getDvMorphoContractFromTemplate, + getDvMTokenContractFromTemplate, getRvAaveContractFromTemplate, getRvContractFromTemplate, getRvMorphoContractFromTemplate, @@ -69,6 +70,7 @@ const generatorPerContract: Partial< dv: getDvContractFromTemplate, dvAave: getDvAaveContractFromTemplate, dvMorpho: getDvMorphoContractFromTemplate, + dvMToken: getDvMTokenContractFromTemplate, rv: getRvContractFromTemplate, rvSwapper: getRvSwapperContractFromTemplate, rvMToken: getRvMTokenContractFromTemplate, 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..2ee6d57a --- /dev/null +++ b/scripts/deploy/codegen/common/templates/dv-mtoken.template.ts @@ -0,0 +1,46 @@ +import { MTokenName } from '../../../../../config'; +import { importWithoutCache } from '../../../../../helpers/utils'; + +export const getDvMTokenContractFromTemplate = async (mToken: MTokenName) => { + 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}; + } + }`, + }; +}; diff --git a/scripts/deploy/codegen/common/templates/index.ts b/scripts/deploy/codegen/common/templates/index.ts index 0444cd2c..46e31aa7 100644 --- a/scripts/deploy/codegen/common/templates/index.ts +++ b/scripts/deploy/codegen/common/templates/index.ts @@ -3,6 +3,7 @@ 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'; diff --git a/scripts/deploy/codegen/common/ui/deployment-config.ts b/scripts/deploy/codegen/common/ui/deployment-config.ts index 71099d57..ff27070b 100644 --- a/scripts/deploy/codegen/common/ui/deployment-config.ts +++ b/scripts/deploy/codegen/common/ui/deployment-config.ts @@ -25,6 +25,7 @@ export const configsPerNetworkConfig = { dv: getDvConfigFromUser, dvAave: getDvAaveConfigFromUser, dvMorpho: getDvMorphoConfigFromUser, + dvMToken: getDvMTokenConfigFromUser, rv: getRvConfigFromUser, rvSwapper: getRvSwapperConfigFromUser, rvMToken: getRvMTokenConfigFromUser, @@ -282,6 +283,87 @@ async function getDvMorphoConfigFromUser(hre: HardhatRuntimeEnvironment) { }; } +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, @@ -624,6 +706,7 @@ export async function getDeploymentConfigFromUser( | 'dv' | 'dvAave' | 'dvMorpho' + | 'dvMToken' > >({ message: @@ -644,6 +727,11 @@ export async function getDeploymentConfigFromUser( 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', diff --git a/scripts/deploy/codegen/common/ui/deployment-contracts.ts b/scripts/deploy/codegen/common/ui/deployment-contracts.ts index 92cf7a0a..9be25033 100644 --- a/scripts/deploy/codegen/common/ui/deployment-contracts.ts +++ b/scripts/deploy/codegen/common/ui/deployment-contracts.ts @@ -118,6 +118,11 @@ export const getContractsToGenerateFromUser = async () => { 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 a733a48d..1cb9f48c 100644 --- a/scripts/deploy/common/dv.ts +++ b/scripts/deploy/common/dv.ts @@ -14,6 +14,7 @@ import { getTokenContractNames } from '../../../helpers/contracts'; import { DepositVault, DepositVaultWithAave, + DepositVaultWithMToken, DepositVaultWithUSTB, } from '../../../typechain-types'; @@ -55,11 +56,17 @@ export type DeployDvMorphoConfig = DeployDvConfigCommon & { type: 'MORPHO'; }; +export type DeployDvMTokenConfig = DeployDvConfigCommon & { + type: 'MTOKEN'; + mTokenDepositVault: string; +}; + export type DeployDvConfig = | DeployDvRegularConfig | DeployDvUstbConfig | DeployDvAaveConfig - | DeployDvMorphoConfig; + | DeployDvMorphoConfig + | DeployDvMTokenConfig; const isAddress = (value: string): value is `0x${string}` => { return value.startsWith('0x'); @@ -68,7 +75,7 @@ const isAddress = (value: string): value is `0x${string}` => { export const deployDepositVault = async ( hre: HardhatRuntimeEnvironment, token: MTokenName, - type: 'dv' | 'dvUstb' | 'dvAave' | 'dvMorpho', + type: 'dv' | 'dvUstb' | 'dvAave' | 'dvMorpho' | 'dvMToken', ) => { const addresses = getCurrentAddresses(hre); const deployer = await getDeployer(hre); @@ -135,6 +142,8 @@ export const deployDepositVault = async ( extraParams.push(ustbContract); } else if (networkConfig.type === 'AAVE') { extraParams.push(networkConfig.aavePool); + } else if (networkConfig.type === 'MTOKEN') { + extraParams.push(networkConfig.mTokenDepositVault); } const params = [ @@ -164,11 +173,16 @@ export const deployDepositVault = async ( > | Parameters< DepositVaultWithAave['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' || networkConfig.type === 'AAVE' + networkConfig.type === 'USTB' || + networkConfig.type === 'AAVE' || + networkConfig.type === 'MTOKEN' ? 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)' : 'initialize', }); diff --git a/scripts/deploy/common/types.ts b/scripts/deploy/common/types.ts index 055d932f..6424b0f4 100644 --- a/scripts/deploy/common/types.ts +++ b/scripts/deploy/common/types.ts @@ -11,6 +11,7 @@ import { import { DeployDvAaveConfig, DeployDvMorphoConfig, + DeployDvMTokenConfig, DeployDvRegularConfig, DeployDvUstbConfig, } from './dv'; @@ -103,6 +104,7 @@ export type DeploymentConfig = { dvUstb?: DeployDvUstbConfig; dvAave?: DeployDvAaveConfig; dvMorpho?: DeployDvMorphoConfig; + dvMToken?: DeployDvMTokenConfig; rv?: DeployRvRegularConfig; rvBuidl?: DeployRvBuidlConfig; rvSwapper?: DeployRvSwapperConfig; diff --git a/scripts/deploy/common/vault-resolver.ts b/scripts/deploy/common/vault-resolver.ts index f735ecb9..305e7bc9 100644 --- a/scripts/deploy/common/vault-resolver.ts +++ b/scripts/deploy/common/vault-resolver.ts @@ -9,6 +9,7 @@ export const defaultDepositVaultPriority: DepositVaultType[] = [ 'depositVaultUstb', 'depositVaultAave', 'depositVaultMorpho', + 'depositVaultMToken', ]; export const routingRedemptionVaultPriority: RedemptionVaultType[] = [ 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/test/common/deposit-vault-mtoken.helpers.ts b/test/common/deposit-vault-mtoken.helpers.ts new file mode 100644 index 00000000..662c1266 --- /dev/null +++ b/test/common/deposit-vault-mtoken.helpers.ts @@ -0,0 +1,182 @@ +import { expect } from 'chai'; +import { BigNumberish } from 'ethers'; + +import { + AccountOrContract, + OptionalCommonParams, + balanceOfBase18, + getAccount, +} from './common.helpers'; +import { depositInstantTest } 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); + } +}; diff --git a/test/common/deposit-vault.helpers.ts b/test/common/deposit-vault.helpers.ts index 35a8f921..8bc00d4b 100644 --- a/test/common/deposit-vault.helpers.ts +++ b/test/common/deposit-vault.helpers.ts @@ -17,6 +17,7 @@ import { DepositVaultTest, DepositVaultWithAaveTest, DepositVaultWithMorphoTest, + DepositVaultWithMTokenTest, DepositVaultWithUSTBTest, ERC20, ERC20__factory, @@ -29,6 +30,7 @@ type CommonParamsDeposit = { | DepositVaultTest | DepositVaultWithAaveTest | DepositVaultWithMorphoTest + | DepositVaultWithMTokenTest | DepositVaultWithUSTBTest; mTBILL: MToken; } & Pick< diff --git a/test/common/fixtures.ts b/test/common/fixtures.ts index 50cbda6a..bf21f3c1 100644 --- a/test/common/fixtures.ts +++ b/test/common/fixtures.ts @@ -46,6 +46,7 @@ import { CustomAggregatorV3CompatibleFeedDiscountedTester__factory, DepositVaultWithAaveTest__factory, DepositVaultWithMorphoTest__factory, + DepositVaultWithMTokenTest__factory, DepositVaultWithUSTBTest__factory, USTBMock__factory, CustomAggregatorV3CompatibleFeedGrowthTester__factory, @@ -533,6 +534,43 @@ export const defaultDeploy = async () => { 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 = @@ -831,6 +869,7 @@ export const defaultDeploy = async () => { depositVaultWithUSTB, depositVaultWithAave, depositVaultWithMorpho, + depositVaultWithMToken, dataFeedGrowth, compositeDataFeed, }; diff --git a/test/common/manageable-vault.helpers.ts b/test/common/manageable-vault.helpers.ts index 839c5e25..2ac6ba78 100644 --- a/test/common/manageable-vault.helpers.ts +++ b/test/common/manageable-vault.helpers.ts @@ -10,6 +10,7 @@ import { DepositVault, DepositVaultWithAave, DepositVaultWithMorpho, + DepositVaultWithMToken, DepositVaultWithUSTB, ERC20, ERC20__factory, @@ -28,6 +29,7 @@ type CommonParamsChangePaymentToken = { | DepositVault | DepositVaultWithAave | DepositVaultWithMorpho + | DepositVaultWithMToken | DepositVaultWithUSTB | RedemptionVault | RedemptionVaultWIthBUIDL @@ -43,6 +45,7 @@ type CommonParams = { | DepositVault | DepositVaultWithAave | DepositVaultWithMorpho + | DepositVaultWithMToken | DepositVaultWithUSTB; } & Pick>, 'owner'>; 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/RedemptionVaultWithMToken.test.ts b/test/integration/RedemptionVaultWithMToken.test.ts new file mode 100644 index 00000000..8d7d7f19 --- /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: insufficient mToken balance'); + }); + }); +}); 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/helpers/deposit.helpers.ts b/test/integration/helpers/deposit.helpers.ts index d1680c92..2c5ad707 100644 --- a/test/integration/helpers/deposit.helpers.ts +++ b/test/integration/helpers/deposit.helpers.ts @@ -6,6 +6,7 @@ import { parseUnits } from 'ethers/lib/utils'; import { DepositVaultWithAaveTest, DepositVaultWithMorphoTest, + DepositVaultWithMTokenTest, IERC20, IERC20Metadata, IMToken, @@ -122,6 +123,60 @@ export async function depositInstantMorpho({ }; } +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), + receiverUsdcReceived: receiverUsdcAfter.sub(receiverUsdcBefore), + }; +} + export function assertAutoInvestEnabled(result: DepositResult) { expect(result.receiverReceiptTokenReceived).to.be.gt( 0, diff --git a/test/unit/DepositVaultWithMToken.test.ts b/test/unit/DepositVaultWithMToken.test.ts new file mode 100644 index 00000000..e54da3e9 --- /dev/null +++ b/test/unit/DepositVaultWithMToken.test.ts @@ -0,0 +1,1600 @@ +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 { acErrors, blackList, greenList } from '../common/ac.helpers'; +import { approveBase18, mintToken, pauseVault } from '../common/common.helpers'; +import { + depositInstantWithMTokenTest, + 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('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('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('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('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: 'MV: token not exists', + }, + ); + }); + }); + + 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], + }, + ); + }); + }); + + 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/RedemptionVaultWithMToken.test.ts b/test/unit/RedemptionVaultWithMToken.test.ts index 38d368ce..0c0c8190 100644 --- a/test/unit/RedemptionVaultWithMToken.test.ts +++ b/test/unit/RedemptionVaultWithMToken.test.ts @@ -574,6 +574,37 @@ describe('RedemptionVaultWithMToken', function () { }); }); + 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 { @@ -1070,8 +1101,6 @@ describe('RedemptionVaultWithMToken', function () { mFONE, mTokenToUsdDataFeed, mFoneToUsdDataFeed, - greenListableTester, - accessControl, } = await loadFixture(defaultDeploy); await redemptionVaultWithMToken.setGreenlistEnable(true); @@ -1474,108 +1503,158 @@ describe('RedemptionVaultWithMToken', function () { 100, ); }); - }); + it('redeem 100 mFONE (custom recipient overload)', async () => { + const { + owner, + redemptionVaultWithMToken, + stableCoins, + mTokenToUsdDataFeed, + regularAccounts, + dataFeed, + customRecipient, + mFoneToUsdDataFeed, + mFONE, + mTBILL, + } = await loadFixture(defaultDeploy); - 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 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], + }, + ); + }); - await redeemInstantWithMTokenTest( - { - redemptionVaultWithMToken, + 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, - }, - stableCoins.dai, - 100, - { - from: regularAccounts[0], - }, - ); - }); + } = await loadFixture(defaultDeploy); - 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 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], + }, + ); + }); - await redeemInstantWithMTokenTest( - { - redemptionVaultWithMToken, + it('redeem 100 mFONE when other fn overload is paused', async () => { + const { owner, + redemptionVaultWithMToken, + stableCoins, mTBILL, mTokenToUsdDataFeed, - customRecipient, + regularAccounts, + dataFeed, mFoneToUsdDataFeed, mFONE, - }, - stableCoins.dai, - 100, - { - from: regularAccounts[0], - }, - ); + } = 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], + }, + ); + }); }); describe('redeemRequest()', () => { From cf6ae91efb848c5bab0e521b7eba8998629d30e0 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Wed, 4 Mar 2026 17:21:10 +0200 Subject: [PATCH 09/20] refactor: update aave vaults to support multiple pools --- contracts/DepositVaultWithAave.sol | 94 ++++----- contracts/RedemptionVaultWithAave.sol | 90 ++++----- contracts/RedemptionVaultWithMorpho.sol | 95 +++++---- .../codegen/common/ui/deployment-config.ts | 24 +-- scripts/deploy/common/dv.ts | 11 +- scripts/deploy/common/rv.ts | 20 +- test/common/deposit-vault-aave.helpers.ts | 39 +++- test/common/fixtures.ts | 21 +- test/common/redemption-vault-aave.helpers.ts | 53 +++++ .../common/redemption-vault-morpho.helpers.ts | 53 +++++ test/integration/DepositVaultWithAave.test.ts | 100 ++++++++-- .../DepositVaultWithMorpho.test.ts | 100 ++++++++-- .../RedemptionVaultWithAave.test.ts | 121 +++++++++++- .../RedemptionVaultWithMorpho.test.ts | 78 +++++++- test/integration/fixtures/aave.fixture.ts | 72 ++++++- test/integration/fixtures/morpho.fixture.ts | 77 +++++++- test/integration/helpers/deposit.helpers.ts | 98 ++++++---- test/integration/helpers/mainnet-addresses.ts | 5 + test/unit/DepositVaultWithAave.test.ts | 108 +++++++++- test/unit/RedemptionVaultWithAave.test.ts | 139 ++++++------- test/unit/RedemptionVaultWithMorpho.test.ts | 184 ++++++++++-------- 21 files changed, 1141 insertions(+), 441 deletions(-) diff --git a/contracts/DepositVaultWithAave.sol b/contracts/DepositVaultWithAave.sol index 472ca324..928b0b1a 100644 --- a/contracts/DepositVaultWithAave.sol +++ b/contracts/DepositVaultWithAave.sol @@ -19,9 +19,9 @@ contract DepositVaultWithAave is DepositVault { using SafeERC20 for IERC20; /** - * @notice Aave V3 Pool contract address + * @notice mapping payment token to Aave V3 Pool */ - IAaveV3Pool public aavePool; + mapping(address => IAaveV3Pool) public aavePools; /** * @notice Whether Aave auto-invest deposits are enabled @@ -35,11 +35,23 @@ contract DepositVaultWithAave is DepositVault { uint256[50] private __gap; /** - * @notice Emitted when the Aave Pool address is updated + * @notice Emitted when an Aave V3 Pool is configured for a payment token * @param caller address of the caller - * @param newPool new Aave Pool address + * @param token payment token address + * @param pool Aave V3 Pool address */ - event SetAavePool(address indexed caller, address indexed newPool); + 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 @@ -48,54 +60,32 @@ contract DepositVaultWithAave is DepositVault { event SetAaveDepositsEnabled(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 _aavePool Aave V3 Pool contract address + * @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 initialize( - address _ac, - MTokenInitParams calldata _mTokenInitParams, - ReceiversInitParams calldata _receiversInitParams, - InstantInitParams calldata _instantInitParams, - address _sanctionsList, - uint256 _variationTolerance, - uint256 _minAmount, - uint256 _minMTokenAmountForFirstDeposit, - uint256 _maxSupplyCap, - address _aavePool - ) external { - initialize( - _ac, - _mTokenInitParams, - _receiversInitParams, - _instantInitParams, - _sanctionsList, - _variationTolerance, - _minAmount, - _minMTokenAmountForFirstDeposit, - _maxSupplyCap - ); - + function setAavePool(address _token, address _aavePool) + external + onlyVaultAdmin + { + _validateAddress(_token, false); _validateAddress(_aavePool, false); - aavePool = IAaveV3Pool(_aavePool); + require( + IAaveV3Pool(_aavePool).getReserveAToken(_token) != address(0), + "DVA: token not in pool" + ); + aavePools[_token] = IAaveV3Pool(_aavePool); + emit SetAavePool(msg.sender, _token, _aavePool); } /** - * @notice Sets the Aave V3 Pool address - * @param _aavePool new Aave V3 Pool address + * @notice Removes the Aave V3 Pool for a specific payment token + * @param _token payment token address */ - function setAavePool(address _aavePool) external onlyVaultAdmin { - _validateAddress(_aavePool, false); - aavePool = IAaveV3Pool(_aavePool); - emit SetAavePool(msg.sender, _aavePool); + function removeAavePool(address _token) external onlyVaultAdmin { + require(address(aavePools[_token]) != address(0), "DVA: pool not set"); + delete aavePools[_token]; + emit RemoveAavePool(msg.sender, _token); } /** @@ -130,6 +120,9 @@ contract DepositVaultWithAave is DepositVault { ); } + IAaveV3Pool pool = aavePools[tokenIn]; + require(address(pool) != address(0), "DVA: no pool for token"); + uint256 transferredAmount = _tokenTransferFromUser( tokenIn, address(this), @@ -137,10 +130,7 @@ contract DepositVaultWithAave is DepositVault { tokensDecimals ); - IERC20(tokenIn).safeIncreaseAllowance( - address(aavePool), - transferredAmount - ); - aavePool.supply(tokenIn, transferredAmount, tokensReceiver, 0); + IERC20(tokenIn).safeIncreaseAllowance(address(pool), transferredAmount); + pool.supply(tokenIn, transferredAmount, tokensReceiver, 0); } } diff --git a/contracts/RedemptionVaultWithAave.sol b/contracts/RedemptionVaultWithAave.sol index c1afd44f..cf918b4c 100644 --- a/contracts/RedemptionVaultWithAave.sol +++ b/contracts/RedemptionVaultWithAave.sol @@ -19,9 +19,9 @@ contract RedemptionVaultWithAave is RedemptionVault { using DecimalsCorrectionLibrary for uint256; /** - * @notice Aave V3 Pool contract used for withdrawals + * @notice mapping payment token to Aave V3 Pool */ - IAaveV3Pool public aavePool; + mapping(address => IAaveV3Pool) public aavePools; /** * @dev leaving a storage gap for futures updates @@ -29,60 +29,51 @@ contract RedemptionVaultWithAave is RedemptionVault { uint256[50] private __gap; /** - * @notice Emitted when the Aave Pool address is updated + * @notice Emitted when an Aave V3 Pool is configured for a payment token * @param caller address of the caller - * @param newPool new Aave Pool address + * @param token payment token address + * @param pool Aave V3 Pool address */ - event SetAavePool(address indexed caller, address indexed newPool); + event SetAavePool( + address indexed caller, + address indexed token, + address indexed pool + ); /** - * @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 _aavePool Aave V3 Pool contract address + * @notice Emitted when an Aave V3 Pool is removed for a payment token + * @param caller address of the caller + * @param token payment token address */ - 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 _aavePool - ) external initializer { - __RedemptionVault_init( - _ac, - _mTokenInitParams, - _receiversInitParams, - _instantInitParams, - _sanctionsList, - _variationTolerance, - _minAmount, - _fiatRedemptionInitParams, - _requestRedeemer - ); + 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, false); _validateAddress(_aavePool, false); - aavePool = IAaveV3Pool(_aavePool); + require( + IAaveV3Pool(_aavePool).getReserveAToken(_token) != address(0), + "RVA: token not in pool" + ); + aavePools[_token] = IAaveV3Pool(_aavePool); + emit SetAavePool(msg.sender, _token, _aavePool); } /** - * @notice Sets the Aave V3 Pool address - * @param _aavePool new Aave V3 Pool address + * @notice Removes the Aave V3 Pool for a specific payment token + * @param _token payment token address */ - function setAavePool(address _aavePool) external onlyVaultAdmin { - _validateAddress(_aavePool, false); - aavePool = IAaveV3Pool(_aavePool); - emit SetAavePool(msg.sender, _aavePool); + function removeAavePool(address _token) external onlyVaultAdmin { + require(address(aavePools[_token]) != address(0), "RVA: pool not set"); + delete aavePools[_token]; + emit RemoveAavePool(msg.sender, _token); } /** @@ -186,9 +177,12 @@ contract RedemptionVaultWithAave is RedemptionVault { ); if (contractBalanceTokenOut >= amountTokenOut) return; + IAaveV3Pool pool = aavePools[tokenOut]; + require(address(pool) != address(0), "RVA: no pool for token"); + uint256 missingAmount = amountTokenOut - contractBalanceTokenOut; - address aToken = aavePool.getReserveAToken(tokenOut); + address aToken = pool.getReserveAToken(tokenOut); require(aToken != address(0), "RVA: token not in Aave pool"); uint256 aTokenBalance = IERC20(aToken).balanceOf(address(this)); @@ -197,7 +191,7 @@ contract RedemptionVaultWithAave is RedemptionVault { "RVA: insufficient aToken balance" ); - uint256 withdrawnAmount = aavePool.withdraw( + uint256 withdrawnAmount = pool.withdraw( tokenOut, missingAmount, address(this) diff --git a/contracts/RedemptionVaultWithMorpho.sol b/contracts/RedemptionVaultWithMorpho.sol index 227c50f5..9a19832c 100644 --- a/contracts/RedemptionVaultWithMorpho.sol +++ b/contracts/RedemptionVaultWithMorpho.sol @@ -20,9 +20,9 @@ contract RedemptionVaultWithMorpho is RedemptionVault { using DecimalsCorrectionLibrary for uint256; /** - * @notice Morpho Vault contract used for withdrawals + * @notice mapping payment token to Morpho Vault */ - IMorphoVault public morphoVault; + mapping(address => IMorphoVault) public morphoVaults; /** * @dev leaving a storage gap for futures updates @@ -30,60 +30,54 @@ contract RedemptionVaultWithMorpho is RedemptionVault { uint256[50] private __gap; /** - * @notice Emitted when the Morpho Vault address is updated + * @notice Emitted when a Morpho Vault is configured for a payment token * @param caller address of the caller - * @param newVault new Morpho Vault address + * @param token payment token address + * @param vault Morpho Vault address */ - event SetMorphoVault(address indexed caller, address indexed newVault); + event SetMorphoVault( + address indexed caller, + address indexed token, + address indexed vault + ); /** - * @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 _morphoVault Morpho Vault (ERC-4626) contract address + * @notice Emitted when a Morpho Vault is removed for a payment token + * @param caller address of the caller + * @param token payment token address */ - 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 _morphoVault - ) external initializer { - __RedemptionVault_init( - _ac, - _mTokenInitParams, - _receiversInitParams, - _instantInitParams, - _sanctionsList, - _variationTolerance, - _minAmount, - _fiatRedemptionInitParams, - _requestRedeemer - ); + 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, false); _validateAddress(_morphoVault, false); - morphoVault = IMorphoVault(_morphoVault); + require( + IMorphoVault(_morphoVault).asset() == _token, + "RVM: asset mismatch" + ); + morphoVaults[_token] = IMorphoVault(_morphoVault); + emit SetMorphoVault(msg.sender, _token, _morphoVault); } /** - * @notice Sets the Morpho Vault address - * @param _morphoVault new Morpho Vault address + * @notice Removes the Morpho Vault for a specific payment token + * @param _token payment token address */ - function setMorphoVault(address _morphoVault) external onlyVaultAdmin { - _validateAddress(_morphoVault, false); - morphoVault = IMorphoVault(_morphoVault); - emit SetMorphoVault(msg.sender, _morphoVault); + function removeMorphoVault(address _token) external onlyVaultAdmin { + require( + address(morphoVaults[_token]) != address(0), + "RVM: vault not set" + ); + delete morphoVaults[_token]; + emit RemoveMorphoVault(msg.sender, _token); } /** @@ -187,16 +181,17 @@ contract RedemptionVaultWithMorpho is RedemptionVault { ); if (contractBalanceTokenOut >= amountTokenOut) return; - require(morphoVault.asset() == tokenOut, "RVM: token not vault asset"); + IMorphoVault vault = morphoVaults[tokenOut]; + require(address(vault) != address(0), "RVM: no vault for token"); uint256 missingAmount = amountTokenOut - contractBalanceTokenOut; - uint256 sharesNeeded = morphoVault.previewWithdraw(missingAmount); + uint256 sharesNeeded = vault.previewWithdraw(missingAmount); require( - morphoVault.balanceOf(address(this)) >= sharesNeeded, + vault.balanceOf(address(this)) >= sharesNeeded, "RVM: insufficient shares" ); - morphoVault.withdraw(missingAmount, address(this), address(this)); + vault.withdraw(missingAmount, address(this), address(this)); } } diff --git a/scripts/deploy/codegen/common/ui/deployment-config.ts b/scripts/deploy/codegen/common/ui/deployment-config.ts index ff27070b..6e599ad4 100644 --- a/scripts/deploy/codegen/common/ui/deployment-config.ts +++ b/scripts/deploy/codegen/common/ui/deployment-config.ts @@ -143,10 +143,6 @@ async function getDvAaveConfigFromUser(hre: HardhatRuntimeEnvironment) { text({ message: 'Tokens Receiver', validate: validateAddress }) .then(requireNotCancelled) .then(requireAddress), - aavePool: () => - text({ message: 'Aave V3 Pool Address', validate: validateAddress }) - .then(requireNotCancelled) - .then(requireAddress), instantDailyLimit: () => text({ message: 'Instant Daily Limit', @@ -425,15 +421,7 @@ async function getRvConfigFromUser( async function getRvAaveConfigFromUser(hre: HardhatRuntimeEnvironment) { const config = await getRvConfigFromUser( hre, - { - aavePool: () => - text({ - message: 'Aave V3 Pool Address', - validate: validateAddress, - }) - .then(requireNotCancelled) - .then(requireAddress), - }, + {}, 'Redemption Vault With Aave', ); @@ -446,15 +434,7 @@ async function getRvAaveConfigFromUser(hre: HardhatRuntimeEnvironment) { async function getRvMorphoConfigFromUser(hre: HardhatRuntimeEnvironment) { const config = await getRvConfigFromUser( hre, - { - morphoVault: () => - text({ - message: 'Morpho Vault Address (ERC-4626)', - validate: validateAddress, - }) - .then(requireNotCancelled) - .then(requireAddress), - }, + {}, 'Redemption Vault With Morpho', ); diff --git a/scripts/deploy/common/dv.ts b/scripts/deploy/common/dv.ts index 1cb9f48c..32bd76a3 100644 --- a/scripts/deploy/common/dv.ts +++ b/scripts/deploy/common/dv.ts @@ -13,7 +13,6 @@ import { import { getTokenContractNames } from '../../../helpers/contracts'; import { DepositVault, - DepositVaultWithAave, DepositVaultWithMToken, DepositVaultWithUSTB, } from '../../../typechain-types'; @@ -49,7 +48,6 @@ export type DeployDvUstbConfig = DeployDvConfigCommon & { export type DeployDvAaveConfig = DeployDvConfigCommon & { type: 'AAVE'; - aavePool: string; }; export type DeployDvMorphoConfig = DeployDvConfigCommon & { @@ -140,8 +138,6 @@ export const deployDepositVault = async ( } extraParams.push(ustbContract); - } else if (networkConfig.type === 'AAVE') { - extraParams.push(networkConfig.aavePool); } else if (networkConfig.type === 'MTOKEN') { extraParams.push(networkConfig.mTokenDepositVault); } @@ -171,18 +167,13 @@ export const deployDepositVault = async ( | Parameters< DepositVaultWithUSTB['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)'] > - | Parameters< - DepositVaultWithAave['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' || - networkConfig.type === 'AAVE' || - networkConfig.type === 'MTOKEN' + 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/rv.ts b/scripts/deploy/common/rv.ts index bf2dcdf9..dfaee5c2 100644 --- a/scripts/deploy/common/rv.ts +++ b/scripts/deploy/common/rv.ts @@ -14,8 +14,6 @@ import { getTokenContractNames } from '../../../helpers/contracts'; import { MBasisRedemptionVaultWithSwapper, RedemptionVault, - RedemptionVaultWithAave, - RedemptionVaultWithMorpho, RedemptionVaultWithMToken, RedemptionVaultWIthBUIDL, } from '../../../typechain-types'; @@ -72,12 +70,10 @@ export type DeployRvSwapperConfig = { export type DeployRvAaveConfig = { type: 'AAVE'; - aavePool: string; } & DeployRvConfigCommon; export type DeployRvMorphoConfig = { type: 'MORPHO'; - morphoVault: string; } & DeployRvConfigCommon; export type DeployRvMTokenConfig = { @@ -118,11 +114,7 @@ export const deployRedemptionVault = async ( const extraParams: unknown[] = []; - if (networkConfig.type === 'AAVE') { - extraParams.push(networkConfig.aavePool); - } else if (networkConfig.type === 'MORPHO') { - extraParams.push(networkConfig.morphoVault); - } else if (networkConfig.type === 'MTOKEN') { + if (networkConfig.type === 'MTOKEN') { extraParams.push(networkConfig.redemptionVault); } else if (networkConfig.type === 'BUIDL') { extraParams.push(networkConfig.buidlRedemption); @@ -211,12 +203,6 @@ export const deployRedemptionVault = async ( | Parameters< MBasisRedemptionVaultWithSwapper['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address,address)'] > - | Parameters< - RedemptionVaultWithAave['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)'] - > - | Parameters< - RedemptionVaultWithMorpho['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)'] - > | Parameters< RedemptionVaultWithMToken['initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)'] >; @@ -227,9 +213,7 @@ 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 === 'AAVE' || - networkConfig.type === 'MORPHO' || - networkConfig.type === 'MTOKEN' + : networkConfig.type === 'MTOKEN' ? 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' : 'initialize', }); diff --git a/test/common/deposit-vault-aave.helpers.ts b/test/common/deposit-vault-aave.helpers.ts index e95efe1d..13c05b90 100644 --- a/test/common/deposit-vault-aave.helpers.ts +++ b/test/common/deposit-vault-aave.helpers.ts @@ -63,25 +63,52 @@ export const setAaveDepositsEnabledTest = async ( export const setAavePoolTest = async ( { depositVaultWithAave, owner }: CommonParamsSetAavePool, - newPool: string, + token: string, + pool: string, opt?: OptionalCommonParams, ) => { if (opt?.revertMessage) { await expect( - depositVaultWithAave.connect(opt?.from ?? owner).setAavePool(newPool), + depositVaultWithAave.connect(opt?.from ?? owner).setAavePool(token, pool), ).revertedWith(opt?.revertMessage); return; } await expect( - depositVaultWithAave.connect(opt?.from ?? owner).setAavePool(newPool), + depositVaultWithAave.connect(opt?.from ?? owner).setAavePool(token, pool), ).to.emit( depositVaultWithAave, - depositVaultWithAave.interface.events['SetAavePool(address,address)'].name, + depositVaultWithAave.interface.events[ + 'SetAavePool(address,address,address)' + ].name, ).to.not.reverted; - const poolAfter = await depositVaultWithAave.aavePool(); - expect(poolAfter).eq(newPool); + 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 ( diff --git a/test/common/fixtures.ts b/test/common/fixtures.ts index bf21f3c1..cbd6a15f 100644 --- a/test/common/fixtures.ts +++ b/test/common/fixtures.ts @@ -393,9 +393,7 @@ export const defaultDeploy = async () => { const redemptionVaultWithAave = await new RedemptionVaultWithAaveTest__factory(owner).deploy(); - await redemptionVaultWithAave[ - 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' - ]( + await redemptionVaultWithAave.initialize( accessControl.address, { mToken: mTBILL.address, @@ -418,6 +416,9 @@ export const defaultDeploy = async () => { minFiatRedeemAmount: 1000, }, requestRedeemer.address, + ); + await redemptionVaultWithAave.setAavePool( + stableCoins.usdc.address, aavePoolMock.address, ); await accessControl.grantRole( @@ -435,9 +436,7 @@ export const defaultDeploy = async () => { const redemptionVaultWithMorpho = await new RedemptionVaultWithMorphoTest__factory(owner).deploy(); - await redemptionVaultWithMorpho[ - 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' - ]( + await redemptionVaultWithMorpho.initialize( accessControl.address, { mToken: mTBILL.address, @@ -460,6 +459,9 @@ export const defaultDeploy = async () => { minFiatRedeemAmount: 1000, }, requestRedeemer.address, + ); + await redemptionVaultWithMorpho.setMorphoVault( + stableCoins.usdc.address, morphoVaultMock.address, ); await accessControl.grantRole( @@ -473,9 +475,7 @@ export const defaultDeploy = async () => { owner, ).deploy(); - await depositVaultWithAave[ - 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)' - ]( + await depositVaultWithAave.initialize( accessControl.address, { mToken: mTBILL.address, @@ -494,6 +494,9 @@ export const defaultDeploy = async () => { parseUnits('100'), 0, constants.MaxUint256, + ); + await depositVaultWithAave.setAavePool( + stableCoins.usdc.address, aavePoolMock.address, ); diff --git a/test/common/redemption-vault-aave.helpers.ts b/test/common/redemption-vault-aave.helpers.ts index 0127533b..c09bdc2f 100644 --- a/test/common/redemption-vault-aave.helpers.ts +++ b/test/common/redemption-vault-aave.helpers.ts @@ -12,6 +12,11 @@ import { DataFeedTest, } from '../../typechain-types'; +type CommonParamsSetAavePool = { + redemptionVault: RedemptionVaultWithAave; + owner: SignerWithAddress; +}; + type RedemptionWithAaveParams = { redemptionVault: RedemptionVaultWithAave; owner: SignerWithAddress; @@ -26,6 +31,54 @@ type RedemptionWithAaveParams = { 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, diff --git a/test/common/redemption-vault-morpho.helpers.ts b/test/common/redemption-vault-morpho.helpers.ts index 1a45b699..a065347e 100644 --- a/test/common/redemption-vault-morpho.helpers.ts +++ b/test/common/redemption-vault-morpho.helpers.ts @@ -12,6 +12,11 @@ import { DataFeedTest, } from '../../typechain-types'; +type CommonParamsSetMorphoVault = { + redemptionVault: RedemptionVaultWithMorpho; + owner: SignerWithAddress; +}; + type RedemptionWithMorphoParams = { redemptionVault: RedemptionVaultWithMorpho; owner: SignerWithAddress; @@ -26,6 +31,54 @@ type RedemptionWithMorphoParams = { 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, diff --git a/test/integration/DepositVaultWithAave.test.ts b/test/integration/DepositVaultWithAave.test.ts index 2a65c40f..52cd0cc0 100644 --- a/test/integration/DepositVaultWithAave.test.ts +++ b/test/integration/DepositVaultWithAave.test.ts @@ -30,11 +30,11 @@ describe('DepositVaultWithAave - Mainnet Fork Integration Tests', function () { const result = await depositInstantAave({ depositVault: depositVaultWithAave, user: testUser, - usdc, - aUsdc, + tokenIn: usdc, + receiptToken: aUsdc, mToken: mTBILL, tokensReceiverAddress: tokensReceiver.address, - usdcWhale, + tokenWhale: usdcWhale, amountUsd: 100, }); @@ -57,11 +57,11 @@ describe('DepositVaultWithAave - Mainnet Fork Integration Tests', function () { const result = await depositInstantAave({ depositVault: depositVaultWithAave, user: testUser, - usdc, - aUsdc, + tokenIn: usdc, + receiptToken: aUsdc, mToken: mTBILL, tokensReceiverAddress: tokensReceiver.address, - usdcWhale, + tokenWhale: usdcWhale, amountUsd: 100, }); @@ -89,11 +89,11 @@ describe('DepositVaultWithAave - Mainnet Fork Integration Tests', function () { const result1 = await depositInstantAave({ depositVault: depositVaultWithAave, user: testUser, - usdc, - aUsdc, + tokenIn: usdc, + receiptToken: aUsdc, mToken: mTBILL, tokensReceiverAddress: tokensReceiver.address, - usdcWhale, + tokenWhale: usdcWhale, amountUsd: 100, }); @@ -106,15 +106,93 @@ describe('DepositVaultWithAave - Mainnet Fork Integration Tests', function () { 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, - usdcWhale, + tokenWhale: usdcWhale, amountUsd: 100, }); - assertAutoInvestDisabled(result2); + 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); }); }); }); diff --git a/test/integration/DepositVaultWithMorpho.test.ts b/test/integration/DepositVaultWithMorpho.test.ts index a812d113..1880d7c1 100644 --- a/test/integration/DepositVaultWithMorpho.test.ts +++ b/test/integration/DepositVaultWithMorpho.test.ts @@ -35,11 +35,11 @@ describe('DepositVaultWithMorpho - Mainnet Fork Integration Tests', function () const result = await depositInstantMorpho({ depositVault: depositVaultWithMorpho, user: testUser, - usdc, - morphoVault, + tokenIn: usdc, + receiptToken: morphoVault, mToken: mTBILL, tokensReceiverAddress: tokensReceiver.address, - usdcWhale, + tokenWhale: usdcWhale, amountUsd: 100, }); @@ -62,11 +62,11 @@ describe('DepositVaultWithMorpho - Mainnet Fork Integration Tests', function () const result = await depositInstantMorpho({ depositVault: depositVaultWithMorpho, user: testUser, - usdc, - morphoVault, + tokenIn: usdc, + receiptToken: morphoVault, mToken: mTBILL, tokensReceiverAddress: tokensReceiver.address, - usdcWhale, + tokenWhale: usdcWhale, amountUsd: 100, }); @@ -94,11 +94,11 @@ describe('DepositVaultWithMorpho - Mainnet Fork Integration Tests', function () const result1 = await depositInstantMorpho({ depositVault: depositVaultWithMorpho, user: testUser, - usdc, - morphoVault, + tokenIn: usdc, + receiptToken: morphoVault, mToken: mTBILL, tokensReceiverAddress: tokensReceiver.address, - usdcWhale, + tokenWhale: usdcWhale, amountUsd: 100, }); @@ -111,15 +111,93 @@ describe('DepositVaultWithMorpho - Mainnet Fork Integration Tests', function () 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, - usdcWhale, + tokenWhale: usdcWhale, amountUsd: 100, }); - assertAutoInvestDisabled(result2); + 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); }); }); diff --git a/test/integration/RedemptionVaultWithAave.test.ts b/test/integration/RedemptionVaultWithAave.test.ts index 6bfa4239..cc1b6f4f 100644 --- a/test/integration/RedemptionVaultWithAave.test.ts +++ b/test/integration/RedemptionVaultWithAave.test.ts @@ -309,9 +309,128 @@ describe('RedemptionVaultWithAave - Mainnet Fork Integration Tests', function () mTBILLAmount, { from: testUser, - revertMessage: 'RVA: token not in Aave pool', + 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/RedemptionVaultWithMorpho.test.ts b/test/integration/RedemptionVaultWithMorpho.test.ts index 5a5782ff..b0b54460 100644 --- a/test/integration/RedemptionVaultWithMorpho.test.ts +++ b/test/integration/RedemptionVaultWithMorpho.test.ts @@ -5,6 +5,7 @@ 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'; @@ -296,7 +297,7 @@ describe('RedemptionVaultWithMorpho - Mainnet Fork Integration Tests', function // Should revert because fakeToken is not the Morpho vault's asset // The vault has no fakeToken balance, so it tries Morpho withdrawal - // which fails with "RVM: token not vault asset" + // which fails because no vault is configured for the fake token await redeemInstantWithMorphoTest( { redemptionVault: redemptionVaultWithMorpho, @@ -309,9 +310,82 @@ describe('RedemptionVaultWithMorpho - Mainnet Fork Integration Tests', function mTBILLAmount, { from: testUser, - revertMessage: 'RVM: token not vault asset', + 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/fixtures/aave.fixture.ts b/test/integration/fixtures/aave.fixture.ts index c23bad7a..25402143 100644 --- a/test/integration/fixtures/aave.fixture.ts +++ b/test/integration/fixtures/aave.fixture.ts @@ -87,6 +87,21 @@ async function setupAaveBase() { 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', [ @@ -104,6 +119,11 @@ async function setupAaveBase() { 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, @@ -116,6 +136,12 @@ async function setupAaveBase() { 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, @@ -126,6 +152,9 @@ async function setupAaveBase() { mockedAggregatorMToken: mtbillAggregator, usdc, aUsdc, + usdt, + aUsdt, + usdtDataFeed, aavePool, owner, tokensReceiver, @@ -135,6 +164,8 @@ async function setupAaveBase() { testUser, usdcWhale, aUsdcWhale, + usdtWhale, + aUsdtWhale, roles: allRoles, }; } @@ -166,9 +197,7 @@ export async function aaveDepositFixture() { parseUnits('0'), 0, ethers.constants.MaxUint256, - MAINNET_ADDRESSES.AAVE_V3_POOL, ], - 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,uint256,uint256,address)', ); // Grant MINTER_ROLE to deposit vault @@ -186,6 +215,24 @@ export async function aaveDepositFixture() { 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, @@ -223,9 +270,8 @@ export async function aaveRedemptionFixture() { fiatFlatFee: parseUnits('10', 18), }, base.requestRedeemer.address, - MAINNET_ADDRESSES.AAVE_V3_POOL, ], - 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)', + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address)', ); // Grant BURN_ROLE to redemption vault @@ -243,6 +289,24 @@ export async function aaveRedemptionFixture() { 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, diff --git a/test/integration/fixtures/morpho.fixture.ts b/test/integration/fixtures/morpho.fixture.ts index b6216a66..299984d6 100644 --- a/test/integration/fixtures/morpho.fixture.ts +++ b/test/integration/fixtures/morpho.fixture.ts @@ -88,6 +88,21 @@ async function setupMorphoBase() { 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', [ @@ -108,6 +123,14 @@ async function setupMorphoBase() { '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( @@ -116,6 +139,12 @@ async function setupMorphoBase() { 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, @@ -126,6 +155,9 @@ async function setupMorphoBase() { mockedAggregatorMToken: mtbillAggregator, usdc, morphoVault, + usdt, + morphoUsdtVault, + usdtDataFeed, owner, tokensReceiver, feeReceiver, @@ -134,6 +166,8 @@ async function setupMorphoBase() { testUser, usdcWhale, morphoShareWhale, + usdtWhale, + morphoUsdtShareWhale, roles: allRoles, }; } @@ -191,6 +225,22 @@ export async function morphoDepositFixture() { 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, @@ -228,9 +278,8 @@ export async function morphoRedemptionFixture() { fiatFlatFee: parseUnits('10', 18), }, base.requestRedeemer.address, - MAINNET_ADDRESSES.MORPHO_STEAKHOUSE_USDC_VAULT, ], - 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)', + 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address)', ); // Grant BURN_ROLE to redemption vault @@ -248,6 +297,30 @@ export async function morphoRedemptionFixture() { 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, diff --git a/test/integration/helpers/deposit.helpers.ts b/test/integration/helpers/deposit.helpers.ts index 2c5ad707..16a36809 100644 --- a/test/integration/helpers/deposit.helpers.ts +++ b/test/integration/helpers/deposit.helpers.ts @@ -16,87 +16,99 @@ import { approveBase18 } from '../../common/common.helpers'; type DepositInstantAaveParams = { depositVault: DepositVaultWithAaveTest; user: SignerWithAddress; - usdc: IERC20Metadata; - aUsdc: IERC20; + tokenIn: IERC20Metadata; + receiptToken: IERC20; mToken: IMToken; tokensReceiverAddress: string; - usdcWhale: SignerWithAddress; + tokenWhale: SignerWithAddress; amountUsd: number; + tokenDecimals?: number; }; type DepositInstantMorphoParams = { depositVault: DepositVaultWithMorphoTest; user: SignerWithAddress; - usdc: IERC20Metadata; - morphoVault: IERC20; + tokenIn: IERC20Metadata; + receiptToken: IERC20; mToken: IMToken; tokensReceiverAddress: string; - usdcWhale: SignerWithAddress; + tokenWhale: SignerWithAddress; amountUsd: number; + tokenDecimals?: number; }; type DepositResult = { userMTokenReceived: BigNumber; receiverReceiptTokenReceived: BigNumber; - receiverUsdcReceived: BigNumber; + receiverTokenReceived: BigNumber; }; export async function depositInstantAave({ depositVault, user, - usdc, - aUsdc, + tokenIn, + receiptToken, mToken, tokensReceiverAddress, - usdcWhale, + tokenWhale, amountUsd, + tokenDecimals, }: DepositInstantAaveParams): 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 receiverAUsdcBefore = await aUsdc.balanceOf(tokensReceiverAddress); + 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)']( - usdc.address, + tokenIn.address, parseUnits(String(amountUsd)), constants.Zero, constants.HashZero, ); - const receiverUsdcAfter = await usdc.balanceOf(tokensReceiverAddress); - const receiverAUsdcAfter = await aUsdc.balanceOf(tokensReceiverAddress); + 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: receiverAUsdcAfter.sub(receiverAUsdcBefore), - receiverUsdcReceived: receiverUsdcAfter.sub(receiverUsdcBefore), + receiverReceiptTokenReceived: receiverReceiptAfter.sub( + receiverReceiptBefore, + ), + receiverTokenReceived: receiverTokenAfter.sub(receiverTokenBefore), }; } export async function depositInstantMorpho({ depositVault, user, - usdc, - morphoVault, + tokenIn, + receiptToken, mToken, tokensReceiverAddress, - usdcWhale, + tokenWhale, amountUsd, + tokenDecimals, }: DepositInstantMorphoParams): 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 receiverSharesBefore = await morphoVault.balanceOf( + 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); @@ -104,22 +116,24 @@ export async function depositInstantMorpho({ await depositVault .connect(user) ['depositInstant(address,uint256,uint256,bytes32)']( - usdc.address, + tokenIn.address, parseUnits(String(amountUsd)), constants.Zero, constants.HashZero, ); - const receiverUsdcAfter = await usdc.balanceOf(tokensReceiverAddress); - const receiverSharesAfter = await morphoVault.balanceOf( + 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: receiverSharesAfter.sub(receiverSharesBefore), - receiverUsdcReceived: receiverUsdcAfter.sub(receiverUsdcBefore), + receiverReceiptTokenReceived: receiverReceiptAfter.sub( + receiverReceiptBefore, + ), + receiverTokenReceived: receiverTokenAfter.sub(receiverTokenBefore), }; } @@ -173,7 +187,7 @@ export async function depositInstantMToken({ return { userMTokenReceived: userMTokenAfter.sub(userMTokenBefore), receiverReceiptTokenReceived: receiverMTokenAfter.sub(receiverMTokenBefore), - receiverUsdcReceived: receiverUsdcAfter.sub(receiverUsdcBefore), + receiverTokenReceived: receiverUsdcAfter.sub(receiverUsdcBefore), }; } @@ -182,9 +196,9 @@ export function assertAutoInvestEnabled(result: DepositResult) { 0, 'tokensReceiver should have received receipt tokens', ); - expect(result.receiverUsdcReceived).to.equal( + expect(result.receiverTokenReceived).to.equal( 0, - 'tokensReceiver raw USDC should not change when auto-invest is on', + 'tokensReceiver raw token should not change when auto-invest is on', ); expect(result.userMTokenReceived).to.be.gt( 0, @@ -193,9 +207,9 @@ export function assertAutoInvestEnabled(result: DepositResult) { } export function assertAutoInvestDisabled(result: DepositResult) { - expect(result.receiverUsdcReceived).to.be.gt( + expect(result.receiverTokenReceived).to.be.gt( 0, - 'tokensReceiver should have received USDC', + 'tokensReceiver should have received token', ); expect(result.receiverReceiptTokenReceived).to.be.lte( 1, diff --git a/test/integration/helpers/mainnet-addresses.ts b/test/integration/helpers/mainnet-addresses.ts index cadad33c..312a25ba 100644 --- a/test/integration/helpers/mainnet-addresses.ts +++ b/test/integration/helpers/mainnet-addresses.ts @@ -6,9 +6,11 @@ export const MAINNET_ADDRESSES = { // 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', @@ -19,5 +21,8 @@ export const MAINNET_ADDRESSES = { 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 index f92f1dbb..10c07565 100644 --- a/test/unit/DepositVaultWithAave.test.ts +++ b/test/unit/DepositVaultWithAave.test.ts @@ -8,6 +8,7 @@ import { acErrors, blackList, greenList } from '../common/ac.helpers'; import { approveBase18, mintToken, pauseVault } from '../common/common.helpers'; import { depositInstantWithAaveTest, + removeAavePoolTest, setAaveDepositsEnabledTest, setAavePoolTest, } from '../common/deposit-vault-aave.helpers'; @@ -45,6 +46,7 @@ describe('DepositVaultWithAave', function () { mTokenToUsdDataFeed, roles, aavePoolMock, + stableCoins, } = await loadFixture(defaultDeploy); expect(await depositVaultWithAave.mToken()).eq(mTBILL.address); @@ -70,17 +72,25 @@ describe('DepositVaultWithAave', function () { expect(await depositVaultWithAave.MANUAL_FULLFILMENT_TOKEN()).eq( ethers.constants.AddressZero, ); - expect(await depositVaultWithAave.aavePool()).eq(aavePoolMock.address); + expect(await depositVaultWithAave.aavePools(stableCoins.usdc.address)).eq( + aavePoolMock.address, + ); expect(await depositVaultWithAave.aaveDepositsEnabled()).eq(false); }); describe('setAavePool()', () => { it('should fail: call from address without DEPOSIT_VAULT_ADMIN_ROLE role', async () => { - const { depositVaultWithAave, owner, regularAccounts, aavePoolMock } = - await loadFixture(defaultDeploy); + const { + depositVaultWithAave, + owner, + regularAccounts, + stableCoins, + aavePoolMock, + } = await loadFixture(defaultDeploy); await setAavePoolTest( { depositVaultWithAave, owner }, + stableCoins.usdc.address, aavePoolMock.address, { from: regularAccounts[0], @@ -90,10 +100,13 @@ describe('DepositVaultWithAave', function () { }); it('should fail: zero address', async () => { - const { depositVaultWithAave, owner } = await loadFixture(defaultDeploy); + const { depositVaultWithAave, owner, stableCoins } = await loadFixture( + defaultDeploy, + ); await setAavePoolTest( { depositVaultWithAave, owner }, + stableCoins.usdc.address, ethers.constants.AddressZero, { revertMessage: 'zero address', @@ -101,13 +114,74 @@ describe('DepositVaultWithAave', function () { ); }); + 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, regularAccounts } = + const { depositVaultWithAave, owner, stableCoins, aavePoolMock } = await loadFixture(defaultDeploy); await setAavePoolTest( { depositVaultWithAave, owner }, - regularAccounts[1].address, + 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, ); }); }); @@ -784,6 +858,12 @@ describe('DepositVaultWithAave', function () { aDAI.address, ); + await setAavePoolTest( + { depositVaultWithAave, owner }, + stableCoins.dai.address, + aavePoolMock.address, + ); + await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); await mintToken(stableCoins.dai, regularAccounts[0], 100); @@ -1057,7 +1137,7 @@ describe('DepositVaultWithAave', function () { ); }); - it('should fail: Aave deposit enabled with unconfigured reserve token', async () => { + it('should fail: aaveDepositsEnabled but no pool for token', async () => { const { owner, depositVaultWithAave, @@ -1066,13 +1146,19 @@ describe('DepositVaultWithAave', function () { mTokenToUsdDataFeed, dataFeed, aavePoolMock, + regularAccounts, } = await loadFixture(defaultDeploy); await setAaveDepositsEnabledTest({ depositVaultWithAave, owner }, true); await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); - await mintToken(stableCoins.dai, owner, 100); - await approveBase18(owner, stableCoins.dai, depositVaultWithAave, 100); + await mintToken(stableCoins.dai, regularAccounts[0], 100); + await approveBase18( + regularAccounts[0], + stableCoins.dai, + depositVaultWithAave, + 100, + ); await addPaymentTokenTest( { vault: depositVaultWithAave, owner }, stableCoins.dai, @@ -1088,11 +1174,13 @@ describe('DepositVaultWithAave', function () { mTBILL, mTokenToUsdDataFeed, aavePoolMock, + expectedAaveDeposited: false, }, stableCoins.dai, 100, { - revertMessage: 'AaveV3PoolMock: NoReserve', + from: regularAccounts[0], + revertMessage: 'DVA: no pool for token', }, ); }); diff --git a/test/unit/RedemptionVaultWithAave.test.ts b/test/unit/RedemptionVaultWithAave.test.ts index dd507850..9ebc060c 100644 --- a/test/unit/RedemptionVaultWithAave.test.ts +++ b/test/unit/RedemptionVaultWithAave.test.ts @@ -50,6 +50,7 @@ describe('RedemptionVaultWithAave', function () { tokensReceiver, feeReceiver, mTokenToUsdDataFeed, + stableCoins, roles, } = await loadFixture(defaultDeploy); @@ -86,7 +87,9 @@ describe('RedemptionVaultWithAave', function () { ethers.constants.AddressZero, ); - expect(await redemptionVaultWithAave.aavePool()).eq(aavePoolMock.address); + expect( + await redemptionVaultWithAave.aavePools(stableCoins.usdc.address), + ).eq(aavePoolMock.address); }); it('failing deployment', async () => { @@ -104,9 +107,7 @@ describe('RedemptionVaultWithAave', function () { await new RedemptionVaultWithAaveTest__factory(owner).deploy(); await expect( - redemptionVaultWithAave[ - 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address)' - ]( + redemptionVaultWithAave.initialize( accessControl.address, { mToken: mTBILL.address, @@ -138,9 +139,7 @@ describe('RedemptionVaultWithAave', function () { const { redemptionVaultWithAave } = await loadFixture(defaultDeploy); await expect( - redemptionVaultWithAave[ - 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' - ]( + redemptionVaultWithAave.initialize( constants.AddressZero, { mToken: constants.AddressZero, @@ -163,7 +162,6 @@ describe('RedemptionVaultWithAave', function () { minFiatRedeemAmount: 0, }, constants.AddressZero, - constants.AddressZero, ), ).revertedWith('Initializable: contract is already initialized'); }); @@ -202,84 +200,92 @@ describe('RedemptionVaultWithAave', function () { ), ).revertedWith('Initializable: contract is not initializing'); }); + }); - it('should fail: when aavePool address zero', async () => { + describe('setAavePool()', () => { + it('should fail: call from address without vault admin role', async () => { const { - owner, - accessControl, - mTBILL, - tokensReceiver, - feeReceiver, - mTokenToUsdDataFeed, - mockedSanctionsList, - requestRedeemer, + 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'); + }); - const redemptionVaultWithAave = - await new RedemptionVaultWithAaveTest__factory(owner).deploy(); - + it('should fail: zero address', async () => { + const { redemptionVaultWithAave, stableCoins } = await loadFixture( + defaultDeploy, + ); await expect( - redemptionVaultWithAave[ - 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' - ]( - 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, + redemptionVaultWithAave.setAavePool( + stableCoins.usdc.address, constants.AddressZero, ), - ).revertedWith('zero address'); + ).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('setAavePool()', () => { + describe('removeAavePool()', () => { it('should fail: call from address without vault admin role', async () => { - const { redemptionVaultWithAave, regularAccounts } = await loadFixture( - defaultDeploy, - ); + const { redemptionVaultWithAave, regularAccounts, stableCoins } = + await loadFixture(defaultDeploy); await expect( redemptionVaultWithAave .connect(regularAccounts[0]) - .setAavePool(regularAccounts[1].address), + .removeAavePool(stableCoins.usdc.address), ).to.be.revertedWith('WMAC: hasnt role'); }); - it('should fail: zero address', async () => { - const { redemptionVaultWithAave } = await loadFixture(defaultDeploy); + it('should fail: pool not set', async () => { + const { redemptionVaultWithAave, stableCoins } = await loadFixture( + defaultDeploy, + ); await expect( - redemptionVaultWithAave.setAavePool(constants.AddressZero), - ).to.be.revertedWith('zero address'); + redemptionVaultWithAave.removeAavePool(stableCoins.dai.address), + ).to.be.revertedWith('RVA: pool not set'); }); - it('should succeed and emit SetAavePool event', async () => { - const { redemptionVaultWithAave, owner, regularAccounts } = - await loadFixture(defaultDeploy); - - const newPool = regularAccounts[0].address; + it('should succeed and emit RemoveAavePool event', async () => { + const { redemptionVaultWithAave, owner, stableCoins } = await loadFixture( + defaultDeploy, + ); - await expect(redemptionVaultWithAave.setAavePool(newPool)) - .to.emit(redemptionVaultWithAave, 'SetAavePool') - .withArgs(owner.address, newPool); + await expect( + redemptionVaultWithAave.removeAavePool(stableCoins.usdc.address), + ) + .to.emit(redemptionVaultWithAave, 'RemoveAavePool') + .withArgs(owner.address, stableCoins.usdc.address); - expect(await redemptionVaultWithAave.aavePool()).eq(newPool); + expect( + await redemptionVaultWithAave.aavePools(stableCoins.usdc.address), + ).eq(constants.AddressZero); }); }); @@ -761,18 +767,17 @@ describe('RedemptionVaultWithAave', function () { expect(aTokenAfter).to.equal(parseUnits('100', 8)); }); - it('should revert when token not in Aave pool', async () => { + it('should revert when no pool set for token', async () => { const { redemptionVaultWithAave, stableCoins } = await loadFixture( defaultDeploy, ); - // DAI is not registered in Aave pool mock await expect( redemptionVaultWithAave.checkAndRedeemAave( stableCoins.dai.address, parseUnits('1000', 9), ), - ).to.be.revertedWith('RVA: token not in Aave pool'); + ).to.be.revertedWith('RVA: no pool for token'); }); it('should revert when contract has insufficient aToken balance', async () => { diff --git a/test/unit/RedemptionVaultWithMorpho.test.ts b/test/unit/RedemptionVaultWithMorpho.test.ts index 1ebb3d92..efd6d23b 100644 --- a/test/unit/RedemptionVaultWithMorpho.test.ts +++ b/test/unit/RedemptionVaultWithMorpho.test.ts @@ -30,6 +30,10 @@ import { changeTokenFeeTest, changeTokenAllowanceTest, } from '../common/manageable-vault.helpers'; +import { + setMorphoVaultTest, + removeMorphoVaultTest, +} from '../common/redemption-vault-morpho.helpers'; import { approveRedeemRequestTest, redeemFiatRequestTest, @@ -46,6 +50,7 @@ describe('RedemptionVaultWithMorpho', function () { const { redemptionVaultWithMorpho, morphoVaultMock, + stableCoins, mTBILL, tokensReceiver, feeReceiver, @@ -88,9 +93,9 @@ describe('RedemptionVaultWithMorpho', function () { ethers.constants.AddressZero, ); - expect(await redemptionVaultWithMorpho.morphoVault()).eq( - morphoVaultMock.address, - ); + expect( + await redemptionVaultWithMorpho.morphoVaults(stableCoins.usdc.address), + ).eq(morphoVaultMock.address); }); it('failing deployment', async () => { @@ -108,9 +113,7 @@ describe('RedemptionVaultWithMorpho', function () { await new RedemptionVaultWithMorphoTest__factory(owner).deploy(); await expect( - redemptionVaultWithMorpho[ - 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address)' - ]( + redemptionVaultWithMorpho.initialize( accessControl.address, { mToken: mTBILL.address, @@ -142,9 +145,7 @@ describe('RedemptionVaultWithMorpho', function () { const { redemptionVaultWithMorpho } = await loadFixture(defaultDeploy); await expect( - redemptionVaultWithMorpho[ - 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' - ]( + redemptionVaultWithMorpho.initialize( constants.AddressZero, { mToken: constants.AddressZero, @@ -167,7 +168,6 @@ describe('RedemptionVaultWithMorpho', function () { minFiatRedeemAmount: 0, }, constants.AddressZero, - constants.AddressZero, ), ).revertedWith('Initializable: contract is already initialized'); }); @@ -206,87 +206,119 @@ describe('RedemptionVaultWithMorpho', function () { ), ).revertedWith('Initializable: contract is not initializing'); }); + }); - it('should fail: when morphoVault address zero', async () => { + describe('setMorphoVault()', () => { + it('should fail: call from address without vault admin role', async () => { const { + redemptionVaultWithMorpho, owner, - accessControl, - mTBILL, - tokensReceiver, - feeReceiver, - mTokenToUsdDataFeed, - mockedSanctionsList, - requestRedeemer, + regularAccounts, + stableCoins, + morphoVaultMock, } = await loadFixture(defaultDeploy); - const redemptionVaultWithMorpho = - await new RedemptionVaultWithMorphoTest__factory(owner).deploy(); + await setMorphoVaultTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc.address, + morphoVaultMock.address, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, + ); + }); - await expect( - redemptionVaultWithMorpho[ - 'initialize(address,(address,address),(address,address),(uint256,uint256),address,uint256,uint256,(uint256,uint256,uint256),address,address)' - ]( - 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, - constants.AddressZero, - ), - ).revertedWith('zero address'); + 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('setMorphoVault()', () => { + describe('removeMorphoVault()', () => { it('should fail: call from address without vault admin role', async () => { - const { redemptionVaultWithMorpho, regularAccounts } = await loadFixture( - defaultDeploy, + const { redemptionVaultWithMorpho, owner, regularAccounts, stableCoins } = + await loadFixture(defaultDeploy); + + await removeMorphoVaultTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc.address, + { + from: regularAccounts[0], + revertMessage: 'WMAC: hasnt role', + }, ); - await expect( - redemptionVaultWithMorpho - .connect(regularAccounts[0]) - .setMorphoVault(regularAccounts[1].address), - ).revertedWith('WMAC: hasnt role'); }); - it('should fail: zero address', async () => { - const { redemptionVaultWithMorpho } = await loadFixture(defaultDeploy); - await expect( - redemptionVaultWithMorpho.setMorphoVault(constants.AddressZero), - ).revertedWith('zero address'); + 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('should succeed and emit SetMorphoVault event', async () => { - const { redemptionVaultWithMorpho, regularAccounts } = await loadFixture( - defaultDeploy, + it('call from address with vault admin role', async () => { + const { redemptionVaultWithMorpho, owner, stableCoins } = + await loadFixture(defaultDeploy); + + await removeMorphoVaultTest( + { redemptionVault: redemptionVaultWithMorpho, owner }, + stableCoins.usdc.address, ); - await expect( - redemptionVaultWithMorpho.setMorphoVault(regularAccounts[1].address), - ) - .to.emit(redemptionVaultWithMorpho, 'SetMorphoVault') - .withArgs( - ( - await ethers.getSigners() - )[0].address, - regularAccounts[1].address, - ); }); }); @@ -779,7 +811,7 @@ describe('RedemptionVaultWithMorpho', function () { stableCoins.dai.address, parseUnits('1000', 9), ), - ).to.be.revertedWith('RVM: token not vault asset'); + ).to.be.revertedWith('RVM: no vault for token'); }); it('should revert when contract has insufficient shares', async () => { From 714194d509d33556654204df79c94925b7c51112 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Wed, 4 Mar 2026 19:09:39 +0200 Subject: [PATCH 10/20] chore: checkAndRedeemMToken tests --- contracts/RedemptionVaultWithMToken.sol | 4 +- test/unit/RedemptionVaultWithMToken.test.ts | 242 ++++++++++++++++++++ 2 files changed, 245 insertions(+), 1 deletion(-) diff --git a/contracts/RedemptionVaultWithMToken.sol b/contracts/RedemptionVaultWithMToken.sol index 4542d982..de614122 100644 --- a/contracts/RedemptionVaultWithMToken.sol +++ b/contracts/RedemptionVaultWithMToken.sol @@ -26,13 +26,15 @@ contract RedemptionVaultWithMToken is RedemptionVault { /** * @notice mToken RedemptionVault used for fallback redemptions */ + /// @custom:oz-renamed-from mTbillRedemptionVault IRedemptionVault public redemptionVault; /** * @dev DEPRECATED storage slot kept for layout compatibility */ + /// @custom:oz-renamed-from liquidityProvider // solhint-disable-next-line var-name-mixedcase - address public liquidityProvider_DEPRECATED; + address public liquidityProvider_deprecated; /** * @dev leaving a storage gap for futures updates diff --git a/test/unit/RedemptionVaultWithMToken.test.ts b/test/unit/RedemptionVaultWithMToken.test.ts index 0c0c8190..5c598931 100644 --- a/test/unit/RedemptionVaultWithMToken.test.ts +++ b/test/unit/RedemptionVaultWithMToken.test.ts @@ -687,6 +687,192 @@ describe('RedemptionVaultWithMToken', function () { }); }); + 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, + ); + const tokenOutRate = await dataFeed.getDataInBase18(); + + await redemptionVaultWithMToken.checkAndRedeemMToken( + stableCoins.dai.address, + parseUnits('500', 9), + tokenOutRate, + ); + + 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, + ); + const tokenOutRate = await dataFeed.getDataInBase18(); + + await redemptionVaultWithMToken.checkAndRedeemMToken( + stableCoins.dai.address, + parseUnits('1000', 9), + tokenOutRate, + ); + + 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, + ); + + const tokenOutRate = await dataFeed.getDataInBase18(); + + await expect( + redemptionVaultWithMToken.checkAndRedeemMToken( + stableCoins.dai.address, + parseUnits('1000', 9), + tokenOutRate, + ), + ).to.be.revertedWith('RVMT: insufficient mToken balance'); + }); + + it('should succeed with truncation-prone rates (+ 1 rounding)', async () => { + const { + redemptionVaultWithMToken, + stableCoins, + mTBILL, + owner, + dataFeed, + redemptionVault, + mockedAggregatorMToken, + } = 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, + ); + + // Set mTBILL rate to 3 — causes (amount * 1e18) / 3e18 to have remainder + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 3); + + await mintToken(mTBILL, redemptionVaultWithMToken, 100_000); + await mintToken(stableCoins.dai, redemptionVault, 1_000_000); + + // Use STABLECOIN_RATE (1e18) — matches what _redeemInstant passes + // for stable tokens via _convertUsdToToken, NOT the data feed rate + // (which is 1.02e18). The inner vault also uses STABLECOIN_RATE for + // stable tokens, so both sides see the same rate and the + 1 matters. + const tokenOutRate = parseUnits('1', 18); + + // Without the + 1, the inner vault reverts because: + // mTokenAAmount = (1000e18 * 1e18) / 3e18 = 333...333 (truncated) + // Inner vault: _truncate((333...333 * 3e18) / 1e18, 9) = 999.999999999e18 < 1000e18 + await redemptionVaultWithMToken.checkAndRedeemMToken( + stableCoins.dai.address, + parseUnits('1000', 9), + tokenOutRate, + ); + + const daiAfter = await stableCoins.dai.balanceOf( + redemptionVaultWithMToken.address, + ); + expect(daiAfter).to.be.gte(parseUnits('1000', 9)); + }); + }); + describe('redeemInstant()', () => { it('should fail: when there is no token in vault', async () => { const { @@ -1226,6 +1412,62 @@ describe('RedemptionVaultWithMToken', function () { ); }); + 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: 'RV: minReceiveAmount > actual', + }, + ); + }); + it('should fail: vault has no mTBILL and no DAI', async () => { const { owner, From c50988ffe0a5b0e29ba64e588ce88ac151677b82 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Wed, 4 Mar 2026 19:22:22 +0200 Subject: [PATCH 11/20] fix: correct mTokenAAmount calculation to use ceil rounding and add new test for exact division case --- contracts/RedemptionVaultWithMToken.sol | 8 ++-- test/unit/RedemptionVaultWithMToken.test.ts | 52 +++++++++++++++++++-- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/contracts/RedemptionVaultWithMToken.sol b/contracts/RedemptionVaultWithMToken.sol index de614122..f314e8a7 100644 --- a/contracts/RedemptionVaultWithMToken.sol +++ b/contracts/RedemptionVaultWithMToken.sol @@ -224,9 +224,11 @@ contract RedemptionVaultWithMToken is RedemptionVault { .mTokenDataFeed() .getDataInBase18(); - uint256 mTokenAAmount = (missingAmountBase18 * tokenOutRate) / - mTokenARate + - 1; + uint256 mTokenAAmountNumerator = missingAmountBase18 * tokenOutRate; + uint256 mTokenAAmount = mTokenAAmountNumerator / mTokenARate; + if (mTokenAAmountNumerator % mTokenARate != 0) { + mTokenAAmount += 1; + } address mTokenA = address(redemptionVault.mToken()); diff --git a/test/unit/RedemptionVaultWithMToken.test.ts b/test/unit/RedemptionVaultWithMToken.test.ts index 5c598931..cd926b25 100644 --- a/test/unit/RedemptionVaultWithMToken.test.ts +++ b/test/unit/RedemptionVaultWithMToken.test.ts @@ -819,7 +819,7 @@ describe('RedemptionVaultWithMToken', function () { ).to.be.revertedWith('RVMT: insufficient mToken balance'); }); - it('should succeed with truncation-prone rates (+ 1 rounding)', async () => { + it('should succeed with truncation-prone rates (ceil rounding)', async () => { const { redemptionVaultWithMToken, stableCoins, @@ -854,10 +854,10 @@ describe('RedemptionVaultWithMToken', function () { // Use STABLECOIN_RATE (1e18) — matches what _redeemInstant passes // for stable tokens via _convertUsdToToken, NOT the data feed rate // (which is 1.02e18). The inner vault also uses STABLECOIN_RATE for - // stable tokens, so both sides see the same rate and the + 1 matters. + // stable tokens, so both sides see the same rate and ceil rounding matters. const tokenOutRate = parseUnits('1', 18); - // Without the + 1, the inner vault reverts because: + // Without ceil rounding, the inner vault reverts because: // mTokenAAmount = (1000e18 * 1e18) / 3e18 = 333...333 (truncated) // Inner vault: _truncate((333...333 * 3e18) / 1e18, 9) = 999.999999999e18 < 1000e18 await redemptionVaultWithMToken.checkAndRedeemMToken( @@ -871,6 +871,52 @@ describe('RedemptionVaultWithMToken', function () { ); expect(daiAfter).to.be.gte(parseUnits('1000', 9)); }); + + it('should not over-redeem when division is exact', async () => { + const { + redemptionVaultWithMToken, + stableCoins, + mTBILL, + owner, + dataFeed, + redemptionVault, + mockedAggregatorMToken, + } = 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, + ); + + // 1000 * 1e18 / 2e18 = 500 exactly, so no +1 should be applied. + await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 2); + + // If rounding is exact ceil, 500 mTBILL is enough. With unconditional +1, this reverts. + await mintToken(mTBILL, redemptionVaultWithMToken, 500); + await mintToken(stableCoins.dai, redemptionVault, 1_000_000); + + const tokenOutRate = parseUnits('1', 18); + await redemptionVaultWithMToken.checkAndRedeemMToken( + stableCoins.dai.address, + parseUnits('1000', 9), + tokenOutRate, + ); + + const daiAfter = await stableCoins.dai.balanceOf( + redemptionVaultWithMToken.address, + ); + expect(daiAfter).to.be.gte(parseUnits('1000', 9)); + }); }); describe('redeemInstant()', () => { From 675402c89766e09e4ffa8ffc3ecbdce2788fbab4 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Thu, 5 Mar 2026 15:09:24 +0200 Subject: [PATCH 12/20] feat: implement ceiling division for mToken calculations and enhance tests for edge cases --- contracts/RedemptionVaultWithMToken.sol | 15 +- contracts/RedemptionVaultWithSwapper.sol | 8 +- test/common/fixtures.ts | 1 + test/unit/RedemptionVaultWithMToken.test.ts | 2329 ++++++++++++++++++- 4 files changed, 2240 insertions(+), 113 deletions(-) diff --git a/contracts/RedemptionVaultWithMToken.sol b/contracts/RedemptionVaultWithMToken.sol index f314e8a7..be6ab5dd 100644 --- a/contracts/RedemptionVaultWithMToken.sol +++ b/contracts/RedemptionVaultWithMToken.sol @@ -4,6 +4,8 @@ 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"; @@ -224,11 +226,14 @@ contract RedemptionVaultWithMToken is RedemptionVault { .mTokenDataFeed() .getDataInBase18(); - uint256 mTokenAAmountNumerator = missingAmountBase18 * tokenOutRate; - uint256 mTokenAAmount = mTokenAAmountNumerator / mTokenARate; - if (mTokenAAmountNumerator % mTokenARate != 0) { - mTokenAAmount += 1; - } + // Ceil so the inner vault's floored output is still >= missingAmountBase18. + // Requires address(this) to have waivedFeeRestriction on the inner vault + uint256 mTokenAAmount = Math.mulDiv( + missingAmountBase18, + tokenOutRate, + mTokenARate, + Math.Rounding.Up + ); address mTokenA = address(redemptionVault.mToken()); 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/test/common/fixtures.ts b/test/common/fixtures.ts index cbd6a15f..d9581e87 100644 --- a/test/common/fixtures.ts +++ b/test/common/fixtures.ts @@ -251,6 +251,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 = { diff --git a/test/unit/RedemptionVaultWithMToken.test.ts b/test/unit/RedemptionVaultWithMToken.test.ts index cd926b25..4230c2c4 100644 --- a/test/unit/RedemptionVaultWithMToken.test.ts +++ b/test/unit/RedemptionVaultWithMToken.test.ts @@ -1,6 +1,6 @@ import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; import { expect } from 'chai'; -import { constants } from 'ethers'; +import { BigNumber, constants } from 'ethers'; import { parseUnits } from 'ethers/lib/utils'; import { ethers } from 'hardhat'; @@ -719,12 +719,11 @@ describe('RedemptionVaultWithMToken', function () { const mTbillBefore = await mTBILL.balanceOf( redemptionVaultWithMToken.address, ); - const tokenOutRate = await dataFeed.getDataInBase18(); await redemptionVaultWithMToken.checkAndRedeemMToken( stableCoins.dai.address, parseUnits('500', 9), - tokenOutRate, + parseUnits('1'), ); const mTbillAfter = await mTBILL.balanceOf( @@ -765,12 +764,11 @@ describe('RedemptionVaultWithMToken', function () { const mTbillBefore = await mTBILL.balanceOf( redemptionVaultWithMToken.address, ); - const tokenOutRate = await dataFeed.getDataInBase18(); await redemptionVaultWithMToken.checkAndRedeemMToken( stableCoins.dai.address, parseUnits('1000', 9), - tokenOutRate, + parseUnits('1'), ); const mTbillAfter = await mTBILL.balanceOf( @@ -808,115 +806,14 @@ describe('RedemptionVaultWithMToken', function () { true, ); - const tokenOutRate = await dataFeed.getDataInBase18(); - await expect( redemptionVaultWithMToken.checkAndRedeemMToken( stableCoins.dai.address, parseUnits('1000', 9), - tokenOutRate, + parseUnits('1'), ), ).to.be.revertedWith('RVMT: insufficient mToken balance'); }); - - it('should succeed with truncation-prone rates (ceil rounding)', async () => { - const { - redemptionVaultWithMToken, - stableCoins, - mTBILL, - owner, - dataFeed, - redemptionVault, - mockedAggregatorMToken, - } = 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, - ); - - // Set mTBILL rate to 3 — causes (amount * 1e18) / 3e18 to have remainder - await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 3); - - await mintToken(mTBILL, redemptionVaultWithMToken, 100_000); - await mintToken(stableCoins.dai, redemptionVault, 1_000_000); - - // Use STABLECOIN_RATE (1e18) — matches what _redeemInstant passes - // for stable tokens via _convertUsdToToken, NOT the data feed rate - // (which is 1.02e18). The inner vault also uses STABLECOIN_RATE for - // stable tokens, so both sides see the same rate and ceil rounding matters. - const tokenOutRate = parseUnits('1', 18); - - // Without ceil rounding, the inner vault reverts because: - // mTokenAAmount = (1000e18 * 1e18) / 3e18 = 333...333 (truncated) - // Inner vault: _truncate((333...333 * 3e18) / 1e18, 9) = 999.999999999e18 < 1000e18 - await redemptionVaultWithMToken.checkAndRedeemMToken( - stableCoins.dai.address, - parseUnits('1000', 9), - tokenOutRate, - ); - - const daiAfter = await stableCoins.dai.balanceOf( - redemptionVaultWithMToken.address, - ); - expect(daiAfter).to.be.gte(parseUnits('1000', 9)); - }); - - it('should not over-redeem when division is exact', async () => { - const { - redemptionVaultWithMToken, - stableCoins, - mTBILL, - owner, - dataFeed, - redemptionVault, - mockedAggregatorMToken, - } = 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, - ); - - // 1000 * 1e18 / 2e18 = 500 exactly, so no +1 should be applied. - await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 2); - - // If rounding is exact ceil, 500 mTBILL is enough. With unconditional +1, this reverts. - await mintToken(mTBILL, redemptionVaultWithMToken, 500); - await mintToken(stableCoins.dai, redemptionVault, 1_000_000); - - const tokenOutRate = parseUnits('1', 18); - await redemptionVaultWithMToken.checkAndRedeemMToken( - stableCoins.dai.address, - parseUnits('1000', 9), - tokenOutRate, - ); - - const daiAfter = await stableCoins.dai.balanceOf( - redemptionVaultWithMToken.address, - ); - expect(daiAfter).to.be.gte(parseUnits('1000', 9)); - }); }); describe('redeemInstant()', () => { @@ -1745,6 +1642,57 @@ describe('RedemptionVaultWithMToken', function () { ); }); + 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, @@ -2259,4 +2207,2171 @@ describe('RedemptionVaultWithMToken', function () { ); }); }); + + 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); + }); + }); + }); }); From 23a59c12af3e98204cf8cf8d83732dbc2a789d57 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Mon, 9 Mar 2026 13:52:09 +0200 Subject: [PATCH 13/20] chore: enhance new vault tests --- test/unit/DepositVaultWithAave.test.ts | 1123 ++++++++++++++--- test/unit/DepositVaultWithMToken.test.ts | 1131 ++++++++++++++--- test/unit/DepositVaultWithMorpho.test.ts | 1210 ++++++++++++++++--- test/unit/RedemptionVaultWithAave.test.ts | 612 +++++++++- test/unit/RedemptionVaultWithMToken.test.ts | 731 ++++++++++- test/unit/RedemptionVaultWithMorpho.test.ts | 612 +++++++++- 6 files changed, 4758 insertions(+), 661 deletions(-) diff --git a/test/unit/DepositVaultWithAave.test.ts b/test/unit/DepositVaultWithAave.test.ts index 10c07565..b7590029 100644 --- a/test/unit/DepositVaultWithAave.test.ts +++ b/test/unit/DepositVaultWithAave.test.ts @@ -4,8 +4,16 @@ 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 } from '../common/common.helpers'; +import { + approveBase18, + mintToken, + pauseVault, + pauseVaultFn, +} from '../common/common.helpers'; +import { setRoundData } from '../common/data-feed.helpers'; import { depositInstantWithAaveTest, removeAavePoolTest, @@ -78,6 +86,70 @@ describe('DepositVaultWithAave', function () { 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 { @@ -508,6 +580,60 @@ describe('DepositVaultWithAave', function () { }); }); + 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 { @@ -696,36 +822,23 @@ describe('DepositVaultWithAave', function () { ); }); - it('deposit 100 USDC when aaveDepositsEnabled is true', async () => { + it('should fail: when trying to deposit 0 amount', async () => { const { owner, depositVaultWithAave, stableCoins, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, dataFeed, + mTokenToUsdDataFeed, 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, + stableCoins.dai, dataFeed.address, 0, true, ); - await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); - await depositInstantWithAaveTest( { depositVaultWithAave, @@ -734,42 +847,33 @@ describe('DepositVaultWithAave', function () { mTokenToUsdDataFeed, aavePoolMock, }, - stableCoins.usdc, - 100, + stableCoins.dai, + 0, { - from: regularAccounts[0], + revertMessage: 'DV: invalid amount', }, ); }); - it('when aaveDepositsEnabled is false, normal DV flow', async () => { + it('should fail: when rounding is invalid', async () => { const { owner, depositVaultWithAave, stableCoins, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, dataFeed, + mTokenToUsdDataFeed, 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, + stableCoins.dai, dataFeed.address, 0, true, ); await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); - await depositInstantWithAaveTest( { depositVaultWithAave, @@ -777,51 +881,35 @@ describe('DepositVaultWithAave', function () { mTBILL, mTokenToUsdDataFeed, aavePoolMock, - expectedAaveDeposited: false, }, - stableCoins.usdc, - 100, + stableCoins.dai, + 100.0000000001, { - from: regularAccounts[0], + revertMessage: 'MV: invalid rounding', }, ); }); - it('deposit with waived fee', async () => { + it('should fail: call with insufficient allowance', async () => { const { owner, depositVaultWithAave, stableCoins, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, dataFeed, + mTokenToUsdDataFeed, 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 mintToken(stableCoins.dai, owner, 100); await addPaymentTokenTest( { vault: depositVaultWithAave, owner }, - stableCoins.usdc, + stableCoins.dai, dataFeed.address, 0, true, ); await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); - await depositInstantWithAaveTest( { depositVaultWithAave, @@ -829,59 +917,35 @@ describe('DepositVaultWithAave', function () { mTBILL, mTokenToUsdDataFeed, aavePoolMock, - waivedFee: true, }, - stableCoins.usdc, + stableCoins.dai, 100, { - from: regularAccounts[0], + revertMessage: 'ERC20: insufficient allowance', }, ); }); - it('deposit 100 DAI with aave enabled (non-stablecoin feed)', async () => { + it('should fail: call with insufficient balance', async () => { const { owner, depositVaultWithAave, stableCoins, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, dataFeed, + mTokenToUsdDataFeed, 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 approveBase18(owner, stableCoins.dai, depositVaultWithAave, 100); await addPaymentTokenTest( { vault: depositVaultWithAave, owner }, stableCoins.dai, dataFeed.address, 0, - false, + true, ); await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); - await depositInstantWithAaveTest( { depositVaultWithAave, @@ -893,50 +957,33 @@ describe('DepositVaultWithAave', function () { stableCoins.dai, 100, { - from: regularAccounts[0], + revertMessage: 'ERC20: transfer amount exceeds balance', }, ); }); - it('deposit with greenlist enabled and user in greenlist', async () => { + it('should fail: dataFeed rate 0 ', async () => { const { owner, depositVaultWithAave, stableCoins, mTBILL, - greenListableTester, - mTokenToUsdDataFeed, - accessControl, - regularAccounts, dataFeed, + mockedAggregator, + mTokenToUsdDataFeed, 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 approveBase18(owner, stableCoins.dai, depositVaultWithAave, 10); await addPaymentTokenTest( { vault: depositVaultWithAave, owner }, - stableCoins.usdc, + stableCoins.dai, dataFeed.address, 0, true, ); - await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); - + await mintToken(stableCoins.dai, owner, 100_000); + await setRoundData({ mockedAggregator }, 0); await depositInstantWithAaveTest( { depositVaultWithAave, @@ -945,43 +992,50 @@ describe('DepositVaultWithAave', function () { mTokenToUsdDataFeed, aavePoolMock, }, - stableCoins.usdc, - 100, + stableCoins.dai, + 1, { - from: regularAccounts[0], + revertMessage: 'DF: feed is deprecated', }, ); }); - it('deposit with custom recipient, aaveDepositsEnabled is true', async () => { + it('should fail: call for amount < minAmountToDepositTest', async () => { const { - owner, depositVaultWithAave, - stableCoins, + mockedAggregator, + owner, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, + stableCoins, dataFeed, + mTokenToUsdDataFeed, 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, + stableCoins.dai, dataFeed.address, 0, true, ); - await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + 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( { @@ -990,43 +1044,51 @@ describe('DepositVaultWithAave', function () { mTBILL, mTokenToUsdDataFeed, aavePoolMock, - customRecipient: regularAccounts[1], }, - stableCoins.usdc, - 100, + stableCoins.dai, + 99_999, { - from: regularAccounts[0], + revertMessage: 'DV: mint amount < min', }, ); }); - it('deposit with custom recipient, aaveDepositsEnabled is false', async () => { + it('should fail: call for amount < minAmount', async () => { const { - owner, depositVaultWithAave, - stableCoins, + mockedAggregator, + owner, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, + stableCoins, dataFeed, + mTokenToUsdDataFeed, 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, + stableCoins.dai, dataFeed.address, 0, true, ); - await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + 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( { @@ -1035,51 +1097,604 @@ describe('DepositVaultWithAave', function () { mTBILL, mTokenToUsdDataFeed, aavePoolMock, - expectedAaveDeposited: false, - customRecipient: regularAccounts[1], }, - stableCoins.usdc, - 100, + stableCoins.dai, + 99, { - from: regularAccounts[0], + revertMessage: 'DV: mToken amount < min', }, ); }); - it('should fail: greenlist enabled and user not in greenlist', async () => { + it('should fail: if exceed allowance of deposit for token', async () => { const { - owner, depositVaultWithAave, - stableCoins, + mockedAggregator, + owner, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, + stableCoins, dataFeed, + mTokenToUsdDataFeed, 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, + stableCoins.dai, dataFeed.address, 0, true, ); - await setMinAmountTest({ vault: depositVaultWithAave, owner }, 10); + await setRoundData({ mockedAggregator }, 4); - await depositInstantWithAaveTest( - { - depositVaultWithAave, - owner, + 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, @@ -1184,6 +1799,164 @@ describe('DepositVaultWithAave', function () { }, ); }); + + 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()', () => { diff --git a/test/unit/DepositVaultWithMToken.test.ts b/test/unit/DepositVaultWithMToken.test.ts index e54da3e9..1d0eca15 100644 --- a/test/unit/DepositVaultWithMToken.test.ts +++ b/test/unit/DepositVaultWithMToken.test.ts @@ -4,8 +4,16 @@ 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 } from '../common/common.helpers'; +import { + approveBase18, + mintToken, + pauseVault, + pauseVaultFn, +} from '../common/common.helpers'; +import { setRoundData } from '../common/data-feed.helpers'; import { depositInstantWithMTokenTest, setMTokenDepositsEnabledTest, @@ -78,6 +86,73 @@ describe('DepositVaultWithMToken', function () { 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 } = @@ -481,6 +556,60 @@ describe('DepositVaultWithMToken', function () { }); }); + 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 { @@ -661,47 +790,22 @@ describe('DepositVaultWithMToken', function () { ); }); - it('deposit 100 USDC when mTokenDepositsEnabled is true', async () => { + it('should fail: when trying to deposit 0 amount', async () => { const { owner, depositVaultWithMToken, - depositVault, stableCoins, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, dataFeed, + mTokenToUsdDataFeed, } = 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, + stableCoins.dai, dataFeed.address, 0, true, ); - await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); - await setMinAmountTest({ vault: depositVault, owner }, 0); - await depositInstantWithMTokenTest( { depositVaultWithMToken, @@ -709,160 +813,100 @@ describe('DepositVaultWithMToken', function () { mTBILL, mTokenToUsdDataFeed, }, - stableCoins.usdc, - 100, + stableCoins.dai, + 0, { - from: regularAccounts[0], + revertMessage: 'DV: invalid amount', }, ); }); - it('when mTokenDepositsEnabled is false, normal DV flow', async () => { + it('should fail: when rounding is invalid', async () => { const { owner, depositVaultWithMToken, stableCoins, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, dataFeed, + mTokenToUsdDataFeed, } = 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, + stableCoins.dai, dataFeed.address, 0, true, ); await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); - await depositInstantWithMTokenTest( { depositVaultWithMToken, owner, mTBILL, mTokenToUsdDataFeed, - expectedMTokenDeposited: false, }, - stableCoins.usdc, - 100, + stableCoins.dai, + 100.0000000001, { - from: regularAccounts[0], + revertMessage: 'MV: invalid rounding', }, ); }); - it('deposit with waived fee', async () => { + it('should fail: call with insufficient allowance', async () => { const { owner, depositVaultWithMToken, - depositVault, stableCoins, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, dataFeed, + mTokenToUsdDataFeed, } = 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 mintToken(stableCoins.dai, owner, 100); await addPaymentTokenTest( { vault: depositVaultWithMToken, owner }, - stableCoins.usdc, - dataFeed.address, - 0, - true, - ); - await addPaymentTokenTest( - { vault: depositVault, owner }, - stableCoins.usdc, + stableCoins.dai, 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, + stableCoins.dai, 100, { - from: regularAccounts[0], + revertMessage: 'ERC20: insufficient allowance', }, ); }); - it('deposit 100 DAI with mToken enabled (non-stablecoin feed)', async () => { + it('should fail: call with insufficient balance', async () => { const { owner, depositVaultWithMToken, - depositVault, stableCoins, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, dataFeed, + mTokenToUsdDataFeed, } = await loadFixture(defaultDeploy); - await setMTokenDepositsEnabledTest( - { depositVaultWithMToken, owner }, - true, - ); - - await mintToken(stableCoins.dai, regularAccounts[0], 100); - await approveBase18( - regularAccounts[0], - stableCoins.dai, - depositVaultWithMToken, - 100, - ); + await approveBase18(owner, 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, + true, ); await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); - await setMinAmountTest({ vault: depositVault, owner }, 0); - await depositInstantWithMTokenTest( { depositVaultWithMToken, @@ -873,61 +917,32 @@ describe('DepositVaultWithMToken', function () { stableCoins.dai, 100, { - from: regularAccounts[0], + revertMessage: 'ERC20: transfer amount exceeds balance', }, ); }); - it('deposit with greenlist enabled and user in greenlist', async () => { + it('should fail: dataFeed rate 0', async () => { const { owner, depositVaultWithMToken, - depositVault, stableCoins, mTBILL, - greenListableTester, - mTokenToUsdDataFeed, - accessControl, - regularAccounts, dataFeed, + mockedAggregator, + mTokenToUsdDataFeed, } = 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 approveBase18(owner, stableCoins.dai, depositVaultWithMToken, 10); await addPaymentTokenTest( { vault: depositVaultWithMToken, owner }, - stableCoins.usdc, - dataFeed.address, - 0, - true, - ); - await addPaymentTokenTest( - { vault: depositVault, owner }, - stableCoins.usdc, + stableCoins.dai, dataFeed.address, 0, true, ); - await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); - await setMinAmountTest({ vault: depositVault, owner }, 0); - + await mintToken(stableCoins.dai, owner, 100_000); + await setRoundData({ mockedAggregator }, 0); await depositInstantWithMTokenTest( { depositVaultWithMToken, @@ -935,54 +950,49 @@ describe('DepositVaultWithMToken', function () { mTBILL, mTokenToUsdDataFeed, }, - stableCoins.usdc, - 100, + stableCoins.dai, + 1, { - from: regularAccounts[0], + revertMessage: 'DF: feed is deprecated', }, ); }); - it('deposit with custom recipient, mTokenDepositsEnabled is true', async () => { + it('should fail: call for amount < minAmountToDepositTest', async () => { const { - owner, depositVaultWithMToken, - depositVault, - stableCoins, + mockedAggregator, + owner, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, + stableCoins, dataFeed, + mTokenToUsdDataFeed, } = await loadFixture(defaultDeploy); - - await setMTokenDepositsEnabledTest( - { depositVaultWithMToken, owner }, + await addPaymentTokenTest( + { vault: depositVaultWithMToken, owner }, + stableCoins.dai, + dataFeed.address, + 0, true, ); + await setRoundData({ mockedAggregator }, 1); - await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await mintToken(stableCoins.dai, owner, 100_000); await approveBase18( - regularAccounts[0], - stableCoins.usdc, + owner, + stableCoins.dai, depositVaultWithMToken, - 100, + 100_000, ); - await addPaymentTokenTest( - { vault: depositVaultWithMToken, owner }, - stableCoins.usdc, - dataFeed.address, - 0, - true, + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithMToken, owner }, + 100_000, ); - await addPaymentTokenTest( - { vault: depositVault, owner }, - stableCoins.usdc, - dataFeed.address, - 0, - true, + await setInstantDailyLimitTest( + { vault: depositVaultWithMToken, owner }, + 150_000, ); - await setMinAmountTest({ vault: depositVaultWithMToken, owner }, 10); - await setMinAmountTest({ vault: depositVault, owner }, 0); await depositInstantWithMTokenTest( { @@ -990,37 +1000,622 @@ describe('DepositVaultWithMToken', function () { owner, mTBILL, mTokenToUsdDataFeed, - customRecipient: regularAccounts[1], }, - stableCoins.usdc, - 100, + stableCoins.dai, + 99_999, { - from: regularAccounts[0], + revertMessage: 'DV: mint amount < min', }, ); }); - it('deposit with custom recipient, mTokenDepositsEnabled is false', async () => { + it('should fail: call for amount < minAmount', async () => { const { - owner, depositVaultWithMToken, - stableCoins, + mockedAggregator, + owner, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, + stableCoins, dataFeed, + mTokenToUsdDataFeed, } = 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, + 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, @@ -1174,6 +1769,156 @@ describe('DepositVaultWithMToken', function () { }, ); }); + + 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', + }, + ); + }); }); describe('depositRequest()', () => { diff --git a/test/unit/DepositVaultWithMorpho.test.ts b/test/unit/DepositVaultWithMorpho.test.ts index db3003f8..3575bd57 100644 --- a/test/unit/DepositVaultWithMorpho.test.ts +++ b/test/unit/DepositVaultWithMorpho.test.ts @@ -4,9 +4,19 @@ import { constants } from 'ethers'; import { parseUnits } from 'ethers/lib/utils'; import { ethers } from 'hardhat'; -import { MorphoVaultMock__factory } from '../../typechain-types'; +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 } from '../common/common.helpers'; +import { + approveBase18, + mintToken, + pauseVault, + pauseVaultFn, +} from '../common/common.helpers'; +import { setRoundData } from '../common/data-feed.helpers'; import { depositInstantWithMorphoTest, removeMorphoVaultTest, @@ -30,10 +40,12 @@ import { 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 () => { @@ -74,6 +86,70 @@ describe('DepositVaultWithMorpho', function () { 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 { @@ -561,6 +637,60 @@ describe('DepositVaultWithMorpho', function () { }); }); + 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 { @@ -698,30 +828,16 @@ describe('DepositVaultWithMorpho', function () { ); }); - it('should fail: morphoDepositsEnabled but no vault for token', async () => { + it('should fail: when trying to deposit 0 amount', async () => { const { owner, depositVaultWithMorpho, stableCoins, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, dataFeed, + mTokenToUsdDataFeed, 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, @@ -729,8 +845,6 @@ describe('DepositVaultWithMorpho', function () { 0, true, ); - await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); - await depositInstantWithMorphoTest( { depositVaultWithMorpho, @@ -738,42 +852,26 @@ describe('DepositVaultWithMorpho', function () { mTBILL, mTokenToUsdDataFeed, morphoVaultMock, - expectedMorphoDeposited: false, }, stableCoins.usdc, - 100, + 0, { - from: regularAccounts[0], - revertMessage: 'DVM: no vault for token', + revertMessage: 'DV: invalid amount', }, ); }); - it('should fail: when Morpho deposit mints zero shares', async () => { + it('should fail: when rounding is invalid', async () => { const { owner, depositVaultWithMorpho, stableCoins, mTBILL, - mTokenToUsdDataFeed, dataFeed, + mTokenToUsdDataFeed, 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, @@ -781,7 +879,7 @@ describe('DepositVaultWithMorpho', function () { 0, true, ); - + await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); await depositInstantWithMorphoTest( { depositVaultWithMorpho, @@ -789,46 +887,27 @@ describe('DepositVaultWithMorpho', function () { mTBILL, mTokenToUsdDataFeed, morphoVaultMock, - expectedMorphoDeposited: false, }, stableCoins.usdc, - 0.000001, + 100.0000000001, { - revertMessage: 'DVM: zero shares', + revertMessage: 'MV: invalid rounding', }, ); }); - it('deposit 100 USDC when morphoDepositsEnabled is true', async () => { + it('should fail: call with insufficient allowance', async () => { const { owner, depositVaultWithMorpho, stableCoins, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, dataFeed, + mTokenToUsdDataFeed, 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 mintToken(stableCoins.usdc, owner, 100); await addPaymentTokenTest( { vault: depositVaultWithMorpho, owner }, stableCoins.usdc, @@ -837,7 +916,6 @@ describe('DepositVaultWithMorpho', function () { true, ); await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); - await depositInstantWithMorphoTest( { depositVaultWithMorpho, @@ -849,30 +927,23 @@ describe('DepositVaultWithMorpho', function () { stableCoins.usdc, 100, { - from: regularAccounts[0], + revertMessage: 'ERC20: insufficient allowance', }, ); }); - it('when morphoDepositsEnabled is false, normal DV flow', async () => { + it('should fail: call with insufficient balance', async () => { const { owner, depositVaultWithMorpho, stableCoins, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, dataFeed, + mTokenToUsdDataFeed, morphoVaultMock, } = await loadFixture(defaultDeploy); - await mintToken(stableCoins.usdc, regularAccounts[0], 100); - await approveBase18( - regularAccounts[0], - stableCoins.usdc, - depositVaultWithMorpho, - 100, - ); + await approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 100); await addPaymentTokenTest( { vault: depositVaultWithMorpho, owner }, stableCoins.usdc, @@ -881,7 +952,6 @@ describe('DepositVaultWithMorpho', function () { true, ); await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); - await depositInstantWithMorphoTest( { depositVaultWithMorpho, @@ -889,51 +959,28 @@ describe('DepositVaultWithMorpho', function () { mTBILL, mTokenToUsdDataFeed, morphoVaultMock, - expectedMorphoDeposited: false, }, stableCoins.usdc, 100, { - from: regularAccounts[0], + revertMessage: 'ERC20: transfer amount exceeds balance', }, ); }); - it('deposit with waived fee', async () => { + it('should fail: dataFeed rate 0', async () => { const { owner, depositVaultWithMorpho, stableCoins, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, dataFeed, + mockedAggregator, + mTokenToUsdDataFeed, 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 approveBase18(owner, stableCoins.usdc, depositVaultWithMorpho, 10); await addPaymentTokenTest( { vault: depositVaultWithMorpho, owner }, stableCoins.usdc, @@ -941,8 +988,8 @@ describe('DepositVaultWithMorpho', function () { 0, true, ); - await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); - + await mintToken(stableCoins.usdc, owner, 100_000); + await setRoundData({ mockedAggregator }, 0); await depositInstantWithMorphoTest( { depositVaultWithMorpho, @@ -950,63 +997,51 @@ describe('DepositVaultWithMorpho', function () { mTBILL, mTokenToUsdDataFeed, morphoVaultMock, - waivedFee: true, }, stableCoins.usdc, - 100, + 1, { - from: regularAccounts[0], + revertMessage: 'DF: feed is deprecated', }, ); }); - it('deposit with greenlist enabled and user in greenlist', async () => { + it('should fail: call for amount < minAmountToDepositTest', async () => { const { - owner, depositVaultWithMorpho, - stableCoins, + mockedAggregator, + owner, mTBILL, - greenListableTester, - mTokenToUsdDataFeed, - accessControl, - regularAccounts, + stableCoins, dataFeed, + mTokenToUsdDataFeed, morphoVaultMock, } = await loadFixture(defaultDeploy); - - await depositVaultWithMorpho.setGreenlistEnable(true); - - await greenList( - { greenlistable: greenListableTester, accessControl, owner }, - regularAccounts[0], - ); - - await setMorphoDepositsEnabledTest( - { depositVaultWithMorpho, owner }, + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, true, ); + await setRoundData({ mockedAggregator }, 1); - await setMorphoVaultTest( - { depositVaultWithMorpho, owner }, - stableCoins.usdc.address, - morphoVaultMock.address, - ); - - await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await mintToken(stableCoins.usdc, owner, 100_000); await approveBase18( - regularAccounts[0], + owner, stableCoins.usdc, depositVaultWithMorpho, - 100, + 100_000, ); - await addPaymentTokenTest( + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithMorpho, owner }, + 100_000, + ); + await setInstantDailyLimitTest( { vault: depositVaultWithMorpho, owner }, - stableCoins.usdc, - dataFeed.address, - 0, - true, + 150_000, ); - await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); await depositInstantWithMorphoTest( { @@ -1017,51 +1052,49 @@ describe('DepositVaultWithMorpho', function () { morphoVaultMock, }, stableCoins.usdc, - 100, + 99_999, { - from: regularAccounts[0], + revertMessage: 'DV: mint amount < min', }, ); }); - it('deposit with custom recipient, morphoDepositsEnabled is true', async () => { + it('should fail: call for amount < minAmount', async () => { const { - owner, depositVaultWithMorpho, - stableCoins, + mockedAggregator, + owner, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, + stableCoins, dataFeed, + mTokenToUsdDataFeed, morphoVaultMock, } = await loadFixture(defaultDeploy); - - await setMorphoDepositsEnabledTest( - { depositVaultWithMorpho, owner }, + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, true, ); + await setRoundData({ mockedAggregator }, 1); - await setMorphoVaultTest( - { depositVaultWithMorpho, owner }, - stableCoins.usdc.address, - morphoVaultMock.address, - ); - - await mintToken(stableCoins.usdc, regularAccounts[0], 100); + await mintToken(stableCoins.usdc, owner, 100_000); await approveBase18( - regularAccounts[0], + owner, stableCoins.usdc, depositVaultWithMorpho, - 100, + 100_000, ); - await addPaymentTokenTest( + + await setMinAmountToDepositTest( + { depositVault: depositVaultWithMorpho, owner }, + 100_000, + ); + await setInstantDailyLimitTest( { vault: depositVaultWithMorpho, owner }, - stableCoins.usdc, - dataFeed.address, - 0, - true, + 150_000, ); - await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); await depositInstantWithMorphoTest( { @@ -1070,39 +1103,687 @@ describe('DepositVaultWithMorpho', function () { mTBILL, mTokenToUsdDataFeed, morphoVaultMock, - customRecipient: regularAccounts[1], }, stableCoins.usdc, - 100, + 99, { - from: regularAccounts[0], + revertMessage: 'DV: mToken amount < min', }, ); }); - it('deposit 100 DAI with morpho enabled (per-asset vault mapping)', async () => { + it('should fail: if exceed allowance of deposit for token', async () => { const { - owner, depositVaultWithMorpho, - stableCoins, + mockedAggregator, + owner, mTBILL, - mTokenToUsdDataFeed, - regularAccounts, + stableCoins, dataFeed, + mTokenToUsdDataFeed, + morphoVaultMock, } = 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 }, + await addPaymentTokenTest( + { vault: depositVaultWithMorpho, owner }, + stableCoins.usdc, + dataFeed.address, + 0, true, ); + await setRoundData({ mockedAggregator }, 4); - await setMorphoVaultTest( - { depositVaultWithMorpho, owner }, + 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('should fail: morphoDepositsEnabled but no vault for token', 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], + revertMessage: 'DVM: no vault for token', + }, + ); + }); + + 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, ); @@ -1217,6 +1898,163 @@ describe('DepositVaultWithMorpho', function () { }, ); }); + + 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()', () => { diff --git a/test/unit/RedemptionVaultWithAave.test.ts b/test/unit/RedemptionVaultWithAave.test.ts index 9ebc060c..2f21ef17 100644 --- a/test/unit/RedemptionVaultWithAave.test.ts +++ b/test/unit/RedemptionVaultWithAave.test.ts @@ -9,7 +9,7 @@ import { ManageableVaultTester__factory, RedemptionVaultWithAaveTest__factory, } from '../../typechain-types'; -import { acErrors, blackList } from '../common/ac.helpers'; +import { acErrors, blackList, greenList } from '../common/ac.helpers'; import { approveBase18, mintToken, @@ -36,6 +36,7 @@ import { redeemInstantTest, redeemRequestTest, rejectRedeemRequestTest, + safeApproveRedeemRequestTest, setFiatAdditionalFeeTest, setMinFiatRedeemAmountTest, } from '../common/redemption-vault.helpers'; @@ -1816,6 +1817,156 @@ describe('RedemptionVaultWithAave', function () { }, ); }); + + 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()', () => { @@ -2032,33 +2183,48 @@ describe('RedemptionVaultWithAave', function () { }); }); - describe('approveRequest()', () => { - it('should fail: when there is no request', async () => { + 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, + redemptionVaultWithAave: redemptionVault, stableCoins, mTBILL, dataFeed, mTokenToUsdDataFeed, } = await loadFixture(defaultDeploy); - await addPaymentTokenTest( - { vault: redemptionVaultWithAave, owner }, - stableCoins.usdc, + { vault: redemptionVault, owner }, + stableCoins.dai, dataFeed.address, 0, true, ); - await approveRedeemRequestTest( - { - redemptionVault: redemptionVaultWithAave, - owner, - mTBILL, - mTokenToUsdDataFeed, - }, - +new Date(), + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + 1, parseUnits('1'), { revertMessage: 'RV: request not exist', @@ -2066,12 +2232,12 @@ describe('RedemptionVaultWithAave', function () { ); }); - it('approve request: happy path', async () => { + it('should fail: request already processed', async () => { const { owner, mockedAggregator, mockedAggregatorMToken, - redemptionVaultWithAave, + redemptionVaultWithAave: redemptionVault, stableCoins, mTBILL, dataFeed, @@ -2083,14 +2249,14 @@ describe('RedemptionVaultWithAave', function () { await approveBase18( requestRedeemer, stableCoins.dai, - redemptionVaultWithAave, + redemptionVault, 100000, ); await mintToken(mTBILL, owner, 100); - await approveBase18(owner, mTBILL, redemptionVaultWithAave, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); await addPaymentTokenTest( - { vault: redemptionVaultWithAave, owner }, + { vault: redemptionVault, owner }, stableCoins.dai, dataFeed.address, 0, @@ -2100,47 +2266,50 @@ describe('RedemptionVaultWithAave', function () { await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); await redeemRequestTest( - { - redemptionVault: redemptionVaultWithAave, - owner, - mTBILL, - mTokenToUsdDataFeed, - }, + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, stableCoins.dai, 100, ); const requestId = 0; await approveRedeemRequestTest( - { - redemptionVault: redemptionVaultWithAave, - owner, - mTBILL, - mTokenToUsdDataFeed, - }, + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('1'), + ); + await approveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, +requestId, parseUnits('1'), + { revertMessage: 'RV: request not pending' }, ); }); - }); - describe('rejectRequest()', () => { - it('reject request: happy path', async () => { + it('approve request from vaut admin account', async () => { const { owner, mockedAggregator, mockedAggregatorMToken, - redemptionVaultWithAave, + 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, redemptionVaultWithAave, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); await addPaymentTokenTest( - { vault: redemptionVaultWithAave, owner }, + { vault: redemptionVault, owner }, stableCoins.dai, dataFeed.address, 0, @@ -2150,24 +2319,383 @@ describe('RedemptionVaultWithAave', function () { 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: redemptionVaultWithAave, - owner, + 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: redemptionVaultWithAave, - owner, + 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 index 4230c2c4..d858745f 100644 --- a/test/unit/RedemptionVaultWithMToken.test.ts +++ b/test/unit/RedemptionVaultWithMToken.test.ts @@ -9,7 +9,7 @@ import { ManageableVaultTester__factory, RedemptionVaultWithMTokenTest__factory, } from '../../typechain-types'; -import { acErrors, blackList } from '../common/ac.helpers'; +import { acErrors, blackList, greenList } from '../common/ac.helpers'; import { approveBase18, mintToken, @@ -36,6 +36,7 @@ import { redeemFiatRequestTest, redeemRequestTest, rejectRedeemRequestTest, + safeApproveRedeemRequestTest, setFiatAdditionalFeeTest, setMinFiatRedeemAmountTest, } from '../common/redemption-vault.helpers'; @@ -1891,6 +1892,172 @@ describe('RedemptionVaultWithMToken', function () { }, ); }); + + 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()', () => { @@ -2098,32 +2265,85 @@ describe('RedemptionVaultWithMToken', function () { }); }); - describe('approveRequest()', () => { - it('approve request', async () => { + 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, + redemptionVaultWithMToken: redemptionVault, stableCoins, mFONE, mFoneToUsdDataFeed, dataFeed, - requestRedeemer, + } = 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, - redemptionVaultWithMToken, + redemptionVault, 100000, ); await mintToken(mFONE, owner, 100); - await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100); + await approveBase18(owner, mFONE, redemptionVault, 100); await addPaymentTokenTest( - { vault: redemptionVaultWithMToken, owner }, + { vault: redemptionVault, owner }, stableCoins.dai, dataFeed.address, 0, @@ -2134,7 +2354,7 @@ describe('RedemptionVaultWithMToken', function () { await redeemRequestTest( { - redemptionVault: redemptionVaultWithMToken, + redemptionVault, owner, mTBILL: mFONE, mTokenToUsdDataFeed: mFoneToUsdDataFeed, @@ -2142,39 +2362,56 @@ describe('RedemptionVaultWithMToken', function () { stableCoins.dai, 100, ); - const requestId = 0; await approveRedeemRequestTest( { - redemptionVault: redemptionVaultWithMToken, + 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' }, ); }); - }); - describe('rejectRequest()', () => { - it('reject request', async () => { + it('approve request from vaut admin account', async () => { const { owner, - redemptionVaultWithMToken, + mockedAggregator, + mockedAggregatorMFone, + redemptionVaultWithMToken: redemptionVault, stableCoins, mFONE, mFoneToUsdDataFeed, dataFeed, - mockedAggregator, - mockedAggregatorMFone, + 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, redemptionVaultWithMToken, 100); + await approveBase18(owner, mFONE, redemptionVault, 100); await addPaymentTokenTest( - { vault: redemptionVaultWithMToken, owner }, + { vault: redemptionVault, owner }, stableCoins.dai, dataFeed.address, 0, @@ -2185,7 +2422,7 @@ describe('RedemptionVaultWithMToken', function () { await redeemRequestTest( { - redemptionVault: redemptionVaultWithMToken, + redemptionVault, owner, mTBILL: mFONE, mTokenToUsdDataFeed: mFoneToUsdDataFeed, @@ -2193,12 +2430,460 @@ describe('RedemptionVaultWithMToken', function () { stableCoins.dai, 100, ); - const requestId = 0; - await rejectRedeemRequestTest( + await approveRedeemRequestTest( { - redemptionVault: redemptionVaultWithMToken, + 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, diff --git a/test/unit/RedemptionVaultWithMorpho.test.ts b/test/unit/RedemptionVaultWithMorpho.test.ts index efd6d23b..1517a731 100644 --- a/test/unit/RedemptionVaultWithMorpho.test.ts +++ b/test/unit/RedemptionVaultWithMorpho.test.ts @@ -9,7 +9,7 @@ import { ManageableVaultTester__factory, RedemptionVaultWithMorphoTest__factory, } from '../../typechain-types'; -import { acErrors, blackList } from '../common/ac.helpers'; +import { acErrors, blackList, greenList } from '../common/ac.helpers'; import { approveBase18, mintToken, @@ -40,6 +40,7 @@ import { redeemInstantTest, redeemRequestTest, rejectRedeemRequestTest, + safeApproveRedeemRequestTest, setFiatAdditionalFeeTest, setMinFiatRedeemAmountTest, } from '../common/redemption-vault.helpers'; @@ -1887,6 +1888,156 @@ describe('RedemptionVaultWithMorpho', function () { }, ); }); + + 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()', () => { @@ -2103,33 +2254,48 @@ describe('RedemptionVaultWithMorpho', function () { }); }); - describe('approveRequest()', () => { - it('should fail: when there is no request', async () => { + 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, + redemptionVaultWithMorpho: redemptionVault, stableCoins, mTBILL, dataFeed, mTokenToUsdDataFeed, } = await loadFixture(defaultDeploy); - await addPaymentTokenTest( - { vault: redemptionVaultWithMorpho, owner }, - stableCoins.usdc, + { vault: redemptionVault, owner }, + stableCoins.dai, dataFeed.address, 0, true, ); - await approveRedeemRequestTest( - { - redemptionVault: redemptionVaultWithMorpho, - owner, - mTBILL, - mTokenToUsdDataFeed, - }, - +new Date(), + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + 1, parseUnits('1'), { revertMessage: 'RV: request not exist', @@ -2137,12 +2303,12 @@ describe('RedemptionVaultWithMorpho', function () { ); }); - it('approve request: happy path', async () => { + it('should fail: request already processed', async () => { const { owner, mockedAggregator, mockedAggregatorMToken, - redemptionVaultWithMorpho, + redemptionVaultWithMorpho: redemptionVault, stableCoins, mTBILL, dataFeed, @@ -2154,14 +2320,14 @@ describe('RedemptionVaultWithMorpho', function () { await approveBase18( requestRedeemer, stableCoins.dai, - redemptionVaultWithMorpho, + redemptionVault, 100000, ); await mintToken(mTBILL, owner, 100); - await approveBase18(owner, mTBILL, redemptionVaultWithMorpho, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); await addPaymentTokenTest( - { vault: redemptionVaultWithMorpho, owner }, + { vault: redemptionVault, owner }, stableCoins.dai, dataFeed.address, 0, @@ -2171,47 +2337,50 @@ describe('RedemptionVaultWithMorpho', function () { await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 5); await redeemRequestTest( - { - redemptionVault: redemptionVaultWithMorpho, - owner, - mTBILL, - mTokenToUsdDataFeed, - }, + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, stableCoins.dai, 100, ); const requestId = 0; await approveRedeemRequestTest( - { - redemptionVault: redemptionVaultWithMorpho, - owner, - mTBILL, - mTokenToUsdDataFeed, - }, + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, + +requestId, + parseUnits('1'), + ); + await approveRedeemRequestTest( + { redemptionVault, owner, mTBILL, mTokenToUsdDataFeed }, +requestId, parseUnits('1'), + { revertMessage: 'RV: request not pending' }, ); }); - }); - describe('rejectRequest()', () => { - it('reject request: happy path', async () => { + it('approve request from vaut admin account', async () => { const { owner, mockedAggregator, mockedAggregatorMToken, - redemptionVaultWithMorpho, + 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, redemptionVaultWithMorpho, 100); + await approveBase18(owner, mTBILL, redemptionVault, 100); await addPaymentTokenTest( - { vault: redemptionVaultWithMorpho, owner }, + { vault: redemptionVault, owner }, stableCoins.dai, dataFeed.address, 0, @@ -2221,24 +2390,383 @@ describe('RedemptionVaultWithMorpho', function () { 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: redemptionVaultWithMorpho, - owner, + 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: redemptionVaultWithMorpho, - owner, + 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, ); }); From 8dbcbcdf6109740cf84145c467b8dd58678855cb Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Wed, 11 Mar 2026 13:32:16 +0200 Subject: [PATCH 14/20] feat: implement auto-invest fallback for Aave, Morpho, and MToken deposit vaults --- contracts/DepositVault.sol | 24 +- contracts/DepositVaultWithAave.sol | 85 ++++- contracts/DepositVaultWithMToken.sol | 95 +++++- contracts/DepositVaultWithMorpho.sol | 88 ++++- contracts/mocks/AaveV3PoolMock.sol | 6 + contracts/mocks/MorphoVaultMock.sol | 6 + test/common/deposit-vault-aave.helpers.ts | 116 ++++++- test/common/deposit-vault-morpho.helpers.ts | 116 ++++++- test/common/deposit-vault-mtoken.helpers.ts | 123 ++++++- test/common/deposit-vault.helpers.ts | 12 +- test/unit/DepositVaultWithAave.test.ts | 296 ++++++++++++++++- test/unit/DepositVaultWithMToken.test.ts | 305 ++++++++++++++++- test/unit/DepositVaultWithMorpho.test.ts | 342 +++++++++++++++++++- 13 files changed, 1565 insertions(+), 49 deletions(-) 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 index 928b0b1a..da5101cc 100644 --- a/contracts/DepositVaultWithAave.sol +++ b/contracts/DepositVaultWithAave.sol @@ -29,6 +29,12 @@ contract DepositVaultWithAave is DepositVault { */ 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 */ @@ -59,6 +65,12 @@ contract DepositVaultWithAave is DepositVault { */ 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 @@ -98,20 +110,27 @@ contract DepositVaultWithAave is DepositVault { } /** - * @dev overrides original transfer to tokens receiver function - * in case of Aave deposits are disabled, it will act as the original transfer - * otherwise it will take payment tokens from user, supply them to Aave V3 Pool - * and aTokens will be minted to tokens receiver - * @param tokenIn token address - * @param amountToken amount of tokens to transfer in base18 - * @param tokensDecimals decimals of tokens + * @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 { - if (!aaveDepositsEnabled) { + IAaveV3Pool pool = aavePools[tokenIn]; + if (!aaveDepositsEnabled || address(pool) == address(0)) { return super._instantTransferTokensToTokensReceiver( tokenIn, @@ -120,8 +139,44 @@ contract DepositVaultWithAave is DepositVault { ); } + _autoInvest(tokenIn, amountToken, tokensDecimals); + } + + /** + * @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); + } + + /** + * @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 + */ + function _autoInvest( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) private { IAaveV3Pool pool = aavePools[tokenIn]; - require(address(pool) != address(0), "DVA: no pool for token"); uint256 transferredAmount = _tokenTransferFromUser( tokenIn, @@ -131,6 +186,16 @@ contract DepositVaultWithAave is DepositVault { ); IERC20(tokenIn).safeIncreaseAllowance(address(pool), transferredAmount); - pool.supply(tokenIn, transferredAmount, tokensReceiver, 0); + + 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 index 01dc7a90..b469d568 100644 --- a/contracts/DepositVaultWithMToken.sol +++ b/contracts/DepositVaultWithMToken.sol @@ -29,6 +29,12 @@ contract DepositVaultWithMToken is DepositVault { */ 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 */ @@ -50,6 +56,12 @@ contract DepositVaultWithMToken is DepositVault { */ 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 @@ -118,13 +130,19 @@ contract DepositVaultWithMToken is DepositVault { } /** - * @dev overrides original transfer to tokens receiver function - * in case of mToken deposits are disabled, it will act as the original transfer - * otherwise it will take payment tokens from user, deposit them into the target - * mToken DepositVault and forward received mTokens to tokens receiver - * @param tokenIn token address - * @param amountToken amount of tokens to transfer in base18 - * @param tokensDecimals decimals of tokens + * @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, @@ -140,6 +158,42 @@ contract DepositVaultWithMToken is DepositVault { ); } + _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 { uint256 transferredAmount = _tokenTransferFromUser( tokenIn, address(this), @@ -155,12 +209,25 @@ contract DepositVaultWithMToken is DepositVault { IERC20 targetMToken = IERC20(address(mTokenDepositVault.mToken())); uint256 balanceBefore = targetMToken.balanceOf(address(this)); - 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); + 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 index 00dc7038..b543683e 100644 --- a/contracts/DepositVaultWithMorpho.sol +++ b/contracts/DepositVaultWithMorpho.sol @@ -29,6 +29,12 @@ contract DepositVaultWithMorpho is DepositVault { */ 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 */ @@ -59,6 +65,12 @@ contract DepositVaultWithMorpho is DepositVault { */ 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 @@ -101,20 +113,27 @@ contract DepositVaultWithMorpho is DepositVault { } /** - * @dev overrides original transfer to tokens receiver function - * in case of Morpho deposits are disabled, it will act as the original transfer - * otherwise it will take payment tokens from user, deposit them into Morpho Vault - * and vault shares will be minted to tokens receiver - * @param tokenIn token address - * @param amountToken amount of tokens to transfer in base18 - * @param tokensDecimals decimals of tokens + * @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 { - if (!morphoDepositsEnabled) { + IMorphoVault vault = morphoVaults[tokenIn]; + if (!morphoDepositsEnabled || address(vault) == address(0)) { return super._instantTransferTokensToTokensReceiver( tokenIn, @@ -123,8 +142,44 @@ contract DepositVaultWithMorpho is DepositVault { ); } + _autoInvest(tokenIn, amountToken, tokensDecimals); + } + + /** + * @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); + } + + /** + * @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 + */ + function _autoInvest( + address tokenIn, + uint256 amountToken, + uint256 tokensDecimals + ) private { IMorphoVault vault = morphoVaults[tokenIn]; - require(address(vault) != address(0), "DVM: no vault for token"); uint256 transferredAmount = _tokenTransferFromUser( tokenIn, @@ -137,7 +192,18 @@ contract DepositVaultWithMorpho is DepositVault { address(vault), transferredAmount ); - uint256 shares = vault.deposit(transferredAmount, tokensReceiver); - require(shares > 0, "DVM: zero shares"); + + 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/mocks/AaveV3PoolMock.sol b/contracts/mocks/AaveV3PoolMock.sol index ba440474..9d962e7f 100644 --- a/contracts/mocks/AaveV3PoolMock.sol +++ b/contracts/mocks/AaveV3PoolMock.sol @@ -11,6 +11,7 @@ contract AaveV3PoolMock { mapping(address => address) public reserveATokens; uint256 public withdrawReturnBps = 10_000; + bool public shouldRevertSupply; function setReserveAToken(address asset, address aToken) external { reserveATokens[asset] = aToken; @@ -39,12 +40,17 @@ contract AaveV3PoolMock { 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"); diff --git a/contracts/mocks/MorphoVaultMock.sol b/contracts/mocks/MorphoVaultMock.sol index 6d933246..8c8f58d9 100644 --- a/contracts/mocks/MorphoVaultMock.sol +++ b/contracts/mocks/MorphoVaultMock.sol @@ -13,6 +13,7 @@ contract MorphoVaultMock is ERC20 { uint256 public exchangeRateNumerator; uint256 public constant RATE_PRECISION = 1e18; + bool public shouldRevertDeposit; constructor(address _underlyingAsset) ERC20("MorphoVaultMock", "mvMOCK") { underlyingAsset = _underlyingAsset; @@ -27,6 +28,10 @@ contract MorphoVaultMock is ERC20 { exchangeRateNumerator = _numerator; } + function setShouldRevertDeposit(bool _shouldRevert) external { + shouldRevertDeposit = _shouldRevert; + } + function withdrawAdmin( address token, address to, @@ -43,6 +48,7 @@ contract MorphoVaultMock is ERC20 { external returns (uint256 shares) { + require(!shouldRevertDeposit, "MorphoVaultMock: DepositReverted"); shares = previewDeposit(assets); IERC20(underlyingAsset).safeTransferFrom( diff --git a/test/common/deposit-vault-aave.helpers.ts b/test/common/deposit-vault-aave.helpers.ts index 13c05b90..4ca9402c 100644 --- a/test/common/deposit-vault-aave.helpers.ts +++ b/test/common/deposit-vault-aave.helpers.ts @@ -7,7 +7,10 @@ import { balanceOfBase18, getAccount, } from './common.helpers'; -import { depositInstantTest } from './deposit-vault.helpers'; +import { + depositInstantTest, + depositRequestTest, +} from './deposit-vault.helpers'; import { defaultDeploy } from './fixtures'; import { @@ -194,3 +197,114 @@ export const depositInstantWithAaveTest = async ( 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 index e3a997b9..c6c4c8ca 100644 --- a/test/common/deposit-vault-morpho.helpers.ts +++ b/test/common/deposit-vault-morpho.helpers.ts @@ -7,7 +7,10 @@ import { balanceOfBase18, getAccount, } from './common.helpers'; -import { depositInstantTest } from './deposit-vault.helpers'; +import { + depositInstantTest, + depositRequestTest, +} from './deposit-vault.helpers'; import { defaultDeploy } from './fixtures'; import { @@ -202,3 +205,114 @@ export const depositInstantWithMorphoTest = async ( 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 index 662c1266..78667460 100644 --- a/test/common/deposit-vault-mtoken.helpers.ts +++ b/test/common/deposit-vault-mtoken.helpers.ts @@ -7,7 +7,10 @@ import { balanceOfBase18, getAccount, } from './common.helpers'; -import { depositInstantTest } from './deposit-vault.helpers'; +import { + depositInstantTest, + depositRequestTest, +} from './deposit-vault.helpers'; import { defaultDeploy } from './fixtures'; import { @@ -180,3 +183,121 @@ export const depositInstantWithMTokenTest = async ( 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.helpers.ts b/test/common/deposit-vault.helpers.ts index 8bc00d4b..26ea8a44 100644 --- a/test/common/deposit-vault.helpers.ts +++ b/test/common/deposit-vault.helpers.ts @@ -205,9 +205,11 @@ export const depositRequestTest = async ( mTokenToUsdDataFeed, waivedFee, customRecipient, + checkTokensReceiver = true, }: CommonParamsDeposit & { waivedFee?: boolean; customRecipient?: AccountOrContract; + checkTokensReceiver?: boolean; }, tokenIn: ERC20 | string, amountUsdIn: number, @@ -324,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), ); @@ -728,6 +732,7 @@ export const getFeePercent = async ( | DepositVaultTest | DepositVaultWithAaveTest | DepositVaultWithMorphoTest + | DepositVaultWithMTokenTest | DepositVaultWithUSTBTest, isInstant: boolean, ) => { @@ -752,6 +757,7 @@ export const calcExpectedMintAmount = async ( | DepositVaultTest | DepositVaultWithAaveTest | DepositVaultWithMorphoTest + | DepositVaultWithMTokenTest | DepositVaultWithUSTBTest, mTokenRate: BigNumber, amountIn: BigNumber, diff --git a/test/unit/DepositVaultWithAave.test.ts b/test/unit/DepositVaultWithAave.test.ts index b7590029..c7b47a55 100644 --- a/test/unit/DepositVaultWithAave.test.ts +++ b/test/unit/DepositVaultWithAave.test.ts @@ -16,9 +16,11 @@ import { import { setRoundData } from '../common/data-feed.helpers'; import { depositInstantWithAaveTest, + depositRequestWithAaveTest, removeAavePoolTest, setAaveDepositsEnabledTest, setAavePoolTest, + setAutoInvestFallbackEnabledAaveTest, } from '../common/deposit-vault-aave.helpers'; import { approveRequestTest, @@ -284,6 +286,45 @@ describe('DepositVaultWithAave', function () { }); }); + 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 } = @@ -1752,7 +1793,7 @@ describe('DepositVaultWithAave', function () { ); }); - it('should fail: aaveDepositsEnabled but no pool for token', async () => { + it('aaveDepositsEnabled but no pool for token: fallback to normal flow', async () => { const { owner, depositVaultWithAave, @@ -1795,7 +1836,109 @@ describe('DepositVaultWithAave', function () { 100, { from: regularAccounts[0], - revertMessage: 'DVA: no pool for token', + }, + ); + }); + + 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', }, ); }); @@ -2001,6 +2144,155 @@ describe('DepositVaultWithAave', function () { }, ); }); + + 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()', () => { diff --git a/test/unit/DepositVaultWithMToken.test.ts b/test/unit/DepositVaultWithMToken.test.ts index 1d0eca15..788e6f4a 100644 --- a/test/unit/DepositVaultWithMToken.test.ts +++ b/test/unit/DepositVaultWithMToken.test.ts @@ -16,6 +16,8 @@ import { import { setRoundData } from '../common/data-feed.helpers'; import { depositInstantWithMTokenTest, + depositRequestWithMTokenTest, + setAutoInvestFallbackEnabledMTokenTest, setMTokenDepositsEnabledTest, setMTokenDepositVaultTest, } from '../common/deposit-vault-mtoken.helpers'; @@ -250,6 +252,49 @@ describe('DepositVaultWithMToken', function () { }); }); + 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 } = @@ -1765,7 +1810,7 @@ describe('DepositVaultWithMToken', function () { stableCoins.dai, 100, { - revertMessage: 'MV: token not exists', + revertMessage: 'DVMT: auto-invest failed', }, ); }); @@ -1919,6 +1964,107 @@ describe('DepositVaultWithMToken', function () { }, ); }); + + 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()', () => { @@ -1963,6 +2109,163 @@ describe('DepositVaultWithMToken', function () { }, ); }); + + 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()', () => { diff --git a/test/unit/DepositVaultWithMorpho.test.ts b/test/unit/DepositVaultWithMorpho.test.ts index 3575bd57..7d57e174 100644 --- a/test/unit/DepositVaultWithMorpho.test.ts +++ b/test/unit/DepositVaultWithMorpho.test.ts @@ -19,7 +19,9 @@ import { import { setRoundData } from '../common/data-feed.helpers'; import { depositInstantWithMorphoTest, + depositRequestWithMorphoTest, removeMorphoVaultTest, + setAutoInvestFallbackEnabledMorphoTest, setMorphoDepositsEnabledTest, setMorphoVaultTest, } from '../common/deposit-vault-morpho.helpers'; @@ -316,6 +318,49 @@ describe('DepositVaultWithMorpho', function () { }); }); + 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 } = @@ -1379,7 +1424,57 @@ describe('DepositVaultWithMorpho', function () { ); }); - it('should fail: morphoDepositsEnabled but no vault for token', async () => { + 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, @@ -1395,6 +1490,16 @@ describe('DepositVaultWithMorpho', function () { { 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( @@ -1412,6 +1517,8 @@ describe('DepositVaultWithMorpho', function () { ); await setMinAmountTest({ vault: depositVaultWithMorpho, owner }, 10); + await morphoVaultMock.setShouldRevertDeposit(true); + await depositInstantWithMorphoTest( { depositVaultWithMorpho, @@ -1425,7 +1532,65 @@ describe('DepositVaultWithMorpho', function () { 100, { from: regularAccounts[0], - revertMessage: 'DVM: no vault for token', + }, + ); + }); + + 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', }, ); }); @@ -2099,6 +2264,179 @@ describe('DepositVaultWithMorpho', function () { }, ); }); + + 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()', () => { From 8dfa3983b371d6a229a420c995cf89dd5fab75c4 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Wed, 11 Mar 2026 14:10:15 +0200 Subject: [PATCH 15/20] refactor: update _autoInvest function to accept pool and vault parameters --- contracts/DepositVaultWithAave.sol | 10 +++++----- contracts/DepositVaultWithMorpho.sol | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/contracts/DepositVaultWithAave.sol b/contracts/DepositVaultWithAave.sol index da5101cc..b295b775 100644 --- a/contracts/DepositVaultWithAave.sol +++ b/contracts/DepositVaultWithAave.sol @@ -139,7 +139,7 @@ contract DepositVaultWithAave is DepositVault { ); } - _autoInvest(tokenIn, amountToken, tokensDecimals); + _autoInvest(tokenIn, amountToken, tokensDecimals, pool); } /** @@ -160,7 +160,7 @@ contract DepositVaultWithAave is DepositVault { ); } - _autoInvest(tokenIn, amountToken, tokensDecimals); + _autoInvest(tokenIn, amountToken, tokensDecimals, pool); } /** @@ -170,14 +170,14 @@ contract DepositVaultWithAave is DepositVault { * @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 + uint256 tokensDecimals, + IAaveV3Pool pool ) private { - IAaveV3Pool pool = aavePools[tokenIn]; - uint256 transferredAmount = _tokenTransferFromUser( tokenIn, address(this), diff --git a/contracts/DepositVaultWithMorpho.sol b/contracts/DepositVaultWithMorpho.sol index b543683e..cf0f5650 100644 --- a/contracts/DepositVaultWithMorpho.sol +++ b/contracts/DepositVaultWithMorpho.sol @@ -142,7 +142,7 @@ contract DepositVaultWithMorpho is DepositVault { ); } - _autoInvest(tokenIn, amountToken, tokensDecimals); + _autoInvest(tokenIn, amountToken, tokensDecimals, vault); } /** @@ -163,7 +163,7 @@ contract DepositVaultWithMorpho is DepositVault { ); } - _autoInvest(tokenIn, amountToken, tokensDecimals); + _autoInvest(tokenIn, amountToken, tokensDecimals, vault); } /** @@ -173,14 +173,14 @@ contract DepositVaultWithMorpho is DepositVault { * @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 + uint256 tokensDecimals, + IMorphoVault vault ) private { - IMorphoVault vault = morphoVaults[tokenIn]; - uint256 transferredAmount = _tokenTransferFromUser( tokenIn, address(this), From e54995e5144331b242f90fa19030848ab3c2d922 Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Wed, 11 Mar 2026 14:27:43 +0200 Subject: [PATCH 16/20] chore: aave and morpho dvs integration tests --- test/integration/DepositVaultWithAave.test.ts | 36 +++++++++++++ .../DepositVaultWithMorpho.test.ts | 54 ++++++++----------- 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/test/integration/DepositVaultWithAave.test.ts b/test/integration/DepositVaultWithAave.test.ts index 52cd0cc0..947a7ced 100644 --- a/test/integration/DepositVaultWithAave.test.ts +++ b/test/integration/DepositVaultWithAave.test.ts @@ -195,4 +195,40 @@ describe('DepositVaultWithAave - Mainnet Fork Integration Tests', function () { 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/DepositVaultWithMorpho.test.ts b/test/integration/DepositVaultWithMorpho.test.ts index 1880d7c1..d404c920 100644 --- a/test/integration/DepositVaultWithMorpho.test.ts +++ b/test/integration/DepositVaultWithMorpho.test.ts @@ -1,7 +1,4 @@ import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; -import { expect } from 'chai'; -import { constants } from 'ethers'; -import { parseUnits } from 'ethers/lib/utils'; import { morphoDepositFixture } from './fixtures/morpho.fixture'; import { @@ -10,8 +7,6 @@ import { depositInstantMorpho, } from './helpers/deposit.helpers'; -import { approveBase18 } from '../common/common.helpers'; - describe('DepositVaultWithMorpho - Mainnet Fork Integration Tests', function () { this.timeout(300000); @@ -201,10 +196,18 @@ describe('DepositVaultWithMorpho - Mainnet Fork Integration Tests', function () }); }); - describe('Error case: No vault configured for token', function () { - it('should revert with DVM: no vault for token', async function () { - const { vaultAdmin, testUser, depositVaultWithMorpho, usdc, usdcWhale } = - await loadFixture(morphoDepositFixture); + 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) @@ -214,29 +217,18 @@ describe('DepositVaultWithMorpho - Mainnet Fork Integration Tests', function () .connect(vaultAdmin) .removeMorphoVault(usdc.address); - const usdcAmountUsd = 100; - - await usdc - .connect(usdcWhale) - .transfer(testUser.address, parseUnits('100', 6)); + const result = await depositInstantMorpho({ + depositVault: depositVaultWithMorpho, + user: testUser, + tokenIn: usdc, + receiptToken: morphoVault, + mToken: mTBILL, + tokensReceiverAddress: tokensReceiver.address, + tokenWhale: usdcWhale, + amountUsd: 100, + }); - await approveBase18( - testUser, - usdc, - depositVaultWithMorpho, - usdcAmountUsd, - ); - - await expect( - depositVaultWithMorpho - .connect(testUser) - ['depositInstant(address,uint256,uint256,bytes32)']( - usdc.address, - parseUnits(String(usdcAmountUsd)), - constants.Zero, - constants.HashZero, - ), - ).to.be.revertedWith('DVM: no vault for token'); + assertAutoInvestDisabled(result); }); }); }); From cb1033ef2343363b378fa3bd68460873e3a205bf Mon Sep 17 00:00:00 2001 From: kostyamospan Date: Wed, 11 Mar 2026 18:12:56 +0200 Subject: [PATCH 17/20] chore: greenlist per token contracts generation --- helpers/roles.ts | 2 ++ scripts/deploy/codegen/common/index.ts | 23 +++++++++++++--- .../common/templates/dv-aave.template.ts | 24 +++++++++++++++-- .../common/templates/dv-morpho.template.ts | 24 +++++++++++++++-- .../common/templates/dv-mtoken.template.ts | 24 +++++++++++++++-- .../codegen/common/templates/dv.template.ts | 20 +++++++++++++- .../common/templates/rv-aave.template.ts | 24 +++++++++++++++-- .../common/templates/rv-morpho.template.ts | 24 +++++++++++++++-- .../common/templates/rv-mtoken.template.ts | 24 +++++++++++++++-- .../common/templates/rv-swapper.template.ts | 20 +++++++++++++- .../common/templates/rv-ustb.template.ts | 20 +++++++++++++- .../codegen/common/templates/rv.template.ts | 20 +++++++++++++- .../common/templates/token-roles.template.ts | 27 ++++++++++++++++--- .../codegen/common/ui/deployment-contracts.ts | 9 ++++++- 14 files changed, 262 insertions(+), 23 deletions(-) diff --git a/helpers/roles.ts b/helpers/roles.ts index 32a66d2c..09212612 100644 --- a/helpers/roles.ts +++ b/helpers/roles.ts @@ -83,6 +83,7 @@ type TokenRoles = { depositVaultAdmin: string; redemptionVaultAdmin: string; customFeedAdmin: string | null; + greenlisted: string; }; type CommonRoles = { @@ -118,6 +119,7 @@ export const getRolesNamesForToken = (token: MTokenName): TokenRoles => { : `${tokenPrefix}_CUSTOM_AGGREGATOR_FEED_ADMIN_ROLE`, depositVaultAdmin: `${restPrefix}DEPOSIT_VAULT_ADMIN_ROLE`, redemptionVaultAdmin: `${restPrefix}REDEMPTION_VAULT_ADMIN_ROLE`, + greenlisted: `${restPrefix}GREENLISTED_ROLE`, }; }; export const getRolesNamesCommon = (): CommonRoles => { diff --git a/scripts/deploy/codegen/common/index.ts b/scripts/deploy/codegen/common/index.ts index 25c04edc..fe2a0003 100644 --- a/scripts/deploy/codegen/common/index.ts +++ b/scripts/deploy/codegen/common/index.ts @@ -36,6 +36,7 @@ import { import { getConfigFromUser, getContractsToGenerateFromUser, + getShouldUseTokenLevelGreenListFromUser, } from './ui/deployment-contracts'; import { MTokenName } from '../../../../config'; @@ -51,7 +52,10 @@ export type CodeExpr = { [EXPR]: string }; const generatorPerContract: Partial< Record< keyof TokenContractNames | 'layerZeroMinterBurner', - (mToken: MTokenName) => + ( + mToken: MTokenName, + optionalParams?: Record, + ) => | Promise< | { name: string; @@ -517,6 +521,15 @@ export const generateContracts = async (hre: HardhatRuntimeEnvironment) => { const contractsToGenerate = await getContractsToGenerateFromUser(); + let shouldUseTokenLevelGreenList = false; + + if ( + contractsToGenerate.find((v) => v.startsWith('dv') || v.startsWith('rv')) + ) { + shouldUseTokenLevelGreenList = + await getShouldUseTokenLevelGreenListFromUser(); + } + const mToken = config.tokenContractName; const folder = path.join( @@ -539,7 +552,7 @@ export const generateContracts = async (hre: HardhatRuntimeEnvironment) => { }, }, { - title: 'Generation files', + title: 'Generating files', task: async () => { const isFolderExists = await fs .access(folder) @@ -559,7 +572,11 @@ export const generateContracts = async (hre: HardhatRuntimeEnvironment) => { ].filter((v) => v !== undefined); const generatedContracts = await Promise.all( - generators.map((generator) => generator(mToken as MTokenName)), + generators.map((generator) => + generator(mToken as MTokenName, { + vaultUseTokenLevelGreenList: shouldUseTokenLevelGreenList, + }), + ), ); for (const contract of generatedContracts) { diff --git a/scripts/deploy/codegen/common/templates/dv-aave.template.ts b/scripts/deploy/codegen/common/templates/dv-aave.template.ts index 34f81bb4..25001389 100644 --- a/scripts/deploy/codegen/common/templates/dv-aave.template.ts +++ b/scripts/deploy/codegen/common/templates/dv-aave.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getDvAaveContractFromTemplate = async (mToken: MTokenName) => { +export const getDvAaveContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -23,7 +28,9 @@ export const getDvAaveContractFromTemplate = async (mToken: MTokenName) => { /** * @title ${contractNames.dvAave} - * @notice Smart contract that handles ${contractNames.token} minting with Aave V3 auto-invest + * @notice Smart contract that handles ${ + contractNames.token + } minting with Aave V3 auto-invest * @author RedDuck Software */ contract ${contractNames.dvAave} is @@ -41,6 +48,19 @@ export const getDvAaveContractFromTemplate = async (mToken: MTokenName) => { 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 index 08eda6dc..1ca6c32c 100644 --- a/scripts/deploy/codegen/common/templates/dv-morpho.template.ts +++ b/scripts/deploy/codegen/common/templates/dv-morpho.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getDvMorphoContractFromTemplate = async (mToken: MTokenName) => { +export const getDvMorphoContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -23,7 +28,9 @@ export const getDvMorphoContractFromTemplate = async (mToken: MTokenName) => { /** * @title ${contractNames.dvMorpho} - * @notice Smart contract that handles ${contractNames.token} minting with Morpho auto-invest + * @notice Smart contract that handles ${ + contractNames.token + } minting with Morpho auto-invest * @author RedDuck Software */ contract ${contractNames.dvMorpho} is @@ -41,6 +48,19 @@ export const getDvMorphoContractFromTemplate = async (mToken: MTokenName) => { 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 index 2ee6d57a..3e901fe0 100644 --- a/scripts/deploy/codegen/common/templates/dv-mtoken.template.ts +++ b/scripts/deploy/codegen/common/templates/dv-mtoken.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getDvMTokenContractFromTemplate = async (mToken: MTokenName) => { +export const getDvMTokenContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -23,7 +28,9 @@ export const getDvMTokenContractFromTemplate = async (mToken: MTokenName) => { /** * @title ${contractNames.dvMToken} - * @notice Smart contract that handles ${contractNames.token} minting with mToken auto-invest + * @notice Smart contract that handles ${ + contractNames.token + } minting with mToken auto-invest * @author RedDuck Software */ contract ${contractNames.dvMToken} is @@ -41,6 +48,19 @@ export const getDvMTokenContractFromTemplate = async (mToken: MTokenName) => { 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.template.ts b/scripts/deploy/codegen/common/templates/dv.template.ts index a6b8fb54..c8de0470 100644 --- a/scripts/deploy/codegen/common/templates/dv.template.ts +++ b/scripts/deploy/codegen/common/templates/dv.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getDvContractFromTemplate = async (mToken: MTokenName) => { +export const getDvContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -38,6 +43,19 @@ export const getDvContractFromTemplate = async (mToken: MTokenName) => { 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/rv-aave.template.ts b/scripts/deploy/codegen/common/templates/rv-aave.template.ts index 441b7dfe..5429132c 100644 --- a/scripts/deploy/codegen/common/templates/rv-aave.template.ts +++ b/scripts/deploy/codegen/common/templates/rv-aave.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getRvAaveContractFromTemplate = async (mToken: MTokenName) => { +export const getRvAaveContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -23,7 +28,9 @@ export const getRvAaveContractFromTemplate = async (mToken: MTokenName) => { /** * @title ${contractNames.rvAave} - * @notice Smart contract that handles ${contractNames.token} redemptions via Aave V3 + * @notice Smart contract that handles ${ + contractNames.token + } redemptions via Aave V3 * @author RedDuck Software */ contract ${contractNames.rvAave} is @@ -41,6 +48,19 @@ export const getRvAaveContractFromTemplate = async (mToken: MTokenName) => { 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 index 9d474a3f..d0ae805c 100644 --- a/scripts/deploy/codegen/common/templates/rv-morpho.template.ts +++ b/scripts/deploy/codegen/common/templates/rv-morpho.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getRvMorphoContractFromTemplate = async (mToken: MTokenName) => { +export const getRvMorphoContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -23,7 +28,9 @@ export const getRvMorphoContractFromTemplate = async (mToken: MTokenName) => { /** * @title ${contractNames.rvMorpho} - * @notice Smart contract that handles ${contractNames.token} redemptions via Morpho Vault + * @notice Smart contract that handles ${ + contractNames.token + } redemptions via Morpho Vault * @author RedDuck Software */ contract ${contractNames.rvMorpho} is @@ -41,6 +48,19 @@ export const getRvMorphoContractFromTemplate = async (mToken: MTokenName) => { 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 index 3a7d0e03..d39d200b 100644 --- a/scripts/deploy/codegen/common/templates/rv-mtoken.template.ts +++ b/scripts/deploy/codegen/common/templates/rv-mtoken.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getRvMTokenContractFromTemplate = async (mToken: MTokenName) => { +export const getRvMTokenContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -23,7 +28,9 @@ export const getRvMTokenContractFromTemplate = async (mToken: MTokenName) => { /** * @title ${contractNames.rvMToken} - * @notice Smart contract that handles ${contractNames.token} redemptions using mToken + * @notice Smart contract that handles ${ + contractNames.token + } redemptions using mToken * liquid strategy. Upgrade-compatible replacement for * ${contractNames.rvSwapper}. * @author RedDuck Software @@ -43,6 +50,19 @@ export const getRvMTokenContractFromTemplate = async (mToken: MTokenName) => { 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-swapper.template.ts b/scripts/deploy/codegen/common/templates/rv-swapper.template.ts index a0fe39c4..07422069 100644 --- a/scripts/deploy/codegen/common/templates/rv-swapper.template.ts +++ b/scripts/deploy/codegen/common/templates/rv-swapper.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getRvSwapperContractFromTemplate = async (mToken: MTokenName) => { +export const getRvSwapperContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -41,6 +46,19 @@ export const getRvSwapperContractFromTemplate = async (mToken: MTokenName) => { 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-ustb.template.ts b/scripts/deploy/codegen/common/templates/rv-ustb.template.ts index 043eaddc..fb7cf6af 100644 --- a/scripts/deploy/codegen/common/templates/rv-ustb.template.ts +++ b/scripts/deploy/codegen/common/templates/rv-ustb.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getRvUstbContractFromTemplate = async (mToken: MTokenName) => { +export const getRvUstbContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -41,6 +46,19 @@ export const getRvUstbContractFromTemplate = async (mToken: MTokenName) => { 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 2a9ad4ec..8a66775c 100644 --- a/scripts/deploy/codegen/common/templates/rv.template.ts +++ b/scripts/deploy/codegen/common/templates/rv.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getRvContractFromTemplate = async (mToken: MTokenName) => { +export const getRvContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -42,6 +47,19 @@ export const getRvContractFromTemplate = async (mToken: MTokenName) => { 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/token-roles.template.ts b/scripts/deploy/codegen/common/templates/token-roles.template.ts index f23d965b..1120bb96 100644 --- a/scripts/deploy/codegen/common/templates/token-roles.template.ts +++ b/scripts/deploy/codegen/common/templates/token-roles.template.ts @@ -1,7 +1,12 @@ import { MTokenName } from '../../../../../config'; import { importWithoutCache } from '../../../../../helpers/utils'; -export const getTokenRolesContractFromTemplate = async (mToken: MTokenName) => { +export const getTokenRolesContractFromTemplate = async ( + mToken: MTokenName, + optionalParams?: Record, +) => { + const { vaultUseTokenLevelGreenList = false } = optionalParams || {}; + const { getTokenContractNames } = await importWithoutCache( require.resolve('../../../../../helpers/contracts'), ); @@ -21,7 +26,9 @@ export const getTokenRolesContractFromTemplate = async (mToken: MTokenName) => { /** * @title ${contractNames.roles} - * @notice Base contract that stores all roles descriptors for ${contractNames.token} contracts + * @notice Base contract that stores all roles descriptors for ${ + contractNames.token + } contracts * @author RedDuck Software */ abstract contract ${contractNames.roles} { @@ -38,10 +45,24 @@ export const getTokenRolesContractFromTemplate = async (mToken: MTokenName) => { keccak256("${roles.redemptionVaultAdmin}"); /** - * @notice actor that can manage ${contractNames.customAggregator} and ${contractNames.dataFeed} + * @notice actor that can manage ${contractNames.customAggregator} and ${ + contractNames.dataFeed + } */ bytes32 public constant ${roles.customFeedAdmin} = keccak256("${roles.customFeedAdmin}"); + + ${ + vaultUseTokenLevelGreenList + ? ` + /** + * @notice greenlist role for ${contractNames.token} + */ + bytes32 public constant ${roles.greenlisted} = + keccak256("${roles.greenlisted}"); + ` + : '' + } }`, }; }; diff --git a/scripts/deploy/codegen/common/ui/deployment-contracts.ts b/scripts/deploy/codegen/common/ui/deployment-contracts.ts index 9be25033..d29546cc 100644 --- a/scripts/deploy/codegen/common/ui/deployment-contracts.ts +++ b/scripts/deploy/codegen/common/ui/deployment-contracts.ts @@ -1,4 +1,4 @@ -import { group, multiselect, text } from '@clack/prompts'; +import { group, multiselect, text, confirm } from '@clack/prompts'; import { requireNotCancelled } from '..'; import { TokenContractNames } from '../../../../../helpers/contracts'; @@ -139,3 +139,10 @@ export const getContractsToGenerateFromUser = async () => { required: true, }).then(requireNotCancelled); }; + +export const getShouldUseTokenLevelGreenListFromUser = async () => { + return confirm({ + message: 'Should use token level green list for vaults?', + initialValue: false, + }).then(requireNotCancelled); +}; From 67c35b9423c1ed35363e3ab5360bb571d7d15a1f Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Thu, 12 Mar 2026 13:46:21 +0200 Subject: [PATCH 18/20] chore: update documentation and formatting in contract templates --- contracts/RedemptionVaultWithMToken.sol | 6 +++--- .../common/templates/dv-morpho.template.ts | 14 +++++++------- .../common/templates/dv-mtoken.template.ts | 16 ++++++++-------- .../codegen/common/templates/rv-aave.template.ts | 14 +++++++------- .../common/templates/rv-morpho.template.ts | 14 +++++++------- .../common/templates/rv-mtoken.template.ts | 14 +++++++------- .../codegen/common/templates/rv.template.ts | 2 +- 7 files changed, 40 insertions(+), 40 deletions(-) diff --git a/contracts/RedemptionVaultWithMToken.sol b/contracts/RedemptionVaultWithMToken.sol index be6ab5dd..c491272a 100644 --- a/contracts/RedemptionVaultWithMToken.sol +++ b/contracts/RedemptionVaultWithMToken.sol @@ -27,14 +27,14 @@ contract RedemptionVaultWithMToken is RedemptionVault { /** * @notice mToken RedemptionVault used for fallback redemptions + * @custom:oz-renamed-from mTbillRedemptionVault */ - /// @custom:oz-renamed-from mTbillRedemptionVault IRedemptionVault public redemptionVault; /** - * @dev DEPRECATED storage slot kept for layout compatibility + * @dev Deprecated storage slot preserved for storage layout compatibility. Must not be removed + * @custom:oz-renamed-from liquidityProvider */ - /// @custom:oz-renamed-from liquidityProvider // solhint-disable-next-line var-name-mixedcase address public liquidityProvider_deprecated; diff --git a/scripts/deploy/codegen/common/templates/dv-morpho.template.ts b/scripts/deploy/codegen/common/templates/dv-morpho.template.ts index 1ca6c32c..edb69743 100644 --- a/scripts/deploy/codegen/common/templates/dv-morpho.template.ts +++ b/scripts/deploy/codegen/common/templates/dv-morpho.template.ts @@ -52,13 +52,13 @@ export const getDvMorphoContractFromTemplate = async ( ${ vaultUseTokenLevelGreenList ? ` - /** - * @inheritdoc Greenlistable - */ - function greenlistedRole() public pure override returns (bytes32) { - return ${roles.greenlisted}; - } - ` + /** + * @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 index 3e901fe0..fabd91ad 100644 --- a/scripts/deploy/codegen/common/templates/dv-mtoken.template.ts +++ b/scripts/deploy/codegen/common/templates/dv-mtoken.template.ts @@ -52,15 +52,15 @@ export const getDvMTokenContractFromTemplate = async ( ${ vaultUseTokenLevelGreenList ? ` - /** - * @inheritdoc Greenlistable - */ - function greenlistedRole() public pure override returns (bytes32) { - return ${roles.greenlisted}; - } - ` + /** + * @inheritdoc Greenlistable + */ + function greenlistedRole() public pure override returns (bytes32) { + return ${roles.greenlisted}; + } + ` : '' - } + } }`, }; }; diff --git a/scripts/deploy/codegen/common/templates/rv-aave.template.ts b/scripts/deploy/codegen/common/templates/rv-aave.template.ts index 5429132c..89e520ae 100644 --- a/scripts/deploy/codegen/common/templates/rv-aave.template.ts +++ b/scripts/deploy/codegen/common/templates/rv-aave.template.ts @@ -52,13 +52,13 @@ export const getRvAaveContractFromTemplate = async ( ${ vaultUseTokenLevelGreenList ? ` - /** - * @inheritdoc Greenlistable - */ - function greenlistedRole() public pure override returns (bytes32) { - return ${roles.greenlisted}; - } - ` + /** + * @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 index d0ae805c..36fc4caa 100644 --- a/scripts/deploy/codegen/common/templates/rv-morpho.template.ts +++ b/scripts/deploy/codegen/common/templates/rv-morpho.template.ts @@ -52,13 +52,13 @@ export const getRvMorphoContractFromTemplate = async ( ${ vaultUseTokenLevelGreenList ? ` - /** - * @inheritdoc Greenlistable - */ - function greenlistedRole() public pure override returns (bytes32) { - return ${roles.greenlisted}; - } - ` + /** + * @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 index d39d200b..6baa4c12 100644 --- a/scripts/deploy/codegen/common/templates/rv-mtoken.template.ts +++ b/scripts/deploy/codegen/common/templates/rv-mtoken.template.ts @@ -54,13 +54,13 @@ export const getRvMTokenContractFromTemplate = async ( ${ vaultUseTokenLevelGreenList ? ` - /** - * @inheritdoc Greenlistable - */ - function greenlistedRole() public pure override returns (bytes32) { - return ${roles.greenlisted}; - } - ` + /** + * @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 From 62d441b32db653ab6cd2515d8e4992f6b4aae5ca Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Mon, 30 Mar 2026 15:43:46 +0300 Subject: [PATCH 19/20] fix: convert balances to base18 before subtraction in _checkAndRedeemMToken --- contracts/DepositVaultWithAave.sol | 4 ++-- contracts/DepositVaultWithMToken.sol | 11 ++++++++-- contracts/DepositVaultWithMorpho.sol | 4 ++-- contracts/RedemptionVaultWithAave.sol | 4 ++-- contracts/RedemptionVaultWithMToken.sol | 24 +++++++++++++-------- contracts/RedemptionVaultWithMorpho.sol | 4 ++-- test/unit/RedemptionVaultWithMToken.test.ts | 2 +- 7 files changed, 33 insertions(+), 20 deletions(-) diff --git a/contracts/DepositVaultWithAave.sol b/contracts/DepositVaultWithAave.sol index b295b775..834f59d4 100644 --- a/contracts/DepositVaultWithAave.sol +++ b/contracts/DepositVaultWithAave.sol @@ -80,8 +80,8 @@ contract DepositVaultWithAave is DepositVault { external onlyVaultAdmin { - _validateAddress(_token, false); - _validateAddress(_aavePool, false); + _validateAddress(_token, true); + _validateAddress(_aavePool, true); require( IAaveV3Pool(_aavePool).getReserveAToken(_token) != address(0), "DVA: token not in pool" diff --git a/contracts/DepositVaultWithMToken.sol b/contracts/DepositVaultWithMToken.sol index b469d568..fb60093e 100644 --- a/contracts/DepositVaultWithMToken.sol +++ b/contracts/DepositVaultWithMToken.sol @@ -99,7 +99,7 @@ contract DepositVaultWithMToken is DepositVault { _maxSupplyCap ); - _validateAddress(_mTokenDepositVault, false); + _validateAddress(_mTokenDepositVault, true); mTokenDepositVault = IDepositVault(_mTokenDepositVault); } @@ -115,7 +115,7 @@ contract DepositVaultWithMToken is DepositVault { _mTokenDepositVault != address(mTokenDepositVault), "DVMT: already set" ); - _validateAddress(_mTokenDepositVault, false); + _validateAddress(_mTokenDepositVault, true); mTokenDepositVault = IDepositVault(_mTokenDepositVault); emit SetMTokenDepositVault(msg.sender, _mTokenDepositVault); } @@ -194,6 +194,13 @@ contract DepositVaultWithMToken is DepositVault { uint256 amountToken, uint256 tokensDecimals ) private { + require( + ManageableVault(address(mTokenDepositVault)).waivedFeeRestriction( + address(this) + ), + "DVMT: fees not waived on target" + ); + uint256 transferredAmount = _tokenTransferFromUser( tokenIn, address(this), diff --git a/contracts/DepositVaultWithMorpho.sol b/contracts/DepositVaultWithMorpho.sol index cf0f5650..d515da38 100644 --- a/contracts/DepositVaultWithMorpho.sol +++ b/contracts/DepositVaultWithMorpho.sol @@ -80,8 +80,8 @@ contract DepositVaultWithMorpho is DepositVault { external onlyVaultAdmin { - _validateAddress(_token, false); - _validateAddress(_morphoVault, false); + _validateAddress(_token, true); + _validateAddress(_morphoVault, true); require( IMorphoVault(_morphoVault).asset() == _token, "DVM: asset mismatch" diff --git a/contracts/RedemptionVaultWithAave.sol b/contracts/RedemptionVaultWithAave.sol index cf918b4c..3dcf09bc 100644 --- a/contracts/RedemptionVaultWithAave.sol +++ b/contracts/RedemptionVaultWithAave.sol @@ -56,8 +56,8 @@ contract RedemptionVaultWithAave is RedemptionVault { external onlyVaultAdmin { - _validateAddress(_token, false); - _validateAddress(_aavePool, false); + _validateAddress(_token, true); + _validateAddress(_aavePool, true); require( IAaveV3Pool(_aavePool).getReserveAToken(_token) != address(0), "RVA: token not in pool" diff --git a/contracts/RedemptionVaultWithMToken.sol b/contracts/RedemptionVaultWithMToken.sol index c491272a..21f3d681 100644 --- a/contracts/RedemptionVaultWithMToken.sol +++ b/contracts/RedemptionVaultWithMToken.sol @@ -211,23 +211,29 @@ contract RedemptionVaultWithMToken is RedemptionVault { uint256 amountTokenOut, uint256 tokenOutRate ) internal { - uint256 contractBalanceTokenOut = IERC20(tokenOut).balanceOf( - address(this) - ); - if (contractBalanceTokenOut >= amountTokenOut) return; - - uint256 missingAmount = amountTokenOut - contractBalanceTokenOut; uint256 tokenDecimals = _tokenDecimals(tokenOut); - - uint256 missingAmountBase18 = missingAmount.convertToBase18( + 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. - // Requires address(this) to have waivedFeeRestriction on the inner vault uint256 mTokenAAmount = Math.mulDiv( missingAmountBase18, tokenOutRate, diff --git a/contracts/RedemptionVaultWithMorpho.sol b/contracts/RedemptionVaultWithMorpho.sol index 9a19832c..bb4db3d8 100644 --- a/contracts/RedemptionVaultWithMorpho.sol +++ b/contracts/RedemptionVaultWithMorpho.sol @@ -57,8 +57,8 @@ contract RedemptionVaultWithMorpho is RedemptionVault { external onlyVaultAdmin { - _validateAddress(_token, false); - _validateAddress(_morphoVault, false); + _validateAddress(_token, true); + _validateAddress(_morphoVault, true); require( IMorphoVault(_morphoVault).asset() == _token, "RVM: asset mismatch" diff --git a/test/unit/RedemptionVaultWithMToken.test.ts b/test/unit/RedemptionVaultWithMToken.test.ts index d858745f..da5c6d39 100644 --- a/test/unit/RedemptionVaultWithMToken.test.ts +++ b/test/unit/RedemptionVaultWithMToken.test.ts @@ -1407,7 +1407,7 @@ describe('RedemptionVaultWithMToken', function () { stableCoins.dai, 100, { - revertMessage: 'RV: minReceiveAmount > actual', + revertMessage: 'RVMT: fees not waived on target', }, ); }); From 470342976860967705f541937c6862d7465f498d Mon Sep 17 00:00:00 2001 From: Dmytro Horbatenko Date: Thu, 2 Apr 2026 11:51:18 +0300 Subject: [PATCH 20/20] fix: shorten revert messages --- contracts/RedemptionVaultWithAave.sol | 5 +---- contracts/RedemptionVaultWithMToken.sol | 2 +- test/integration/RedemptionVaultWithMToken.test.ts | 2 +- test/unit/RedemptionVaultWithAave.test.ts | 4 ++-- test/unit/RedemptionVaultWithMToken.test.ts | 4 ++-- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/contracts/RedemptionVaultWithAave.sol b/contracts/RedemptionVaultWithAave.sol index 3dcf09bc..2ac74b85 100644 --- a/contracts/RedemptionVaultWithAave.sol +++ b/contracts/RedemptionVaultWithAave.sol @@ -196,9 +196,6 @@ contract RedemptionVaultWithAave is RedemptionVault { missingAmount, address(this) ); - require( - withdrawnAmount >= missingAmount, - "RVA: insufficient withdrawal amount" - ); + require(withdrawnAmount >= missingAmount, "RVA: withdrawn < needed"); } } diff --git a/contracts/RedemptionVaultWithMToken.sol b/contracts/RedemptionVaultWithMToken.sol index 21f3d681..7fa9024d 100644 --- a/contracts/RedemptionVaultWithMToken.sol +++ b/contracts/RedemptionVaultWithMToken.sol @@ -245,7 +245,7 @@ contract RedemptionVaultWithMToken is RedemptionVault { require( IERC20(mTokenA).balanceOf(address(this)) >= mTokenAAmount, - "RVMT: insufficient mToken balance" + "RVMT: balance < needed" ); IERC20(mTokenA).safeIncreaseAllowance( diff --git a/test/integration/RedemptionVaultWithMToken.test.ts b/test/integration/RedemptionVaultWithMToken.test.ts index 8d7d7f19..53d5da7c 100644 --- a/test/integration/RedemptionVaultWithMToken.test.ts +++ b/test/integration/RedemptionVaultWithMToken.test.ts @@ -252,7 +252,7 @@ describe('RedemptionVaultWithMToken - Mainnet Fork Integration Tests', function parseUnits(String(mFONEAmount)), 0, ), - ).to.be.revertedWith('RVMT: insufficient mToken balance'); + ).to.be.revertedWith('RVMT: balance < needed'); }); }); }); diff --git a/test/unit/RedemptionVaultWithAave.test.ts b/test/unit/RedemptionVaultWithAave.test.ts index 2f21ef17..72869bae 100644 --- a/test/unit/RedemptionVaultWithAave.test.ts +++ b/test/unit/RedemptionVaultWithAave.test.ts @@ -857,7 +857,7 @@ describe('RedemptionVaultWithAave', function () { stableCoins.usdc.address, parseUnits('1000', 8), ), - ).to.be.revertedWith('RVA: insufficient withdrawal amount'); + ).to.be.revertedWith('RVA: withdrawn < needed'); }); }); @@ -1675,7 +1675,7 @@ describe('RedemptionVaultWithAave', function () { parseUnits('1000'), 0, ), - ).to.be.revertedWith('RVA: insufficient withdrawal amount'); + ).to.be.revertedWith('RVA: withdrawn < needed'); }); // ── Custom recipient tests ─────────────────────────────────────────── diff --git a/test/unit/RedemptionVaultWithMToken.test.ts b/test/unit/RedemptionVaultWithMToken.test.ts index da5c6d39..0210ccb1 100644 --- a/test/unit/RedemptionVaultWithMToken.test.ts +++ b/test/unit/RedemptionVaultWithMToken.test.ts @@ -813,7 +813,7 @@ describe('RedemptionVaultWithMToken', function () { parseUnits('1000', 9), parseUnits('1'), ), - ).to.be.revertedWith('RVMT: insufficient mToken balance'); + ).to.be.revertedWith('RVMT: balance < needed'); }); }); @@ -1455,7 +1455,7 @@ describe('RedemptionVaultWithMToken', function () { stableCoins.dai, 100, { - revertMessage: 'RVMT: insufficient mToken balance', + revertMessage: 'RVMT: balance < needed', }, ); });