Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions contracts/ChainlinkSwapAssetInvestStrategy.sol
Original file line number Diff line number Diff line change
@@ -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 [email protected]
* @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());
}
}
115 changes: 115 additions & 0 deletions contracts/MerklRewardsInvestStrategy.sol
Original file line number Diff line number Diff line change
@@ -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 [email protected]
* @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);
}
}
205 changes: 205 additions & 0 deletions contracts/SwapAssetInvestStrategy.sol
Original file line number Diff line number Diff line change
@@ -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 [email protected]
* @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_);
}
}
Loading
Loading