diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0e1253a..f7b2540 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,7 +24,7 @@ jobs: - run: yarn ${{ matrix.task }} coverage: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags') + if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags') || contains(github.event.head_commit.message, 'coverage') steps: - uses: actions/setup-node@v1 with: diff --git a/.solcover.js b/.solcover.js index 5bf9779..e8fe295 100644 --- a/.solcover.js +++ b/.solcover.js @@ -1,6 +1,34 @@ +const Web3 = require('web3') + module.exports = { mocha: { timeout: 30000 }, + forceBackupServer: true, + providerOptions: { + port: 8545, + seed: 'TestRPC is awesome!' + }, + onServerReady: async (config) => { + const web3 = new Web3(config.provider) + const abi = [{ + inputs: [{ name: "", type: "address"}], + outputs: [{ name: "", type: "uint256" }], + name: "balanceOf", + stateMutability: "view", + type: "function" + }] + const cDai = new web3.eth.Contract(abi, '0x615cba17EE82De39162BB87dBA9BcfD6E8BcF298') + const faucet = (await web3.eth.getAccounts())[6] + while (true) { + try { + if (await cDai.methods.balanceOf(faucet).call() !== '0') { + break + } + } catch (e) { + await new Promise(res => setTimeout(res, 1000)) + } + } + }, skipFiles: ['mocks'] } diff --git a/contracts/interfaces/ICToken.sol b/contracts/interfaces/ICToken.sol new file mode 100644 index 0000000..4d4c191 --- /dev/null +++ b/contracts/interfaces/ICToken.sol @@ -0,0 +1,19 @@ +pragma solidity 0.7.5; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface ICToken is IERC20 { + function underlying() external returns (address); + + function mint(uint256 mintAmount) external returns (uint256); + + function redeem(uint256 redeemAmount) external returns (uint256); + + function redeemUnderlying(uint256 redeemAmount) external returns (uint256); + + function balanceOfUnderlying(address account) external returns (uint256); + + function borrow(uint256 borrowAmount) external returns (uint256); + + function repayBorrow(uint256 borrowAmount) external returns (uint256); +} diff --git a/contracts/interfaces/IComptroller.sol b/contracts/interfaces/IComptroller.sol new file mode 100644 index 0000000..2ac1de4 --- /dev/null +++ b/contracts/interfaces/IComptroller.sol @@ -0,0 +1,12 @@ +pragma solidity 0.7.5; + +interface IComptroller { + function claimComp( + address[] calldata holders, + address[] calldata cTokens, + bool borrowers, + bool suppliers + ) external; + + function compAccrued(address holder) external view returns (uint256); +} diff --git a/contracts/interfaces/IHarnessComptroller.sol b/contracts/interfaces/IHarnessComptroller.sol new file mode 100644 index 0000000..8a5b774 --- /dev/null +++ b/contracts/interfaces/IHarnessComptroller.sol @@ -0,0 +1,5 @@ +pragma solidity 0.7.5; + +interface IHarnessComptroller { + function fastForward(uint256 blocks) external; +} diff --git a/contracts/interfaces/IInterestImplementation.sol b/contracts/interfaces/IInterestImplementation.sol new file mode 100644 index 0000000..20bab02 --- /dev/null +++ b/contracts/interfaces/IInterestImplementation.sol @@ -0,0 +1,15 @@ +pragma solidity 0.7.5; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IInterestImplementation { + function isInterestSupported(address _token) external view returns (bool); + + function invest(address _token, uint256 _amount) external; + + function withdraw(address _token, uint256 _amount) external; + + function investedAmount(address _token) external view returns (uint256); + + function claimCompAndPay(address[] calldata _markets) external; +} diff --git a/contracts/interfaces/IInterestReceiver.sol b/contracts/interfaces/IInterestReceiver.sol new file mode 100644 index 0000000..c6a5b27 --- /dev/null +++ b/contracts/interfaces/IInterestReceiver.sol @@ -0,0 +1,5 @@ +pragma solidity 0.7.5; + +interface IInterestReceiver { + function onInterestReceived(address _token) external; +} diff --git a/contracts/interfaces/IOwnable.sol b/contracts/interfaces/IOwnable.sol new file mode 100644 index 0000000..f3afa40 --- /dev/null +++ b/contracts/interfaces/IOwnable.sol @@ -0,0 +1,5 @@ +pragma solidity 0.7.5; + +interface IOwnable { + function owner() external view returns (address); +} diff --git a/contracts/mocks/CompoundInterestERC20Mock.sol b/contracts/mocks/CompoundInterestERC20Mock.sol new file mode 100644 index 0000000..6ef6493 --- /dev/null +++ b/contracts/mocks/CompoundInterestERC20Mock.sol @@ -0,0 +1,20 @@ +pragma solidity 0.7.5; + +import "../upgradeable_contracts/modules/interest/CompoundInterestERC20.sol"; + +contract CompoundInterestERC20Mock is CompoundInterestERC20 { + constructor( + address _omnibridge, + address _owner, + uint256 _minCompPaid, + address _compReceiver + ) CompoundInterestERC20(_omnibridge, _owner, _minCompPaid, _compReceiver) {} + + function comptroller() public pure override returns (IComptroller) { + return IComptroller(0x85e855b22F01BdD33eE194490c7eB16b7EdaC019); + } + + function compToken() public pure override returns (IERC20) { + return IERC20(0x6f51036Ec66B08cBFdb7Bd7Fb7F40b184482d724); + } +} diff --git a/contracts/upgradeable_contracts/BasicOmnibridge.sol b/contracts/upgradeable_contracts/BasicOmnibridge.sol index 7c18678..0829ea3 100644 --- a/contracts/upgradeable_contracts/BasicOmnibridge.sol +++ b/contracts/upgradeable_contracts/BasicOmnibridge.sol @@ -248,10 +248,8 @@ abstract contract BasicOmnibridge is { require(isRegisteredAsNativeToken(_token)); - uint256 balance = IERC677(_token).balanceOf(address(this)); - uint256 expectedBalance = mediatorBalance(_token); - require(balance > expectedBalance); - uint256 diff = balance - expectedBalance; + uint256 diff = _unaccountedBalance(_token); + require(diff > 0); uint256 available = maxAvailablePerTx(_token); require(available > 0); if (diff > available) { @@ -488,6 +486,16 @@ abstract contract BasicOmnibridge is return result; } + /** + * @dev Internal function for counting excess balance which is not tracked within the bridge. + * Represents the amount of forced tokens on this contract. + * @param _token address of the token contract. + * @return amount of excess tokens. + */ + function _unaccountedBalance(address _token) internal view virtual returns (uint256) { + return IERC677(_token).balanceOf(address(this)).sub(mediatorBalance(_token)); + } + function _handleTokens( address _token, bool _isNative, diff --git a/contracts/upgradeable_contracts/ForeignOmnibridge.sol b/contracts/upgradeable_contracts/ForeignOmnibridge.sol index 498e146..929c6e8 100644 --- a/contracts/upgradeable_contracts/ForeignOmnibridge.sol +++ b/contracts/upgradeable_contracts/ForeignOmnibridge.sol @@ -2,6 +2,7 @@ pragma solidity 0.7.5; import "./BasicOmnibridge.sol"; import "./components/common/GasLimitManager.sol"; +import "./components/common/InterestConnector.sol"; import "../libraries/SafeMint.sol"; /** @@ -9,7 +10,7 @@ import "../libraries/SafeMint.sol"; * @dev Foreign side implementation for multi-token mediator intended to work on top of AMB bridge. * It is designed to be used as an implementation contract of EternalStorageProxy contract. */ -contract ForeignOmnibridge is BasicOmnibridge, GasLimitManager { +contract ForeignOmnibridge is BasicOmnibridge, GasLimitManager, InterestConnector { using SafeERC20 for IERC677; using SafeMint for IBurnableMintableERC677Token; using SafeMath for uint256; @@ -136,11 +137,33 @@ contract ForeignOmnibridge is BasicOmnibridge, GasLimitManager { uint256 _balanceChange ) internal override { if (_isNative) { + // There are two edge cases related to withdrawals on the foreign side of the bridge. + // 1) Minting of extra STAKE tokens, if supply on the Home side exceeds total bridge amount on the Foreign side. + // 2) Withdrawal of the invested tokens back from the Compound-like protocol, if currently available funds are insufficient. + // Most of the time, these cases do not intersect. However, in case STAKE tokens are also invested (e.g. via EasyStaking), + // the situation can be the following: + // - 20 STAKE are bridged through the OB. 15 STAKE of which are invested into EasyStaking, and 5 STAKE are locked directly on the bridge. + // - 5 STAKE are mistakenly locked on the bridge via regular transfer, they are not accounted in mediatorBalance(STAKE) + // - User requests withdrawal of 30 STAKE from the Home side. + // Correct sequence of actions should be the following: + // - Mint new STAKE tokens (value - mediatorBalance(STAKE) = 30 STAKE - 20 STAKE = 10 STAKE) + // - Set local variable balance to 30 STAKE + // - Withdraw all invested STAKE tokens (value - (balance - investedAmount(STAKE)) = 30 STAKE - (30 STAKE - 15 STAKE) = 15 STAKE) + uint256 balance = mediatorBalance(_token); if (_token == address(0x0Ae055097C6d159879521C384F1D2123D1f195e6) && balance < _value) { IBurnableMintableERC677Token(_token).safeMint(address(this), _value - balance); balance = _value; } + + IInterestImplementation impl = interestImplementation(_token); + if (Address.isContract(address(impl))) { + uint256 availableBalance = balance.sub(impl.investedAmount(_token)); + if (_value > availableBalance) { + impl.withdraw(_token, (_value - availableBalance).add(minCashThreshold(_token))); + } + } + _setMediatorBalance(_token, balance.sub(_balanceChange)); IERC677(_token).safeTransfer(_recipient, _value); } else { @@ -159,4 +182,16 @@ contract ForeignOmnibridge is BasicOmnibridge, GasLimitManager { return bridgeContract().requireToPassMessage(mediatorContractOnOtherSide(), _data, requestGasLimit()); } + + /** + * @dev Internal function for counting excess balance which is not tracked within the bridge. + * Represents the amount of forced tokens on this contract. + * @param _token address of the token contract. + * @return amount of excess tokens. + */ + function _unaccountedBalance(address _token) internal view override returns (uint256) { + IInterestImplementation impl = interestImplementation(_token); + uint256 invested = Address.isContract(address(impl)) ? impl.investedAmount(_token) : 0; + return IERC677(_token).balanceOf(address(this)).sub(mediatorBalance(_token).sub(invested)); + } } diff --git a/contracts/upgradeable_contracts/Upgradeable.sol b/contracts/upgradeable_contracts/Upgradeable.sol index 0712d76..46cdab7 100644 --- a/contracts/upgradeable_contracts/Upgradeable.sol +++ b/contracts/upgradeable_contracts/Upgradeable.sol @@ -3,9 +3,18 @@ pragma solidity 0.7.5; import "../interfaces/IUpgradeabilityOwnerStorage.sol"; contract Upgradeable { - // Avoid using onlyUpgradeabilityOwner name to prevent issues with implementation from proxy contract + /** + * @dev Throws if called by any account other than the upgradeability owner. + */ modifier onlyIfUpgradeabilityOwner() { - require(msg.sender == IUpgradeabilityOwnerStorage(address(this)).upgradeabilityOwner()); + _onlyIfUpgradeabilityOwner(); _; } + + /** + * @dev Internal function for reducing onlyIfUpgradeabilityOwner modifier bytecode overhead. + */ + function _onlyIfUpgradeabilityOwner() internal view { + require(msg.sender == IUpgradeabilityOwnerStorage(address(this)).upgradeabilityOwner()); + } } diff --git a/contracts/upgradeable_contracts/components/common/InterestConnector.sol b/contracts/upgradeable_contracts/components/common/InterestConnector.sol new file mode 100644 index 0000000..6b241a7 --- /dev/null +++ b/contracts/upgradeable_contracts/components/common/InterestConnector.sol @@ -0,0 +1,112 @@ +pragma solidity 0.7.5; + +import "@openzeppelin/contracts/utils/Address.sol"; +import "@openzeppelin/contracts/math/SafeMath.sol"; +import "../../Ownable.sol"; +import "../../../interfaces/IInterestReceiver.sol"; +import "../../../interfaces/IInterestImplementation.sol"; +import "../native/MediatorBalanceStorage.sol"; + +/** + * @title InterestConnector + * @dev This contract gives an abstract way of receiving interest on locked tokens. + */ +contract InterestConnector is Ownable, MediatorBalanceStorage { + using SafeMath for uint256; + + /** + * @dev Tells address of the interest earning implementation for the specific token contract. + * If interest earning is disabled, will return 0x00..00. + * Can be an address of the deployed CompoundInterestERC20 contract. + * @param _token address of the locked token contract. + * @return address of the implementation contract. + */ + function interestImplementation(address _token) public view returns (IInterestImplementation) { + return IInterestImplementation(addressStorage[keccak256(abi.encodePacked("interestImpl", _token))]); + } + + /** + * @dev Initializes interest receiving functionality for the particular locked token. + * Only owner can call this method. + * @param _token address of the token for interest earning. + * @param _impl address of the interest earning implementation contract. + * @param _minCashThreshold minimum amount of underlying tokens that are not invested. + */ + function initializeInterest( + address _token, + address _impl, + uint256 _minCashThreshold + ) external onlyOwner { + require(address(interestImplementation(_token)) == address(0)); + _setInterestImplementation(_token, _impl); + _setMinCashThreshold(_token, _minCashThreshold); + } + + /** + * @dev Sets minimum amount of tokens that cannot be invested. + * Only owner can call this method. + * @param _token address of the token contract. + * @param _minCashThreshold minimum amount of underlying tokens that are not invested. + */ + function setMinCashThreshold(address _token, uint256 _minCashThreshold) external onlyOwner { + _setMinCashThreshold(_token, _minCashThreshold); + } + + /** + * @dev Tells minimum amount of tokens that are not being invested. + * @param _token address of the invested token contract. + * @return amount of tokens. + */ + function minCashThreshold(address _token) public view returns (uint256) { + return uintStorage[keccak256(abi.encodePacked("minCashThreshold", _token))]; + } + + /** + * @dev Disables interest for locked funds. + * Only owner can call this method. + * Prior to calling this function, consider to call payInterest and claimCompAndPay. + * @param _token of token to disable interest for. + */ + function disableInterest(address _token) external onlyOwner { + interestImplementation(_token).withdraw(_token, uint256(-1)); + _setInterestImplementation(_token, address(0)); + } + + /** + * @dev Invests all excess tokens. Leaves only minCashThreshold in underlying tokens. + * Requires interest for the given token to be enabled first. + * @param _token address of the token contract considered. + */ + function invest(address _token) external { + IInterestImplementation impl = interestImplementation(_token); + // less than _token.balanceOf(this), since it does not take into account mistakenly locked tokens that should be processed via fixMediatorBalance. + uint256 balance = mediatorBalance(_token).sub(impl.investedAmount(_token)); + uint256 minCash = minCashThreshold(_token); + + require(balance > minCash); + uint256 amount = balance - minCash; + + IERC20(_token).transfer(address(impl), amount); + impl.invest(_token, amount); + } + + /** + * @dev Internal function for setting interest earning implementation contract for some token. + * Also acts as an interest enabled flag. + * @param _token address of the token contract. + * @param _impl address of the implementation contract. + */ + function _setInterestImplementation(address _token, address _impl) internal { + require(_impl == address(0) || IInterestImplementation(_impl).isInterestSupported(_token)); + addressStorage[keccak256(abi.encodePacked("interestImpl", _token))] = _impl; + } + + /** + * @dev Internal function for setting minimum amount of tokens that cannot be invested. + * @param _token address of the token contract. + * @param _minCashThreshold minimum amount of underlying tokens that are not invested. + */ + function _setMinCashThreshold(address _token, uint256 _minCashThreshold) internal { + uintStorage[keccak256(abi.encodePacked("minCashThreshold", _token))] = _minCashThreshold; + } +} diff --git a/contracts/upgradeable_contracts/components/common/OmnibridgeInfo.sol b/contracts/upgradeable_contracts/components/common/OmnibridgeInfo.sol index 033014b..ccff4e4 100644 --- a/contracts/upgradeable_contracts/components/common/OmnibridgeInfo.sol +++ b/contracts/upgradeable_contracts/components/common/OmnibridgeInfo.sol @@ -31,7 +31,7 @@ contract OmnibridgeInfo is VersionableBridge { uint64 patch ) { - return (3, 0, 0); + return (3, 1, 0); } /** diff --git a/contracts/upgradeable_contracts/modules/interest/CompoundInterestERC20.sol b/contracts/upgradeable_contracts/modules/interest/CompoundInterestERC20.sol new file mode 100644 index 0000000..f8bcdeb --- /dev/null +++ b/contracts/upgradeable_contracts/modules/interest/CompoundInterestERC20.sol @@ -0,0 +1,282 @@ +pragma solidity 0.7.5; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/math/SafeMath.sol"; +import "../../../interfaces/ICToken.sol"; +import "../../../interfaces/IComptroller.sol"; +import "../../../interfaces/IOwnable.sol"; +import "../../../interfaces/IInterestReceiver.sol"; +import "../../../interfaces/IInterestImplementation.sol"; +import "../MediatorOwnableModule.sol"; + +/** + * @title CompoundInterestERC20 + * @dev This contract contains token-specific logic for investing ERC20 tokens into Compound protocol. + */ +contract CompoundInterestERC20 is IInterestImplementation, MediatorOwnableModule { + using SafeMath for uint256; + + event PaidInterest(address indexed token, address to, uint256 value); + + uint256 internal constant SUCCESS = 0; + + struct InterestParams { + ICToken cToken; + uint96 dust; + uint256 investedAmount; + address interestReceiver; + uint256 minInterestPaid; + } + + mapping(address => InterestParams) public interestParams; + uint256 public minCompPaid; + address public compReceiver; + + constructor( + address _omnibridge, + address _owner, + uint256 _minCompPaid, + address _compReceiver + ) MediatorOwnableModule(_omnibridge, _owner) { + minCompPaid = _minCompPaid; + compReceiver = _compReceiver; + } + + /** + * @dev Enables support for interest earning through specific cToken. + * @param _cToken address of the cToken contract. Underlying token address is derived from this contract. + * @param _dust small amount of underlying tokens that cannot be paid as an interest. Accounts for possible truncation errors. + * @param _interestReceiver address of the interest receiver for underlying token and associated COMP tokens. + * @param _minInterestPaid min amount of underlying tokens to be paid as an interest. + */ + function enableInterestToken( + ICToken _cToken, + uint96 _dust, + address _interestReceiver, + uint256 _minInterestPaid + ) external onlyOwner { + address token = _cToken.underlying(); + // disallow reinitialization of tokens that were already initialized and invested + require(interestParams[token].investedAmount == 0); + + interestParams[token] = InterestParams(_cToken, _dust, 0, _interestReceiver, _minInterestPaid); + + IERC20(token).approve(address(_cToken), uint256(-1)); + } + + /** + * @dev Tells the current amount of underlying tokens that was invested into the Compound protocol. + * @param _token address of the underlying token. + * @return currently invested value. + */ + function investedAmount(address _token) external view override returns (uint256) { + return interestParams[_token].investedAmount; + } + + /** + * @dev Tells the address of the COMP token in the Ethereum Mainnet. + */ + function compToken() public pure virtual returns (IERC20) { + return IERC20(0xc00e94Cb662C3520282E6f5717214004A7f26888); + } + + /** + * @dev Tells the address of the Comptroller contract in the Ethereum Mainnet. + */ + function comptroller() public pure virtual returns (IComptroller) { + return IComptroller(0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B); + } + + /** + * @dev Tells if interest earning is supported for the specific underlying token contract. + * @param _token address of the token contract. + * @return true, if interest earning is supported for the given token. + */ + function isInterestSupported(address _token) external view override returns (bool) { + return address(interestParams[_token].cToken) != address(0); + } + + /** + * @dev Invests the given amount of tokens to the Compound protocol. + * Only Omnibridge contract is allowed to call this method. + * Converts _amount of TOKENs into X cTOKENs. + * @param _token address of the invested token contract. + * @param _amount amount of tokens to invest. + */ + function invest(address _token, uint256 _amount) external override onlyMediator { + InterestParams storage params = interestParams[_token]; + params.investedAmount = params.investedAmount.add(_amount); + require(params.cToken.mint(_amount) == SUCCESS); + } + + /** + * @dev Withdraws at least the given amount of tokens from the Compound protocol. + * Only Omnibridge contract is allowed to call this method. + * Converts X cTOKENs into _amount of TOKENs. + * @param _token address of the invested token contract. + * @param _amount minimal amount of tokens to withdraw. + */ + function withdraw(address _token, uint256 _amount) external override onlyMediator { + InterestParams storage params = interestParams[_token]; + uint256 invested = params.investedAmount; + uint256 redeemed = _safeWithdraw(_token, _amount > invested ? invested : _amount); + params.investedAmount = redeemed > invested ? 0 : invested - redeemed; + IERC20(_token).transfer(mediator, redeemed); + } + + /** + * @dev Tells the current accumulated interest on the invested tokens, that can be withdrawn and payed to the interest receiver. + * @param _token address of the invested token contract. + * @return amount of accumulated interest. + */ + function interestAmount(address _token) public returns (uint256) { + InterestParams storage params = interestParams[_token]; + (ICToken cToken, uint96 dust) = (params.cToken, params.dust); + uint256 balance = cToken.balanceOfUnderlying(address(this)); + // small portion of tokens are reserved for possible truncation/round errors + uint256 reserved = params.investedAmount.add(dust); + return balance > reserved ? balance - reserved : 0; + } + + /** + * @dev Pays collected interest for the underlying token. + * Anyone can call this function. + * Earned interest is withdrawn and transferred to the specified interest receiver account. + * @param _token address of the invested token contract in which interest should be paid. + */ + function payInterest(address _token) external { + InterestParams storage params = interestParams[_token]; + uint256 interest = interestAmount(_token); + require(interest >= params.minInterestPaid); + _transferInterest(params.interestReceiver, address(_token), _safeWithdraw(_token, interest)); + } + + /** + * @dev Tells the amount of earned COMP tokens for supplying assets into the protocol that can be withdrawn. + * Intended to be called via eth_call to obtain the current accumulated value for COMP. + * @return amount of accumulated COMP tokens across given markets. + */ + function compAmount(address[] calldata _markets) public returns (uint256) { + address[] memory holders = new address[](1); + holders[0] = address(this); + comptroller().claimComp(holders, _markets, false, true); + + return compToken().balanceOf(address(this)); + } + + /** + * @dev Claims Comp token received by supplying underlying tokens and transfers it to the associated COMP receiver. + * @param _markets cTokens addresses to claim COMP for. + */ + function claimCompAndPay(address[] calldata _markets) external override { + uint256 balance = compAmount(_markets); + require(balance >= minCompPaid); + _transferInterest(compReceiver, address(compToken()), balance); + } + + /** + * @dev Last-resort function for returning assets to the Omnibridge contract in case of some failures in the logic. + * Disables this contract and transfers locked tokens back to the mediator. + * Only owner is allowed to call this method. + * @param _token address of the invested token contract that should be disabled. + */ + function forceDisable(address _token) external onlyOwner { + InterestParams storage params = interestParams[_token]; + ICToken cToken = params.cToken; + + uint256 cTokenBalance = cToken.balanceOf(address(this)); + // try to redeem all cTokens + if (cToken.redeem(cTokenBalance) != SUCCESS) { + // transfer cTokens as-is, if redeem has failed + cToken.transfer(mediator, cTokenBalance); + } + IERC20(_token).transfer(mediator, IERC20(_token).balanceOf(address(this))); + + delete params.cToken; + delete params.dust; + delete params.investedAmount; + delete params.minInterestPaid; + delete params.interestReceiver; + } + + /** + * @dev Updates address of the interest receiver. Can be any address, EOA or contract. + * Set to 0x00..00 to disable interest transfers. + * Only owner is allowed to call this method. + * @param _token address of the invested token contract. + * @param _receiver address of the interest receiver. + */ + function setInterestReceiver(address _token, address _receiver) external onlyOwner { + interestParams[_token].interestReceiver = _receiver; + } + + /** + * @dev Updates min interest amount that can be transferred in single call. + * Only owner is allowed to call this method. + * @param _token address of the invested token contract. + * @param _minInterestPaid new amount of TOKENS and can be transferred to the interest receiver in single operation. + */ + function setMinInterestPaid(address _token, uint256 _minInterestPaid) external onlyOwner { + interestParams[_token].minInterestPaid = _minInterestPaid; + } + + /** + * @dev Updates min COMP amount that can be transferred in single call. + * Only owner is allowed to call this method. + * @param _minCompPaid new amount of COMP and can be transferred to the interest receiver in single operation. + */ + function setMinCompPaid(uint256 _minCompPaid) external onlyOwner { + minCompPaid = _minCompPaid; + } + + /** + * @dev Updates address of the accumulated COMP receiver. Can be any address, EOA or contract. + * Set to 0x00..00 to disable COMP claims and transfers. + * Only owner is allowed to call this method. + * @param _receiver address of the interest receiver. + */ + function setCompReceiver(address _receiver) external onlyOwner { + compReceiver = _receiver; + } + + /** + * @dev Internal function for securely withdrawing assets from the underlying protocol. + * @param _token address of the invested token contract. + * @param _amount minimal amount of underlying tokens to withdraw from Compound. + * @return amount of redeemed tokens, at least as much as was requested. + */ + function _safeWithdraw(address _token, uint256 _amount) private returns (uint256) { + uint256 balance = IERC20(_token).balanceOf(address(this)); + + require(interestParams[_token].cToken.redeemUnderlying(_amount) == SUCCESS); + + uint256 redeemed = IERC20(_token).balanceOf(address(this)) - balance; + + require(redeemed >= _amount); + + return redeemed; + } + + /** + * @dev Internal function transferring interest tokens to the interest receiver. + * Calls a callback on the receiver, interest receiver is a contract. + * @param _receiver address of the tokens receiver. + * @param _token address of the token contract to send. + * @param _amount amount of tokens to transfer. + */ + function _transferInterest( + address _receiver, + address _token, + uint256 _amount + ) internal { + require(_receiver != address(0)); + + IERC20(_token).transfer(_receiver, _amount); + + if (Address.isContract(_receiver)) { + IInterestReceiver(_receiver).onInterestReceived(_token); + } + + emit PaidInterest(_token, _receiver, _amount); + } +} diff --git a/e2e-tests/docker-compose.yml b/e2e-tests/docker-compose.yml index 6f4a945..bac3746 100644 --- a/e2e-tests/docker-compose.yml +++ b/e2e-tests/docker-compose.yml @@ -5,7 +5,7 @@ services: command: --deterministic --chainId 1337 --blockTime 1 --gasLimit 10000000 foreign: image: trufflesuite/ganache-cli - command: --deterministic --chainId 1338 --blockTime 1 --gasLimit 10000000 + command: --deterministic --chainId 1338 --blockTime 1 --gasLimit 20000000 --allowUnlimitedContractSize deploy-amb: image: poanetwork/tokenbridge-contracts env_file: local-envs/deploy-amb.env @@ -14,6 +14,12 @@ services: build: .. env_file: local-envs/deploy-omni.env entrypoint: deploy.sh + deploy-compound: + image: kirillfedoseev/compound-test-deploy + environment: + PROVIDER: 'http://foreign:8545' + stdin_open: true + tty: true e2e-tests: build: context: .. diff --git a/e2e-tests/local-envs/tests.env b/e2e-tests/local-envs/tests.env index e41324d..6c2b215 100644 --- a/e2e-tests/local-envs/tests.env +++ b/e2e-tests/local-envs/tests.env @@ -16,3 +16,9 @@ FOREIGN_TOKEN_ADDRESS= HOME_CLAIMABLE_TOKEN_ADDRESS= FOREIGN_CLAIMABLE_TOKEN_ADDRESS= + +COMPOUND_FAUCET_PRIVATE_KEY=e485d098507f54e7733a205420dfddbe58db035fa577fc294ebd14db90767a52 +COMPOUND_TOKEN_ADDRESS=0x0a4dBaF9656Fd88A32D087101Ee8bf399f4bd55f +COMPOUND_CTOKEN_ADDRESS=0x615cba17EE82De39162BB87dBA9BcfD6E8BcF298 +COMPOUND_COMP_ADDRESS=0x6f51036Ec66B08cBFdb7Bd7Fb7F40b184482d724 +COMPOUND_COMPTROLLER_ADDRESS=0x85e855b22F01BdD33eE194490c7eB16b7EdaC019 diff --git a/e2e-tests/run-tests.sh b/e2e-tests/run-tests.sh index 6506fc2..97924a8 100755 --- a/e2e-tests/run-tests.sh +++ b/e2e-tests/run-tests.sh @@ -25,6 +25,7 @@ if [[ "$1" == 'local' ]]; then docker-compose run --rm deploy-amb docker-compose run --rm deploy-omni + docker-compose up deploy-compound || true docker-compose up -d rabbit redis bridge_affirmation bridge_request bridge_collected bridge_senderhome bridge_senderforeign diff --git a/e2e-tests/run.js b/e2e-tests/run.js index 5cc20dc..cf99819 100644 --- a/e2e-tests/run.js +++ b/e2e-tests/run.js @@ -16,6 +16,9 @@ const ForeignABI = [...require('../build/contracts/ForeignOmnibridge.json').abi, const FeeManagerABI = [...require('../build/contracts/OmnibridgeFeeManager.json').abi] const WETH = require('../build/contracts/WETH.json') const WETHOmnibridgeRouter = require('../build/contracts/WETHOmnibridgeRouter.json') +const InterestImpl = require('../build/contracts/CompoundInterestERC20Mock.json') +const ComptrollerABI = require('../build/contracts/IHarnessComptroller.json').abi +const CTokenABI = require('../build/contracts/ICToken.json').abi const WETHOmnibridgeRouterABI = [...WETHOmnibridgeRouter.abi, ...AMBEventABI] @@ -36,6 +39,7 @@ const scenarios = [ require('./scenarios/bridgeForeignTokensAndCall'), require('./scenarios/bridgeHomeTokensAndCall'), require('./scenarios/bridgeNativeETH'), + require('./scenarios/investForeignTokensIntoCompound'), ] const { toWei, toBN, ZERO_ADDRESS, toAddress, addPendingTxLogger } = require('./utils') @@ -55,6 +59,11 @@ const { TEST_ACCOUNT_PRIVATE_KEY, SECOND_TEST_ACCOUNT_PRIVATE_KEY, OWNER_ACCOUNT_PRIVATE_KEY, + COMPOUND_FAUCET_PRIVATE_KEY, + COMPOUND_TOKEN_ADDRESS, + COMPOUND_CTOKEN_ADDRESS, + COMPOUND_COMP_ADDRESS, + COMPOUND_COMPTROLLER_ADDRESS, } = process.env function deploy(web3, options, abi, bytecode, args) { @@ -95,6 +104,14 @@ async function deployWETHRouter(web3, options, bridge, WETH, owner) { return contract } +async function deployInterestImpl(web3, mediator, owner, cToken, options) { + const args = [toAddress(mediator), owner, '1', owner] + const contract = await deploy(web3, options, InterestImpl.abi, InterestImpl.bytecode, args) + await contract.methods.enableInterestToken(toAddress(cToken), toWei('1'), owner, toWei('1')).send({ from: owner }) + console.log(`Deployed Interest implementation contract ${contract.options.address}`) + return contract +} + const findMessageId = (receipt) => Object.values(receipt.events) .flat() @@ -267,6 +284,60 @@ async function createEnv(web3Home, web3Foreign) { const WETH = await deployWETH(web3Foreign, foreignOptions) const WETHRouter = await deployWETHRouter(web3Foreign, foreignOptions, foreignMediator, WETH, owner) + console.log('Initializing Compound environment') + const compound = {} + if ( + COMPOUND_FAUCET_PRIVATE_KEY && + COMPOUND_COMP_ADDRESS && + COMPOUND_TOKEN_ADDRESS && + COMPOUND_CTOKEN_ADDRESS && + COMPOUND_COMPTROLLER_ADDRESS + ) { + compound.faucet = web3Foreign.eth.accounts.wallet.add(COMPOUND_FAUCET_PRIVATE_KEY).address + const faucetOptions = { ...foreignOptions, from: compound.faucet } + const comp = new web3Foreign.eth.Contract(TokenABI, COMPOUND_COMP_ADDRESS, faucetOptions) + const cToken = new web3Foreign.eth.Contract(CTokenABI, COMPOUND_CTOKEN_ADDRESS, faucetOptions) + const comptroller = new web3Foreign.eth.Contract(ComptrollerABI, COMPOUND_COMPTROLLER_ADDRESS, foreignOptions) + console.log('Deploy Interest implementation') + const interestImpl = await deployInterestImpl(web3Foreign, foreignMediator, owner, cToken, foreignOptions) + + compound.token = new web3Foreign.eth.Contract(TokenABI, COMPOUND_TOKEN_ADDRESS, faucetOptions) + + compound.enableInterest = async () => { + console.log('Enabling interest for the given token') + await foreignMediator.methods + .initializeInterest(toAddress(compound.token), toAddress(interestImpl), toWei('1')) + .send({ from: owner }) + + console.log('Investing excess tokens') + await foreignMediator.methods.invest(toAddress(compound.token)).send() + } + + compound.disableInterest = async () => { + console.log('Disabling interest for the given token') + await foreignMediator.methods.disableInterest(toAddress(compound.token)).send({ from: owner }) + } + + compound.waitForInterest = async () => { + console.log('Generating some amount of interest to Compound suppliers') + await cToken.methods.borrow(toWei('10')).send() + await comptroller.methods.fastForward(200000).send() + await cToken.methods.repayBorrow(toWei('20')).send() + } + + compound.acquireInterest = async () => { + console.log('Paying earned interest in 2 tokens') + const balance = await compound.token.methods.balanceOf(owner).call() + const balanceComp = await comp.methods.balanceOf(owner).call() + await interestImpl.methods.payInterest(toAddress(compound.token)).send() + await interestImpl.methods.claimCompAndPay([toAddress(cToken)]).send() + const diff = toBN(await compound.token.methods.balanceOf(owner).call()).sub(toBN(balance)) + const diffComp = toBN(await comp.methods.balanceOf(owner).call()).sub(toBN(balanceComp)) + assert.ok(diff.gtn(0), 'No interest in regular tokens was earned') + assert.ok(diffComp.gtn(0), 'No interest in COMP tokens was earned') + } + } + return { home: { web3: web3Home, @@ -293,6 +364,7 @@ async function createEnv(web3Home, web3Foreign) { waitUntilProcessed: makeWaitUntilProcessed(foreignAMB, 'RelayedMessage', foreignBlockNumber), withDisabledExecution: makeWithDisabledExecution(foreignMediator, owner), checkTransfer: makeCheckTransfer(web3Foreign), + compound, WETH, WETHRouter, }, diff --git a/e2e-tests/scenarios/investForeignTokensIntoCompound.js b/e2e-tests/scenarios/investForeignTokensIntoCompound.js new file mode 100644 index 0000000..e570aac --- /dev/null +++ b/e2e-tests/scenarios/investForeignTokensIntoCompound.js @@ -0,0 +1,40 @@ +const { toWei, ZERO_ADDRESS } = require('../utils') + +async function run({ foreign, home, users }) { + const { mediator, compound } = foreign + const { faucet, token, enableInterest, disableInterest, waitForInterest, acquireInterest } = compound + const value = toWei('10') + + console.log('Sending 10 tokens to the Foreign Mediator') + await token.methods.approve(mediator.options.address, value).send() + const relayTokens = mediator.methods['relayTokens(address,address,uint256)'] + const receipt1 = await relayTokens(token.options.address, users[0], value).send({ from: faucet }) + const relayTxHash1 = await home.waitUntilProcessed(receipt1) + const bridgedToken = await home.getBridgedToken(token) + + await home.checkTransfer(relayTxHash1, bridgedToken, ZERO_ADDRESS, users[0], home.reduceByForeignFee(value)) + + await enableInterest() + + await waitForInterest() + + await acquireInterest() + + console.log('\nSending 5 bridged tokens to the Home Mediator') + const receipt3 = await bridgedToken.methods.transferAndCall(home.mediator.options.address, toWei('5'), '0x').send() + const relayTxHash3 = await foreign.waitUntilProcessed(receipt3) + + await foreign.checkTransfer(relayTxHash3, token, mediator, users[0], home.reduceByHomeFee(toWei('5'))) + + await waitForInterest() + + await acquireInterest() + + await disableInterest() +} + +module.exports = { + name: 'Bridge operations when compound investing logic is enabled', + shouldRun: ({ owner, foreign }) => !!owner && !!foreign.compound.token, + run, +} diff --git a/flatten.sh b/flatten.sh index e788ff9..1d177fd 100755 --- a/flatten.sh +++ b/flatten.sh @@ -20,6 +20,7 @@ ${FLATTENER} ${BRIDGE_CONTRACTS_DIR}/modules/factory/TokenProxy.sol > flats/Toke ${FLATTENER} ${BRIDGE_CONTRACTS_DIR}/modules/forwarding_rules/MultiTokenForwardingRulesManager.sol > flats/MultiTokenForwardingRulesManager_flat.sol ${FLATTENER} ${BRIDGE_CONTRACTS_DIR}/modules/fee_manager/OmnibridgeFeeManager.sol > flats/OmnibridgeFeeManager_flat.sol ${FLATTENER} ${BRIDGE_CONTRACTS_DIR}/modules/gas_limit/SelectorTokenGasLimitManager.sol > flats/SelectorTokenGasLimitManager_flat.sol +${FLATTENER} ${BRIDGE_CONTRACTS_DIR}/modules/interest/CompoundInterestERC20.sol > flats/CompoundInterestERC20_flat.sol echo "Flattening token contracts" cp ./precompiled/PermittableToken_flat.sol flats diff --git a/package.json b/package.json index dd681c2..eea7734 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "omnibridge", - "version": "1.0.0", + "version": "1.1.0-rc0", "description": "Omnibridge AMB extension", "main": "index.js", "scripts": { - "test": "scripts/test.sh", - "coverage": "SOLIDITY_COVERAGE=true scripts/test.sh", + "test": "test/test.sh", + "coverage": "SOLIDITY_COVERAGE=true yarn test", "compile": "truffle compile", "flatten": "bash flatten.sh 2>/dev/null", "lint": "yarn run lint:js && yarn run lint:sol", diff --git a/scripts/test.sh b/scripts/test.sh deleted file mode 100755 index fdb2fc8..0000000 --- a/scripts/test.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash - -# Exit script as soon as a command fails. -set -o errexit -node_modules/.bin/truffle version -# Executes cleanup function at script exit. -trap cleanup EXIT - -cleanup() { - # Kill the ganache instance that we started (if we started one and if it's still running). - if [ -n "$ganache_pid" ] && ps -p $ganache_pid > /dev/null; then - kill -9 $ganache_pid - fi -} - -kill -9 $(lsof -t -i:8080) > /dev/null 2>&1 || true - -echo "Starting our own ganache instance" - -# We define 10 accounts with balance 1M ether, needed for high-value tests. -accounts=( - --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501200,1000000000000000000000000" - --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501201,1000000000000000000000000" - --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501202,1000000000000000000000000" - --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501203,1000000000000000000000000" - --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501204,1000000000000000000000000" - --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501205,1000000000000000000000000" - --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501206,1000000000000000000000000" - --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501207,1000000000000000000000000" - --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501208,1000000000000000000000000" - --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501209,1000000000000000000000000" - --account="0x19fba401d77e4113b15095e9aa7117bcd25adcfac7f6111f8298894eef443600,1000000000000000000000000" -) - -if [ "$SOLIDITY_COVERAGE" != true ]; then - node --max-old-space-size=4096 node_modules/.bin/ganache-cli --gasLimit 0xfffffffffff "${accounts[@]}" > /dev/null & - ganache_pid=$! - node --max-old-space-size=4096 node_modules/.bin/truffle test --network ganache "$@" -else - node --max-old-space-size=4096 node_modules/.bin/truffle run coverage --network ganache 2>/dev/null -fi diff --git a/test/compound/Dockerfile b/test/compound/Dockerfile new file mode 100644 index 0000000..19acca4 --- /dev/null +++ b/test/compound/Dockerfile @@ -0,0 +1,22 @@ +FROM node:13 + +RUN wget https://github.com/ethereum/solidity/releases/download/v0.5.16/solc-static-linux -O /usr/local/bin/solc +RUN chmod +x /usr/local/bin/solc + +RUN git clone https://github.com/compound-finance/compound-protocol.git + +WORKDIR /compound-protocol + +RUN yarn +RUN cd scenario && yarn + +RUN yarn compile +RUN scenario/script/tsc + +COPY entrypoint.scen ./ + +ENV PROVIDER='http://ganache:8545' +ENV NO_TSC=1 + +ENTRYPOINT ["yarn", "repl"] +CMD ["-s", "entrypoint.scen", "c", "t"] diff --git a/test/compound/contracts.js b/test/compound/contracts.js new file mode 100644 index 0000000..13a1d41 --- /dev/null +++ b/test/compound/contracts.js @@ -0,0 +1,13 @@ +const Comptroller = artifacts.require('IHarnessComptroller') +const IERC20 = artifacts.require('IERC20') +const ICToken = artifacts.require('ICToken') + +async function getCompoundContracts() { + const comptroller = await Comptroller.at('0x85e855b22F01BdD33eE194490c7eB16b7EdaC019') + const dai = await IERC20.at('0x0a4dBaF9656Fd88A32D087101Ee8bf399f4bd55f') + const cDai = await ICToken.at('0x615cba17EE82De39162BB87dBA9BcfD6E8BcF298') + const comp = await IERC20.at('0x6f51036Ec66B08cBFdb7Bd7Fb7F40b184482d724') + return { comptroller, dai, cDai, comp } +} + +module.exports = getCompoundContracts diff --git a/test/compound/entrypoint.scen b/test/compound/entrypoint.scen new file mode 100644 index 0000000..cb3bac6 --- /dev/null +++ b/test/compound/entrypoint.scen @@ -0,0 +1,33 @@ +MyAddress 0x3E5e9111Ae8eB78Fe1CC3bb8915d5D461F3Ef9A9 + +-- Deploy contract +NewComptroller +ListedEtherToken cETH +ListedCToken DAI cDAI +Erc20 Deploy Standard COMP "COMP Token" 18 + +-- Set contracts params +Comptroller SetCollateralFactor cETH 0.9 +Comptroller SetCompSpeed cDAI 1 +Comptroller Send "setCompAddress(address)" (Address COMP) +Give (Address Comptroller) 5000000e18 COMP + +-- Put ETH collateral +SendMintEth Me 20e18 cETH +EnterMarkets Me cETH + +-- Mint some DAI +Give Me 500000e18 DAI + +-- Mint some cDAI +Allow Me cDAI +Mint Me 10e18 cDAI + +-- Print addresses +Read Comptroller Address +Read ERC20 DAI Address +Read ERC20 Comp Address +Read CToken cETH Address +Read CToken cDAI Address + +Exit diff --git a/test/docker-compose.yml b/test/docker-compose.yml new file mode 100644 index 0000000..a6b6223 --- /dev/null +++ b/test/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' +services: + ganache: + image: trufflesuite/ganache-cli + command: --deterministic --gasLimit 20000000 --allowUnlimitedContractSize + ports: + - 8545:8545 + compound: + image: kirillfedoseev/compound-test-deploy + # build: compound + environment: + - PROVIDER + stdin_open: true + tty: true + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/test/omnibridge/common.test.js b/test/omnibridge/common.test.js index 477d36b..fd3a220 100644 --- a/test/omnibridge/common.test.js +++ b/test/omnibridge/common.test.js @@ -8,10 +8,12 @@ const MultiTokenForwardingRulesManager = artifacts.require('MultiTokenForwarding const OmnibridgeFeeManager = artifacts.require('OmnibridgeFeeManager') const SelectorTokenGasLimitManager = artifacts.require('SelectorTokenGasLimitManager') const TokenReceiver = artifacts.require('TokenReceiver') +const CompoundInterestERC20 = artifacts.require('CompoundInterestERC20Mock') const { expect } = require('chai') const { getEvents, ether, expectEventInLogs } = require('../helpers/helpers') const { ZERO_ADDRESS, toBN, requirePrecompiled } = require('../setup') +const getCompoundContracts = require('../compound/contracts') const ZERO = toBN(0) const halfEther = ether('0.5') @@ -2249,6 +2251,219 @@ function runTests(accounts, isHome) { }) }) } + + if (!isHome) { + describe('compound connector', () => { + const faucet = accounts[6] // account where all Compound-related DAIs where minted + + let dai + let cDai + let comptroller + let comp + let daiInterestImpl + + before(async () => { + const contracts = await getCompoundContracts() + dai = contracts.dai + cDai = contracts.cDai + comptroller = contracts.comptroller + comp = contracts.comp + }) + + beforeEach(async () => { + const storageProxy = await EternalStorageProxy.new() + await storageProxy.upgradeTo('1', contract.address).should.be.fulfilled + contract = await Mediator.at(storageProxy.address) + await initialize({ + limits: [ether('100'), ether('99'), ether('0.01')], + executionLimits: [ether('100'), ether('99')], + }) + daiInterestImpl = await CompoundInterestERC20.new(contract.address, owner, 1, accounts[2]) + await daiInterestImpl.enableInterestToken(cDai.address, oneEther, accounts[2], ether('0.01')) + await dai.approve(contract.address, ether('100'), { from: faucet }) + await contract.methods['relayTokens(address,uint256)'](dai.address, ether('10'), { from: faucet }) + }) + + async function generateInterest() { + await cDai.borrow(ether('10'), { from: faucet }).should.be.fulfilled + await comptroller.fastForward(200000).should.be.fulfilled + await cDai.repayBorrow(ether('20'), { from: faucet }).should.be.fulfilled + } + + it('should initialize interest', async () => { + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('10')) + expect(await contract.interestImplementation(dai.address)).to.be.equal(ZERO_ADDRESS) + + const args = [dai.address, daiInterestImpl.address, oneEther] + await contract.initializeInterest(...args, { from: user }).should.be.rejected + await contract.initializeInterest(...args, { from: owner }).should.be.fulfilled + + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('10')) + expect(await cDai.balanceOf(contract.address)).to.be.bignumber.equal(ZERO) + expect(await contract.interestImplementation(dai.address)).to.be.equal(daiInterestImpl.address) + expect(await contract.minCashThreshold(dai.address)).to.be.bignumber.equal(oneEther) + }) + + it('should enable and earn interest', async () => { + const initialBalance = await dai.balanceOf(accounts[2]) + await contract.initializeInterest(dai.address, daiInterestImpl.address, oneEther) + + expect(await daiInterestImpl.interestAmount.call(dai.address)).to.be.bignumber.equal(ZERO) + await contract.invest(dai.address).should.be.fulfilled + + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('1')) + expect(await dai.balanceOf(accounts[2])).to.be.bignumber.equal(initialBalance) + expect(await dai.balanceOf(daiInterestImpl.address)).to.be.bignumber.equal(ZERO) + expect(await cDai.balanceOf(daiInterestImpl.address)).to.be.bignumber.gt(ZERO) + expect(await daiInterestImpl.interestAmount.call(dai.address)).to.be.bignumber.equal(ZERO) + + await generateInterest() + + expect(await daiInterestImpl.interestAmount.call(dai.address)).to.be.bignumber.gt(ZERO) + }) + + it('should pay interest', async () => { + const initialBalance = await dai.balanceOf(accounts[2]) + await contract.initializeInterest(dai.address, daiInterestImpl.address, oneEther) + await contract.invest(dai.address).should.be.fulfilled + await generateInterest() + + expect(await daiInterestImpl.interestAmount.call(dai.address)).to.be.bignumber.gt(ether('0.01')) + + await daiInterestImpl.payInterest(dai.address).should.be.fulfilled + + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('1')) + expect(await dai.balanceOf(accounts[2])).to.be.bignumber.gt(initialBalance) + expect(await cDai.balanceOf(daiInterestImpl.address)).to.be.bignumber.gt(ZERO) + expect(await daiInterestImpl.interestAmount.call(dai.address)).to.be.bignumber.lt(ether('0.01')) + }) + + it('should disable interest', async () => { + await contract.initializeInterest(dai.address, daiInterestImpl.address, oneEther) + await contract.invest(dai.address).should.be.fulfilled + await generateInterest() + await daiInterestImpl.payInterest(dai.address).should.be.fulfilled + + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('1')) + + await contract.disableInterest(dai.address, { from: user }).should.be.rejected + await contract.disableInterest(dai.address, { from: owner }).should.be.fulfilled + + expect(await contract.interestImplementation(dai.address)).to.be.equal(ZERO_ADDRESS) + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('10')) + expect(await cDai.balanceOf(daiInterestImpl.address)).to.be.bignumber.gt(ZERO) + }) + + it('configuration', async () => { + await contract.initializeInterest(dai.address, daiInterestImpl.address, oneEther) + + await contract.setMinCashThreshold(dai.address, ether('2'), { from: user }).should.be.rejected + await contract.setMinCashThreshold(dai.address, ether('2'), { from: owner }).should.be.fulfilled + expect(await contract.minCashThreshold(dai.address)).to.be.bignumber.equal(ether('2')) + + await daiInterestImpl.setMinInterestPaid(dai.address, oneEther, { from: user }).should.be.rejected + await daiInterestImpl.setMinInterestPaid(dai.address, oneEther, { from: owner }).should.be.fulfilled + expect((await daiInterestImpl.interestParams(dai.address)).minInterestPaid).to.be.bignumber.equal(oneEther) + + await daiInterestImpl.setInterestReceiver(dai.address, accounts[1], { from: user }).should.be.rejected + await daiInterestImpl.setInterestReceiver(dai.address, accounts[1], { from: owner }).should.be.fulfilled + expect((await daiInterestImpl.interestParams(dai.address)).interestReceiver).to.be.equal(accounts[1]) + + await daiInterestImpl.setMinCompPaid(oneEther, { from: user }).should.be.rejected + await daiInterestImpl.setMinCompPaid(oneEther, { from: owner }).should.be.fulfilled + expect(await daiInterestImpl.minCompPaid()).to.be.bignumber.equal(oneEther) + + await daiInterestImpl.setCompReceiver(user, { from: user }).should.be.rejected + await daiInterestImpl.setCompReceiver(user, { from: owner }).should.be.fulfilled + expect(await daiInterestImpl.compReceiver()).to.be.equal(user) + }) + + it('should claim comp', async () => { + await contract.initializeInterest(dai.address, daiInterestImpl.address, oneEther) + await contract.invest(dai.address) + await generateInterest() + + const initialBalance = await comp.balanceOf(accounts[2]) + expect(await daiInterestImpl.compAmount.call([cDai.address])).to.be.bignumber.gt(ZERO) + await daiInterestImpl.claimCompAndPay([cDai.address]) + expect(await daiInterestImpl.compAmount.call([cDai.address])).to.be.bignumber.equal(ZERO) + expect(await comp.balanceOf(accounts[2])).to.be.bignumber.gt(initialBalance) + }) + + it('should return invested tokens on withdrawal if needed', async () => { + await contract.initializeInterest(dai.address, daiInterestImpl.address, oneEther) + await contract.invest(dai.address) + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('1')) + expect(await daiInterestImpl.investedAmount(dai.address)).to.be.bignumber.equal(ether('9')) + expect(await contract.mediatorBalance(dai.address)).to.be.bignumber.equal(ether('10')) + + const data1 = contract.contract.methods.handleNativeTokens(dai.address, user, ether('0.5')).encodeABI() + expect(await executeMessageCall(exampleMessageId, data1)).to.be.equal(true) + + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('0.5')) + expect(await contract.mediatorBalance(dai.address)).to.be.bignumber.equal(ether('9.5')) + expect(await daiInterestImpl.investedAmount(dai.address)).to.be.bignumber.equal(ether('9')) + + const data2 = contract.contract.methods.handleNativeTokens(dai.address, user, ether('2')).encodeABI() + expect(await executeMessageCall(otherMessageId, data2)).to.be.equal(true) + + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('1')) + expect(await contract.mediatorBalance(dai.address)).to.be.bignumber.equal(ether('7.5')) + expect(await daiInterestImpl.investedAmount(dai.address)).to.be.bignumber.equal(ether('6.5')) + }) + + it('should allow to fix correct amount of tokens when compound is used', async () => { + await contract.initializeInterest(dai.address, daiInterestImpl.address, oneEther) + await contract.invest(dai.address) + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('1')) + expect(await daiInterestImpl.investedAmount(dai.address)).to.be.bignumber.equal(ether('9')) + expect(await contract.mediatorBalance(dai.address)).to.be.bignumber.equal(ether('10')) + + await dai.transfer(contract.address, ether('1'), { from: faucet }) + + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('2')) + expect(await daiInterestImpl.investedAmount(dai.address)).to.be.bignumber.equal(ether('9')) + expect(await contract.mediatorBalance(dai.address)).to.be.bignumber.equal(ether('10')) + + await contract.fixMediatorBalance(dai.address, owner, { from: owner }).should.be.fulfilled + + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('2')) + expect(await daiInterestImpl.investedAmount(dai.address)).to.be.bignumber.equal(ether('9')) + expect(await contract.mediatorBalance(dai.address)).to.be.bignumber.equal(ether('11')) + }) + + it('should force disable interest implementation', async () => { + await contract.initializeInterest(dai.address, daiInterestImpl.address, oneEther) + await contract.invest(dai.address) + + expect(await dai.balanceOf(contract.address)).to.be.bignumber.equal(ether('1')) + expect(await daiInterestImpl.investedAmount(dai.address)).to.be.bignumber.equal(ether('9')) + expect(await contract.mediatorBalance(dai.address)).to.be.bignumber.equal(ether('10')) + + await daiInterestImpl.forceDisable(dai.address, { from: user }).should.be.rejected + await daiInterestImpl.forceDisable(dai.address, { from: owner }).should.be.fulfilled + + expect(await dai.balanceOf(contract.address)).to.be.bignumber.gt(ether('9.999')) + expect(await daiInterestImpl.investedAmount(dai.address)).to.be.bignumber.equal(ether('0')) + expect(await contract.mediatorBalance(dai.address)).to.be.bignumber.equal(ether('10')) + }) + + it('should allow to reinitialize when there are no invested funds', async () => { + await contract.initializeInterest(dai.address, daiInterestImpl.address, oneEther) + await contract.invest(dai.address) + await generateInterest() + + await daiInterestImpl.enableInterestToken(cDai.address, oneEther, accounts[2], ether('0.01')).should.be.rejected + + await contract.disableInterest(dai.address, { from: owner }).should.be.fulfilled + + await daiInterestImpl.enableInterestToken(cDai.address, oneEther, accounts[2], ether('0.01')).should.be + .fulfilled + await contract.initializeInterest(dai.address, daiInterestImpl.address, oneEther) + await contract.invest(dai.address) + }) + }) + } } contract('ForeignOmnibridge', (accounts) => { diff --git a/test/test.sh b/test/test.sh new file mode 100755 index 0000000..3ba751f --- /dev/null +++ b/test/test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -e + +trap cleanup EXIT + +cleanup() { + if [ "$KEEP_RUNNING" != true ]; then + docker-compose -f test/docker-compose.yml down + fi +} +cleanup + +ganache_running() { + nc -z localhost 8545 +} + +if [ "$SOLIDITY_COVERAGE" = true ]; then + node --max-old-space-size=4096 node_modules/.bin/truffle run coverage --network ganache 2>/dev/null & + pid=$! + + echo "Waiting in-process ganache to launch on port 8545" + while ! ganache_running; do + sleep 0.5 + done + + echo "Deploy Compound protocol contracts" + PROVIDER=http://host.docker.internal:8545 docker-compose -f test/docker-compose.yml up compound || true + + wait $pid +else + if ganache_running; then + echo "Using existing ganache instance" + else + echo "Starting our own ganache instance" + docker-compose -f test/docker-compose.yml up -d ganache + sleep 5 + echo "Deploy Compound protocol contracts" + PROVIDER=http://ganache:8545 docker-compose -f test/docker-compose.yml up compound || true + fi + + node --max-old-space-size=4096 node_modules/.bin/truffle test --network ganache "$@" +fi