diff --git a/contracts/ChainlinkSwapAssetInvestStrategy.sol b/contracts/ChainlinkSwapAssetInvestStrategy.sol new file mode 100644 index 0000000..a9b1232 --- /dev/null +++ b/contracts/ChainlinkSwapAssetInvestStrategy.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; +import {SwapAssetInvestStrategy} from "./SwapAssetInvestStrategy.sol"; +import {AggregatorV3Interface} from "./dependencies/chainlink/AggregatorV3Interface.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +/** + * @title ChainlinkSwapAssetInvestStrategy + * @dev Strategy that invests/deinvests by swapping into another token, where the price of both tokens is obtained + * from chainlink oracles. + * + * The oracles should express the prices in the same base. For example if asset=USDC and investAsset=WPOL, + * then `assetOracle()` is an oracle that returns the price of USDC in USD (or other base) and + * `investAssetOracle()` is an oracle that returns the price of WPOL in USD (or other base, but the same as + * `assetOracle()`). + * + * @custom:security-contact security@ensuro.co + * @author Ensuro + */ +contract ChainlinkSwapAssetInvestStrategy is SwapAssetInvestStrategy { + AggregatorV3Interface public immutable assetOracle; + AggregatorV3Interface public immutable investAssetOracle; + uint256 public immutable priceTolerance; + + error PriceTooOld(uint256 minUpdateAt, uint256 updatedAt); + error InvalidPrice(int256 chainlinkAnswer); + + /** + * @dev Constructor of the strategy + * + * @param asset_ The address of the underlying token used for accounting, depositing, and withdrawing. + * @param investAsset_ The address of the tokens hold by the strategy. Typically a rebasing yield bearing token + * @param assetOracle_ The chainlink oracle to obtain the price of the asset. If address(0) the price is 1. + * @param investAssetOracle_ The chainlink oracle to obtain the price of the invest asset. If address(0) => 1 + */ + constructor( + IERC20Metadata asset_, + IERC20Metadata investAsset_, + AggregatorV3Interface assetOracle_, + AggregatorV3Interface investAssetOracle_, + uint256 priceTolerance_ + ) SwapAssetInvestStrategy(asset_, investAsset_) { + investAssetOracle = investAssetOracle_; + assetOracle = assetOracle_; + priceTolerance = priceTolerance_; + } + + function investAssetPrice() public view virtual override returns (uint256) { + return Math.mulDiv(_getOraclePrice(investAssetOracle), WAD, _getOraclePrice(assetOracle)); + } + + function _getOraclePrice(AggregatorV3Interface oracle) internal view returns (uint256) { + if (address(oracle) == address(0)) return WAD; + (, int256 answer, , uint256 updatedAt, ) = oracle.latestRoundData(); + require(updatedAt > block.timestamp - priceTolerance, PriceTooOld(block.timestamp - priceTolerance, updatedAt)); + require(answer > 0, InvalidPrice(answer)); + return uint256(answer) * 10 ** (18 - oracle.decimals()); + } +} diff --git a/contracts/MerklRewardsInvestStrategy.sol b/contracts/MerklRewardsInvestStrategy.sol new file mode 100644 index 0000000..f21fa02 --- /dev/null +++ b/contracts/MerklRewardsInvestStrategy.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; +import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {SwapLibrary} from "@ensuro/swaplibrary/contracts/SwapLibrary.sol"; +import {IInvestStrategy} from "./interfaces/IInvestStrategy.sol"; +import {ChainlinkSwapAssetInvestStrategy} from "./ChainlinkSwapAssetInvestStrategy.sol"; +import {AggregatorV3Interface} from "./dependencies/chainlink/AggregatorV3Interface.sol"; +import {MSVBase} from "./MSVBase.sol"; + +interface IMerklDistributor { + function claim( + address[] calldata users, + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs + ) external; +} + +/** + * @title MerklRewardsInvestStrategy + * @dev Strategy that collects the Merkl Rewards and accounts them. Also supports swapping them in reinjecting + * them into the vault. + * + * Uses Chainlink oracles to price the asset + * + * @custom:security-contact security@ensuro.co + * @author Ensuro + */ +contract MerklRewardsInvestStrategy is ChainlinkSwapAssetInvestStrategy { + using Address for address; + using SwapLibrary for SwapLibrary.SwapConfig; + + IMerklDistributor public immutable distributor; + + enum MerklForwardMethods { + setSwapConfig, + claimRewards, + claimAndSwapRewards, + swapRewards + } + + event RewardsClaimed(address indexed token, uint256 amount); + event RewardsSwapped(address indexed token, uint256 amountIn, uint256 amountOut); + + /** + * @dev Constructor of the strategy + * + * @param asset_ The address of the underlying token used for accounting, depositing, and withdrawing. + * @param investAsset_ The address of the tokens hold by the strategy. Typically a rebasing yield bearing token + * @param assetOracle_ The chainlink oracle to obtain the price of the asset. If address(0) the price is 1. + * @param investAssetOracle_ The chainlink oracle to obtain the price of the invest asset. If address(0) => 1 + * @param investAssetOracle_ The chainlink oracle to obtain the price of the invest asset. If address(0) => 1 + */ + constructor( + IERC20Metadata asset_, + IERC20Metadata investAsset_, + AggregatorV3Interface assetOracle_, + AggregatorV3Interface investAssetOracle_, + uint256 priceTolerance_, + IMerklDistributor distributor_ + ) ChainlinkSwapAssetInvestStrategy(asset_, investAsset_, assetOracle_, investAssetOracle_, priceTolerance_) { + distributor = distributor_; + } + + /// @inheritdoc IInvestStrategy + function maxDeposit(address /*contract_*/) public view virtual override returns (uint256) { + // Disable deposits + return 0; + } + + function _claimRewards(bytes memory params) internal { + uint256[] memory amounts = new uint256[](1); + address[] memory users = new address[](1); + address[] memory tokens = new address[](1); + bytes32[][] memory proofs = new bytes32[][](1); + (amounts[0], proofs[0]) = abi.decode(params, (uint256, bytes32[])); + users[0] = address(this); + tokens[0] = investAsset(address(this)); + distributor.claim(users, tokens, amounts, proofs); + emit RewardsClaimed(tokens[0], amounts[0]); + } + + function _swapRewards(uint256 amount) internal { + if (amount == type(uint256).max) amount = _investAsset.balanceOf(address(this)); + uint256 amountOut = _getSwapConfigSelf().exactInput( + address(_investAsset), + address(_asset), + amount, + sellInvestAssetPrice() + ); + + // Reinjects the rewards in the vault calling `depositToStrategies` on the implementation contract + ERC1967Utils.getImplementation().functionDelegateCall(abi.encodeCall(MSVBase.depositToStrategies, amountOut)); + emit RewardsSwapped(investAsset(address(this)), amount, amountOut); + } + + /// @inheritdoc IInvestStrategy + function forwardEntryPoint( + uint8 method, + bytes memory params + ) public virtual override onlyDelegCall returns (bytes memory result) { + MerklForwardMethods checkedMethod = MerklForwardMethods(method); + if (checkedMethod == MerklForwardMethods.claimRewards) { + _claimRewards(params); + } else if (checkedMethod == MerklForwardMethods.claimAndSwapRewards) { + _claimRewards(params); + _swapRewards(type(uint256).max); + } else if (checkedMethod == MerklForwardMethods.swapRewards) { + _swapRewards(abi.decode(params, (uint256))); + } else return super.forwardEntryPoint(method, params); + } +} diff --git a/contracts/SwapAssetInvestStrategy.sol b/contracts/SwapAssetInvestStrategy.sol new file mode 100644 index 0000000..1c0e484 --- /dev/null +++ b/contracts/SwapAssetInvestStrategy.sol @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; +import {SwapLibrary} from "@ensuro/swaplibrary/contracts/SwapLibrary.sol"; +import {IInvestStrategy} from "./interfaces/IInvestStrategy.sol"; +import {StorageSlot} from "@openzeppelin/contracts/utils/StorageSlot.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {IExposeStorage} from "./interfaces/IExposeStorage.sol"; +import {InvestStrategyClient} from "./InvestStrategyClient.sol"; + +/** + * @title SwapAssetInvestStrategy + * @dev Strategy that invests/deinvests by swapping into another token. Abstract contract, childs must define how + * to get the swap price. + * + * @custom:security-contact security@ensuro.co + * @author Ensuro + */ +abstract contract SwapAssetInvestStrategy is IInvestStrategy { + using SwapLibrary for SwapLibrary.SwapConfig; + + uint256 internal constant WAD = 1e18; + + address internal immutable __self = address(this); + bytes32 public immutable storageSlot = InvestStrategyClient.makeStorageSlot(this); + + IERC20Metadata internal immutable _asset; + IERC20Metadata internal immutable _investAsset; + + event SwapConfigChanged(SwapLibrary.SwapConfig oldConfig, SwapLibrary.SwapConfig newConfig); + + error CanBeCalledOnlyThroughDelegateCall(); + error CannotDisconnectWithAssets(); + error NoExtraDataAllowed(); + error InvalidAsset(); + + enum ForwardMethods { + setSwapConfig + } + + modifier onlyDelegCall() { + if (address(this) == __self) revert CanBeCalledOnlyThroughDelegateCall(); + _; + } + + /** + * @dev Constructor of the strategy + * + * @param asset_ The address of the underlying token used for accounting, depositing, and withdrawing. + * @param investAsset_ The address of the tokens hold by the strategy. Typically a rebasing yield bearing token + */ + constructor(IERC20Metadata asset_, IERC20Metadata investAsset_) { + require(asset_.decimals() <= 18, InvalidAsset()); + require(investAsset_.decimals() <= 18, InvalidAsset()); + require(asset_ != investAsset_, InvalidAsset()); + _asset = asset_; + _investAsset = investAsset_; + } + + function _toWadFactor(IERC20Metadata token) internal view returns (uint256) { + return 10 ** (18 - token.decimals()); + } + + /// @inheritdoc IInvestStrategy + function connect(bytes memory initData) external virtual override onlyDelegCall { + _setSwapConfig(SwapLibrary.SwapConfig(SwapLibrary.SwapProtocol.undefined, 0, bytes("")), initData); + } + + /// @inheritdoc IInvestStrategy + function disconnect(bool force) external virtual override onlyDelegCall { + if (!force && totalAssets(address(this)) != 0) revert CannotDisconnectWithAssets(); + } + + /// @inheritdoc IInvestStrategy + function maxWithdraw(address contract_) public view virtual override returns (uint256) { + return totalAssets(contract_); // TODO: check how much can be swapped without breaking the slippage + } + + /// @inheritdoc IInvestStrategy + function maxDeposit(address /*contract_*/) public view virtual override returns (uint256) { + return type(uint256).max; // TODO: check how much can be swapped without breaking the slippage + } + + /// @inheritdoc IInvestStrategy + function asset(address) public view virtual override returns (address) { + return address(_asset); + } + + /** + * @dev Returns the address of the asset invested in the strategy. + */ + function investAsset(address) public view returns (address) { + return address(_investAsset); + } + + /** + * @dev Returns the amount of `asset()` required to acquire one unit of `investAsset()` or the amount of `asset()` + * that should be received by selling a unit of `investAsset()`. It doesn't consider slippage. + * + * @return The amount is expressed in WAD (18 decimals), units: (asset/investAsset) + */ + function investAssetPrice() public view virtual returns (uint256); + + function sellInvestAssetPrice() internal view returns (uint256) { + return Math.mulDiv(WAD, WAD, investAssetPrice()); // 1/investAssetPrice() - Units: investAsset/asset + } + + /** + * @dev Converts a given amount of investAssets into assets, considering the difference in decimals and the + * maxSlippage accepted + * + * @param investAssets Amount in investAssets + * @param contract_ The address of the vault, not used in the implementation, but it might be required by + * inheriting contracts. + * @return assets The minimum amount in assets that will result from swapping `investAssets` + */ + function _convertAssets(uint256 investAssets, address contract_) internal view virtual returns (uint256 assets) { + return + Math.mulDiv( + Math.mulDiv(investAssets * _toWadFactor(_investAsset), investAssetPrice(), WAD), + WAD - _getSwapConfig(contract_).maxSlippage, + WAD + ) / _toWadFactor(_asset); + } + + /// @inheritdoc IInvestStrategy + function totalAssets(address contract_) public view virtual override returns (uint256 assets) { + return _convertAssets(_investAsset.balanceOf(contract_), contract_); + } + + /// @inheritdoc IInvestStrategy + /** + * @dev Withdraws the amount of assets given from the strategy swapping _investAsset to _asset + * + * @param assets Amount of assets to be withdrawn. + */ + function withdraw(uint256 assets) public virtual override onlyDelegCall { + if (assets == 0) return; + SwapLibrary.SwapConfig memory swapConfig = _getSwapConfigSelf(); + uint256 price = sellInvestAssetPrice(); + if (assets >= _convertAssets(_investAsset.balanceOf(address(this)), address(this))) { + // When the intention is to withdraw all the strategy assets, I convert all the _investAsset. + // This might result in more assets, but it's fine, better than leaving extra _investAsset in the strategy + swapConfig.exactInput(address(_investAsset), address(_asset), _investAsset.balanceOf(address(this)), price); + } else { + swapConfig.exactOutput(address(_investAsset), address(_asset), assets, price); + } + } + + /// @inheritdoc IInvestStrategy + /** + * @dev Deposit the amount of assets given into the strategy by swapping _asset to _investAsset + * + * @param assets Amount of assets to be deposited. + */ + function deposit(uint256 assets) public virtual override onlyDelegCall { + if (assets == 0) return; + // swapLibrary expects a price expressed in tokenOut/tokenIn - OK since investAssetPrice() is in _asset/_investAsset + _getSwapConfigSelf().exactInput(address(_asset), address(_investAsset), assets, investAssetPrice()); + } + + function _setSwapConfig(SwapLibrary.SwapConfig memory oldSwapConfig, bytes memory newSwapConfigAsBytes) internal { + SwapLibrary.SwapConfig memory swapConfig = abi.decode(newSwapConfigAsBytes, (SwapLibrary.SwapConfig)); + swapConfig.validate(); + if (abi.encode(swapConfig).length != newSwapConfigAsBytes.length) revert NoExtraDataAllowed(); + emit SwapConfigChanged(oldSwapConfig, swapConfig); + StorageSlot.getBytesSlot(storageSlot).value = newSwapConfigAsBytes; + } + + /// @inheritdoc IInvestStrategy + function forwardEntryPoint(uint8 method, bytes memory params) public virtual onlyDelegCall returns (bytes memory) { + ForwardMethods checkedMethod = ForwardMethods(method); + if (checkedMethod == ForwardMethods.setSwapConfig) { + // The change of the swap config, that involves both the DEX to use and the maxSlippage is a critical operation + // that should be access controlled, probably imposing timelocks, because it can produce a conversion of the + // assets at a non-fair price + _setSwapConfig(_getSwapConfig(address(this)), params); + } + // Should never reach to this revert, since method should be one of the enum values but leave it in case + // we add new values in the enum and we forgot to add them here + // solhint-disable-next-line gas-custom-errors,reason-string + else revert(); + + return bytes(""); + } + + function _getSwapConfig(address contract_) internal view returns (SwapLibrary.SwapConfig memory) { + bytes memory swapConfigAsBytes = IExposeStorage(contract_).getBytesSlot(storageSlot); + return abi.decode(swapConfigAsBytes, (SwapLibrary.SwapConfig)); + } + + function _getSwapConfigSelf() internal view returns (SwapLibrary.SwapConfig memory) { + return abi.decode(StorageSlot.getBytesSlot(storageSlot).value, (SwapLibrary.SwapConfig)); + } + + /** + * @dev Returns the swap configuration of the given contract. + * + * @param contract_ Address of the vault contract + */ + function getSwapConfig(address contract_) public view returns (SwapLibrary.SwapConfig memory) { + return _getSwapConfig(contract_); + } +} diff --git a/contracts/SwapStableInvestStrategy.sol b/contracts/SwapStableInvestStrategy.sol index 2fb5f8c..78dd461 100644 --- a/contracts/SwapStableInvestStrategy.sol +++ b/contracts/SwapStableInvestStrategy.sol @@ -2,12 +2,7 @@ pragma solidity ^0.8.0; import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; -import {SwapLibrary} from "@ensuro/swaplibrary/contracts/SwapLibrary.sol"; -import {IInvestStrategy} from "./interfaces/IInvestStrategy.sol"; -import {StorageSlot} from "@openzeppelin/contracts/utils/StorageSlot.sol"; -import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; -import {IExposeStorage} from "./interfaces/IExposeStorage.sol"; -import {InvestStrategyClient} from "./InvestStrategyClient.sol"; +import {SwapAssetInvestStrategy} from "./SwapAssetInvestStrategy.sol"; /** * @title SwapStableInvestStrategy @@ -17,34 +12,9 @@ import {InvestStrategyClient} from "./InvestStrategyClient.sol"; * @custom:security-contact security@ensuro.co * @author Ensuro */ -contract SwapStableInvestStrategy is IInvestStrategy { - using SwapLibrary for SwapLibrary.SwapConfig; - - uint256 private constant WAD = 1e18; - - address private immutable __self = address(this); - bytes32 public immutable storageSlot = InvestStrategyClient.makeStorageSlot(this); - - IERC20Metadata internal immutable _asset; - IERC20Metadata internal immutable _investAsset; +contract SwapStableInvestStrategy is SwapAssetInvestStrategy { uint256 internal immutable _price; // One unit of _investAsset in _asset (in Wad), units: (asset/investAsset) - event SwapConfigChanged(SwapLibrary.SwapConfig oldConfig, SwapLibrary.SwapConfig newConfig); - - error CanBeCalledOnlyThroughDelegateCall(); - error CannotDisconnectWithAssets(); - error NoExtraDataAllowed(); - error InvalidAsset(); - - enum ForwardMethods { - setSwapConfig - } - - modifier onlyDelegCall() { - if (address(this) == __self) revert CanBeCalledOnlyThroughDelegateCall(); - _; - } - /** * @dev Constructor of the strategy * @@ -52,149 +22,15 @@ contract SwapStableInvestStrategy is IInvestStrategy { * @param investAsset_ The address of the tokens hold by the strategy. Typically a rebasing yield bearing token * @param price_ Approximate amount of units of _asset required to acquire a unit of _investAsset */ - constructor(IERC20Metadata asset_, IERC20Metadata investAsset_, uint256 price_) { - require(asset_.decimals() <= 18, InvalidAsset()); - require(investAsset_.decimals() <= 18, InvalidAsset()); - require(asset_ != investAsset_, InvalidAsset()); - _asset = asset_; - _investAsset = investAsset_; + constructor( + IERC20Metadata asset_, + IERC20Metadata investAsset_, + uint256 price_ + ) SwapAssetInvestStrategy(asset_, investAsset_) { _price = price_; } - function _toWadFactor(IERC20Metadata token) internal view returns (uint256) { - return 10 ** (18 - token.decimals()); - } - - /// @inheritdoc IInvestStrategy - function connect(bytes memory initData) external virtual override onlyDelegCall { - _setSwapConfig(SwapLibrary.SwapConfig(SwapLibrary.SwapProtocol.undefined, 0, bytes("")), initData); - } - - /// @inheritdoc IInvestStrategy - function disconnect(bool force) external virtual override onlyDelegCall { - if (!force && _investAsset.balanceOf(address(this)) != 0) revert CannotDisconnectWithAssets(); - } - - /// @inheritdoc IInvestStrategy - function maxWithdraw(address contract_) public view virtual override returns (uint256) { - return totalAssets(contract_); // TODO: check how much can be swapped without breaking the slippage - } - - /// @inheritdoc IInvestStrategy - function maxDeposit(address /*contract_*/) public view virtual override returns (uint256) { - return type(uint256).max; // TODO: check how much can be swapped without breaking the slippage - } - - /// @inheritdoc IInvestStrategy - function asset(address) public view virtual override returns (address) { - return address(_asset); - } - - /** - * @dev Returns the address of the asset invested in the strategy. - */ - function investAsset(address) public view returns (address) { - return address(_investAsset); - } - - /** - * @dev Converts a given amount of investAssets into assets, considering the difference in decimals and the - * maxSlippage accepted - * - * @param investAssets Amount in investAssets - * @param contract_ The address of the vault, not used in the implementation, but it might be required by - * inheriting contracts. - * @return assets The minimum amount in assets that will result from swapping `investAssets` - */ - function _convertAssets(uint256 investAssets, address contract_) internal view virtual returns (uint256 assets) { - return - Math.mulDiv( - Math.mulDiv(investAssets * _toWadFactor(_investAsset), _price, WAD), - WAD - _getSwapConfig(contract_).maxSlippage, - WAD - ) / _toWadFactor(_asset); - } - - /// @inheritdoc IInvestStrategy - function totalAssets(address contract_) public view virtual override returns (uint256 assets) { - return _convertAssets(_investAsset.balanceOf(contract_), contract_); - } - - /// @inheritdoc IInvestStrategy - /** - * @dev Withdraws the amount of assets given from the strategy swapping _investAsset to _asset - * - * @param assets Amount of assets to be withdrawn. - */ - function withdraw(uint256 assets) public virtual override onlyDelegCall { - if (assets == 0) return; - SwapLibrary.SwapConfig memory swapConfig = abi.decode( - StorageSlot.getBytesSlot(storageSlot).value, - (SwapLibrary.SwapConfig) - ); - // swapLibrary expects a price expressed in tokenOut/tokenIn - OK since price is in _investAsset/_price - uint256 price = Math.mulDiv(WAD, WAD, _price); // 1/_price - Units: investAsset/asset - if (assets >= _convertAssets(_investAsset.balanceOf(address(this)), address(this))) { - // When the intention is to withdraw all the strategy assets, I convert all the _investAsset. - // This might result in more assets, but it's fine, better than leaving extra _investAsset in the strategy - swapConfig.exactInput(address(_investAsset), address(_asset), _investAsset.balanceOf(address(this)), price); - } else { - swapConfig.exactOutput(address(_investAsset), address(_asset), assets, price); - } - } - - /// @inheritdoc IInvestStrategy - /** - * @dev Deposit the amount of assets given into the strategy by swapping _asset to _investAsset - * - * @param assets Amount of assets to be deposited. - */ - function deposit(uint256 assets) public virtual override onlyDelegCall { - if (assets == 0) return; - SwapLibrary.SwapConfig memory swapConfig = abi.decode( - StorageSlot.getBytesSlot(storageSlot).value, - (SwapLibrary.SwapConfig) - ); - // swapLibrary expects a price expressed in tokenOut/tokenIn - OK since _price is in _asset/_investAsset - swapConfig.exactInput(address(_asset), address(_investAsset), assets, _price); - } - - function _setSwapConfig(SwapLibrary.SwapConfig memory oldSwapConfig, bytes memory newSwapConfigAsBytes) internal { - SwapLibrary.SwapConfig memory swapConfig = abi.decode(newSwapConfigAsBytes, (SwapLibrary.SwapConfig)); - swapConfig.validate(); - if (abi.encode(swapConfig).length != newSwapConfigAsBytes.length) revert NoExtraDataAllowed(); - emit SwapConfigChanged(oldSwapConfig, swapConfig); - StorageSlot.getBytesSlot(storageSlot).value = newSwapConfigAsBytes; - } - - /// @inheritdoc IInvestStrategy - function forwardEntryPoint(uint8 method, bytes memory params) external onlyDelegCall returns (bytes memory) { - ForwardMethods checkedMethod = ForwardMethods(method); - if (checkedMethod == ForwardMethods.setSwapConfig) { - // The change of the swap config, that involves both the DEX to use and the maxSlippage is a critical operation - // that should be access controlled, probably imposing timelocks, because it can produce a conversion of the - // assets at a non-fair price - _setSwapConfig(_getSwapConfig(address(this)), params); - } - // Should never reach to this revert, since method should be one of the enum values but leave it in case - // we add new values in the enum and we forgot to add them here - // solhint-disable-next-line gas-custom-errors,reason-string - else revert(); - - return bytes(""); - } - - function _getSwapConfig(address contract_) internal view returns (SwapLibrary.SwapConfig memory) { - bytes memory swapConfigAsBytes = IExposeStorage(contract_).getBytesSlot(storageSlot); - return abi.decode(swapConfigAsBytes, (SwapLibrary.SwapConfig)); - } - - /** - * @dev Returns the swap configuration of the given contract. - * - * @param contract_ Address of the vault contract - */ - function getSwapConfig(address contract_) public view returns (SwapLibrary.SwapConfig memory) { - return _getSwapConfig(contract_); + function investAssetPrice() public view virtual override returns (uint256) { + return _price; } } diff --git a/contracts/dependencies/chainlink/AggregatorV3Interface.sol b/contracts/dependencies/chainlink/AggregatorV3Interface.sol new file mode 100644 index 0000000..2ba1925 --- /dev/null +++ b/contracts/dependencies/chainlink/AggregatorV3Interface.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// solhint-disable-next-line interface-starts-with-i +interface AggregatorV3Interface { + function decimals() external view returns (uint8); + + function description() external view returns (string memory); + + function version() external view returns (uint256); + + function getRoundData( + uint80 _roundId + ) external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); + + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); +} diff --git a/contracts/mock/ChainlinkOracleMock.sol b/contracts/mock/ChainlinkOracleMock.sol new file mode 100644 index 0000000..84edae4 --- /dev/null +++ b/contracts/mock/ChainlinkOracleMock.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {AggregatorV3Interface} from "../dependencies/chainlink/AggregatorV3Interface.sol"; + +contract ChainlinkOracleMock is AggregatorV3Interface { + uint8 public decimals; + string public description; + uint256 public version; + + struct Round { + uint80 roundId; + int256 answer; + uint256 startedAt; + uint256 updatedAt; + uint80 answeredInRound; + } + + mapping(uint80 => Round) internal _rounds; + uint80 public lastRoundId; + + constructor(uint8 decimals_, string memory description_, uint256 version_) { + decimals = decimals_; + description = description_; + version = version_; + } + + function addRound( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) external { + _rounds[roundId] = Round(roundId, answer, startedAt, updatedAt, answeredInRound); + if (roundId > lastRoundId) lastRoundId = roundId; + } + + function getRoundData( + uint80 _roundId + ) + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + Round storage round = _rounds[_roundId]; + return (round.roundId, round.answer, round.startedAt, round.updatedAt, round.answeredInRound); + } + + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + Round storage round = _rounds[lastRoundId]; + return (round.roundId, round.answer, round.startedAt, round.updatedAt, round.answeredInRound); + } +} diff --git a/package-lock.json b/package-lock.json index b87831c..6464ea3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "dependencies": { "@ensuro/swaplibrary": "1.0.0", - "@ensuro/utils": "^0.2.6", + "@ensuro/utils": "^0.2.9", "@openzeppelin/contracts": "^5.1.0", "@openzeppelin/contracts-upgradeable": "^5.1.0", "@uniswap/v3-periphery": "^1.4.4", @@ -157,9 +157,9 @@ "license": "Apache-2.0" }, "node_modules/@ensuro/utils": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@ensuro/utils/-/utils-0.2.6.tgz", - "integrity": "sha512-uwdT6FR1M3Z6RJllsEKpYq1tonoesTfUs6zFeWfKOGYQGvno49KqvP/i/IV0qOfF13TxDcnK9xA+T763GBv+ZA==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@ensuro/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-lfHt+cPZRMBC6tR9kCgMrJCmGsYq+6f9VFbHkGtLtyUjvEZezqbFGPtiDSHDQ79vqFBAev3BMQc6LF5guP2Dfw==", "license": "Apache-2.0" }, "node_modules/@eslint-community/eslint-utils": { diff --git a/package.json b/package.json index c571897..f1ec0b7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "solhint-plugin-prettier": "0.1.0" }, "dependencies": { - "@ensuro/utils": "^0.2.6", + "@ensuro/utils": "^0.2.9", "@ensuro/swaplibrary": "1.0.0", "@openzeppelin/contracts": "^5.1.0", "@openzeppelin/contracts-upgradeable": "^5.1.0", diff --git a/test/merkl-api-resp.json b/test/merkl-api-resp.json new file mode 100644 index 0000000..3867469 --- /dev/null +++ b/test/merkl-api-resp.json @@ -0,0 +1,121 @@ +[ + { + "chain" : { + "Explorer" : [ + { + "chainId" : 137, + "id" : "17928672915163412988", + "type" : "ETHERSCAN", + "url" : "https://polygonscan.com" + } + ], + "icon" : "https://raw.githubusercontent.com/AngleProtocol/angle-token-list/main/src/assets/chains/polygon.svg", + "id" : 137, + "name" : "Polygon" + }, + "rewards" : [ + { + "amount" : "2798648000469071645990", + "breakdowns" : [ + { + "amount" : "1388900985422614344310", + "campaignId" : "0x093a42a87acfcff4bf481e7a8027999223e2d87c5a21c17bd4254f006fdc45da", + "claimed" : "0", + "pending" : "86739837455515742358", + "reason" : "ERC20_0x781FB7F6d845E3bE129289833b04d43Aa8558c42" + }, + { + "amount" : "1409747015046457301680", + "campaignId" : "0x28acc0ade884ae478ba4a9fe61124b5180886b5931e603e16d5e7e1570e6abdc", + "claimed" : "0", + "pending" : "0", + "reason" : "ERC20_0x781FB7F6d845E3bE129289833b04d43Aa8558c42" + } + ], + "claimed" : "0", + "pending" : "86739837455515742358", + "proofs" : [ + "0xa085c7b2d4ca61be540edba27491edebfa0c295ca832c10004e9caa2c6b659d4", + "0x069216c85a684ecdd3d6267c109d125ea07236da8b3826440857bf244cc1bcb5", + "0xe61345e784f4ed0d2144f32bda81fe9a0530d7b8bf4a704575bfd0060faba3d0", + "0xe5392458beb5de78dcfd2f35412b29a5ec5fd44b70b0d81272a7776a33739585", + "0x72ce63f77e07aa20f9336a7d0d942492b625456316a6231815aff1f18fea3362", + "0xcc4ca097bc69129e9feb1c60cb01c794e146a4ac9c25ce05786f31d71b02e121", + "0x59be23e6fe61ce3ebb3430f626fcad338577e8edcd2176df340fba2e8294dd21", + "0x6a9fcd1229f3adf3122668562cdfbebf5e7fedffd5e9c63d9c90975fdb01f399", + "0xf91b5ea41a0fdf7092a3abc2a9821262b006bb5dee0879ce1704a3b090038563", + "0x4bb0b45aea8a6dc4f55c041df33c9b1071db5dd0f7b4f87cc3b5faaef88df2cb", + "0x359dc6b9b0251f3dfe13fab32062a59059238e466a6da7e66d333a95c9e925ca", + "0x2a194dceb4018c74f86363bca06dca9f4977146c854cf953aaf343bacf62e4fd", + "0x3338073fe8ed58b55ccc88424b5511274adca37e0f7ba32a240d2b8a71a87abb", + "0xdfb73f582d4432db6abd5eb0092f23c85375fe395eaea2ee9ca3478e9886be45", + "0xa424fb475837870e783255b21cceabf386ac00d4170187818a9ebbe97c888c41", + "0xaa37984683cbd2c61af0372bd10f34436a2adb25555884d6aa58f54d74788eb4", + "0x2dbb5aada1b50333a0b0755654f849486f42e614f8df8b3b198ae9e4af17072b", + "0x6c48e22d94cf6a57e817805e3aff6532af596cf702ec567cfbff8c0337ea97d3", + "0x43fc6cea7e309e513d6360cc0b781d49b1b6474414a4b69101a2ebdeeca7b620" + ], + "recipient" : "0x14F6DFEE761455247C6bf2b2b052a1F6245dD6FB", + "root" : "0x094e446706b0dbe2254899aadf7c097db240075f7491090dc93b40cf25d4b4ff", + "token" : { + "address" : "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", + "chainId" : 137, + "decimals" : 18, + "price" : 0.22052, + "symbol" : "WPOL" + } + }, + { + "amount" : "14099859066042647896", + "breakdowns" : [ + { + "amount" : "6498424995566120496", + "campaignId" : "0xeb4c0cba480dee8cadf1f14d6030c6bd92fb1cb76adb481ecfc3333f4ddb28a0", + "claimed" : "0", + "pending" : "405840541369299023", + "reason" : "ERC20_0x781FB7F6d845E3bE129289833b04d43Aa8558c42" + }, + { + "amount" : "7601434070476527400", + "campaignId" : "0xff73b15e4d812abe95e5ef064be42993222c191363671dfc0a0bd3bc2508b8ae", + "claimed" : "0", + "pending" : "0", + "reason" : "ERC20_0x781FB7F6d845E3bE129289833b04d43Aa8558c42" + } + ], + "claimed" : "0", + "pending" : "405840541369299023", + "proofs" : [ + "0xbcfa1614cdc7d8a54b5fa837d04ebc285fbc7ef8661d13ab6e1f8f5d917a20ba", + "0x45d98b79bedc17cec3b2587aa9143a5f7cfc565207843cc0a84edcce95731952", + "0x450847e61212d1ec89a2e9d11d7a202bf4ff4f679979a411db8ca6b0ef4c1134", + "0xdcf9c1c869a96d1a91b0aa04d9d4647c81ae6839764eb8f699538587e0c25fd2", + "0xc9ffe94255391e1dfdfd3dafd21102eb05a90430d51359046586ad9ded79ac1d", + "0x2141a96147fc11556712e9fc6bdf695ac1715d88f6573c15cbf01544dd6c538a", + "0x488e0891d662e5aa73be2fdf2360d9c2d8d3cc8861c1d2480f80a15747ac42a1", + "0xce2a20d6c2e93f08411c9c1b2cdecd4429aecc822d2aff9ea83aae1fc0ba7c8d", + "0xa3c494ab103a45070a31a24b4fcdeac9c241e1301bb264bae6cae4bc399f3b37", + "0x9c87b6b50794903bab0365bc7cd399639975820da8163aee26ea981720f8e80f", + "0xcf7b4ee92c998e5aef70a8a0ffb54769c789421d2b40923e756c1a97a8e6a39f", + "0xe81d0e6da7a6b964bdbdb973310354eda8e30f0c46253c9eb087fd16c6c058ed", + "0x6cf23682746a5f17f2a75ad4870aef23371d9827a3eb08f46b9891f8b9a3900e", + "0x57dc719557dbf63099b31f11a50c24a4ab6d416e733c17ab9221023bd24269c3", + "0xd68d73dfd02c90d2748b02bf7f56a845bbd1bb5ad3da037f5bbc6a2014bb93db", + "0x73a2b7b1bfd169c9babb2007a188a7e2c35bea8e6d08da20106fa91ecf607f9c", + "0xbb9b2de999fa7774e1401f4bdd9affd1d1efd23173ca2a760ef4dc7c68714662", + "0x6c48e22d94cf6a57e817805e3aff6532af596cf702ec567cfbff8c0337ea97d3", + "0x43fc6cea7e309e513d6360cc0b781d49b1b6474414a4b69101a2ebdeeca7b620" + ], + "recipient" : "0x14F6DFEE761455247C6bf2b2b052a1F6245dD6FB", + "root" : "0x094e446706b0dbe2254899aadf7c097db240075f7491090dc93b40cf25d4b4ff", + "token" : { + "address" : "0x8505b9d2254A7Ae468c0E9dd10Ccea3A837aef5c", + "chainId" : 137, + "decimals" : 18, + "price" : 57.58, + "symbol" : "COMP" + } + } + ] + } +] diff --git a/test/test-compound-v3-vault.js b/test/test-compound-v3-vault.js index 4fa3a36..7080469 100644 --- a/test/test-compound-v3-vault.js +++ b/test/test-compound-v3-vault.js @@ -1,15 +1,18 @@ const { expect } = require("chai"); -const { amountFunction, _W, getRole, grantRole, getTransactionEvent } = require("@ensuro/utils/js/utils"); -const { initForkCurrency, setupChain } = require("@ensuro/utils/js/test-utils"); -const { buildUniswapConfig } = require("@ensuro/swaplibrary/js/utils"); const { - encodeSwapConfig, - encodeDummyStorage, - tagit, - makeAllViewsPublic, - setupAMRole, + amountFunction, + _W, + getRole, + grantRole, + getTransactionEvent, mergeFragments, -} = require("./utils"); + setupAMRole, + makeAllViewsPublic, + tagitVariant, +} = require("@ensuro/utils/js/utils"); +const { initForkCurrency, setupChain } = require("@ensuro/utils/js/test-utils"); +const { buildUniswapConfig } = require("@ensuro/swaplibrary/js/utils"); +const { encodeSwapConfig, encodeDummyStorage } = require("./utils"); const { anyUint } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); const hre = require("hardhat"); const helpers = require("@nomicfoundation/hardhat-network-helpers"); @@ -156,7 +159,6 @@ const SwapStableAaveV3InvestStrategyMethods = { const variants = [ { name: "CompoundV3ERC4626", - tagit: tagit, cToken: ADDRESSES.cUSDCv3, fixture: async () => { const { currency, swapLibrary, adminAddr, swapConfig, admin, lp, lp2, guardian, anon } = await setUp(); @@ -206,7 +208,6 @@ const variants = [ }, { name: "CompoundV3Strategy", - tagit: tagit, cToken: ADDRESSES.cUSDCv3, fixture: async () => { const { currency, swapLibrary, adminAddr, swapConfig, admin, lp, lp2, guardian, anon } = await setUp(); @@ -278,7 +279,6 @@ const variants = [ }, { name: "AAVEV3Strategy", - tagit: tagit, cToken: ADDRESSES.aUSDCv3, supplyToken: ADDRESSES.USDC, fixture: async () => { @@ -332,7 +332,6 @@ const variants = [ }, { name: "CompoundV3Strategy+AccessManaged", - tagit: tagit, cToken: ADDRESSES.cUSDCv3, supplyToken: ADDRESSES.USDC, fixture: async (accessManagedMSVClass = "AccessManagedMSV") => { @@ -452,7 +451,6 @@ const variants = [ }, { name: "SwapStableAAVEV3Strategy", - tagit: tagit, supplyToken: ADDRESSES.USDC_NATIVE, fixture: async () => { const { currency, swapLibrary, adminAddr, admin, lp, lp2, guardian, anon } = await setUp(); @@ -531,12 +529,16 @@ variants.push({ }); variants.forEach((variant) => { + const it = (testDescription, test) => tagitVariant(variant, false, testDescription, test); + it.only = (testDescription, test) => tagitVariant(variant, true, testDescription, test); + it.foobar = 123; + describe(`${variant.name} contract tests`, function () { before(async () => { await setupChain(TEST_BLOCK); }); - variant.tagit("Checks vault inititializes correctly", async () => { + it("Checks vault inititializes correctly", async () => { const { currency, vault, admin, anon } = await helpers.loadFixture(variant.fixture); expect(await vault.name()).to.equal(NAME); @@ -549,7 +551,7 @@ variants.forEach((variant) => { } }); - variant.tagit("Checks vault constructs with disabled initializer [CompoundV3ERC4626]", async () => { + it("Checks vault constructs with disabled initializer [CompoundV3ERC4626]", async () => { const { CompoundV3ERC4626, adminAddr, swapConfig } = await helpers.loadFixture(variant.fixture); const newVault = await CompoundV3ERC4626.deploy(ADDRESSES.cUSDCv3, ADDRESSES.REWARDS); await expect(newVault.deploymentTransaction()).to.emit(newVault, "Initialized"); @@ -559,7 +561,7 @@ variants.forEach((variant) => { ); }); - variant.tagit("Checks strategy can't be constructed with rewards=0 [CompoundV3Strategy]", async () => { + it("Checks strategy can't be constructed with rewards=0 [CompoundV3Strategy]", async () => { const { CompoundV3InvestStrategy } = await helpers.loadFixture(variant.fixture); await expect(CompoundV3InvestStrategy.deploy(ADDRESSES.cUSDCv3, ZeroAddress)).to.be.revertedWithCustomError( CompoundV3InvestStrategy, @@ -567,7 +569,7 @@ variants.forEach((variant) => { ); }); - variant.tagit("Checks reverts if extraData is sent on initialization [!CompoundV3ERC4626]", async () => { + it("Checks reverts if extraData is sent on initialization [!CompoundV3ERC4626]", async () => { if (variant.accessManaged) return; // tagit doens't support double neg const { MultiStrategyERC4626, @@ -593,7 +595,7 @@ variants.forEach((variant) => { ).to.be.revertedWithCustomError(Strategy, "NoExtraDataAllowed"); }); - variant.tagit("Checks entering the vault is permissioned, exit isn't [!SwapStableAAVEV3Strategy]", async () => { + it("Checks entering the vault is permissioned, exit isn't [!SwapStableAAVEV3Strategy]", async () => { const { currency, vault, anon, lp } = await helpers.loadFixture(variant.fixture); if (variant.accessManaged) { @@ -642,7 +644,7 @@ variants.forEach((variant) => { .withArgs(vault, anon, _A(50)); }); - variant.tagit("Checks vault accrues compound earnings", async () => { + it("Checks vault accrues compound earnings", async () => { const { currency, vault, lp, lp2 } = await helpers.loadFixture(variant.fixture); await expect(vault.connect(lp).mint(_A(1000), lp)) @@ -676,7 +678,7 @@ variants.forEach((variant) => { expect(await currency.balanceOf(lp2)).to.closeTo(_A(INITIAL), _A(1)); }); - variant.tagit("Checks rewards can be harvested [!AAVEV3Strategy] [!SwapStableAAVEV3Strategy]", async () => { + it("Checks rewards can be harvested [!AAVEV3Strategy] [!SwapStableAAVEV3Strategy]", async () => { const { currency, vault, admin, anon, lp, lp2, strategy, acMgr, roles } = await helpers.loadFixture( variant.fixture ); @@ -747,8 +749,8 @@ variants.forEach((variant) => { expect(evt).not.equal(null); expect(evt.args.token).to.equal(ADDRESSES.COMP); - expect(evt.args.rewards).to.equal(_W("0.126432")); - expect(evt.args.receivedInAsset).to.equal(_A("10.684546")); + expect(evt.args.rewards).to.closeTo(_W("0.126432"), _W("0.000002")); + expect(evt.args.receivedInAsset).to.closeTo(_A("10.684546"), _A("0.001")); await expect(tx).to.emit(currency, "Transfer").withArgs(vault, ADDRESSES.cUSDCv3, _A("10.684546")); @@ -758,7 +760,7 @@ variants.forEach((variant) => { expect(await vault.totalSupply()).to.be.equal(_A(3000)); }); - variant.tagit("Checks it can't disconnect without harvesting rewards [CompoundV3Strategy]", async () => { + it("Checks it can't disconnect without harvesting rewards [CompoundV3Strategy]", async () => { const { vault, admin, anon, lp, lp2, strategy } = await helpers.loadFixture(variant.fixture); await expect(vault.connect(lp).mint(_A(1000), lp)).not.to.be.reverted; @@ -800,35 +802,32 @@ variants.forEach((variant) => { .withArgs(strategy, dummyStrategy); }); - variant.tagit( - "Checks it can disconnect without harvesting rewards if forced [CompoundV3Strategy+AccessManaged]", - async () => { - const { vault, admin, anon, lp, lp2, strategy, acMgr, roles } = await helpers.loadFixture(variant.fixture); + it("Checks it can disconnect without harvesting rewards if forced [CompoundV3Strategy+AccessManaged]", async () => { + const { vault, admin, anon, lp, lp2, strategy, acMgr, roles } = await helpers.loadFixture(variant.fixture); - await expect(vault.connect(lp).mint(_A(1000), lp)).not.to.be.reverted; - await expect(vault.connect(lp2).mint(_A(2000), lp2)).not.to.be.reverted; + await expect(vault.connect(lp).mint(_A(1000), lp)).not.to.be.reverted; + await expect(vault.connect(lp2).mint(_A(2000), lp2)).not.to.be.reverted; - expect(await vault.totalAssets()).to.be.closeTo(_A(3000), MCENT); - await acMgr.connect(admin).grantRole(roles.STRATEGY_ADMIN_ROLE, anon, 0); + expect(await vault.totalAssets()).to.be.closeTo(_A(3000), MCENT); + await acMgr.connect(admin).grantRole(roles.STRATEGY_ADMIN_ROLE, anon, 0); - await helpers.time.increase(MONTH); - const assets = await vault.totalAssets(); - expect(assets).to.be.closeTo(_A("3028.53"), CENT); + await helpers.time.increase(MONTH); + const assets = await vault.totalAssets(); + expect(assets).to.be.closeTo(_A("3028.53"), CENT); - const DummyInvestStrategy = await ethers.getContractFactory("DummyInvestStrategy"); - const dummyStrategy = await DummyInvestStrategy.deploy(ADDRESSES.USDC); + const DummyInvestStrategy = await ethers.getContractFactory("DummyInvestStrategy"); + const dummyStrategy = await DummyInvestStrategy.deploy(ADDRESSES.USDC); - await expect( - vault.connect(anon).replaceStrategy(0, dummyStrategy, encodeDummyStorage({}), false) - ).to.be.revertedWithCustomError(strategy, "CannotDisconnectWithAssets"); + await expect( + vault.connect(anon).replaceStrategy(0, dummyStrategy, encodeDummyStorage({}), false) + ).to.be.revertedWithCustomError(strategy, "CannotDisconnectWithAssets"); - await expect(vault.connect(anon).replaceStrategy(0, dummyStrategy, encodeDummyStorage({}), true)) - .to.emit(vault, "StrategyChanged") - .withArgs(strategy, dummyStrategy); - } - ); + await expect(vault.connect(anon).replaceStrategy(0, dummyStrategy, encodeDummyStorage({}), true)) + .to.emit(vault, "StrategyChanged") + .withArgs(strategy, dummyStrategy); + }); - variant.tagit("Checks only authorized user can change swap config [!AAVEV3Strategy]", async () => { + it("Checks only authorized user can change swap config [!AAVEV3Strategy]", async () => { const { currency, vault, admin, anon, lp, swapConfig, strategy, swapLibrary, acMgr, roles } = await helpers.loadFixture(variant.fixture); @@ -919,69 +918,63 @@ variants.forEach((variant) => { expect(await vault.totalAssets()).to.be.closeTo(assets + _A("10.684546"), CENT); }); - variant.tagit( - "Checks can't deposit or withdraw when Compound is paused [!AAVEV3Strategy][!SwapStableAAVEV3Strategy]", - async () => { - const { vault, lp, currency } = await helpers.loadFixture(variant.fixture); + it("Checks can't deposit or withdraw when Compound is paused [!AAVEV3Strategy][!SwapStableAAVEV3Strategy]", async () => { + const { vault, lp, currency } = await helpers.loadFixture(variant.fixture); - await helpers.impersonateAccount(ADDRESSES.cUSDCv3_GUARDIAN); - await helpers.setBalance(ADDRESSES.cUSDCv3_GUARDIAN, ethers.parseEther("100")); - const compGuardian = await ethers.getSigner(ADDRESSES.cUSDCv3_GUARDIAN); + await helpers.impersonateAccount(ADDRESSES.cUSDCv3_GUARDIAN); + await helpers.setBalance(ADDRESSES.cUSDCv3_GUARDIAN, ethers.parseEther("100")); + const compGuardian = await ethers.getSigner(ADDRESSES.cUSDCv3_GUARDIAN); - const cUSDCv3 = await ethers.getContractAt(CometABI, ADDRESSES.cUSDCv3); + const cUSDCv3 = await ethers.getContractAt(CometABI, ADDRESSES.cUSDCv3); - expect(await vault.maxMint(lp)).to.equal(MaxUint256); - expect(await vault.maxDeposit(lp)).to.equal(MaxUint256); + expect(await vault.maxMint(lp)).to.equal(MaxUint256); + expect(await vault.maxDeposit(lp)).to.equal(MaxUint256); - // If I pause supply, maxMint / maxDeposit becomes 0 and can't deposit or mint - await cUSDCv3.connect(compGuardian).pause(true, false, false, false, false); + // If I pause supply, maxMint / maxDeposit becomes 0 and can't deposit or mint + await cUSDCv3.connect(compGuardian).pause(true, false, false, false, false); - expect(await vault.maxMint(lp)).to.equal(0); - expect(await vault.maxDeposit(lp)).to.equal(0); - await expect(vault.connect(lp).mint(_A(3000), lp)).to.be.revertedWithCustomError( - vault, - "ERC4626ExceededMaxMint" - ); - await expect(vault.connect(lp).deposit(_A(3000), lp)).to.be.revertedWithCustomError( - vault, - "ERC4626ExceededMaxDeposit" - ); + expect(await vault.maxMint(lp)).to.equal(0); + expect(await vault.maxDeposit(lp)).to.equal(0); + await expect(vault.connect(lp).mint(_A(3000), lp)).to.be.revertedWithCustomError(vault, "ERC4626ExceededMaxMint"); + await expect(vault.connect(lp).deposit(_A(3000), lp)).to.be.revertedWithCustomError( + vault, + "ERC4626ExceededMaxDeposit" + ); - // Then I unpause deposit - await cUSDCv3.connect(compGuardian).pause(false, false, false, false, false); + // Then I unpause deposit + await cUSDCv3.connect(compGuardian).pause(false, false, false, false, false); - await expect(vault.connect(lp).mint(_A(3000), lp)).not.to.be.reverted; + await expect(vault.connect(lp).mint(_A(3000), lp)).not.to.be.reverted; - expect(await vault.totalAssets()).to.closeTo(_A(3000), MCENT); - expect(await vault.maxRedeem(lp)).to.closeTo(_A(3000), MCENT); - expect(await vault.maxWithdraw(lp)).to.closeTo(_A(3000), MCENT); + expect(await vault.totalAssets()).to.closeTo(_A(3000), MCENT); + expect(await vault.maxRedeem(lp)).to.closeTo(_A(3000), MCENT); + expect(await vault.maxWithdraw(lp)).to.closeTo(_A(3000), MCENT); - // If I pause withdraw, maxRedeem / maxWithdraw becomes 0 and can't withdraw or redeem - await cUSDCv3.connect(compGuardian).pause(false, false, true, false, false); + // If I pause withdraw, maxRedeem / maxWithdraw becomes 0 and can't withdraw or redeem + await cUSDCv3.connect(compGuardian).pause(false, false, true, false, false); - expect(await vault.maxRedeem(lp)).to.equal(0); - expect(await vault.maxWithdraw(lp)).to.equal(0); + expect(await vault.maxRedeem(lp)).to.equal(0); + expect(await vault.maxWithdraw(lp)).to.equal(0); - await expect(vault.connect(lp).redeem(_A(1000), lp, lp)).to.be.revertedWithCustomError( - vault, - "ERC4626ExceededMaxRedeem" - ); - await expect(vault.connect(lp).withdraw(_A(1000), lp, lp)).to.be.revertedWithCustomError( - vault, - "ERC4626ExceededMaxWithdraw" - ); + await expect(vault.connect(lp).redeem(_A(1000), lp, lp)).to.be.revertedWithCustomError( + vault, + "ERC4626ExceededMaxRedeem" + ); + await expect(vault.connect(lp).withdraw(_A(1000), lp, lp)).to.be.revertedWithCustomError( + vault, + "ERC4626ExceededMaxWithdraw" + ); - // Then I unpause everything - await cUSDCv3.connect(compGuardian).pause(false, false, false, false, false); + // Then I unpause everything + await cUSDCv3.connect(compGuardian).pause(false, false, false, false, false); - await expect(vault.connect(lp).redeem(_A(3000), lp, lp)).not.to.be.reverted; - expect(await vault.totalAssets()).to.closeTo(0, MCENT); - // Check LP has more or less the same initial funds - expect(await currency.balanceOf(lp)).to.closeTo(_A(INITIAL), MCENT * 10n); - } - ); + await expect(vault.connect(lp).redeem(_A(3000), lp, lp)).not.to.be.reverted; + expect(await vault.totalAssets()).to.closeTo(0, MCENT); + // Check LP has more or less the same initial funds + expect(await currency.balanceOf(lp)).to.closeTo(_A(INITIAL), MCENT * 10n); + }); - variant.tagit("Checks can't operate when AAVE is paused [AAVEV3Strategy] [SwapStableAAVEV3Strategy]", async () => { + it("Checks can't operate when AAVE is paused [AAVEV3Strategy] [SwapStableAAVEV3Strategy]", async () => { const { vault, lp, currency } = await helpers.loadFixture(variant.fixture); await helpers.impersonateAccount(ADDRESSES.AAVEPoolAdmin); @@ -1048,7 +1041,7 @@ variants.forEach((variant) => { expect(await currency.balanceOf(lp)).to.closeTo(_A(INITIAL), _A(5)); }); - variant.tagit("Checks only authorized can setStrategy [CompoundV3Strategy]", async () => { + it("Checks only authorized can setStrategy [CompoundV3Strategy]", async () => { const { currency, vault, lp, swapConfig, strategy, anon, admin, CompoundV3InvestStrategy } = await helpers.loadFixture(variant.fixture); @@ -1139,7 +1132,7 @@ variants.forEach((variant) => { expect(await currency.balanceOf(await dummyStrategy.other())).to.closeTo(_A(3000), CENT); }); - variant.tagit("Checks only authorized can setStrategy [AAVEV3Strategy] [SwapStableAAVEV3Strategy]", async () => { + it("Checks only authorized can setStrategy [AAVEV3Strategy] [SwapStableAAVEV3Strategy]", async () => { const { currency, vault, @@ -1249,7 +1242,7 @@ variants.forEach((variant) => { expect(await currency.balanceOf(await dummyStrategy.other())).to.closeTo(_A(3000), _A(5)); }); - variant.tagit("Checks methods can't be called directly [!CompoundV3ERC4626]", async () => { + it("Checks methods can't be called directly [!CompoundV3ERC4626]", async () => { const { strategy } = await helpers.loadFixture(variant.fixture); await expect(strategy.getFunction("connect")(ethers.toUtf8Bytes(""))).to.be.revertedWithCustomError( strategy, diff --git a/test/test-erc4626-invest-strategy.js b/test/test-erc4626-invest-strategy.js index 824eb1b..a82ecf5 100644 --- a/test/test-erc4626-invest-strategy.js +++ b/test/test-erc4626-invest-strategy.js @@ -1,8 +1,7 @@ const { expect } = require("chai"); -const { amountFunction, _W, getRole, getTransactionEvent } = require("@ensuro/utils/js/utils"); -const { encodeDummyStorage, tagit } = require("./utils"); +const { amountFunction, getRole } = require("@ensuro/utils/js/utils"); +const { encodeDummyStorage } = require("./utils"); const { initCurrency } = require("@ensuro/utils/js/test-utils"); -const { anyUint } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); const hre = require("hardhat"); const helpers = require("@nomicfoundation/hardhat-network-helpers"); diff --git a/test/test-limit-outflow.js b/test/test-limit-outflow.js index 4496afb..de62580 100644 --- a/test/test-limit-outflow.js +++ b/test/test-limit-outflow.js @@ -1,8 +1,14 @@ const { expect } = require("chai"); -const { amountFunction } = require("@ensuro/utils/js/utils"); +const { + amountFunction, + makeAllViewsPublic, + mergeFragments, + setupAMRole, + tagitVariant, +} = require("@ensuro/utils/js/utils"); const { WEEK, DAY } = require("@ensuro/utils/js/constants"); const { initCurrency } = require("@ensuro/utils/js/test-utils"); -const { encodeDummyStorage, tagit, makeAllViewsPublic, mergeFragments, setupAMRole } = require("./utils"); +const { encodeDummyStorage } = require("./utils"); const hre = require("hardhat"); const helpers = require("@nomicfoundation/hardhat-network-helpers"); @@ -51,7 +57,6 @@ async function setUp() { const variants = [ { name: "AMProxy+OutflowLimitedAMMSV", - tagit: tagit, accessManaged: true, accessError: "revertedWithAMError", fixture: async () => { @@ -160,6 +165,9 @@ const variants = [ ]; variants.forEach((variant) => { + const it = (testDescription, test) => tagitVariant(variant, false, testDescription, test); + it.only = (testDescription, test) => tagitVariant(variant, true, testDescription, test); + describe(`${variant.name} contract tests`, function () { it("Initializes the vault correctly", async () => { const { deployVault, currency, strategies } = await helpers.loadFixture(variant.fixture); diff --git a/test/test-merkl-rewards-invest-strategy.js b/test/test-merkl-rewards-invest-strategy.js new file mode 100644 index 0000000..1cc6e03 --- /dev/null +++ b/test/test-merkl-rewards-invest-strategy.js @@ -0,0 +1,323 @@ +const fs = require("fs"); +const { expect } = require("chai"); +const { amountFunction, _W, getAccessManagerRole, getAddress, mergeFragments } = require("@ensuro/utils/js/utils"); +const { WEEK } = require("@ensuro/utils/js/constants"); +const { buildUniswapConfig } = require("@ensuro/swaplibrary/js/utils"); +const { encodeSwapConfig } = require("./utils"); +const { initForkCurrency, amScheduleAndExecuteBatch, setupChain } = require("@ensuro/utils/js/test-utils"); +const { anyUint } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); +const hre = require("hardhat"); +const helpers = require("@nomicfoundation/hardhat-network-helpers"); + +const { ethers } = hre; +const { MaxUint256, ZeroAddress } = hre.ethers; + +const CURRENCY_DECIMALS = 6; +const _A = amountFunction(CURRENCY_DECIMALS); + +const ADDRESSES = { + // polygon mainnet addresses + UNISWAP: "0xE592427A0AEce92De3Edee1F18E0157C05861564", + MERKL_DISTRIBUTOR: "0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae", + QUICKSWAP: "0xf5b509bB0909a69B1c207E495f687a596C168E12", + USDC: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + USDCWhale: "0xF977814e90dA44bFA03b6295A0616a897441aceC", + MSV: "0x14F6DFEE761455247C6bf2b2b052a1F6245dD6FB", + COMP: "0x8505b9d2254A7Ae468c0E9dd10Ccea3A837aef5c", + WPOL: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270", + COMP_ORACLE: "0x2A8758b7257102461BC958279054e372C2b1bDE6", + USDC_ORACLE: "0xfE4A8cc5b5B2366C1B58Bea3858e81843581b2F7", + WPOL_ORACLE: "0x66aCD49dB829005B3681E29b6F4Ba1d93843430e", + ACCESS_MANAGER: "0xa29DF9825F283B2fA7a26B4627F84aDa80cDD79a", + ADMINS_MULTISIG: "0xCfcd29CD20B6c64A4C0EB56e29E5ce3CD69336D2", +}; + +const MerklForwardMethods = { + setSwapConfig: 0, + claimRewards: 1, + claimAndSwapRewards: 2, + swapRewards: 3, +}; + +const TEST_BLOCK = 72684500; +const INITIAL = 10000; + +async function setUp() { + const [, lp, lp2, anon, guardian, admin] = await ethers.getSigners(); + + const USDC = await initForkCurrency(ADDRESSES.USDC, ADDRESSES.USDCWhale, [lp, lp2], [_A(INITIAL), _A(INITIAL)]); + const WPOL = await ethers.getContractAt("IERC20Metadata", ADDRESSES.WPOL); + const COMP = await ethers.getContractAt("IERC20Metadata", ADDRESSES.COMP); + const acMgr = await ethers.getContractAt("AccessManager", ADDRESSES.ACCESS_MANAGER); + const compOracle = await ethers.getContractAt("AggregatorV3Interface", ADDRESSES.COMP_ORACLE); + const wpolOracle = await ethers.getContractAt("AggregatorV3Interface", ADDRESSES.WPOL_ORACLE); + const usdcOracle = await ethers.getContractAt("AggregatorV3Interface", ADDRESSES.USDC_ORACLE); + + // Impersonate ADMINS_MULTISIG + await helpers.impersonateAccount(ADDRESSES.ADMINS_MULTISIG); + await helpers.setBalance(ADDRESSES.ADMINS_MULTISIG, ethers.parseEther("100")); + const adminsMultisig = await ethers.getSigner(ADDRESSES.ADMINS_MULTISIG); + + const SwapLibrary = await ethers.getContractFactory("SwapLibrary"); + const swapLibrary = await SwapLibrary.deploy(); + const MerklRewardsInvestStrategy = await ethers.getContractFactory("MerklRewardsInvestStrategy", { + libraries: { + SwapLibrary: await ethers.resolveAddress(swapLibrary), + }, + }); + const AccessManagedMSV = await ethers.getContractFactory("AccessManagedMSV"); + const msv = await ethers.getContractAt( + mergeFragments(AccessManagedMSV.interface.fragments, MerklRewardsInvestStrategy.interface.fragments), + ADDRESSES.MSV + ); + + return { + USDC, + COMP, + WPOL, + usdcOracle, + compOracle, + wpolOracle, + msv, + acMgr, + adminsMultisig, + MerklRewardsInvestStrategy, + swapLibrary, + lp, + lp2, + anon, + guardian, + admin, + }; +} + +async function fetchRewards(userAddress) { + if (TEST_BLOCK === null) { + const resp = await fetch(`https://api.merkl.xyz/v4/users/${userAddress}/rewards?chainId=137&breakdownPage=0`); + expect(resp.status).to.equal(200); + const rewardData = await resp.json(); + fs.writeFileSync("./test/merkl-api-resp-2.json", JSON.stringify(rewardData)); + return rewardData; + } else { + return JSON.parse(fs.readFileSync("./test/merkl-api-resp.json")); + } +} + +describe("MerklRewardsInvestStrategy contract tests", function () { + before(async () => { + await setupChain(TEST_BLOCK); + }); + + it("Can claim COMP and WPOL rewards and they are added to MSV totalAssets", async () => { + const { + USDC, + COMP, + WPOL, + usdcOracle, + compOracle, + wpolOracle, + msv, + MerklRewardsInvestStrategy, + acMgr, + adminsMultisig, + } = await helpers.loadFixture(setUp); + const wpolStrategy = await MerklRewardsInvestStrategy.deploy( + USDC, + WPOL, + usdcOracle, + wpolOracle, + WEEK, + ADDRESSES.MERKL_DISTRIBUTOR + ); + const compStrategy = await MerklRewardsInvestStrategy.deploy( + USDC, + COMP, + usdcOracle, + compOracle, + WEEK, + ADDRESSES.MERKL_DISTRIBUTOR + ); + const slippage = 0.05; + + // Add the new strategies + await amScheduleAndExecuteBatch( + acMgr.connect(adminsMultisig), + [msv, msv], + [ + msv.interface.encodeFunctionData("addStrategy", [ + getAddress(wpolStrategy), + encodeSwapConfig(buildUniswapConfig(_W(slippage), 500, ADDRESSES.UNISWAP)), + ]), + msv.interface.encodeFunctionData("addStrategy", [ + getAddress(compStrategy), + encodeSwapConfig(buildUniswapConfig(_W(slippage), 100, ADDRESSES.UNISWAP)), + ]), + ] + ); + expect(await wpolStrategy.totalAssets(msv)).to.equal(_A(0)); + expect(await compStrategy.totalAssets(msv)).to.equal(_A(0)); + + // Grant permissions to call claimRewards and swapRewards forward method on both strategies + await amScheduleAndExecuteBatch( + acMgr.connect(adminsMultisig), + [acMgr, acMgr], + [ + acMgr.interface.encodeFunctionData("setTargetFunctionRole", [ + getAddress(msv), + [ + await msv.getForwardToStrategySelector(2, MerklForwardMethods.claimRewards), + await msv.getForwardToStrategySelector(3, MerklForwardMethods.claimRewards), + await msv.getForwardToStrategySelector(2, MerklForwardMethods.swapRewards), + await msv.getForwardToStrategySelector(3, MerklForwardMethods.swapRewards), + await msv.getForwardToStrategySelector(3, MerklForwardMethods.setSwapConfig), + ], + getAccessManagerRole("CLAIM_ROLE"), + ]), + acMgr.interface.encodeFunctionData("grantRole", [ + getAccessManagerRole("CLAIM_ROLE"), + ADDRESSES.ADMINS_MULTISIG, + 0, + ]), + ] + ); + + const totalAssetsBefore = await msv.totalAssets(); + const encoder = ethers.AbiCoder.defaultAbiCoder(); + + // Claim Rewards + const rewardData = await fetchRewards(ADDRESSES.MSV); + + const wpolReward = rewardData[0].rewards.find((r) => r.token.address === ADDRESSES.WPOL); + const wpolClaimParams = encoder.encode(["uint256", "bytes32[]"], [BigInt(wpolReward.amount), wpolReward.proofs]); + await expect(() => + msv.connect(adminsMultisig).forwardToStrategy(2, MerklForwardMethods.claimRewards, wpolClaimParams) + ).to.changeTokenBalances(WPOL, [msv], [BigInt(wpolReward.amount)]); + + const expectedAmountWpol = + (wpolReward.token.price * (1 - slippage) * Number(BigInt(wpolReward.amount) / BigInt(1e12))) / 1e6; + + expect(await wpolStrategy.totalAssets(msv)).to.closeTo(_A(expectedAmountWpol), _A(expectedAmountWpol * slippage)); + + const compReward = rewardData[0].rewards.find((r) => r.token.address === ADDRESSES.COMP); + const compClaimParams = encoder.encode(["uint256", "bytes32[]"], [BigInt(compReward.amount), compReward.proofs]); + await expect(() => + msv.connect(adminsMultisig).forwardToStrategy(3, MerklForwardMethods.claimRewards, compClaimParams) + ).to.changeTokenBalances(COMP, [msv], [BigInt(compReward.amount)]); + + let expectedAmountComp = + (compReward.token.price * (1 - slippage) * Number(BigInt(compReward.amount) / BigInt(1e12))) / 1e6; + + expect(await compStrategy.totalAssets(msv)).to.closeTo(_A(expectedAmountComp), _A(expectedAmountComp * slippage)); + + const totalAssetsAfter = await msv.totalAssets(); + expect(totalAssetsAfter).to.be.closeTo(totalAssetsBefore + _A(expectedAmountComp) + _A(expectedAmountWpol), _A(10)); + + // Checks totalAssets matchs the expected value for each strategy. The tolerance is because the expected amounts + // are computed based on Merkl prices, not the oracle prices + expect(await wpolStrategy.totalAssets(msv)).to.closeTo(_A(expectedAmountWpol), _A(0.02 * expectedAmountWpol)); + expect(await compStrategy.totalAssets(msv)).to.closeTo(_A(expectedAmountComp), _A(0.02 * expectedAmountComp)); + + // Claim Rewards - First partially, then full + await expect(() => + msv + .connect(adminsMultisig) + .forwardToStrategy(2, MerklForwardMethods.swapRewards, encoder.encode(["uint256"], [_W(100)])) + ).to.changeTokenBalances(WPOL, [msv], [-_W(100)]); + expect(await wpolStrategy.totalAssets(msv)).to.closeTo( + _A(expectedAmountWpol) - _A(wpolReward.token.price * 100), + _A(0.02 * expectedAmountWpol) + ); + await expect(() => + msv + .connect(adminsMultisig) + .forwardToStrategy(2, MerklForwardMethods.swapRewards, encoder.encode(["uint256"], [MaxUint256])) + ).to.changeTokenBalances(WPOL, [msv], [-BigInt(wpolReward.amount) + _W(100)]); + expect(await wpolStrategy.totalAssets(msv)).to.equal(0); + + // Change the swapConfig for COMP increasing the slippage and verify the totalAssets changes + await expect( + msv + .connect(adminsMultisig) + .forwardToStrategy( + 3, + MerklForwardMethods.setSwapConfig, + encodeSwapConfig(buildUniswapConfig(_W(slippage * 5), 123, ADDRESSES.QUICKSWAP)) + ) + ).to.emit(msv, "SwapConfigChanged"); + // Recompute expectedAmountComp with 5 times more slippage + expectedAmountComp = + (compReward.token.price * (1 - slippage * 5) * Number(BigInt(compReward.amount) / BigInt(1e12))) / 1e6; + expect(await compStrategy.totalAssets(msv)).to.closeTo(_A(expectedAmountComp), _A(0.02 * expectedAmountComp)); + }); + + it("Can claim and swap WPOL rewards in the same operation", async () => { + const { USDC, WPOL, usdcOracle, wpolOracle, msv, MerklRewardsInvestStrategy, acMgr, adminsMultisig } = + await helpers.loadFixture(setUp); + const wpolStrategy = await MerklRewardsInvestStrategy.deploy( + USDC, + WPOL, + usdcOracle, + wpolOracle, + WEEK, + ADDRESSES.MERKL_DISTRIBUTOR + ); + const slippage = 0.01; + + // Add the new strategies + await amScheduleAndExecuteBatch( + acMgr.connect(adminsMultisig), + [msv], + [ + msv.interface.encodeFunctionData("addStrategy", [ + getAddress(wpolStrategy), + encodeSwapConfig(buildUniswapConfig(_W(slippage), 500, ADDRESSES.UNISWAP)), + ]), + ] + ); + expect(await wpolStrategy.totalAssets(msv)).to.equal(_A(0)); + + // Grant permissions to call claimRewards and swapRewards forward method on both strategies + await amScheduleAndExecuteBatch( + acMgr.connect(adminsMultisig), + [acMgr, acMgr], + [ + acMgr.interface.encodeFunctionData("setTargetFunctionRole", [ + getAddress(msv), + [await msv.getForwardToStrategySelector(2, MerklForwardMethods.claimAndSwapRewards)], + getAccessManagerRole("CLAIM_ROLE"), + ]), + acMgr.interface.encodeFunctionData("grantRole", [ + getAccessManagerRole("CLAIM_ROLE"), + ADDRESSES.ADMINS_MULTISIG, + 0, + ]), + ] + ); + + const totalAssetsBefore = await msv.totalAssets(); + const encoder = ethers.AbiCoder.defaultAbiCoder(); + + // Claim Rewards + const rewardData = await fetchRewards(ADDRESSES.MSV); + + const wpolReward = rewardData[0].rewards.find((r) => r.token.address === ADDRESSES.WPOL); + const wpolClaimParams = encoder.encode(["uint256", "bytes32[]"], [BigInt(wpolReward.amount), wpolReward.proofs]); + await expect(() => + msv.connect(adminsMultisig).forwardToStrategy(2, MerklForwardMethods.claimAndSwapRewards, wpolClaimParams) + ).to.changeTokenBalances(WPOL, [msv], [0]); + + const expectedAmountWpol = + (wpolReward.token.price * (1 - slippage) * Number(BigInt(wpolReward.amount) / BigInt(1e12))) / 1e6; + + expect(await wpolStrategy.totalAssets(msv)).to.equal(0); + + const totalAssetsAfter = await msv.totalAssets(); + expect(totalAssetsAfter).to.be.closeTo(totalAssetsBefore + _A(expectedAmountWpol), _A(5)); + + // Final coverage tests + expect(await wpolStrategy.maxDeposit(adminsMultisig)).to.equal(0); // This way acquiring WPOL is disabled + await expect( + wpolStrategy.forwardEntryPoint(MerklForwardMethods.claimAndSwapRewards, wpolClaimParams) + ).to.be.revertedWithCustomError(wpolStrategy, "CanBeCalledOnlyThroughDelegateCall"); + }); +}); diff --git a/test/test-multi-strategy-erc4626.js b/test/test-multi-strategy-erc4626.js index 552d8a2..8c38a63 100644 --- a/test/test-multi-strategy-erc4626.js +++ b/test/test-multi-strategy-erc4626.js @@ -1,7 +1,7 @@ const { expect } = require("chai"); -const { _A, getRole } = require("@ensuro/utils/js/utils"); +const { _A, getRole, tagitVariant, makeAllViewsPublic, setupAMRole } = require("@ensuro/utils/js/utils"); const { initCurrency } = require("@ensuro/utils/js/test-utils"); -const { encodeDummyStorage, dummyStorage, tagit, makeAllViewsPublic, setupAMRole } = require("./utils"); +const { encodeDummyStorage, dummyStorage } = require("./utils"); const hre = require("hardhat"); const helpers = require("@nomicfoundation/hardhat-network-helpers"); const { deploy: ozUpgradesDeploy } = require("@openzeppelin/hardhat-upgrades/dist/utils"); @@ -48,7 +48,6 @@ async function setUp() { const variants = [ { name: "MultiStrategyERC4626", - tagit: tagit, accessError: "revertedWithACError", fixture: async () => { const ret = await setUp(); @@ -107,7 +106,6 @@ const variants = [ }, { name: "AMProxy+AccessManagedMSV", - tagit: tagit, accessManaged: true, accessError: "revertedWithAMError", fixture: async () => { @@ -211,7 +209,6 @@ const variants = [ }, { name: "AMProxy+OutflowLimitedAMMSV", - tagit: tagit, accessManaged: true, accessError: "revertedWithAMError", fixture: async () => { @@ -343,8 +340,11 @@ async function invariantChecks(vault) { } variants.forEach((variant) => { + const it = (testDescription, test) => tagitVariant(variant, false, testDescription, test); + it.only = (testDescription, test) => tagitVariant(variant, true, testDescription, test); + describe(`${variant.name} contract tests`, function () { - variant.tagit("Checks vault constructs with disabled initializer [MultiStrategyERC4626]", async () => { + it("Checks vault constructs with disabled initializer [MultiStrategyERC4626]", async () => { const { MultiStrategyERC4626, adminAddr, currency, strategies } = await helpers.loadFixture(variant.fixture); const newVault = await MultiStrategyERC4626.deploy(); await expect(newVault.deploymentTransaction()).to.emit(newVault, "Initialized"); @@ -362,7 +362,7 @@ variants.forEach((variant) => { ).to.be.revertedWithCustomError(MultiStrategyERC4626, "InvalidInitialization"); }); - variant.tagit("Checks vault constructs with disabled initializer [!MultiStrategyERC4626]", async () => { + it("Checks vault constructs with disabled initializer [!MultiStrategyERC4626]", async () => { const { AccessManagedMSV, OutflowLimitedAMMSV, currency, strategies } = await helpers.loadFixture( variant.fixture ); @@ -382,7 +382,7 @@ variants.forEach((variant) => { ).to.be.revertedWithCustomError(factory, "InvalidInitialization"); }); - variant.tagit("Initializes the vault correctly", async () => { + it("Initializes the vault correctly", async () => { const { deployVault, currency, strategies } = await helpers.loadFixture(variant.fixture); const vault = await deployVault(1); expect(await vault.name()).to.equal(NAME); @@ -396,7 +396,7 @@ variants.forEach((variant) => { expect(await vault.totalAssets()).to.equal(0); }); - variant.tagit("Initialization fails if strategy connect fails", async () => { + it("Initialization fails if strategy connect fails", async () => { const { deployVault, DummyInvestStrategy } = await helpers.loadFixture(variant.fixture); let vault = deployVault(1, [encodeDummyStorage({ failConnect: true })]); await expect(vault).to.be.revertedWithCustomError(DummyInvestStrategy, "Fail").withArgs("connect"); @@ -412,7 +412,7 @@ variants.forEach((variant) => { } }); - variant.tagit("It checks calls to forwardToStrategy require permission [MultiStrategyERC4626]", async () => { + it("It checks calls to forwardToStrategy require permission [MultiStrategyERC4626]", async () => { const { deployVault, strategies, anon, grantRole } = await helpers.loadFixture(variant.fixture); const vault = await deployVault(3); await expect(vault.connect(anon).forwardToStrategy(4, 0, encodeDummyStorage({}))).to.be.revertedWithACError( @@ -468,7 +468,7 @@ variants.forEach((variant) => { } }); - variant.tagit("It checks calls to forwardToStrategy require permission [!MultiStrategyERC4626]", async () => { + it("It checks calls to forwardToStrategy require permission [!MultiStrategyERC4626]", async () => { const { deployVault, strategies, anon, grantRole, acMgr, admin } = await helpers.loadFixture(variant.fixture); const vault = await deployVault(3); await expect(vault.connect(anon).forwardToStrategy(4, 0, encodeDummyStorage({}))).to.be.revertedWithAMError( @@ -528,7 +528,7 @@ variants.forEach((variant) => { } }); - variant.tagit("It sets and reads the right value from strategy storage", async () => { + it("It sets and reads the right value from strategy storage", async () => { const { deployVault, strategies, grantForwardToStrategy, anon } = await helpers.loadFixture(variant.fixture); const vault = (await deployVault(3)).connect(anon); await grantForwardToStrategy(vault, 4, 0, anon); @@ -566,7 +566,7 @@ variants.forEach((variant) => { } }); - variant.tagit("It fails when initialized with wrong parameters", async () => { + it("It fails when initialized with wrong parameters", async () => { const { strategies, MultiStrategyERC4626, deployVault } = await helpers.loadFixture(variant.fixture); // Sending 0 strategies fails await expect(deployVault(0)).to.be.revertedWithCustomError(MultiStrategyERC4626, "InvalidStrategiesLength"); @@ -626,7 +626,7 @@ variants.forEach((variant) => { await invariantChecks(vault); }); - variant.tagit("It respects the order of deposit and withdrawal queues", async () => { + it("It respects the order of deposit and withdrawal queues", async () => { const { deployVault, lp, lp2, currency, grantRole, grantForwardToStrategy, strategies } = await helpers.loadFixture(variant.fixture); const vault = await deployVault(4, undefined, [3, 2, 1, 0], [2, 0, 3, 1]); @@ -694,7 +694,7 @@ variants.forEach((variant) => { expect(await vault.totalAssets()).to.be.equal(_A(0)); }); - variant.tagit("It respects the order of deposit and authorized user can rebalance", async () => { + it("It respects the order of deposit and authorized user can rebalance", async () => { const { deployVault, lp, lp2, currency, grantRole, grantForwardToStrategy, strategies } = await helpers.loadFixture(variant.fixture); const vault = await deployVault(4, undefined, [3, 2, 1, 0], [2, 0, 3, 1]); @@ -746,7 +746,7 @@ variants.forEach((variant) => { await expect(vault.connect(lp2).rebalance(3, 0, MaxUint256)).not.to.emit(vault, "Rebalance"); }); - variant.tagit("It can addStrategy and is added at the bottom of the queues", async () => { + it("It can addStrategy and is added at the bottom of the queues", async () => { const { deployVault, lp, lp2, currency, grantRole, strategies } = await helpers.loadFixture(variant.fixture); const vault = await deployVault(3, undefined, [1, 0, 2], [2, 0, 1]); await currency.connect(lp).approve(vault, MaxUint256); @@ -791,7 +791,7 @@ variants.forEach((variant) => { await invariantChecks(vault); }); - variant.tagit("It can add up to 32 strategies", async () => { + it("It can add up to 32 strategies", async () => { const { deployVault, lp2, DummyInvestStrategy, currency, grantRole, strategies } = await helpers.loadFixture( variant.fixture ); @@ -821,7 +821,7 @@ variants.forEach((variant) => { await invariantChecks(vault); }); - variant.tagit("It can removeStrategy only if doesn't have funds unless forced", async () => { + it("It can removeStrategy only if doesn't have funds unless forced", async () => { const { deployVault, lp, lp2, currency, grantRole, grantForwardToStrategy, strategies } = await helpers.loadFixture(variant.fixture); const vault = await deployVault(3, undefined, [1, 0, 2], [2, 0, 1]); @@ -859,7 +859,7 @@ variants.forEach((variant) => { await invariantChecks(vault); }); - variant.tagit("It can removeStrategy only if doesn't have funds", async () => { + it("It can removeStrategy only if doesn't have funds", async () => { const { deployVault, lp, lp2, currency, grantRole, grantForwardToStrategy, strategies } = await helpers.loadFixture(variant.fixture); const vault = await deployVault(3, undefined, [1, 0, 2], [2, 0, 1]); @@ -921,7 +921,7 @@ variants.forEach((variant) => { ); }); - variant.tagit("It can removeStrategy in different order", async () => { + it("It can removeStrategy in different order", async () => { const { deployVault, lp2, grantRole, strategies } = await helpers.loadFixture(variant.fixture); const vault = await deployVault(3, undefined, [1, 0, 2], [2, 0, 1]); @@ -950,7 +950,7 @@ variants.forEach((variant) => { ); }); - variant.tagit("It can change the depositQueue if authorized", async () => { + it("It can change the depositQueue if authorized", async () => { const { deployVault, lp2, grantRole } = await helpers.loadFixture(variant.fixture); const vault = await deployVault(3, undefined, [1, 0, 2], [2, 0, 1]); expect(await vault.depositQueue()).to.deep.equal([2, 1, 3].concat(Array(MAX_STRATEGIES - 3).fill(0))); @@ -991,7 +991,7 @@ variants.forEach((variant) => { ); }); - variant.tagit("It can change the withdrawQueue if authorized", async () => { + it("It can change the withdrawQueue if authorized", async () => { const { deployVault, lp2, grantRole } = await helpers.loadFixture(variant.fixture); const vault = await deployVault(3, undefined, [1, 0, 2], [2, 0, 1]); expect(await vault.withdrawQueue()).to.deep.equal([3, 1, 2].concat(Array(MAX_STRATEGIES - 3).fill(0))); @@ -1032,7 +1032,7 @@ variants.forEach((variant) => { ); }); - variant.tagit("It can replaceStrategy if authorized", async () => { + it("It can replaceStrategy if authorized", async () => { const { deployVault, lp, lp2, currency, grantRole, grantForwardToStrategy, strategies } = await helpers.loadFixture(variant.fixture); const vault = await deployVault(3, undefined, [1, 0, 2], [2, 0, 1]); @@ -1109,7 +1109,7 @@ variants.forEach((variant) => { .withArgs(strategies[6], strategies[6]); }); - variant.tagit("Initialization fails if any strategy and vault have different assets", async () => { + it("Initialization fails if any strategy and vault have different assets", async () => { const { MultiStrategyERC4626, DummyInvestStrategy, adminAddr, currency } = await helpers.loadFixture( variant.fixture ); @@ -1141,7 +1141,7 @@ variants.forEach((variant) => { ).to.be.revertedWithCustomError(MultiStrategyERC4626, "InvalidStrategyAsset"); }); - variant.tagit("Fails to add strategy to vault if assets are different", async () => { + it("Fails to add strategy to vault if assets are different", async () => { const { deployVault, DummyInvestStrategy, grantRole, admin, MultiStrategyERC4626 } = await helpers.loadFixture( variant.fixture ); @@ -1162,7 +1162,7 @@ variants.forEach((variant) => { ).to.be.revertedWithCustomError(MultiStrategyERC4626, "InvalidStrategyAsset"); }); - variant.tagit("Fails to replace strategy to vault if assets are different", async () => { + it("Fails to replace strategy to vault if assets are different", async () => { // Obtener instancias necesarias para el test (contract, roles, etc.) const { deployVault, DummyInvestStrategy, grantRole, admin, MultiStrategyERC4626 } = await helpers.loadFixture( variant.fixture diff --git a/test/test-swap-chainlink-asset-invest-strategy.js b/test/test-swap-chainlink-asset-invest-strategy.js new file mode 100644 index 0000000..c0d1118 --- /dev/null +++ b/test/test-swap-chainlink-asset-invest-strategy.js @@ -0,0 +1,376 @@ +const { expect } = require("chai"); +const { amountFunction, _W, getRole, tagitVariant } = require("@ensuro/utils/js/utils"); +const { DAY } = require("@ensuro/utils/js/constants"); +const { buildUniswapConfig } = require("@ensuro/swaplibrary/js/utils"); +const { encodeSwapConfig } = require("./utils"); +const { initCurrency } = require("@ensuro/utils/js/test-utils"); +const { anyUint } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); +const hre = require("hardhat"); +const helpers = require("@nomicfoundation/hardhat-network-helpers"); + +const { ethers } = hre; +const { MaxUint256, ZeroAddress } = hre.ethers; + +const CURRENCY_DECIMALS = 6; +const _A = amountFunction(CURRENCY_DECIMALS); +const INITIAL = 10000; +const NAME = "Single Strategy Vault"; +const SYMB = "SSV"; + +async function setUp() { + const [, lp, lp2, anon, guardian, admin] = await ethers.getSigners(); + + const SwapRouterMock = await ethers.getContractFactory("SwapRouterMock"); + const uniswapRouterMock = await SwapRouterMock.deploy(admin); + + const USDC = await initCurrency( + { + name: "Test Currency with 6 decimals", + symbol: "USDC", + decimals: 6, + initial_supply: _A(50000), + extraArgs: [admin], + }, + [lp, lp2, uniswapRouterMock], + [_A(INITIAL), _A(INITIAL), _A(INITIAL * 3)] + ); + const WPOL = await initCurrency( + { + name: "Wrapped POL", + symbol: "WPOL", + decimals: 18, + initial_supply: _W(50000), + extraArgs: [admin], + }, + [lp, lp2, uniswapRouterMock], + [_W(INITIAL), _W(INITIAL), _W(INITIAL * 3)] + ); + const COMP = await initCurrency( + { + name: "Compound", + symbol: "COMP", + decimals: 18, + initial_supply: _W(50000), + extraArgs: [admin], + }, + [lp, lp2, uniswapRouterMock], + [_W(INITIAL), _W(INITIAL), _W(INITIAL * 3)] + ); + + const adminAddr = await ethers.resolveAddress(admin); + const DummyInvestStrategy = await ethers.getContractFactory("DummyInvestStrategy"); + const SwapLibrary = await ethers.getContractFactory("SwapLibrary"); + const swapLibrary = await SwapLibrary.deploy(); + const ChainlinkSwapAssetInvestStrategy = await ethers.getContractFactory("ChainlinkSwapAssetInvestStrategy", { + libraries: { + SwapLibrary: await ethers.resolveAddress(swapLibrary), + }, + }); + const SingleStrategyERC4626 = await ethers.getContractFactory("SingleStrategyERC4626"); + const ChainlinkOracleMock = await ethers.getContractFactory("ChainlinkOracleMock"); + + const now = await helpers.time.latest(); + + const compOracle = await ChainlinkOracleMock.deploy(8, "COMP Oracle", 0); + await compOracle.addRound(1, 40n * 10n ** 8n, 0, now, 0); + const wpolOracle = await ChainlinkOracleMock.deploy(18, "WPOL Oracle", 0); + await wpolOracle.addRound(1, (2n * 10n ** 18n) / 10n, 0, now, 0); // 0.2 + const usdcOracle = await ChainlinkOracleMock.deploy(18, "USDC Oracle", 0); + await usdcOracle.addRound(1, _W(1), 0, now, 0); // 1 + + // After this, the uniswapRouterMock has enough liquidity to execute swaps. Now only needs prices + // Initializing the prices as 1:1 + await uniswapRouterMock.setCurrentPrice(COMP, USDC, _W(1 / 40)); + await uniswapRouterMock.setCurrentPrice(USDC, COMP, _W(40)); + await uniswapRouterMock.setCurrentPrice(WPOL, USDC, _W(1 / 0.2)); + await uniswapRouterMock.setCurrentPrice(USDC, WPOL, _W(0.2)); + + const swapConfig = buildUniswapConfig(_W("0.001"), 100, uniswapRouterMock.target); + + async function setupVault(asset, strategy, strategyData = encodeSwapConfig(swapConfig)) { + const vault = await hre.upgrades.deployProxy( + SingleStrategyERC4626, + [NAME, SYMB, adminAddr, await ethers.resolveAddress(asset), await ethers.resolveAddress(strategy), strategyData], + { + kind: "uups", + unsafeAllow: ["delegatecall"], + } + ); + // Whitelist LPs + await asset.connect(lp).approve(vault, MaxUint256); + await asset.connect(lp2).approve(vault, MaxUint256); + await vault.connect(admin).grantRole(getRole("LP_ROLE"), lp); + await vault.connect(admin).grantRole(getRole("LP_ROLE"), lp2); + return vault; + } + + return { + now, + USDC, + COMP, + WPOL, + usdcOracle, + compOracle, + wpolOracle, + SingleStrategyERC4626, + ChainlinkSwapAssetInvestStrategy, + DummyInvestStrategy, + adminAddr, + swapLibrary, + lp, + lp2, + anon, + guardian, + admin, + swapConfig, + uniswapRouterMock, + setupVault, + }; +} + +function makeFixture( + asset, + investAsset, + assetFn, + investFn, + hasAssetOracle = true, + hasInvestOracle = true, + priceTolerance = DAY +) { + return async () => { + const ret = await helpers.loadFixture(setUp); + const assetOracle = hasAssetOracle ? ret[`${asset.toLowerCase()}Oracle`] : ZeroAddress; + const investOracle = hasInvestOracle ? ret[`${investAsset.toLowerCase()}Oracle`] : ZeroAddress; + return { + ...ret, + _a: assetFn, + _i: investFn, + asset: ret[`${asset}`], + investAsset: ret[`${investAsset}`], + assetOracle, + investOracle, + priceTolerance, + }; + }; +} + +const variants = [ + { + name: "USDC->COMP", + fixture: makeFixture("USDC", "COMP", _A, _W), + price: 40, + }, + { + name: "USDC->WPOL", + fixture: makeFixture("USDC", "WPOL", _A, _W), + price: 0.2, + }, + { + name: "USDC->COMP - No asset oracle", + fixture: makeFixture("USDC", "COMP", _A, _W, false), + price: 40, + }, + { + name: "USDC->WPOL - No asset oracle", + fixture: makeFixture("USDC", "WPOL", _A, _W, false), + price: 0.2, + }, +]; + +variants.forEach((variant) => { + const it = (testDescription, test) => tagitVariant(variant, false, testDescription, test); + it.only = (testDescription, test) => tagitVariant(variant, true, testDescription, test); + + describe(`ChainlinkSwapAssetInvestStrategy contract tests ${variant.name}`, function () { + it("Initializes the vault correctly", async () => { + const { + ChainlinkSwapAssetInvestStrategy, + setupVault, + asset, + investAsset, + assetOracle, + investOracle, + priceTolerance, + } = await variant.fixture(); + const strategy = await ChainlinkSwapAssetInvestStrategy.deploy( + asset, + investAsset, + assetOracle, + investOracle, + priceTolerance + ); + const vault = await setupVault(asset, strategy); + expect(await vault.name()).to.equal(NAME); + expect(await vault.symbol()).to.equal(SYMB); + expect(await vault.strategy()).to.equal(strategy); + expect(await vault.asset()).to.equal(asset); + expect(await vault.totalAssets()).to.equal(0); + expect(await strategy.asset(vault)).to.equal(asset); + expect(await strategy.investAsset(vault)).to.equal(investAsset); + expect(await strategy.priceTolerance()).to.equal(priceTolerance); + expect(await strategy.assetOracle()).to.equal(assetOracle); + expect(await strategy.investAssetOracle()).to.equal(investOracle); + }); + + it("Deposit and accounting works", async () => { + const { + ChainlinkSwapAssetInvestStrategy, + setupVault, + asset, + investAsset, + assetOracle, + investOracle, + priceTolerance, + _a, + _i, + lp, + } = await variant.fixture(); + const strategy = await ChainlinkSwapAssetInvestStrategy.deploy( + asset, + investAsset, + assetOracle, + investOracle, + priceTolerance + ); + const vault = await setupVault(asset, strategy); + await vault.connect(lp).deposit(_a(100), lp); + expect(await strategy.investAssetPrice()).to.closeTo(_W(variant.price), _W("0.0000001")); + expect(await vault.totalAssets()).to.equal(_a("99.9")); // 0.01 slippage + expect(await investAsset.balanceOf(vault)).to.equal(_i(100 / variant.price)); + expect(await asset.balanceOf(vault)).to.equal(_a(0)); + }); + + it("Withdraw function executes swap correctly and emits correct events", async () => { + const { + ChainlinkSwapAssetInvestStrategy, + setupVault, + asset, + investAsset, + assetOracle, + investOracle, + priceTolerance, + _a, + _i, + lp, + } = await variant.fixture(); + const strategy = await ChainlinkSwapAssetInvestStrategy.deploy( + asset, + investAsset, + assetOracle, + investOracle, + priceTolerance + ); + const vault = await setupVault(asset, strategy); + + await vault.connect(lp).deposit(_a(100), lp); + + const initialBalanceInvestAsset = await investAsset.balanceOf(vault); + + await expect(vault.connect(lp).withdraw(_a(50), lp, lp)) + .to.emit(vault, "Withdraw") + .withArgs(lp, lp, lp, _a(50), anyUint); + + expect(await investAsset.balanceOf(vault)).to.equal(initialBalanceInvestAsset - _i(50 / variant.price)); + + await expect(vault.connect(lp).withdraw(_a(49.9), lp, lp)) + .to.emit(vault, "Withdraw") + .withArgs(lp, lp, lp, _a(49.9), anyUint); + + expect(await investAsset.balanceOf(vault)).to.equal(_i(0.1 / variant.price)); + }); + + it("Deposit and accounting works when price changes", async () => { + const { + ChainlinkSwapAssetInvestStrategy, + setupVault, + asset, + investAsset, + assetOracle, + investOracle, + priceTolerance, + _a, + now, + lp, + } = await variant.fixture(); + const strategy = await ChainlinkSwapAssetInvestStrategy.deploy( + asset, + investAsset, + assetOracle, + investOracle, + priceTolerance + ); + const vault = await setupVault(asset, strategy); + await vault.connect(lp).deposit(_a(100), lp); + expect(await vault.totalAssets()).to.equal(_a("99.9")); // 0.01 slippage + + // Increase price of the invest asset 20% + await investOracle.addRound( + 2, + BigInt(variant.price * 1.2 * 10000) * 10n ** ((await investOracle.decimals()) - 4n), + now, + now, + 0 + ); + expect(await strategy.investAssetPrice()).to.closeTo(_W(variant.price * 1.2), _W("0.0000001")); + expect(await vault.totalAssets()).to.closeTo(_a(120 * 0.999), _a("0.01")); + + if (assetOracle === ZeroAddress) return; + + // Duplicate the price of the asset + const [, oldAssetPrice] = await assetOracle.latestRoundData(); + await assetOracle.addRound(2, oldAssetPrice * 2n, now, now, 0); + + expect(await strategy.investAssetPrice()).to.closeTo(_W((variant.price * 1.2) / 2), _W("0.0000001")); + expect(await vault.totalAssets()).to.closeTo(_a(60 * 0.999), _a("0.01")); + }); + + it("Fails when price is too old or invalid (<=0)", async () => { + const { + ChainlinkSwapAssetInvestStrategy, + setupVault, + asset, + investAsset, + assetOracle, + investOracle, + priceTolerance, + _a, + now, + lp, + } = await variant.fixture(); + const strategy = await ChainlinkSwapAssetInvestStrategy.deploy( + asset, + investAsset, + assetOracle, + investOracle, + priceTolerance + ); + const vault = await setupVault(asset, strategy); + await vault.connect(lp).deposit(_a(100), lp); + expect(await vault.totalAssets()).to.equal(_a("99.9")); // 0.01 slippage + + // Set price to 0 + await investOracle.addRound(2, 0, now, now, 0); + await expect(strategy.investAssetPrice()).to.be.revertedWithCustomError(strategy, "InvalidPrice").withArgs(0); + + // Increase price of the invest asset 20% + await investOracle.addRound( + 3, + BigInt(variant.price * 1.2 * 10000) * 10n ** ((await investOracle.decimals()) - 4n), + now, + now, + 0 + ); + expect(await strategy.investAssetPrice()).to.closeTo(_W(variant.price * 1.2), _W("0.0000001")); + expect(await vault.totalAssets()).to.closeTo(_a(120 * 0.999), _a("0.01")); + + if (assetOracle === ZeroAddress) return; + + // Duplicate the price of the asset but with an old update date + const [, oldAssetPrice] = await assetOracle.latestRoundData(); + await assetOracle.addRound(3, oldAssetPrice * 2n, now, now - 2 * DAY, 0); + await expect(strategy.investAssetPrice()) + .to.be.revertedWithCustomError(strategy, "PriceTooOld") + .withArgs(anyUint, now - 2 * DAY); + }); + }); +}); diff --git a/test/test-swap-stable-aave-v3-invest-strategy.js b/test/test-swap-stable-aave-v3-invest-strategy.js index 127d6f0..c3e5055 100644 --- a/test/test-swap-stable-aave-v3-invest-strategy.js +++ b/test/test-swap-stable-aave-v3-invest-strategy.js @@ -1,7 +1,7 @@ const { expect } = require("chai"); -const { amountFunction, _W, getRole } = require("@ensuro/utils/js/utils"); +const { amountFunction, _W, getRole, tagitVariant } = require("@ensuro/utils/js/utils"); const { buildUniswapConfig } = require("@ensuro/swaplibrary/js/utils"); -const { encodeSwapConfig, encodeDummyStorage, tagit } = require("./utils"); +const { encodeSwapConfig, encodeDummyStorage } = require("./utils"); const { initForkCurrency, setupChain } = require("@ensuro/utils/js/test-utils"); const { anyUint } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); const hre = require("hardhat"); @@ -144,24 +144,24 @@ function makeFixture(asset, investAsset) { const variants = [ { name: "USDC(6)->USDC_NATIVE(6) with AAVE", - tagit: tagit, fixture: makeFixture("USDC", "USDC_NATIVE"), }, { name: "USDC(6)->USDT(6) with AAVE", - tagit: tagit, fixture: makeFixture("USDC", "USDT"), }, { name: "USDC(6)->DAI(18) with AAVE", - tagit: tagit, fixture: makeFixture("USDC", "DAI"), }, ]; variants.forEach((variant) => { + const it = (testDescription, test) => tagitVariant(variant, false, testDescription, test); + it.only = (testDescription, test) => tagitVariant(variant, true, testDescription, test); + describe(`SwapStableAaveV3InvestStrategy contract tests ${variant.name}`, function () { - variant.tagit("Initializes the vault correctly with AAVE", async () => { + it("Initializes the vault correctly with AAVE", async () => { const { SwapStableAaveV3InvestStrategy, setupVault, currA, currB } = await variant.fixture(); const strategy = await SwapStableAaveV3InvestStrategy.deploy(currA, currB, _W(1), ADDRESSES.AAVEv3); @@ -177,7 +177,7 @@ variants.forEach((variant) => { expect(await strategy.investAsset(vault)).to.equal(currB); }); - variant.tagit("Deposit and accounting works", async () => { + it("Deposit and accounting works", async () => { const { SwapStableAaveV3InvestStrategy, setupVault, currA, currB, aToken, lp, _a, _i } = await variant.fixture(); const strategy = await SwapStableAaveV3InvestStrategy.deploy(currA, currB, _W(1), ADDRESSES.AAVEv3); const vault = await setupVault(currA, strategy); @@ -187,7 +187,7 @@ variants.forEach((variant) => { expect(await currA.balanceOf(vault)).to.equal(_a(0)); }); - variant.tagit("Withdraw works with original slippage and validates balances", async () => { + it("Withdraw works with original slippage and validates balances", async () => { const { SwapStableAaveV3InvestStrategy, setupVault, currA, currB, aToken, lp, _a, _i } = await variant.fixture(); const strategy = await SwapStableAaveV3InvestStrategy.deploy(currA, currB, _W(1), ADDRESSES.AAVEv3); const vault = await setupVault(currA, strategy); @@ -205,7 +205,7 @@ variants.forEach((variant) => { expect(await currA.balanceOf(lp)).to.equal(initialLpBalance - _a(40)); }); - variant.tagit("maxWithdraw returns correct values initially, afert deposit & withdraw", async () => { + it("maxWithdraw returns correct values initially, afert deposit & withdraw", async () => { const { SwapStableAaveV3InvestStrategy, setupVault, currA, currB, aToken, lp, _a, _i } = await variant.fixture(); const strategy = await SwapStableAaveV3InvestStrategy.deploy(currA, currB, _W(1), ADDRESSES.AAVEv3); const vault = await setupVault(currA, strategy); @@ -227,7 +227,7 @@ variants.forEach((variant) => { expect(maxWithdrawAfterWithdraw).to.equal(await strategy.totalAssets(vault)); }); - variant.tagit("Checks methods can't be called directly", async () => { + it("Checks methods can't be called directly", async () => { const { SwapStableAaveV3InvestStrategy, currA, currB } = await variant.fixture(); const strategy = await SwapStableAaveV3InvestStrategy.deploy(currA, currB, _W(1), ADDRESSES.AAVEv3); @@ -249,7 +249,7 @@ variants.forEach((variant) => { ); }); - variant.tagit("Should disconnect when strategy change & when authorized", async function () { + it("Should disconnect when strategy change & when authorized", async function () { const { SwapStableAaveV3InvestStrategy, setupVault, currA, currB, anon, admin } = await variant.fixture(); const strategy = await SwapStableAaveV3InvestStrategy.deploy(currA, currB, _W(1), ADDRESSES.AAVEv3); const vault = await setupVault(currA, strategy); @@ -264,7 +264,7 @@ variants.forEach((variant) => { await expect(tx).to.emit(vault, "StrategyChanged").withArgs(strategy, dummyStrategy); }); - variant.tagit("Disconnect doesn't fail when changing strategy", async function () { + it("Disconnect doesn't fail when changing strategy", async function () { const { SwapStableAaveV3InvestStrategy, setupVault, currA, currB, lp, admin, _a } = await variant.fixture(); const strategy = await SwapStableAaveV3InvestStrategy.deploy(currA, currB, _W(1), ADDRESSES.AAVEv3); const vault = await setupVault(currA, strategy); diff --git a/test/test-swap-stable-invest-strategy.js b/test/test-swap-stable-invest-strategy.js index ee86cda..bed73fa 100644 --- a/test/test-swap-stable-invest-strategy.js +++ b/test/test-swap-stable-invest-strategy.js @@ -1,7 +1,7 @@ const { expect } = require("chai"); -const { amountFunction, _W, getRole, getTransactionEvent } = require("@ensuro/utils/js/utils"); +const { amountFunction, _W, getRole, getTransactionEvent, tagitVariant } = require("@ensuro/utils/js/utils"); const { buildUniswapConfig } = require("@ensuro/swaplibrary/js/utils"); -const { encodeSwapConfig, encodeDummyStorage, tagit } = require("./utils"); +const { encodeSwapConfig, encodeDummyStorage } = require("./utils"); const { initCurrency } = require("@ensuro/utils/js/test-utils"); const { anyUint } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); const hre = require("hardhat"); @@ -150,29 +150,28 @@ function makeFixture(asset, investAsset, assetFn, investFn) { const variants = [ { name: "A(6)->B(6)", - tagit: tagit, fixture: makeFixture("A", "B", _A, _A), }, { name: "A(6)->M(18)", - tagit: tagit, fixture: makeFixture("A", "M", _A, _W), }, { name: "M(18)->X(18)", - tagit: tagit, fixture: makeFixture("M", "X", _W, _W), }, { name: "M(18)->A(6)", - tagit: tagit, fixture: makeFixture("M", "A", _W, _A), }, ]; variants.forEach((variant) => { + const it = (testDescription, test) => tagitVariant(variant, false, testDescription, test); + it.only = (testDescription, test) => tagitVariant(variant, true, testDescription, test); + describe(`SwapStableInvestStrategy contract tests ${variant.name}`, function () { - variant.tagit("Initializes the vault correctly", async () => { + it("Initializes the vault correctly", async () => { const { SwapStableInvestStrategy, setupVault, currA, currB } = await variant.fixture(); const strategy = await SwapStableInvestStrategy.deploy(currA, currB, _W(1)); const vault = await setupVault(currA, strategy); @@ -185,7 +184,7 @@ variants.forEach((variant) => { expect(await strategy.investAsset(vault)).to.equal(currB); }); - variant.tagit("Deposit and accounting works", async () => { + it("Deposit and accounting works", async () => { const { SwapStableInvestStrategy, setupVault, currA, currB, lp, _a, _i } = await variant.fixture(); const strategy = await SwapStableInvestStrategy.deploy(currA, currB, _W(1)); const vault = await setupVault(currA, strategy); @@ -195,32 +194,29 @@ variants.forEach((variant) => { expect(await currA.balanceOf(vault)).to.equal(_a(0)); }); - variant.tagit( - "Withdraw function executes swap correctly and emits correct events - currA(6) -> currB(6)", - async () => { - const { SwapStableInvestStrategy, setupVault, currA, currB, lp, _a, _i } = await variant.fixture(); - const strategy = await SwapStableInvestStrategy.deploy(currA, currB, _W(1)); - const vault = await setupVault(currA, strategy); + it("Withdraw function executes swap correctly and emits correct events - currA(6) -> currB(6)", async () => { + const { SwapStableInvestStrategy, setupVault, currA, currB, lp, _a, _i } = await variant.fixture(); + const strategy = await SwapStableInvestStrategy.deploy(currA, currB, _W(1)); + const vault = await setupVault(currA, strategy); - await vault.connect(lp).deposit(_a(100), lp); + await vault.connect(lp).deposit(_a(100), lp); - const initialBalanceInvestAsset = await currB.balanceOf(vault); + const initialBalanceInvestAsset = await currB.balanceOf(vault); - await expect(vault.connect(lp).withdraw(_a(50), lp, lp)) - .to.emit(vault, "Withdraw") - .withArgs(lp, lp, lp, _a(50), anyUint); + await expect(vault.connect(lp).withdraw(_a(50), lp, lp)) + .to.emit(vault, "Withdraw") + .withArgs(lp, lp, lp, _a(50), anyUint); - expect(await currB.balanceOf(vault)).to.equal(initialBalanceInvestAsset - _i(50)); + expect(await currB.balanceOf(vault)).to.equal(initialBalanceInvestAsset - _i(50)); - await expect(vault.connect(lp).withdraw(_a(49.9), lp, lp)) - .to.emit(vault, "Withdraw") - .withArgs(lp, lp, lp, _a(49.9), anyUint); + await expect(vault.connect(lp).withdraw(_a(49.9), lp, lp)) + .to.emit(vault, "Withdraw") + .withArgs(lp, lp, lp, _a(49.9), anyUint); - expect(await currB.balanceOf(vault)).to.equal(_i(0.1)); - } - ); + expect(await currB.balanceOf(vault)).to.equal(_i(0.1)); + }); - variant.tagit("Withdraw function fails", async () => { + it("Withdraw function fails", async () => { const { SwapStableInvestStrategy, setupVault, currA, currB, lp, _a } = await variant.fixture(); const strategy = await SwapStableInvestStrategy.deploy(currA, currB, _W(1)); const vault = await setupVault(currA, strategy); @@ -236,7 +232,7 @@ variants.forEach((variant) => { await expect(vault.connect(lp).withdraw(_a(0), lp, lp)).not.to.be.reverted; }); - variant.tagit("Deposit and accounting works when price != 1", async () => { + it("Deposit and accounting works when price != 1", async () => { const { SwapStableInvestStrategy, setupVault, currA, currB, lp, _a, _i, uniswapRouterMock } = await variant.fixture(); await uniswapRouterMock.setCurrentPrice(currA, currB, _W("0.5")); @@ -256,7 +252,7 @@ variants.forEach((variant) => { expect(await currA.balanceOf(lp)).to.equal(_a(INITIAL) - _a(50)); }); - variant.tagit("Deposit and accounting works when price != 1 and slippage", async () => { + it("Deposit and accounting works when price != 1 and slippage", async () => { const { SwapStableInvestStrategy, setupVault, currA, currB, lp, _a, _i, uniswapRouterMock } = await variant.fixture(); await uniswapRouterMock.setCurrentPrice(currA, currB, _W("0.49")); @@ -276,7 +272,7 @@ variants.forEach((variant) => { expect(await currA.balanceOf(lp)).to.equal(_a(INITIAL) - _a(50)); }); - variant.tagit("Checks methods can't be called directly", async () => { + it("Checks methods can't be called directly", async () => { const { SwapStableInvestStrategy, currA, currB } = await variant.fixture(); const strategy = await SwapStableInvestStrategy.deploy(currA, currB, _W(1)); @@ -303,7 +299,7 @@ variants.forEach((variant) => { ); }); - variant.tagit("Checks onlyRole modifier & setSwapConfig function", async () => { + it("Checks onlyRole modifier & setSwapConfig function", async () => { const { SwapStableInvestStrategy, currA, currB, anon, admin, swapConfig, setupVault, uniswapRouterMock } = await variant.fixture(); const strategy = await SwapStableInvestStrategy.deploy(currA, currB, _W(1)); @@ -342,7 +338,7 @@ variants.forEach((variant) => { ).to.be.revertedWithCustomError(strategy, "NoExtraDataAllowed"); }); - variant.tagit("Should return the correct swap configuration", async () => { + it("Should return the correct swap configuration", async () => { const { SwapStableInvestStrategy, setupVault, currA, currB, admin, anon, uniswapRouterMock } = await variant.fixture(); const strategy = await SwapStableInvestStrategy.deploy(currA, currB, _W(1)); @@ -358,7 +354,7 @@ variants.forEach((variant) => { expect(await strategy.getSwapConfig(vault, strategy)).to.deep.equal(newSwapConfig); }); - variant.tagit("setStrategy should work and disconnect strategy when authorized", async function () { + it("setStrategy should work and disconnect strategy when authorized", async function () { const { SwapStableInvestStrategy, setupVault, currA, currB, anon, admin } = await variant.fixture(); const strategy = await SwapStableInvestStrategy.deploy(currA, currB, _W(1)); const vault = await setupVault(currA, strategy); @@ -373,7 +369,7 @@ variants.forEach((variant) => { await expect(tx).to.emit(vault, "StrategyChanged").withArgs(strategy, dummyStrategy); }); - variant.tagit("Disconnect doesn't fail when changing strategy", async function () { + it("Disconnect doesn't fail when changing strategy", async function () { const { SwapStableInvestStrategy, setupVault, currA, currB, lp, admin, _a } = await variant.fixture(); const strategy = await SwapStableInvestStrategy.deploy(currA, currB, _W(1)); const vault = await setupVault(currA, strategy); @@ -387,7 +383,7 @@ variants.forEach((variant) => { await expect(vault.connect(lp).setStrategy(dummyStrategy, encodeDummyStorage({}), false)).not.to.be.reverted; }); - variant.tagit("Disconnect without assets doesn't revert", async function () { + it("Disconnect without assets doesn't revert", async function () { const { SwapStableInvestStrategy, setupVault, currA, currB, lp, admin } = await variant.fixture(); const strategy = await SwapStableInvestStrategy.deploy(currA, currB, _W(1)); const vault = await setupVault(currA, strategy); @@ -404,7 +400,7 @@ variants.forEach((variant) => { describe("SwapStableInvestStrategy constructor tests", function () { it("It reverts when asset or invest asset has >18 decimals", async () => { - const [, lp, lp2, admin] = await ethers.getSigners(); + const [, , , admin] = await ethers.getSigners(); const SwapLibrary = await ethers.getContractFactory("SwapLibrary"); const swapLibrary = await SwapLibrary.deploy(); const SwapStableInvestStrategy = await ethers.getContractFactory("SwapStableInvestStrategy", { diff --git a/test/utils.js b/test/utils.js index 0aa6673..bccf71d 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,5 +1,4 @@ const ethers = require("ethers"); -const { Assertion } = require("chai"); function encodeSwapConfig(swapConfig) { return ethers.AbiCoder.defaultAbiCoder().encode(["tuple(uint8, uint256, bytes)"], [swapConfig]); @@ -16,63 +15,8 @@ function dummyStorage({ failConnect, failDisconnect, failDeposit, failWithdraw } return [failConnect || false, failDisconnect || false, failDeposit || false, failWithdraw || false]; } -const tagRegExp = new RegExp("\\[(?[!])?(?[a-zA-Z0-9+]+)\\]", "gu"); - -function tagit(testDescription, test, only = false) { - let any = false; - const iit = only || this.only ? it.only : it; - for (const m of testDescription.matchAll(tagRegExp)) { - if (m === undefined) break; - const neg = m.groups.neg !== undefined; - any = any || !neg; - if (m.groups.variant === this.name) { - if (!neg) { - // If tag found and not negated, run the it - iit(testDescription, test); - return; - } - // If tag found and negated, don't run the it - return; - } - } - // If no positive tags, run the it - if (!any) iit(testDescription, test); -} - -async function makeAllViewsPublic(acMgr, contract) { - const PUBLIC_ROLE = await acMgr.PUBLIC_ROLE(); - for (const fragment of contract.interface.fragments) { - if (fragment.type !== "function") continue; - if (fragment.stateMutability !== "pure" && fragment.stateMutability !== "view") continue; - await acMgr.setTargetFunctionRole(contract, [fragment.selector], PUBLIC_ROLE); - } -} - -function mergeFragments(a, b) { - const fallback = a.find((f) => f.type === "fallback"); - return a.concat( - b.filter((fragment) => fragment.type !== "constructor" && (fallback === undefined || fragment.type !== "fallback")) - ); -} - -// Install chai matchear for AccessManagedError -Assertion.addMethod("revertedWithAMError", function (contract, user) { - return new Assertion(this._obj).to.be.revertedWithCustomError(contract, "AccessManagedUnauthorized").withArgs(user); -}); - -async function setupAMRole(acMgr, vault, roles, role, methods) { - await acMgr.labelRole(roles[role], role); - for (const method of methods) { - await acMgr.setTargetFunctionRole(vault, [vault.interface.getFunction(method).selector], roles[role]); - } -} - module.exports = { encodeDummyStorage, encodeSwapConfig, dummyStorage, - tagit, - makeAllViewsPublic, - mergeFragments, - setupAMRole, };