diff --git a/contracts/mainnet/connectors/mstable/events.sol b/contracts/mainnet/connectors/mstable/events.sol new file mode 100644 index 00000000..11497875 --- /dev/null +++ b/contracts/mainnet/connectors/mstable/events.sol @@ -0,0 +1,18 @@ +pragma solidity ^0.7.6; + +contract Events { + event LogDeposit(address token, uint256 amount, address path, bool stake); + event LogWithdraw( + address token, + uint256 amount, + address path, + bool unstake + ); + event LogClaimRewards(address token, uint256 amount); + event LogSwap( + address from, + address to, + uint256 amountIn, + uint256 amountOut + ); +} diff --git a/contracts/mainnet/connectors/mstable/helpers.sol b/contracts/mainnet/connectors/mstable/helpers.sol new file mode 100644 index 00000000..9d569963 --- /dev/null +++ b/contracts/mainnet/connectors/mstable/helpers.sol @@ -0,0 +1,109 @@ +pragma solidity ^0.7.6; + +import { DSMath } from "../../common/math.sol"; +import { Basic } from "../../common/basic.sol"; + +import { TokenInterface } from "../../common/interfaces.sol"; +import { ISavingsContractV2, IBoostedSavingsVault } from "./interface.sol"; + +abstract contract Helpers is DSMath, Basic { + address internal constant mUsdToken = + 0xe2f2a5C287993345a840Db3B0845fbC70f5935a5; + address internal constant imUsdToken = + 0x30647a72Dc82d7Fbb1123EA74716aB8A317Eac19; + address internal constant imUsdVault = + 0x78BefCa7de27d07DC6e71da295Cc2946681A6c7B; + + /*************************************** + Internal + ****************************************/ + + /** + * @dev Deposit to Save from any asset + * @notice Called internally from deposit functions + * @param _token Address of token to deposit + * @param _amount Amount of token to deposit + * @param _path Path to mint mUSD (only needed for Feeder Pool) + * @param _stake stake token in Vault? + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function _deposit( + address _token, + uint256 _amount, + address _path, + bool _stake + ) internal returns (string memory _eventName, bytes memory _eventParam) { + // 1. Deposit mUSD to Save + approve(TokenInterface(mUsdToken), imUsdToken, _amount); + uint256 credits = ISavingsContractV2(imUsdToken).depositSavings( + _amount + ); + + if (_stake) { + // 2. Stake imUSD to Vault + approve(TokenInterface(imUsdToken), imUsdVault, credits); + IBoostedSavingsVault(imUsdVault).stake(credits); + } + // 3. Log Events + _eventName = "LogDeposit(address,uint256,address,bool)"; + _eventParam = abi.encode(_token, _amount, _path, _stake); + } + + /** + * @dev Withdraws from Save + * @notice Withdraws token supported by mStable from Save + * @param _credits Credits to withdraw + * @param _unstake unstake from Vault? + * @return amountWithdrawn Amount withdrawn in mUSD + */ + + function _withdraw(uint256 _credits, bool _unstake) + internal + returns (uint256 amountWithdrawn) + { + uint256 credits; + // 1. Withdraw from Vault + if (_unstake) { + credits = _credits == uint256(-1) + ? TokenInterface(imUsdVault).balanceOf(address(this)) + : _credits; + IBoostedSavingsVault(imUsdVault).withdraw(credits); + } + + // 2. Withdraw from Save + credits = _credits == uint256(-1) + ? TokenInterface(imUsdToken).balanceOf(address(this)) + : _credits; + approve(TokenInterface(imUsdToken), imUsdVault, _credits); + amountWithdrawn = ISavingsContractV2(imUsdToken).redeemCredits(credits); + } + + /** + * @dev Returns the reward tokens + * @notice Gets the reward tokens from the vault contract + * @return rewardToken Address of reward token + */ + + function _getRewardTokens() internal view returns (address rewardToken) { + rewardToken = address( + IBoostedSavingsVault(imUsdVault).getRewardToken() + ); + } + + /** + * @dev Returns the internal balances of the rewardToken and platformToken + * @notice Gets current balances of rewardToken and platformToken, used for calculating rewards accrued + * @param _rewardToken Address of reward token + * @return a Amount of reward token + */ + + function _getRewardInternalBal(address _rewardToken) + internal + view + returns (uint256 a) + { + a = TokenInterface(_rewardToken).balanceOf(address(this)); + } +} diff --git a/contracts/mainnet/connectors/mstable/interface.sol b/contracts/mainnet/connectors/mstable/interface.sol new file mode 100644 index 00000000..cb58ea0e --- /dev/null +++ b/contracts/mainnet/connectors/mstable/interface.sol @@ -0,0 +1,379 @@ +pragma solidity ^0.7.6; + +interface IMasset { + function mint( + address _input, + uint256 _inputQuantity, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 mintOutput); + + function mintMulti( + address[] calldata _inputs, + uint256[] calldata _inputQuantities, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 mintOutput); + + function getMintOutput(address _input, uint256 _inputQuantity) + external + view + returns (uint256 mintOutput); + + function getMintMultiOutput( + address[] calldata _inputs, + uint256[] calldata _inputQuantities + ) external view returns (uint256 mintOutput); + + function swap( + address _input, + address _output, + uint256 _inputQuantity, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 swapOutput); + + function getSwapOutput( + address _input, + address _output, + uint256 _inputQuantity + ) external view returns (uint256 swapOutput); + + function redeem( + address _output, + uint256 _mAssetQuantity, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 outputQuantity); + + function redeemMasset( + uint256 _mAssetQuantity, + uint256[] calldata _minOutputQuantities, + address _recipient + ) external returns (uint256[] memory outputQuantities); + + function redeemExactBassets( + address[] calldata _outputs, + uint256[] calldata _outputQuantities, + uint256 _maxMassetQuantity, + address _recipient + ) external returns (uint256 mAssetRedeemed); + + function getRedeemOutput(address _output, uint256 _mAssetQuantity) + external + view + returns (uint256 bAssetOutput); + + function getRedeemExactBassetsOutput( + address[] calldata _outputs, + uint256[] calldata _outputQuantities + ) external view returns (uint256 mAssetAmount); + + // Views + // This return an index, could be used to check if it's part of the basket + function bAssetIndexes(address) external view returns (uint8); + + function getPrice() external view returns (uint256 price, uint256 k); +} + +interface ISavingsContractV2 { + function depositInterest(uint256 _amount) external; // V1 & V2 + + function depositSavings(uint256 _amount) + external + returns (uint256 creditsIssued); // V1 & V2 + + function depositSavings(uint256 _amount, address _beneficiary) + external + returns (uint256 creditsIssued); // V2 + + function redeemCredits(uint256 _amount) + external + returns (uint256 underlyingReturned); // V2 + + function redeemUnderlying(uint256 _amount) + external + returns (uint256 creditsBurned); // V2 + + function exchangeRate() external view returns (uint256); // V1 & V2 + + function balanceOfUnderlying(address _user) + external + view + returns (uint256 balance); // V2 + + function underlyingToCredits(uint256 _credits) + external + view + returns (uint256 underlying); // V2 + + function creditsToUnderlying(uint256 _underlying) + external + view + returns (uint256 credits); // V2 +} + +interface IBoostedSavingsVault { + /** + * @dev Stakes a given amount of the StakingToken for the sender + * @param _amount Units of StakingToken + */ + function stake(uint256 _amount) external; + + /** + * @dev Stakes a given amount of the StakingToken for a given beneficiary + * @param _beneficiary Staked tokens are credited to this address + * @param _amount Units of StakingToken + */ + function stake(address _beneficiary, uint256 _amount) external; + + /** + * @dev Withdraws stake from pool and claims any unlocked rewards. + * Note, this function is costly - the args for _claimRewards + * should be determined off chain and then passed to other fn + */ + function exit() external; + + /** + * @dev Withdraws stake from pool and claims any unlocked rewards. + * @param _first Index of the first array element to claim + * @param _last Index of the last array element to claim + */ + function exit(uint256 _first, uint256 _last) external; + + /** + * @dev Withdraws given stake amount from the pool + * @param _amount Units of the staked token to withdraw + */ + function withdraw(uint256 _amount) external; + + /** + * @dev Claims only the tokens that have been immediately unlocked, not including + * those that are in the lockers. + */ + function claimReward() external; + + /** + * @dev Claims all unlocked rewards for sender. + * Note, this function is costly - the args for _claimRewards + * should be determined off chain and then passed to other fn + */ + function claimRewards() external; + + /** + * @dev Claims all unlocked rewards for sender. Both immediately unlocked + * rewards and also locked rewards past their time lock. + * @param _first Index of the first array element to claim + * @param _last Index of the last array element to claim + */ + function claimRewards(uint256 _first, uint256 _last) external; + + /** + * @dev Pokes a given account to reset the boost + */ + function pokeBoost(address _account) external; + + /** + * @dev Gets the RewardsToken + */ + function getRewardToken() external view returns (IERC20); + + /** + * @dev Gets the last applicable timestamp for this reward period + */ + function lastTimeRewardApplicable() external view returns (uint256); + + /** + * @dev Calculates the amount of unclaimed rewards per token since last update, + * and sums with stored to give the new cumulative reward per token + * @return 'Reward' per staked token + */ + function rewardPerToken() external view returns (uint256); + + /** + * @dev Returned the units of IMMEDIATELY claimable rewards a user has to receive. Note - this + * does NOT include the majority of rewards which will be locked up. + * @param _account User address + * @return Total reward amount earned + */ + function earned(address _account) external view returns (uint256); + + /** + * @dev Calculates all unclaimed reward data, finding both immediately unlocked rewards + * and those that have passed their time lock. + * @param _account User address + * @return amount Total units of unclaimed rewards + * @return first Index of the first userReward that has unlocked + * @return last Index of the last userReward that has unlocked + */ + function unclaimedRewards(address _account) + external + view + returns ( + uint256 amount, + uint256 first, + uint256 last + ); +} + +interface IFeederPool { + // Mint + function mint( + address _input, + uint256 _inputQuantity, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 mintOutput); + + function mintMulti( + address[] calldata _inputs, + uint256[] calldata _inputQuantities, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 mintOutput); + + function getMintOutput(address _input, uint256 _inputQuantity) + external + view + returns (uint256 mintOutput); + + function getMintMultiOutput( + address[] calldata _inputs, + uint256[] calldata _inputQuantities + ) external view returns (uint256 mintOutput); + + // Swaps + function swap( + address _input, + address _output, + uint256 _inputQuantity, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 swapOutput); + + function getSwapOutput( + address _input, + address _output, + uint256 _inputQuantity + ) external view returns (uint256 swapOutput); + + // Redemption + function redeem( + address _output, + uint256 _fpTokenQuantity, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 outputQuantity); + + function redeemProportionately( + uint256 _fpTokenQuantity, + uint256[] calldata _minOutputQuantities, + address _recipient + ) external returns (uint256[] memory outputQuantities); + + function redeemExactBassets( + address[] calldata _outputs, + uint256[] calldata _outputQuantities, + uint256 _maxMassetQuantity, + address _recipient + ) external returns (uint256 mAssetRedeemed); + + function getRedeemOutput(address _output, uint256 _fpTokenQuantity) + external + view + returns (uint256 bAssetOutput); + + function getRedeemExactBassetsOutput( + address[] calldata _outputs, + uint256[] calldata _outputQuantities + ) external view returns (uint256 mAssetAmount); + + // Views + function mAsset() external view returns (address); + + function getPrice() external view returns (uint256 price, uint256 k); +} + +interface IERC20 { + /** + * @dev Returns the amount of tokens in existence. + */ + function totalSupply() external view returns (uint256); + + /** + * @dev Returns the amount of tokens owned by `account`. + */ + function balanceOf(address account) external view returns (uint256); + + /** + * @dev Moves `amount` tokens from the caller's account to `recipient`. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Emits a {Transfer} event. + */ + function transfer(address recipient, uint256 amount) + external + returns (bool); + + /** + * @dev Returns the remaining number of tokens that `spender` will be + * allowed to spend on behalf of `owner` through {transferFrom}. This is + * zero by default. + * + * This value changes when {approve} or {transferFrom} are called. + */ + function allowance(address owner, address spender) + external + view + returns (uint256); + + /** + * @dev Sets `amount` as the allowance of `spender` over the caller's tokens. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * IMPORTANT: Beware that changing an allowance with this method brings the risk + * that someone may use both the old and the new allowance by unfortunate + * transaction ordering. One possible solution to mitigate this race + * condition is to first reduce the spender's allowance to 0 and set the + * desired value afterwards: + * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 + * + * Emits an {Approval} event. + */ + function approve(address spender, uint256 amount) external returns (bool); + + /** + * @dev Moves `amount` tokens from `sender` to `recipient` using the + * allowance mechanism. `amount` is then deducted from the caller's + * allowance. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Emits a {Transfer} event. + */ + function transferFrom( + address sender, + address recipient, + uint256 amount + ) external returns (bool); + + /** + * @dev Emitted when `value` tokens are moved from one account (`from`) to + * another (`to`). + * + * Note that `value` may be zero. + */ + event Transfer(address indexed from, address indexed to, uint256 value); + + /** + * @dev Emitted when the allowance of a `spender` for an `owner` is set by + * a call to {approve}. `value` is the new allowance. + */ + event Approval( + address indexed owner, + address indexed spender, + uint256 value + ); +} diff --git a/contracts/mainnet/connectors/mstable/main.sol b/contracts/mainnet/connectors/mstable/main.sol new file mode 100644 index 00000000..fcbbc690 --- /dev/null +++ b/contracts/mainnet/connectors/mstable/main.sol @@ -0,0 +1,383 @@ +pragma solidity ^0.7.6; + +/** + * @title mStable SAVE. + * @dev Depositing and withdrawing directly to Save + */ + +import { Helpers } from "./helpers.sol"; +import { Events } from "./events.sol"; + +import { TokenInterface } from "../../common/interfaces.sol"; +import { IMasset, IBoostedSavingsVault, IFeederPool } from "./interface.sol"; + +abstract contract mStableResolver is Events, Helpers { + /*************************************** + CORE + ****************************************/ + + /** + * @dev Deposit to Save via mUSD or bAsset + * @notice Deposits token supported by mStable to Save + * @param _token Address of token to deposit + * @param _amount Amount of token to deposit + * @param _minOut Minimum amount of token to mint/deposit, equal to _amount if mUSD + * @param _stake stake token in Vault? + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens deposited + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function deposit( + address _token, + uint256 _amount, + uint256 _minOut, + bool _stake, + uint256 _setId, + uint256 _getId + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + uint256 amount = getUint(_getId, _amount); + amount = amount == uint256(-1) + ? TokenInterface(_token).balanceOf(address(this)) + : amount; + uint256 mintedAmount; + address path; + + // Check if needs to be minted first + if (IMasset(mUsdToken).bAssetIndexes(_token) != 0) { + // mint first + approve(TokenInterface(_token), mUsdToken, amount); + mintedAmount = IMasset(mUsdToken).mint( + _token, + amount, + _minOut, + address(this) + ); + path = mUsdToken; + } else { + require(amount >= _minOut, "mintedAmount < _minOut"); + mintedAmount = amount; + path = imUsdToken; + } + + setUint(_setId, mintedAmount); + (_eventName, _eventParam) = _deposit( + _token, + mintedAmount, + path, + _stake + ); + } + + /** + * @dev Deposit to Save via feeder pool + * @notice Deposits token, requires _minOut for minting and _path + * @param _token Address of token to deposit + * @param _amount Amount of token to deposit + * @param _minOut Minimum amount of token to mint + * @param _path Feeder Pool address for _token + * @param _stake stake token in Vault? + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens deposited + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function depositViaSwap( + address _token, + uint256 _amount, + uint256 _minOut, + address _path, + bool _stake, + uint256 _setId, + uint256 _getId + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + require(_path != address(0), "Path must be set"); + require( + IMasset(mUsdToken).bAssetIndexes(_token) == 0, + "Token is bAsset" + ); + + uint256 amount = getUint(_getId, _amount); + amount = amount == uint256(-1) + ? TokenInterface(_token).balanceOf(address(this)) + : amount; + + approve(TokenInterface(_token), _path, amount); + uint256 mintedAmount = IFeederPool(_path).swap( + _token, + mUsdToken, + amount, + _minOut, + address(this) + ); + + setUint(_setId, mintedAmount); + (_eventName, _eventParam) = _deposit( + _token, + mintedAmount, + _path, + _stake + ); + } + + /** + * @dev Withdraw from Save to mUSD or bAsset + * @notice Withdraws from Save Vault to mUSD + * @param _token Address of token to withdraw + * @param _credits Credits to withdraw + * @param _minOut Minimum amount of token to withdraw + * @param _unstake from the Vault first? + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens withdrawn + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function withdraw( + address _token, + uint256 _credits, + uint256 _minOut, + bool _unstake, + uint256 _getId, + uint256 _setId + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + uint256 credits = getUint(_getId, _credits); + uint256 amountWithdrawn = _withdraw(credits, _unstake); + + // Check if needs to be redeemed + if (IMasset(mUsdToken).bAssetIndexes(_token) != 0) { + amountWithdrawn = IMasset(mUsdToken).redeem( + _token, + amountWithdrawn, + _minOut, + address(this) + ); + } else { + require(amountWithdrawn >= _minOut, "amountWithdrawn < _minOut"); + } + + setUint(_setId, amountWithdrawn); + _eventName = "LogWithdraw(address,uint256,address,bool)"; + _eventParam = abi.encode( + mUsdToken, + amountWithdrawn, + imUsdToken, + _unstake + ); + } + + /** + * @dev Withdraw from Save via Feeder Pool + * @notice Withdraws from Save Vault to asset via Feeder Pool + * @param _token bAsset to withdraw to + * @param _credits Credits to withdraw + * @param _minOut Minimum amount of token to mint + * @param _path Feeder Pool address for _token + * @param _unstake from the Vault first? + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens withdrawn + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function withdrawViaSwap( + address _token, + uint256 _credits, + uint256 _minOut, + address _path, + bool _unstake, + uint256 _getId, + uint256 _setId + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + require(_path != address(0), "Path must be set"); + require( + IMasset(mUsdToken).bAssetIndexes(_token) == 0, + "Token is bAsset" + ); + + uint256 credits = getUint(_getId, _credits); + + uint256 amountWithdrawn = _withdraw(credits, _unstake); + + approve(TokenInterface(mUsdToken), _path, amountWithdrawn); + uint256 amountRedeemed = IFeederPool(_path).swap( + mUsdToken, + _token, + amountWithdrawn, + _minOut, + address(this) + ); + + setUint(_setId, amountRedeemed); + + _eventName = "LogWithdraw(address,uint256,address,bool)"; + _eventParam = abi.encode(_token, amountRedeemed, _path, _unstake); + } + + /** + * @dev Claims Rewards + * @notice Claims accrued rewards from the Vault + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens withdrawn + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function claimRewards(uint256 _getId, uint256 _setId) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + address rewardToken = _getRewardTokens(); + uint256 rewardAmount = _getRewardInternalBal(rewardToken); + + IBoostedSavingsVault(imUsdVault).claimReward(); + + uint256 rewardAmountUpdated = _getRewardInternalBal(rewardToken); + + uint256 claimedRewardToken = sub(rewardAmountUpdated, rewardAmount); + + setUint(_setId, claimedRewardToken); + + _eventName = "LogClaimRewards(address,uint256)"; + _eventParam = abi.encode(rewardToken, claimedRewardToken); + } + + /** + * @dev Swap tokens + * @notice Swaps tokens via Masset basket + * @param _input Token address to swap from + * @param _output Token address to swap to + * @param _amount Amount of tokens to swap + * @param _minOut Minimum amount of token to mint + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens swapped + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function swap( + address _input, + address _output, + uint256 _amount, + uint256 _minOut, + uint256 _getId, + uint256 _setId + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + uint256 amount = getUint(_getId, _amount); + amount = amount == uint256(-1) + ? TokenInterface(_input).balanceOf(address(this)) + : amount; + approve(TokenInterface(_input), mUsdToken, amount); + uint256 amountSwapped; + + // Check the assets and swap accordingly + if (_output == mUsdToken) { + // bAsset to mUSD => mint + amountSwapped = IMasset(mUsdToken).mint( + _input, + amount, + _minOut, + address(this) + ); + } else if (_input == mUsdToken) { + // mUSD to bAsset => redeem + amountSwapped = IMasset(mUsdToken).redeem( + _output, + amount, + _minOut, + address(this) + ); + } else { + // bAsset to another bAsset => swap + amountSwapped = IMasset(mUsdToken).swap( + _input, + _output, + amount, + _minOut, + address(this) + ); + } + + setUint(_setId, amountSwapped); + _eventName = "LogSwap(address,address,uint256,uint256)"; + _eventParam = abi.encode(_input, _output, amount, amountSwapped); + } + + /** + * @dev Swap tokens via Feeder Pool + * @notice Swaps tokens via Feeder Pool + * @param _input Token address to swap from + * @param _output Token address to swap to + * @param _amount Amount of tokens to swap + * @param _minOut Minimum amount of token to mint + * @param _path Feeder Pool address to use + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens swapped + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function swapViaFeeder( + address _input, + address _output, + uint256 _amount, + uint256 _minOut, + address _path, + uint256 _getId, + uint256 _setId + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + uint256 amountSwapped; + uint256 amount = getUint(_getId, _amount); + amount = amount == uint256(-1) + ? TokenInterface(_input).balanceOf(address(this)) + : amount; + + approve(TokenInterface(_input), _path, amount); + + // swaps fAsset to mUSD via Feeder Pool + // swaps mUSD to fAsset via Feeder Pool + amountSwapped = IFeederPool(_path).swap( + _input, + _output, + amount, + _minOut, + address(this) + ); + + setUint(_setId, amountSwapped); + + _eventName = "LogSwap(address,address,uint256,uint256)"; + _eventParam = abi.encode(_input, _output, amount, amountSwapped); + } +} + +contract ConnectV2mStable is mStableResolver { + string public constant name = "mStable-v1.0"; +} diff --git a/contracts/polygon/connectors/mstable/events.sol b/contracts/polygon/connectors/mstable/events.sol new file mode 100644 index 00000000..c463b75d --- /dev/null +++ b/contracts/polygon/connectors/mstable/events.sol @@ -0,0 +1,23 @@ +pragma solidity ^0.7.6; + +contract Events { + event LogDeposit(address token, uint256 amount, address path, bool stake); + event LogWithdraw( + address token, + uint256 amount, + address path, + bool unstake + ); + event LogClaimRewards( + address token, + uint256 amount, + address platformToken, + uint256 platformAmount + ); + event LogSwap( + address from, + address to, + uint256 amountIn, + uint256 amountOut + ); +} diff --git a/contracts/polygon/connectors/mstable/helpers.sol b/contracts/polygon/connectors/mstable/helpers.sol new file mode 100644 index 00000000..d4330fb6 --- /dev/null +++ b/contracts/polygon/connectors/mstable/helpers.sol @@ -0,0 +1,116 @@ +pragma solidity ^0.7.6; + +import { DSMath } from "../../common/math.sol"; +import { Basic } from "../../common/basic.sol"; + +import { ISavingsContractV2, IStakingRewardsWithPlatformToken } from "./interface.sol"; +import { TokenInterface } from "../../common/interfaces.sol"; + +abstract contract Helpers is DSMath, Basic { + address internal constant mUsdToken = + 0xE840B73E5287865EEc17d250bFb1536704B43B21; + address internal constant imUsdToken = + 0x5290Ad3d83476CA6A2b178Cd9727eE1EF72432af; + address internal constant imUsdVault = + 0x32aBa856Dc5fFd5A56Bcd182b13380e5C855aa29; + + /*************************************** + Internal + ****************************************/ + + /** + * @dev Deposit to Save from any asset + * @notice Called internally from deposit functions + * @param _token Address of token to deposit + * @param _amount Amount of token to deposit + * @param _path Path to mint mUSD (only needed for Feeder Pool) + * @param _stake stake token in Vault? + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function _deposit( + address _token, + uint256 _amount, + address _path, + bool _stake + ) internal returns (string memory _eventName, bytes memory _eventParam) { + // 1. Deposit mUSD to Save + approve(TokenInterface(mUsdToken), imUsdToken, _amount); + uint256 credits = ISavingsContractV2(imUsdToken).depositSavings( + _amount + ); + if (_stake) { + // 2. Stake imUSD to Vault + approve(TokenInterface(imUsdToken), imUsdVault, credits); + IStakingRewardsWithPlatformToken(imUsdVault).stake(credits); + } + // 3. Log Events + _eventName = "LogDeposit(address,uint256,address,bool)"; + _eventParam = abi.encode(_token, _amount, _path, _stake); + } + + /** + * @dev Withdraws from Save + * @notice Withdraws token supported by mStable from Save + * @param _credits Credits to withdraw + * @param _unstake unstake from Vault? + * @return amountWithdrawn Amount withdrawn in mUSD + */ + + function _withdraw(uint256 _credits, bool _unstake) + internal + returns (uint256 amountWithdrawn) + { + uint256 credits; + // 1. Withdraw from Vault + if (_unstake) { + credits = _credits == uint256(-1) + ? TokenInterface(imUsdVault).balanceOf(address(this)) + : _credits; + IStakingRewardsWithPlatformToken(imUsdVault).withdraw(credits); + } + + // 2. Withdraw from Save + credits = _credits == uint256(-1) + ? TokenInterface(imUsdToken).balanceOf(address(this)) + : _credits; + approve(TokenInterface(imUsdToken), imUsdVault, _credits); + amountWithdrawn = ISavingsContractV2(imUsdToken).redeemCredits(credits); + } + + /** + * @dev Returns the reward tokens + * @notice Gets the reward tokens from the vault contract + * @return rewardToken Address of reward token + * @return platformToken Address of platform token + */ + + function _getRewardTokens() + internal + returns (address rewardToken, address platformToken) + { + rewardToken = IStakingRewardsWithPlatformToken(imUsdVault) + .getRewardToken(); + platformToken = IStakingRewardsWithPlatformToken(imUsdVault) + .getPlatformToken(); + } + + /** + * @dev Returns the internal balances of the rewardToken and platformToken + * @notice Gets current balances of rewardToken and platformToken, used for calculating rewards accrued + * @param _rewardToken Address of reward token + * @param _platformToken Address of platform token + * @return a Amount of reward token + * @return b Amount of platform token + */ + + function _getRewardInternalBal(address _rewardToken, address _platformToken) + internal + view + returns (uint256 a, uint256 b) + { + a = TokenInterface(_rewardToken).balanceOf(address(this)); + b = TokenInterface(_platformToken).balanceOf(address(this)); + } +} diff --git a/contracts/polygon/connectors/mstable/interface.sol b/contracts/polygon/connectors/mstable/interface.sol new file mode 100644 index 00000000..028b728f --- /dev/null +++ b/contracts/polygon/connectors/mstable/interface.sol @@ -0,0 +1,234 @@ +pragma solidity ^0.7.6; + +interface IMasset { + // Mint + function mint( + address _input, + uint256 _inputQuantity, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 mintOutput); + + function mintMulti( + address[] calldata _inputs, + uint256[] calldata _inputQuantities, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 mintOutput); + + function getMintOutput(address _input, uint256 _inputQuantity) + external + view + returns (uint256 mintOutput); + + function getMintMultiOutput( + address[] calldata _inputs, + uint256[] calldata _inputQuantities + ) external view returns (uint256 mintOutput); + + // Swaps + function swap( + address _input, + address _output, + uint256 _inputQuantity, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 swapOutput); + + function getSwapOutput( + address _input, + address _output, + uint256 _inputQuantity + ) external view returns (uint256 swapOutput); + + // Redemption + function redeem( + address _output, + uint256 _mAssetQuantity, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 outputQuantity); + + function redeemMasset( + uint256 _mAssetQuantity, + uint256[] calldata _minOutputQuantities, + address _recipient + ) external returns (uint256[] memory outputQuantities); + + function redeemExactBassets( + address[] calldata _outputs, + uint256[] calldata _outputQuantities, + uint256 _maxMassetQuantity, + address _recipient + ) external returns (uint256 mAssetRedeemed); + + function getRedeemOutput(address _output, uint256 _mAssetQuantity) + external + view + returns (uint256 bAssetOutput); + + function getRedeemExactBassetsOutput( + address[] calldata _outputs, + uint256[] calldata _outputQuantities + ) external view returns (uint256 mAssetAmount); + + // Views + // This return an index, could be used to check if it's part of the basket + function bAssetIndexes(address) external view returns (uint8); + + function getPrice() external view returns (uint256 price, uint256 k); +} + +interface ISavingsContractV2 { + function depositInterest(uint256 _amount) external; // V1 & V2 + + function depositSavings(uint256 _amount) + external + returns (uint256 creditsIssued); // V1 & V2 + + function depositSavings(uint256 _amount, address _beneficiary) + external + returns (uint256 creditsIssued); // V2 + + function redeemCredits(uint256 _amount) + external + returns (uint256 underlyingReturned); // V2 + + function redeemUnderlying(uint256 _amount) + external + returns (uint256 creditsBurned); // V2 + + function exchangeRate() external view returns (uint256); // V1 & V2 + + function balanceOfUnderlying(address _user) + external + view + returns (uint256 balance); // V2 + + function underlyingToCredits(uint256 _credits) + external + view + returns (uint256 underlying); // V2 + + function creditsToUnderlying(uint256 _underlying) + external + view + returns (uint256 credits); // V2 +} + +interface IStakingRewardsWithPlatformToken { + /** + * @dev Stakes a given amount of the StakingToken for the sender + * @param _amount Units of StakingToken + */ + function stake(uint256 _amount) external; + + /** + * @dev Stakes a given amount of the StakingToken for a given beneficiary + * @param _beneficiary Staked tokens are credited to this address + * @param _amount Units of StakingToken + */ + function stake(address _beneficiary, uint256 _amount) external; + + function exit() external; + + /** + * @dev Withdraws given stake amount from the pool + * @param _amount Units of the staked token to withdraw + */ + function withdraw(uint256 _amount) external; + + /** + * @dev Claims outstanding rewards (both platform and native) for the sender. + * First updates outstanding reward allocation and then transfers. + */ + function claimReward() external; + + /** + * @dev Claims outstanding rewards for the sender. Only the native + * rewards token, and not the platform rewards + */ + function claimRewardOnly() external; + + function getRewardToken() external returns (address token); + + function getPlatformToken() external returns (address token); +} + +interface IFeederPool { + // Mint + function mint( + address _input, + uint256 _inputQuantity, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 mintOutput); + + function mintMulti( + address[] calldata _inputs, + uint256[] calldata _inputQuantities, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 mintOutput); + + function getMintOutput(address _input, uint256 _inputQuantity) + external + view + returns (uint256 mintOutput); + + function getMintMultiOutput( + address[] calldata _inputs, + uint256[] calldata _inputQuantities + ) external view returns (uint256 mintOutput); + + // Swaps + function swap( + address _input, + address _output, + uint256 _inputQuantity, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 swapOutput); + + function getSwapOutput( + address _input, + address _output, + uint256 _inputQuantity + ) external view returns (uint256 swapOutput); + + // Redemption + function redeem( + address _output, + uint256 _fpTokenQuantity, + uint256 _minOutputQuantity, + address _recipient + ) external returns (uint256 outputQuantity); + + function redeemProportionately( + uint256 _fpTokenQuantity, + uint256[] calldata _minOutputQuantities, + address _recipient + ) external returns (uint256[] memory outputQuantities); + + function redeemExactBassets( + address[] calldata _outputs, + uint256[] calldata _outputQuantities, + uint256 _maxMassetQuantity, + address _recipient + ) external returns (uint256 mAssetRedeemed); + + function getRedeemOutput(address _output, uint256 _fpTokenQuantity) + external + view + returns (uint256 bAssetOutput); + + function getRedeemExactBassetsOutput( + address[] calldata _outputs, + uint256[] calldata _outputQuantities + ) external view returns (uint256 mAssetAmount); + + // Views + function mAsset() external view returns (address); + + function getPrice() external view returns (uint256 price, uint256 k); +} diff --git a/contracts/polygon/connectors/mstable/main.sol b/contracts/polygon/connectors/mstable/main.sol new file mode 100644 index 00000000..d2939fb8 --- /dev/null +++ b/contracts/polygon/connectors/mstable/main.sol @@ -0,0 +1,398 @@ +pragma solidity ^0.7.6; + +/** + * @title mStable SAVE. + * @dev Depositing and withdrawing directly to Save + */ + +import { Helpers } from "./helpers.sol"; +import { Events } from "./events.sol"; +import { IMasset, IStakingRewardsWithPlatformToken, IFeederPool } from "./interface.sol"; +import { TokenInterface } from "../../common/interfaces.sol"; + +abstract contract PmStableResolver is Events, Helpers { + /*************************************** + CORE + ****************************************/ + + /** + * @dev Deposit to Save via mUSD or bAsset + * @notice Deposits token supported by mStable to Save + * @param _token Address of token to deposit + * @param _amount Amount of token to deposit + * @param _minOut Minimum amount of token to mint/deposit, equal to _amount if mUSD + * @param _stake stake token in Vault? + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens deposited + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function deposit( + address _token, + uint256 _amount, + uint256 _minOut, + bool _stake, + uint256 _getId, + uint256 _setId + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + uint256 amount = getUint(_getId, _amount); + amount = amount == uint256(-1) + ? TokenInterface(_token).balanceOf(address(this)) + : amount; + uint256 mintedAmount; + address path; + + // Check if needs to be minted first + if (IMasset(mUsdToken).bAssetIndexes(_token) != 0) { + // mint first + approve(TokenInterface(_token), mUsdToken, amount); + mintedAmount = IMasset(mUsdToken).mint( + _token, + amount, + _minOut, + address(this) + ); + path = mUsdToken; + } else { + require(amount >= _minOut, "mintedAmount < _minOut"); + mintedAmount = amount; + path = imUsdToken; + } + + setUint(_setId, mintedAmount); + + (_eventName, _eventParam) = _deposit( + _token, + mintedAmount, + path, + _stake + ); + } + + /** + * @dev Deposit to Save via feeder pool + * @notice Deposits token, requires _minOut for minting and _path + * @param _token Address of token to deposit + * @param _amount Amount of token to deposit + * @param _minOut Minimum amount of token to mint + * @param _path Feeder Pool address for _token + * @param _stake stake token in Vault? + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens deposited + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function depositViaSwap( + address _token, + uint256 _amount, + uint256 _minOut, + address _path, + bool _stake, + uint256 _getId, + uint256 _setId + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + require(_path != address(0), "Path must be set"); + require( + IMasset(mUsdToken).bAssetIndexes(_token) == 0, + "Token is bAsset" + ); + + uint256 amount = getUint(_getId, _amount); + amount = amount == uint256(-1) + ? TokenInterface(_token).balanceOf(address(this)) + : amount; + + approve(TokenInterface(_token), _path, amount); + uint256 mintedAmount = IFeederPool(_path).swap( + _token, + mUsdToken, + amount, + _minOut, + address(this) + ); + + setUint(_setId, mintedAmount); + (_eventName, _eventParam) = _deposit( + _token, + mintedAmount, + _path, + _stake + ); + } + + /** + * @dev Withdraw from Save to mUSD or bAsset + * @notice Withdraws from Save Vault to mUSD + * @param _token Address of token to withdraw + * @param _credits Credits to withdraw + * @param _minOut Minimum amount of token to withdraw + * @param _unstake from the Vault first? + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens withdrawn + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function withdraw( + address _token, + uint256 _credits, + uint256 _minOut, + bool _unstake, + uint256 _getId, + uint256 _setId + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + uint256 credits = getUint(_getId, _credits); + uint256 amountWithdrawn = _withdraw(credits, _unstake); + + // Check if needs to be redeemed + if (IMasset(mUsdToken).bAssetIndexes(_token) != 0) { + amountWithdrawn = IMasset(mUsdToken).redeem( + _token, + amountWithdrawn, + _minOut, + address(this) + ); + } else { + require(amountWithdrawn >= _minOut, "amountWithdrawn < _minOut"); + } + + setUint(_setId, amountWithdrawn); + _eventName = "LogWithdraw(address,uint256,address,bool)"; + _eventParam = abi.encode( + mUsdToken, + amountWithdrawn, + imUsdToken, + _unstake + ); + } + + /** + * @dev Withdraw from Save via Feeder Pool + * @notice Withdraws from Save Vault to asset via Feeder Pool + * @param _token bAsset to withdraw to + * @param _credits Credits to withdraw + * @param _minOut Minimum amount of token to mint + * @param _path Feeder Pool address for _token + * @param _unstake from the Vault first? + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens withdrawn + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function withdrawViaSwap( + address _token, + uint256 _credits, + uint256 _minOut, + address _path, + bool _unstake, + uint256 _getId, + uint256 _setId + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + require(_path != address(0), "Path must be set"); + require( + IMasset(mUsdToken).bAssetIndexes(_token) == 0, + "Token is bAsset" + ); + + uint256 credits = getUint(_getId, _credits); + uint256 amountWithdrawn = _withdraw(credits, _unstake); + + approve(TokenInterface(mUsdToken), _path, amountWithdrawn); + uint256 amountRedeemed = IFeederPool(_path).swap( + mUsdToken, + _token, + amountWithdrawn, + _minOut, + address(this) + ); + + setUint(_setId, amountRedeemed); + _eventName = "LogWithdraw(address,uint256,address,bool)"; + _eventParam = abi.encode(_token, amountRedeemed, _path, _unstake); + } + + /** + * @dev Claims Rewards + * @notice Claims accrued rewards from the Vault + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens withdrawn + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function claimRewards(uint256 _getId, uint256 _setId) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + (address rewardToken, address platformToken) = _getRewardTokens(); + (uint256 rewardAmount, uint256 platformAmount) = _getRewardInternalBal( + rewardToken, + platformToken + ); + + IStakingRewardsWithPlatformToken(imUsdVault).claimReward(); + + ( + uint256 rewardAmountUpdated, + uint256 platformAmountUpdated + ) = _getRewardInternalBal(rewardToken, platformToken); + + uint256 claimedRewardToken = sub(rewardAmountUpdated, rewardAmount); + + uint256 claimedPlatformToken = sub( + platformAmountUpdated, + platformAmount + ); + + setUint(_setId, claimedRewardToken); + _eventName = "LogClaimRewards(address,uint256,address,uint256)"; + _eventParam = abi.encode( + rewardToken, + claimedRewardToken, + platformToken, + claimedPlatformToken + ); + } + + /** + * @dev Swap tokens + * @notice Swaps tokens via Masset basket + * @param _input Token address to swap from + * @param _output Token address to swap to + * @param _amount Amount of tokens to swap + * @param _minOut Minimum amount of token to mint + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens swapped + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function swap( + address _input, + address _output, + uint256 _amount, + uint256 _minOut, + uint256 _getId, + uint256 _setId + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + uint256 amount = getUint(_getId, _amount); + amount = amount == uint256(-1) + ? TokenInterface(_input).balanceOf(address(this)) + : amount; + + approve(TokenInterface(_input), mUsdToken, amount); + uint256 amountSwapped; + + // Check the assets and swap accordingly + if (_output == mUsdToken) { + // bAsset to mUSD => mint + amountSwapped = IMasset(mUsdToken).mint( + _input, + amount, + _minOut, + address(this) + ); + } else if (_input == mUsdToken) { + // mUSD to bAsset => redeem + amountSwapped = IMasset(mUsdToken).redeem( + _output, + amount, + _minOut, + address(this) + ); + } else { + // bAsset to another bAsset => swap + amountSwapped = IMasset(mUsdToken).swap( + _input, + _output, + amount, + _minOut, + address(this) + ); + } + + setUint(_setId, amountSwapped); + + _eventName = "LogSwap(address,address,uint256,uint256)"; + _eventParam = abi.encode(_input, _output, amount, amountSwapped); + } + + /** + * @dev Swap tokens via Feeder Pool + * @notice Swaps tokens via Feeder Pool + * @param _input Token address to swap from + * @param _output Token address to swap to + * @param _amount Amount of tokens to swap + * @param _minOut Minimum amount of token to mint + * @param _path Feeder Pool address to use + * @param _getId ID to retrieve amt + * @param _setId ID stores the amount of tokens swapped + * @return _eventName Event name + * @return _eventParam Event parameters + */ + + function swapViaFeeder( + address _input, + address _output, + uint256 _amount, + uint256 _minOut, + address _path, + uint256 _getId, + uint256 _setId + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + uint256 amountSwapped; + uint256 amount = getUint(_getId, _amount); + amount = amount == uint256(-1) + ? TokenInterface(_input).balanceOf(address(this)) + : amount; + + approve(TokenInterface(_input), _path, amount); + + // swaps fAsset to mUSD via Feeder Pool + // swaps mUSD to fAsset via Feeder Pool + amountSwapped = IFeederPool(_path).swap( + _input, + _output, + amount, + _minOut, + address(this) + ); + + setUint(_setId, amountSwapped); + + _eventName = "LogSwap(address,address,uint256,uint256)"; + _eventParam = abi.encode(_input, _output, amount, amountSwapped); + } +} + +contract ConnectV2PmStable is PmStableResolver { + string public constant name = "mStable-Polygon-v1.0"; +} diff --git a/test/mainnet/mstable/mstable.helpers.ts b/test/mainnet/mstable/mstable.helpers.ts new file mode 100644 index 00000000..1dde1637 --- /dev/null +++ b/test/mainnet/mstable/mstable.helpers.ts @@ -0,0 +1,160 @@ +import hre, { ethers } from "hardhat"; +import { IERC20Minimal__factory } from "../../../typechain"; +import { BigNumber as BN } from "ethers"; + +export const DEAD_ADDRESS = "0x0000000000000000000000000000000000000001"; +export const ZERO_ADDRESS = ethers.constants.AddressZero; + +export const DEFAULT_DECIMALS = 18; + +export const ZERO = BN.from(0); +export const ONE_MIN = BN.from(60); +export const TEN_MINS = BN.from(60 * 10); +export const ONE_HOUR = BN.from(60 * 60); +export const ONE_DAY = BN.from(60 * 60 * 24); +export const FIVE_DAYS = BN.from(60 * 60 * 24 * 5); +export const TEN_DAYS = BN.from(60 * 60 * 24 * 10); +export const ONE_WEEK = BN.from(60 * 60 * 24 * 7); +export const ONE_YEAR = BN.from(60 * 60 * 24 * 365); + +export const connectorName = "MStable"; + +interface TokenData { + tokenAddress: string; + tokenWhaleAddress?: string; + feederPool?: string; +} + +export const toEther = (amount: BN) => ethers.utils.formatEther(amount); + +export const getToken = (tokenSymbol: string): TokenData => { + switch (tokenSymbol) { + case "MTA": + return { + tokenAddress: "0xa3BeD4E1c75D00fa6f4E5E6922DB7261B5E9AcD2" + }; + case "mUSD": + return { + tokenAddress: "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", + tokenWhaleAddress: "0x503828976D22510aad0201ac7EC88293211D23Da" + }; + + case "DAI": + return { + tokenAddress: "0x6b175474e89094c44da98b954eedeac495271d0f", + tokenWhaleAddress: "0xF977814e90dA44bFA03b6295A0616a897441aceC" + }; + case "USDC": + return { + tokenAddress: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + }; + case "imUSD": + return { + tokenAddress: "0x30647a72dc82d7fbb1123ea74716ab8a317eac19" + }; + + case "imUSDVault": + return { + tokenAddress: "0x78BefCa7de27d07DC6e71da295Cc2946681A6c7B" + }; + + // Feeder Asset + case "alUSD": + return { + tokenAddress: "0xbc6da0fe9ad5f3b0d58160288917aa56653660e9", + tokenWhaleAddress: "0x115f95c00e8cf2f5C57250caA555A6B4e50B446b", + feederPool: "0x4eaa01974B6594C0Ee62fFd7FEE56CF11E6af936" + }; + + default: + throw new Error(`Token ${tokenSymbol} not supported`); + } +}; + +export const sendToken = async (token: string, amount: any, from: string, to: string): Promise => { + await hre.network.provider.request({ + method: "hardhat_impersonateAccount", + params: [from] + }); + const [signer] = await ethers.getSigners(); + const sender = hre.ethers.provider.getSigner(from); + + await signer.sendTransaction({ + to: from, + value: ethers.utils.parseEther("1") + }); + + return await IERC20Minimal__factory.connect(token, sender).transfer(to, amount); +}; + +export const fundWallet = async (token: string, amount: any, to: string) => { + const { tokenAddress, tokenWhaleAddress } = getToken(token); + await sendToken(tokenAddress, amount, tokenWhaleAddress!, to); +}; + +export const calcMinOut = (amount: BN, slippage: number): BN => { + const value = simpleToExactAmount(1 - slippage); + const minOut = amount.mul(value).div(ethers.BigNumber.from(10).pow(DEFAULT_DECIMALS)); + return minOut; +}; + +export const simpleToExactAmount = (amount: number | string | BN, decimals: number | BN = DEFAULT_DECIMALS): BN => { + let amountString = amount.toString(); + const decimalsBN = BN.from(decimals); + + if (decimalsBN.gt(100)) { + throw new Error(`Invalid decimals amount`); + } + + const scale = BN.from(10).pow(decimals); + const scaleString = scale.toString(); + + // Is it negative? + const negative = amountString.substring(0, 1) === "-"; + if (negative) { + amountString = amountString.substring(1); + } + + if (amountString === ".") { + throw new Error(`Error converting number ${amountString} to precise unit, invalid value`); + } + + // Split it into a whole and fractional part + // eslint-disable-next-line prefer-const + let [whole, fraction, ...rest] = amountString.split("."); + if (rest.length > 0) { + throw new Error(`Error converting number ${amountString} to precise unit, too many decimal points`); + } + + if (!whole) { + whole = "0"; + } + if (!fraction) { + fraction = "0"; + } + + if (fraction.length > scaleString.length - 1) { + throw new Error(`Error converting number ${amountString} to precise unit, too many decimal places`); + } + + while (fraction.length < scaleString.length - 1) { + fraction += "0"; + } + + const wholeBN = BN.from(whole); + const fractionBN = BN.from(fraction); + let result = wholeBN.mul(scale).add(fractionBN); + + if (negative) { + result = result.mul("-1"); + } + + return result; +}; + +export const advanceBlock = async (): Promise => ethers.provider.send("evm_mine", []); + +export const increaseTime = async (length: BN | number): Promise => { + await ethers.provider.send("evm_increaseTime", [BN.from(length).toNumber()]); + await advanceBlock(); +}; diff --git a/test/mainnet/mstable/mstable.test.ts b/test/mainnet/mstable/mstable.test.ts new file mode 100644 index 00000000..8ca09a8c --- /dev/null +++ b/test/mainnet/mstable/mstable.test.ts @@ -0,0 +1,234 @@ +import { expect } from "chai"; +import hre from "hardhat"; +const { waffle, ethers } = hre; +const { provider } = waffle; + +import { deployAndEnableConnector } from "../../../scripts/tests/deployAndEnableConnector"; +import { buildDSAv2 } from "../../../scripts/tests/buildDSAv2"; +import { encodeSpells } from "../../../scripts/tests/encodeSpells"; +import { getMasterSigner } from "../../../scripts/tests/getMasterSigner"; + +import { addresses } from "../../../scripts/tests/mainnet/addresses"; +import { abis } from "../../../scripts/constant/abis"; +import type { Signer, Contract } from "ethers"; + +import { ConnectV2mStable__factory, IERC20Minimal__factory, IERC20Minimal } from "../../../typechain"; + +import { executeAndAssertDeposit, executeAndAssertSwap, executeAndAssertWithdraw } from "./mstable.utils"; + +import { + fundWallet, + getToken, + simpleToExactAmount, + DEAD_ADDRESS, + calcMinOut, + ONE_DAY, + increaseTime, + connectorName, + toEther +} from "./mstable.helpers"; + +describe("MStable", async () => { + let dsaWallet0: Contract; + let masterSigner: Signer; + let instaConnectorsV2: Contract; + let connector: Contract; + + let mtaToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("MTA").tokenAddress, provider); + let mUsdToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("mUSD").tokenAddress, provider); + let imUsdToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("imUSD").tokenAddress, provider); + let imUsdVault: IERC20Minimal = IERC20Minimal__factory.connect(getToken("imUSDVault").tokenAddress, provider); + + let daiToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("DAI").tokenAddress, provider); + let usdcToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("USDC").tokenAddress, provider); + let alusdToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("alUSD").tokenAddress, provider); + + const wallets = provider.getWallets(); + const [wallet0, wallet1, wallet2, wallet3] = wallets; + + describe("DSA wallet", async () => { + const fundAmount = simpleToExactAmount(10000); + + const setup = async () => { + await hre.network.provider.request({ + method: "hardhat_reset", + params: [ + { + forking: { + // @ts-ignore + jsonRpcUrl: hre.config.networks.hardhat.forking.url, + blockNumber: 13905885 + } + } + ] + }); + + masterSigner = await getMasterSigner(); + instaConnectorsV2 = await ethers.getContractAt(abis.core.connectorsV2, addresses.core.connectorsV2); + connector = await deployAndEnableConnector({ + connectorName, + contractArtifact: ConnectV2mStable__factory, + signer: masterSigner, + connectors: instaConnectorsV2 + }); + + console.log("Connector address", connector.address); + + dsaWallet0 = await buildDSAv2(wallet0.address); + + await wallet0.sendTransaction({ + to: dsaWallet0.address, + value: simpleToExactAmount(10) + }); + + await fundWallet("mUSD", fundAmount, dsaWallet0.address); + await fundWallet("DAI", fundAmount, dsaWallet0.address); + await fundWallet("alUSD", fundAmount, dsaWallet0.address); + }; + + describe("Deploy", async () => { + before(async () => { + await setup(); + }); + + it("Should deploy properly", async () => { + expect(instaConnectorsV2.address).to.be.properAddress; + expect(connector.address).to.be.properAddress; + expect(await masterSigner.getAddress()).to.be.properAddress; + + expect(dsaWallet0.address).to.be.properAddress; + }); + it("Should fund the wallet", async () => { + expect(await ethers.provider.getBalance(dsaWallet0.address)).to.be.gte(ethers.utils.parseEther("10")); + + expect(await mUsdToken.balanceOf(dsaWallet0.address)).to.be.gte(fundAmount); + expect(await daiToken.balanceOf(dsaWallet0.address)).to.be.gte(fundAmount); + expect(await alusdToken.balanceOf(dsaWallet0.address)).to.be.gte(fundAmount); + }); + it("Should not have vault tokens prior", async () => { + // No deposits prior + expect(await imUsdToken.balanceOf(dsaWallet0.address)).to.be.eq(0); + expect(await imUsdVault.balanceOf(dsaWallet0.address)).to.be.eq(0); + }); + }); + + describe("Main SAVE", async () => { + before(async () => { + await setup(); + }); + it("Should deposit mUSD to Vault successfully", async () => { + const depositAmount = simpleToExactAmount(100); + const minOut = depositAmount; + + await executeAndAssertDeposit("deposit", mUsdToken, depositAmount, dsaWallet0, wallet0, [minOut, true]); + }); + it("Should deposit DAI to Vault successfully (mUSD bAsset)", async () => { + const depositAmount = simpleToExactAmount(100); + const minOut = calcMinOut(depositAmount, 0.02); + + await executeAndAssertDeposit("deposit", daiToken, depositAmount, dsaWallet0, wallet0, [minOut, true]); + }); + it("Should deposit alUSD to Vault successfully (via Feeder Pool)", async () => { + const depositAmount = simpleToExactAmount(100); + const minOut = calcMinOut(depositAmount, 0.02); + const path = getToken("alUSD").feederPool; + + await executeAndAssertDeposit("depositViaSwap", alusdToken, depositAmount, dsaWallet0, wallet0, [ + minOut, + path, + true + ]); + }); + it("Should withdraw from Vault to mUSD", async () => { + const withdrawAmount = simpleToExactAmount(100); + const minOut = simpleToExactAmount(1); + + await executeAndAssertWithdraw("withdraw", mUsdToken, withdrawAmount, dsaWallet0, wallet0, [minOut, true]); + }); + it("Should withdraw from Vault to DAI (mUSD bAsset)", async () => { + const withdrawAmount = simpleToExactAmount(100); + const minOut = simpleToExactAmount(1); + + await executeAndAssertWithdraw("withdraw", mUsdToken, withdrawAmount, dsaWallet0, wallet0, [minOut, true]); + }); + it("Should withdraw from Vault to alUSD (via Feeder Pool)", async () => { + const withdrawAmount = simpleToExactAmount(100); + const minOut = simpleToExactAmount(1); + const path = getToken("alUSD").feederPool; + + await executeAndAssertWithdraw("withdrawViaSwap", alusdToken, withdrawAmount, dsaWallet0, wallet0, [ + minOut, + path, + true + ]); + }); + it("Should claim Rewards", async () => { + const mtaBalanceBefore = await mtaToken.balanceOf(dsaWallet0.address); + console.log("MTA balance before: ", toEther(mtaBalanceBefore)); + + // Wait a bit and let the rewards accumulate + await increaseTime(ONE_DAY); + + const spells = [ + { + connector: connectorName, + method: "claimRewards", + args: [0, 0] + } + ]; + + const tx = await dsaWallet0.connect(wallet0).cast(...encodeSpells(spells), DEAD_ADDRESS); + + const mtaBalanceAfter = await mtaToken.balanceOf(dsaWallet0.address); + console.log("MTA balance after: ", toEther(mtaBalanceAfter)); + + expect(mtaBalanceAfter).to.be.gt(mtaBalanceBefore); + }); + }); + describe("Main SWAP", async () => { + before(async () => { + await setup(); + }); + it("Should swap mUSD to bAsset (redeem)", async () => { + const swapAmount = simpleToExactAmount(100); + await executeAndAssertSwap("swap", mUsdToken, 18, daiToken, 18, swapAmount, dsaWallet0, wallet0); + }); + it("Should swap mUSD to fAsset (via feeder pool)", async () => { + const swapAmount = simpleToExactAmount(100); + const path = getToken("alUSD").feederPool; + await executeAndAssertSwap("swapViaFeeder", mUsdToken, 18, alusdToken, 18, swapAmount, dsaWallet0, wallet0, [ + path + ]); + }); + it("Should swap bAsset to mUSD (mint)", async () => { + const swapAmount = simpleToExactAmount(100); + await executeAndAssertSwap("swap", daiToken, 18, mUsdToken, 18, swapAmount, dsaWallet0, wallet0); + }); + it("Should swap bAsset to bAsset (swap)", async () => { + const swapAmount = simpleToExactAmount(100); + await executeAndAssertSwap("swap", daiToken, 18, usdcToken, 6, swapAmount, dsaWallet0, wallet0); + }); + it("Should swap bAsset to fAsset (via feeder)", async () => { + const swapAmount = simpleToExactAmount(100); + const path = getToken("alUSD").feederPool; + await executeAndAssertSwap("swapViaFeeder", daiToken, 18, alusdToken, 18, swapAmount, dsaWallet0, wallet0, [ + path + ]); + }); + it("Should swap fAsset to bAsset (via feeder)", async () => { + const swapAmount = simpleToExactAmount(100); + const path = getToken("alUSD").feederPool; + await executeAndAssertSwap("swapViaFeeder", alusdToken, 18, daiToken, 18, swapAmount, dsaWallet0, wallet0, [ + path + ]); + }); + it("Should swap fAsset to mUSD (via feeder)", async () => { + const swapAmount = simpleToExactAmount(100); + const path = getToken("alUSD").feederPool; + await executeAndAssertSwap("swapViaFeeder", alusdToken, 18, mUsdToken, 18, swapAmount, dsaWallet0, wallet0, [ + path + ]); + }); + }); + }); +}); diff --git a/test/mainnet/mstable/mstable.utils.ts b/test/mainnet/mstable/mstable.utils.ts new file mode 100644 index 00000000..88bb21ea --- /dev/null +++ b/test/mainnet/mstable/mstable.utils.ts @@ -0,0 +1,137 @@ +import hre from "hardhat"; +import { ethers } from "hardhat"; +import { assert, expect } from "chai"; + +import { + DEFAULT_DECIMALS, + DEAD_ADDRESS, + toEther, + connectorName, + simpleToExactAmount, + getToken +} from "./mstable.helpers"; + +import { IERC20Minimal, IERC20Minimal__factory } from "../../../typechain"; +import { BigNumber, Contract, Wallet } from "ethers"; + +import { encodeSpells } from "../../../scripts/tests/encodeSpells"; + +const provider = hre.waffle.provider; + +let imUsdToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("imUSD").tokenAddress, provider); +let imUsdVault: IERC20Minimal = IERC20Minimal__factory.connect(getToken("imUSDVault").tokenAddress, provider); + +export const executeAndAssertSwap = async ( + method: string, + tokenFrom: IERC20Minimal, + tokenFromDecimals: number, + tokenTo: IERC20Minimal, + tokenToDecimals: number, + swapAmount: BigNumber, + dsaWallet0: Contract, + wallet0: Wallet, + args?: any[] +) => { + const diffFrom = ethers.BigNumber.from(10).pow(DEFAULT_DECIMALS - tokenFromDecimals); + const diffTo = ethers.BigNumber.from(10).pow(DEFAULT_DECIMALS - tokenToDecimals); + + const tokenFromBalanceBefore = (await tokenFrom.balanceOf(dsaWallet0.address)).mul(diffFrom); + console.log("Token From balance before: ", toEther(tokenFromBalanceBefore)); + + const tokenToBalanceBefore = (await tokenTo.balanceOf(dsaWallet0.address)).mul(diffTo); + console.log("Token To balance before: ", toEther(tokenToBalanceBefore)); + + const spells = [ + { + connector: connectorName, + method, + args: [tokenFrom.address, tokenTo.address, swapAmount, 1, ...(args ? args : []), 0, 0] + } + ]; + + console.log("Swapping...", toEther(swapAmount)); + + const tx = await dsaWallet0.connect(wallet0).cast(...encodeSpells(spells), DEAD_ADDRESS); + + const tokenFromBalanceAfter = (await tokenFrom.balanceOf(dsaWallet0.address)).mul(diffFrom); + console.log("Token From balance after: ", toEther(tokenFromBalanceAfter)); + + const tokenToBalanceAfter = (await tokenTo.balanceOf(dsaWallet0.address)).mul(diffTo); + console.log("Token To balance after: ", toEther(tokenToBalanceAfter)); + + expect(tokenFromBalanceAfter).to.be.eq(tokenFromBalanceBefore.sub(swapAmount)); + expect(tokenToBalanceAfter).to.be.gt(tokenToBalanceBefore); +}; + +export const executeAndAssertDeposit = async ( + method: string, + tokenFrom: IERC20Minimal, + depositAmount: BigNumber, + dsaWallet0: Contract, + wallet0: Wallet, + args?: any[] +) => { + const FromBalanceBefore = await tokenFrom.balanceOf(dsaWallet0.address); + console.log("Balance before: ", toEther(FromBalanceBefore)); + + const imUsdVaultBalanceBefore = await imUsdVault.balanceOf(dsaWallet0.address); + console.log("imUSD Vault balance before: ", toEther(imUsdVaultBalanceBefore)); + + const spells = [ + { + connector: connectorName, + method, + args: [tokenFrom.address, depositAmount, ...(args ? args : []), 0, 0] + } + ]; + + const tx = await dsaWallet0.connect(wallet0).cast(...encodeSpells(spells), DEAD_ADDRESS); + + const FromBalanceAfter = await tokenFrom.balanceOf(dsaWallet0.address); + console.log("Balance after: ", toEther(FromBalanceAfter)); + + const imUsdBalance = await imUsdToken.balanceOf(dsaWallet0.address); + console.log("imUSD balance: ", toEther(imUsdBalance)); + + const imUsdVaultBalance = await imUsdVault.balanceOf(dsaWallet0.address); + console.log("imUSD Vault balance: ", toEther(imUsdVaultBalance)); + + // Should have something in the vault but no imUSD + expect(await imUsdToken.balanceOf(dsaWallet0.address)).to.be.eq(0); + expect(await imUsdVault.balanceOf(dsaWallet0.address)).to.be.gt(imUsdVaultBalanceBefore); + expect(FromBalanceAfter).to.eq(FromBalanceBefore.sub(depositAmount)); +}; + +export const executeAndAssertWithdraw = async ( + method: string, + tokenTo: IERC20Minimal, + withdrawAmount: BigNumber, + dsaWallet0: Contract, + wallet0: Wallet, + args: any[] +) => { + const tokenToBalanceBefore = await tokenTo.balanceOf(dsaWallet0.address); + console.log("Balance before: ", toEther(tokenToBalanceBefore)); + + const imUsdVaultBalanceBefore = await imUsdVault.balanceOf(dsaWallet0.address); + console.log("imUSD Vault balance before: ", toEther(imUsdVaultBalanceBefore)); + + const spells = [ + { + connector: connectorName, + method, + args: [tokenTo.address, withdrawAmount, ...(args ? args : []), 0, 0] + } + ]; + + const tx = await dsaWallet0.connect(wallet0).cast(...encodeSpells(spells), DEAD_ADDRESS); + + const imUsdVaultBalanceAfter = await imUsdVault.balanceOf(dsaWallet0.address); + console.log("imUSD Vault balance after: ", toEther(imUsdVaultBalanceAfter)); + + const tokenToBalanceAfter = await tokenTo.balanceOf(dsaWallet0.address); + console.log("Balance after: ", toEther(tokenToBalanceAfter)); + + expect(imUsdVaultBalanceAfter).to.be.eq(imUsdVaultBalanceBefore.sub(withdrawAmount)); + expect(tokenToBalanceAfter).to.gt(tokenToBalanceBefore); +}; diff --git a/test/polygon/mstable/mstable.helpers.ts b/test/polygon/mstable/mstable.helpers.ts new file mode 100644 index 00000000..f717efb3 --- /dev/null +++ b/test/polygon/mstable/mstable.helpers.ts @@ -0,0 +1,159 @@ +import hre, { ethers } from "hardhat"; +import { IERC20Minimal__factory } from "../../../typechain"; +import { BigNumber as BN } from "ethers"; + +export const DEAD_ADDRESS = "0x0000000000000000000000000000000000000001"; +export const ZERO_ADDRESS = ethers.constants.AddressZero; + +export const DEFAULT_DECIMALS = 18; + +export const ZERO = BN.from(0); +export const ONE_MIN = BN.from(60); +export const TEN_MINS = BN.from(60 * 10); +export const ONE_HOUR = BN.from(60 * 60); +export const ONE_DAY = BN.from(60 * 60 * 24); +export const FIVE_DAYS = BN.from(60 * 60 * 24 * 5); +export const TEN_DAYS = BN.from(60 * 60 * 24 * 10); +export const ONE_WEEK = BN.from(60 * 60 * 24 * 7); +export const ONE_YEAR = BN.from(60 * 60 * 24 * 365); + +export const connectorName = "MStable"; + +interface TokenData { + tokenAddress: string; + tokenWhaleAddress?: string; + feederPool?: string; +} + +export const toEther = (amount: BN) => ethers.utils.formatEther(amount); + +export const getToken = (tokenSymbol: string): TokenData => { + switch (tokenSymbol) { + case "MTA": + return { + tokenAddress: "0xf501dd45a1198c2e1b5aef5314a68b9006d842e0" + }; + case "mUSD": + return { + tokenAddress: "0xe840b73e5287865eec17d250bfb1536704b43b21", + tokenWhaleAddress: "0x4393b9c542bf79e5235180d6da1915c0f9bc02c3" + }; + + case "DAI": + return { + tokenAddress: "0x8f3cf7ad23cd3cadbd9735aff958023239c6a063", + tokenWhaleAddress: "0x49854708A8c42eEB837A97Dd97D597890CEb1334" + }; + case "USDC": + return { + tokenAddress: "0x2791bca1f2de4661ed88a30c99a7a9449aa84174" + }; + case "imUSD": + return { + tokenAddress: "0x5290Ad3d83476CA6A2b178Cd9727eE1EF72432af" + }; + + case "imUSDVault": + return { + tokenAddress: "0x32aBa856Dc5fFd5A56Bcd182b13380e5C855aa29" + }; + + case "FRAX": + return { + tokenAddress: "0x104592a158490a9228070E0A8e5343B499e125D0", + tokenWhaleAddress: "0xAE0f77C239f72da36d4dA20a4bBdaAe4Ca48e03F", + feederPool: "0xb30a907084ac8a0d25dddab4e364827406fd09f0" + }; + + default: + throw new Error(`Token ${tokenSymbol} not supported`); + } +}; + +export const sendToken = async (token: string, amount: any, from: string, to: string): Promise => { + await hre.network.provider.request({ + method: "hardhat_impersonateAccount", + params: [from] + }); + const [signer] = await ethers.getSigners(); + const sender = hre.ethers.provider.getSigner(from); + + await signer.sendTransaction({ + to: from, + value: ethers.utils.parseEther("1") + }); + + return await IERC20Minimal__factory.connect(token, sender).transfer(to, amount); +}; + +export const fundWallet = async (token: string, amount: any, to: string) => { + const { tokenAddress, tokenWhaleAddress } = getToken(token); + await sendToken(tokenAddress, amount, tokenWhaleAddress!, to); +}; + +export const calcMinOut = (amount: BN, slippage: number): BN => { + const value = simpleToExactAmount(1 - slippage); + const minOut = amount.mul(value).div(ethers.BigNumber.from(10).pow(DEFAULT_DECIMALS)); + return minOut; +}; + +export const simpleToExactAmount = (amount: number | string | BN, decimals: number | BN = DEFAULT_DECIMALS): BN => { + let amountString = amount.toString(); + const decimalsBN = BN.from(decimals); + + if (decimalsBN.gt(100)) { + throw new Error(`Invalid decimals amount`); + } + + const scale = BN.from(10).pow(decimals); + const scaleString = scale.toString(); + + // Is it negative? + const negative = amountString.substring(0, 1) === "-"; + if (negative) { + amountString = amountString.substring(1); + } + + if (amountString === ".") { + throw new Error(`Error converting number ${amountString} to precise unit, invalid value`); + } + + // Split it into a whole and fractional part + // eslint-disable-next-line prefer-const + let [whole, fraction, ...rest] = amountString.split("."); + if (rest.length > 0) { + throw new Error(`Error converting number ${amountString} to precise unit, too many decimal points`); + } + + if (!whole) { + whole = "0"; + } + if (!fraction) { + fraction = "0"; + } + + if (fraction.length > scaleString.length - 1) { + throw new Error(`Error converting number ${amountString} to precise unit, too many decimal places`); + } + + while (fraction.length < scaleString.length - 1) { + fraction += "0"; + } + + const wholeBN = BN.from(whole); + const fractionBN = BN.from(fraction); + let result = wholeBN.mul(scale).add(fractionBN); + + if (negative) { + result = result.mul("-1"); + } + + return result; +}; + +export const advanceBlock = async (): Promise => ethers.provider.send("evm_mine", []); + +export const increaseTime = async (length: BN | number): Promise => { + await ethers.provider.send("evm_increaseTime", [BN.from(length).toNumber()]); + await advanceBlock(); +}; diff --git a/test/polygon/mstable/mstable.test.ts b/test/polygon/mstable/mstable.test.ts new file mode 100644 index 00000000..1015f8dd --- /dev/null +++ b/test/polygon/mstable/mstable.test.ts @@ -0,0 +1,234 @@ +import { expect } from "chai"; +import hre from "hardhat"; +const { waffle, ethers } = hre; +const { provider } = waffle; + +import { deployAndEnableConnector } from "../../../scripts/tests/deployAndEnableConnector"; +import { buildDSAv2 } from "../../../scripts/tests/buildDSAv2"; +import { encodeSpells } from "../../../scripts/tests/encodeSpells"; +import { getMasterSigner } from "../../../scripts/tests/getMasterSigner"; + +import { addresses } from "../../../scripts/tests/polygon/addresses"; +import { abis } from "../../../scripts/constant/abis"; +import type { Signer, Contract } from "ethers"; + +import { ConnectV2PmStable__factory, IERC20Minimal__factory, IERC20Minimal } from "../../../typechain"; + +import { executeAndAssertDeposit, executeAndAssertSwap, executeAndAssertWithdraw } from "./mstable.utils"; + +import { + fundWallet, + getToken, + simpleToExactAmount, + DEAD_ADDRESS, + calcMinOut, + ONE_DAY, + increaseTime, + connectorName, + toEther +} from "./mstable.helpers"; + +describe("MStable", async () => { + let dsaWallet0: Contract; + let masterSigner: Signer; + let instaConnectorsV2: Contract; + let connector: Contract; + + let mtaToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("MTA").tokenAddress, provider); + let mUsdToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("mUSD").tokenAddress, provider); + let imUsdToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("imUSD").tokenAddress, provider); + let imUsdVault: IERC20Minimal = IERC20Minimal__factory.connect(getToken("imUSDVault").tokenAddress, provider); + + let daiToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("DAI").tokenAddress, provider); + let usdcToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("USDC").tokenAddress, provider); + let fraxToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("FRAX").tokenAddress, provider); + + const wallets = provider.getWallets(); + const [wallet0, wallet1, wallet2, wallet3] = wallets; + + describe("DSA wallet", async () => { + const fundAmount = simpleToExactAmount(10000); + + const setup = async () => { + await hre.network.provider.request({ + method: "hardhat_reset", + params: [ + { + forking: { + // @ts-ignore + jsonRpcUrl: hre.config.networks.hardhat.forking.url, + blockNumber: 23059414 + } + } + ] + }); + + masterSigner = await getMasterSigner(); + instaConnectorsV2 = await ethers.getContractAt(abis.core.connectorsV2, addresses.core.connectorsV2); + connector = await deployAndEnableConnector({ + connectorName, + contractArtifact: ConnectV2PmStable__factory, + signer: masterSigner, + connectors: instaConnectorsV2 + }); + + console.log("Connector address", connector.address); + + dsaWallet0 = await buildDSAv2(wallet0.address); + + await wallet0.sendTransaction({ + to: dsaWallet0.address, + value: simpleToExactAmount(10) + }); + + await fundWallet("mUSD", fundAmount, dsaWallet0.address); + await fundWallet("DAI", fundAmount, dsaWallet0.address); + await fundWallet("FRAX", fundAmount, dsaWallet0.address); + }; + + describe("Deploy", async () => { + before(async () => { + await setup(); + }); + + it("Should deploy properly", async () => { + expect(instaConnectorsV2.address).to.be.properAddress; + expect(connector.address).to.be.properAddress; + expect(await masterSigner.getAddress()).to.be.properAddress; + + expect(dsaWallet0.address).to.be.properAddress; + }); + it("Should fund the wallet", async () => { + expect(await ethers.provider.getBalance(dsaWallet0.address)).to.be.gte(ethers.utils.parseEther("10")); + + expect(await mUsdToken.balanceOf(dsaWallet0.address)).to.be.gte(fundAmount); + expect(await daiToken.balanceOf(dsaWallet0.address)).to.be.gte(fundAmount); + expect(await fraxToken.balanceOf(dsaWallet0.address)).to.be.gte(fundAmount); + }); + it("Should not have vault tokens prior", async () => { + // No deposits prior + expect(await imUsdToken.balanceOf(dsaWallet0.address)).to.be.eq(0); + expect(await imUsdVault.balanceOf(dsaWallet0.address)).to.be.eq(0); + }); + }); + + describe("Main SAVE", async () => { + before(async () => { + await setup(); + }); + it("Should deposit mUSD to Vault successfully", async () => { + const depositAmount = simpleToExactAmount(100); + const minOut = depositAmount; + + await executeAndAssertDeposit("deposit", mUsdToken, depositAmount, dsaWallet0, wallet0, [minOut, true]); + }); + it("Should deposit DAI to Vault successfully (mUSD bAsset)", async () => { + const depositAmount = simpleToExactAmount(100); + const minOut = calcMinOut(depositAmount, 0.02); + + await executeAndAssertDeposit("deposit", daiToken, depositAmount, dsaWallet0, wallet0, [minOut, true]); + }); + it("Should deposit FRAX to Vault successfully (via Feeder Pool)", async () => { + const depositAmount = simpleToExactAmount(100); + const minOut = calcMinOut(depositAmount, 0.02); + const path = getToken("FRAX").feederPool; + + await executeAndAssertDeposit("depositViaSwap", fraxToken, depositAmount, dsaWallet0, wallet0, [ + minOut, + path, + true + ]); + }); + it("Should withdraw from Vault to mUSD", async () => { + const withdrawAmount = simpleToExactAmount(100); + const minOut = simpleToExactAmount(1); + + await executeAndAssertWithdraw("withdraw", mUsdToken, withdrawAmount, dsaWallet0, wallet0, [minOut, true]); + }); + it("Should withdraw from Vault to DAI (mUSD bAsset)", async () => { + const withdrawAmount = simpleToExactAmount(100); + const minOut = simpleToExactAmount(1); + + await executeAndAssertWithdraw("withdraw", mUsdToken, withdrawAmount, dsaWallet0, wallet0, [minOut, true]); + }); + it("Should withdraw from Vault to FRAX (via Feeder Pool)", async () => { + const withdrawAmount = simpleToExactAmount(100); + const minOut = simpleToExactAmount(1); + const path = getToken("FRAX").feederPool; + + await executeAndAssertWithdraw("withdrawViaSwap", fraxToken, withdrawAmount, dsaWallet0, wallet0, [ + minOut, + path, + true + ]); + }); + it("Should claim Rewards", async () => { + const mtaBalanceBefore = await mtaToken.balanceOf(dsaWallet0.address); + console.log("MTA balance before: ", toEther(mtaBalanceBefore)); + + // Wait a bit and let the rewards accumulate + await increaseTime(ONE_DAY); + + const spells = [ + { + connector: connectorName, + method: "claimRewards", + args: [0, 0] + } + ]; + + const tx = await dsaWallet0.connect(wallet0).cast(...encodeSpells(spells), DEAD_ADDRESS); + + const mtaBalanceAfter = await mtaToken.balanceOf(dsaWallet0.address); + console.log("MTA balance after: ", toEther(mtaBalanceAfter)); + + expect(mtaBalanceAfter).to.be.gt(mtaBalanceBefore); + }); + }); + describe("Main SWAP", async () => { + before(async () => { + await setup(); + }); + it("Should swap mUSD to bAsset (redeem)", async () => { + const swapAmount = simpleToExactAmount(100); + await executeAndAssertSwap("swap", mUsdToken, 18, daiToken, 18, swapAmount, dsaWallet0, wallet0); + }); + it("Should swap mUSD to fAsset (via feeder pool)", async () => { + const swapAmount = simpleToExactAmount(100); + const path = getToken("FRAX").feederPool; + await executeAndAssertSwap("swapViaFeeder", mUsdToken, 18, fraxToken, 18, swapAmount, dsaWallet0, wallet0, [ + path + ]); + }); + it("Should swap bAsset to mUSD (mint)", async () => { + const swapAmount = simpleToExactAmount(100); + await executeAndAssertSwap("swap", daiToken, 18, mUsdToken, 18, swapAmount, dsaWallet0, wallet0); + }); + it("Should swap bAsset to bAsset (swap)", async () => { + const swapAmount = simpleToExactAmount(100); + await executeAndAssertSwap("swap", daiToken, 18, usdcToken, 6, swapAmount, dsaWallet0, wallet0); + }); + it("Should swap bAsset to fAsset (via feeder)", async () => { + const swapAmount = simpleToExactAmount(100); + const path = getToken("FRAX").feederPool; + await executeAndAssertSwap("swapViaFeeder", daiToken, 18, fraxToken, 18, swapAmount, dsaWallet0, wallet0, [ + path + ]); + }); + it("Should swap fAsset to bAsset (via feeder)", async () => { + const swapAmount = simpleToExactAmount(100); + const path = getToken("FRAX").feederPool; + await executeAndAssertSwap("swapViaFeeder", fraxToken, 18, daiToken, 18, swapAmount, dsaWallet0, wallet0, [ + path + ]); + }); + it("Should swap fAsset to mUSD (via feeder)", async () => { + const swapAmount = simpleToExactAmount(100); + const path = getToken("FRAX").feederPool; + await executeAndAssertSwap("swapViaFeeder", fraxToken, 18, mUsdToken, 18, swapAmount, dsaWallet0, wallet0, [ + path + ]); + }); + }); + }); +}); diff --git a/test/polygon/mstable/mstable.utils.ts b/test/polygon/mstable/mstable.utils.ts new file mode 100644 index 00000000..88bb21ea --- /dev/null +++ b/test/polygon/mstable/mstable.utils.ts @@ -0,0 +1,137 @@ +import hre from "hardhat"; +import { ethers } from "hardhat"; +import { assert, expect } from "chai"; + +import { + DEFAULT_DECIMALS, + DEAD_ADDRESS, + toEther, + connectorName, + simpleToExactAmount, + getToken +} from "./mstable.helpers"; + +import { IERC20Minimal, IERC20Minimal__factory } from "../../../typechain"; +import { BigNumber, Contract, Wallet } from "ethers"; + +import { encodeSpells } from "../../../scripts/tests/encodeSpells"; + +const provider = hre.waffle.provider; + +let imUsdToken: IERC20Minimal = IERC20Minimal__factory.connect(getToken("imUSD").tokenAddress, provider); +let imUsdVault: IERC20Minimal = IERC20Minimal__factory.connect(getToken("imUSDVault").tokenAddress, provider); + +export const executeAndAssertSwap = async ( + method: string, + tokenFrom: IERC20Minimal, + tokenFromDecimals: number, + tokenTo: IERC20Minimal, + tokenToDecimals: number, + swapAmount: BigNumber, + dsaWallet0: Contract, + wallet0: Wallet, + args?: any[] +) => { + const diffFrom = ethers.BigNumber.from(10).pow(DEFAULT_DECIMALS - tokenFromDecimals); + const diffTo = ethers.BigNumber.from(10).pow(DEFAULT_DECIMALS - tokenToDecimals); + + const tokenFromBalanceBefore = (await tokenFrom.balanceOf(dsaWallet0.address)).mul(diffFrom); + console.log("Token From balance before: ", toEther(tokenFromBalanceBefore)); + + const tokenToBalanceBefore = (await tokenTo.balanceOf(dsaWallet0.address)).mul(diffTo); + console.log("Token To balance before: ", toEther(tokenToBalanceBefore)); + + const spells = [ + { + connector: connectorName, + method, + args: [tokenFrom.address, tokenTo.address, swapAmount, 1, ...(args ? args : []), 0, 0] + } + ]; + + console.log("Swapping...", toEther(swapAmount)); + + const tx = await dsaWallet0.connect(wallet0).cast(...encodeSpells(spells), DEAD_ADDRESS); + + const tokenFromBalanceAfter = (await tokenFrom.balanceOf(dsaWallet0.address)).mul(diffFrom); + console.log("Token From balance after: ", toEther(tokenFromBalanceAfter)); + + const tokenToBalanceAfter = (await tokenTo.balanceOf(dsaWallet0.address)).mul(diffTo); + console.log("Token To balance after: ", toEther(tokenToBalanceAfter)); + + expect(tokenFromBalanceAfter).to.be.eq(tokenFromBalanceBefore.sub(swapAmount)); + expect(tokenToBalanceAfter).to.be.gt(tokenToBalanceBefore); +}; + +export const executeAndAssertDeposit = async ( + method: string, + tokenFrom: IERC20Minimal, + depositAmount: BigNumber, + dsaWallet0: Contract, + wallet0: Wallet, + args?: any[] +) => { + const FromBalanceBefore = await tokenFrom.balanceOf(dsaWallet0.address); + console.log("Balance before: ", toEther(FromBalanceBefore)); + + const imUsdVaultBalanceBefore = await imUsdVault.balanceOf(dsaWallet0.address); + console.log("imUSD Vault balance before: ", toEther(imUsdVaultBalanceBefore)); + + const spells = [ + { + connector: connectorName, + method, + args: [tokenFrom.address, depositAmount, ...(args ? args : []), 0, 0] + } + ]; + + const tx = await dsaWallet0.connect(wallet0).cast(...encodeSpells(spells), DEAD_ADDRESS); + + const FromBalanceAfter = await tokenFrom.balanceOf(dsaWallet0.address); + console.log("Balance after: ", toEther(FromBalanceAfter)); + + const imUsdBalance = await imUsdToken.balanceOf(dsaWallet0.address); + console.log("imUSD balance: ", toEther(imUsdBalance)); + + const imUsdVaultBalance = await imUsdVault.balanceOf(dsaWallet0.address); + console.log("imUSD Vault balance: ", toEther(imUsdVaultBalance)); + + // Should have something in the vault but no imUSD + expect(await imUsdToken.balanceOf(dsaWallet0.address)).to.be.eq(0); + expect(await imUsdVault.balanceOf(dsaWallet0.address)).to.be.gt(imUsdVaultBalanceBefore); + expect(FromBalanceAfter).to.eq(FromBalanceBefore.sub(depositAmount)); +}; + +export const executeAndAssertWithdraw = async ( + method: string, + tokenTo: IERC20Minimal, + withdrawAmount: BigNumber, + dsaWallet0: Contract, + wallet0: Wallet, + args: any[] +) => { + const tokenToBalanceBefore = await tokenTo.balanceOf(dsaWallet0.address); + console.log("Balance before: ", toEther(tokenToBalanceBefore)); + + const imUsdVaultBalanceBefore = await imUsdVault.balanceOf(dsaWallet0.address); + console.log("imUSD Vault balance before: ", toEther(imUsdVaultBalanceBefore)); + + const spells = [ + { + connector: connectorName, + method, + args: [tokenTo.address, withdrawAmount, ...(args ? args : []), 0, 0] + } + ]; + + const tx = await dsaWallet0.connect(wallet0).cast(...encodeSpells(spells), DEAD_ADDRESS); + + const imUsdVaultBalanceAfter = await imUsdVault.balanceOf(dsaWallet0.address); + console.log("imUSD Vault balance after: ", toEther(imUsdVaultBalanceAfter)); + + const tokenToBalanceAfter = await tokenTo.balanceOf(dsaWallet0.address); + console.log("Balance after: ", toEther(tokenToBalanceAfter)); + + expect(imUsdVaultBalanceAfter).to.be.eq(imUsdVaultBalanceBefore.sub(withdrawAmount)); + expect(tokenToBalanceAfter).to.gt(tokenToBalanceBefore); +};