diff --git a/contracts/masset/liquidator/ILiquidator.sol b/contracts/masset/liquidator/ILiquidator.sol index 1ecf9d76..d6504450 100644 --- a/contracts/masset/liquidator/ILiquidator.sol +++ b/contracts/masset/liquidator/ILiquidator.sol @@ -9,7 +9,8 @@ contract ILiquidator { address _bAsset, int128 _curvePosition, address[] calldata _uniswapPath, - uint256 _trancheAmount + uint256 _trancheAmount, + uint256 _minReturn ) external; @@ -18,7 +19,8 @@ contract ILiquidator { address _bAsset, int128 _curvePosition, address[] calldata _uniswapPath, - uint256 _trancheAmount + uint256 _trancheAmount, + uint256 _minReturn ) external; diff --git a/contracts/masset/liquidator/Liquidator.sol b/contracts/masset/liquidator/Liquidator.sol index cf56f2ec..7e65a769 100644 --- a/contracts/masset/liquidator/Liquidator.sol +++ b/contracts/masset/liquidator/Liquidator.sol @@ -9,6 +9,7 @@ import { InitializableModule } from "../../shared/InitializableModule.sol"; import { ILiquidator } from "./ILiquidator.sol"; import { MassetHelpers } from "../../masset/shared/MassetHelpers.sol"; +import { IBasicToken } from "../../shared/IBasicToken.sol"; import { SafeMath } from "@openzeppelin/contracts/math/SafeMath.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -40,6 +41,7 @@ contract Liquidator is uint256 private interval = 7 days; mapping(address => Liquidation) public liquidations; + mapping(address => uint256) public minReturn; struct Liquidation { address sellToken; @@ -85,6 +87,7 @@ contract Liquidator is * @param _curvePosition Position of the bAsset in Curves MetaPool * @param _uniswapPath The Uniswap path as an array of addresses e.g. [COMP, WETH, DAI] * @param _trancheAmount The amount of bAsset units to buy in each weekly tranche + * @param _minReturn Minimum exact amount of bAsset to get for each (whole) sellToken unit */ function createLiquidation( address _integration, @@ -92,7 +95,8 @@ contract Liquidator is address _bAsset, int128 _curvePosition, address[] calldata _uniswapPath, - uint256 _trancheAmount + uint256 _trancheAmount, + uint256 _minReturn ) external onlyGovernance @@ -103,7 +107,8 @@ contract Liquidator is _integration != address(0) && _sellToken != address(0) && _bAsset != address(0) && - _uniswapPath.length >= 2, + _uniswapPath.length >= 2 && + _minReturn > 0, "Invalid inputs" ); require(_validUniswapPath(_sellToken, _bAsset, _uniswapPath), "Invalid uniswap path"); @@ -116,6 +121,7 @@ contract Liquidator is lastTriggered: 0, trancheAmount: _trancheAmount }); + minReturn[_integration] = _minReturn; emit LiquidationModified(_integration); } @@ -127,13 +133,15 @@ contract Liquidator is * @param _curvePosition Position of the bAsset in Curves MetaPool * @param _uniswapPath The Uniswap path as an array of addresses e.g. [COMP, WETH, DAI] * @param _trancheAmount The amount of bAsset units to buy in each weekly tranche + * @param _minReturn Minimum exact amount of bAsset to get for each (whole) sellToken unit */ function updateBasset( address _integration, address _bAsset, int128 _curvePosition, address[] calldata _uniswapPath, - uint256 _trancheAmount + uint256 _trancheAmount, + uint256 _minReturn ) external onlyGovernance @@ -142,7 +150,8 @@ contract Liquidator is address oldBasset = liquidation.bAsset; require(oldBasset != address(0), "Liquidation does not exist"); - + + require(_minReturn > 0, "Must set some minimum value"); require(_bAsset != address(0), "Invalid bAsset"); require(_validUniswapPath(liquidation.sellToken, _bAsset, _uniswapPath), "Invalid uniswap path"); @@ -150,6 +159,7 @@ contract Liquidator is liquidations[_integration].curvePosition = _curvePosition; liquidations[_integration].uniswapPath = _uniswapPath; liquidations[_integration].trancheAmount = _trancheAmount; + minReturn[_integration] = _minReturn; emit LiquidationModified(_integration); } @@ -180,6 +190,8 @@ contract Liquidator is require(liquidation.bAsset != address(0), "Liquidation does not exist"); delete liquidations[_integration]; + delete minReturn[_integration]; + emit LiquidationEnded(_integration); } @@ -197,6 +209,9 @@ contract Liquidator is function triggerLiquidation(address _integration) external { + // solium-disable-next-line security/no-tx-origin + require(tx.origin == msg.sender, "Must be EOA"); + Liquidation memory liquidation = liquidations[_integration]; address bAsset = liquidation.bAsset; @@ -234,19 +249,24 @@ contract Liquidator is IERC20(sellToken).safeApprove(address(uniswap), 0); IERC20(sellToken).safeApprove(address(uniswap), sellAmount); // 3.2. Make the sale > https://uniswap.org/docs/v2/smart-contracts/router02/#swapexacttokensfortokens + + // min amount out = sellAmount * priceFloor / 1e18 + // e.g. 1e18 * 100e6 / 1e18 = 100e6 + // e.g. 30e8 * 100e6 / 1e8 = 3000e6 + // e.g. 30e18 * 100e18 / 1e18 = 3000e18 + uint256 sellTokenDec = IBasicToken(sellToken).decimals(); + uint256 minOut = sellAmount.mul(minReturn[_integration]).div(10 ** sellTokenDec); + require(minOut > 0, "Must have some price floor"); uniswap.swapExactTokensForTokens( sellAmount, - 0, + minOut, uniswapPath, address(this), block.timestamp.add(1800) ); - uint256 bAssetBal = IERC20(bAsset).balanceOf(address(this)); // 3.3. Trade on Curve - IERC20(bAsset).safeApprove(address(curve), 0); - IERC20(bAsset).safeApprove(address(curve), bAssetBal); - uint256 purchased = curve.exchange_underlying(liquidation.curvePosition, 0, bAssetBal, 0); + uint256 purchased = _sellOnCrv(bAsset, liquidation.curvePosition); // 4.0. Send to SavingsManager address savings = _savingsManager(); @@ -256,4 +276,15 @@ contract Liquidator is emit Liquidated(sellToken, mUSD, purchased, bAsset); } + + function _sellOnCrv(address _bAsset, int128 _curvePosition) internal returns (uint256 purchased) { + uint256 bAssetBal = IERC20(_bAsset).balanceOf(address(this)); + + IERC20(_bAsset).safeApprove(address(curve), 0); + IERC20(_bAsset).safeApprove(address(curve), bAssetBal); + uint256 bAssetDec = IBasicToken(_bAsset).decimals(); + // e.g. 100e6 * 95e16 / 1e6 = 100e18 + uint256 minOutCrv = bAssetBal.mul(95e16).div(10 ** bAssetDec); + purchased = curve.exchange_underlying(_curvePosition, 0, bAssetBal, minOutCrv); + } } \ No newline at end of file diff --git a/contracts/z_mocks/shared/MockCurveMetaPool.sol b/contracts/z_mocks/shared/MockCurveMetaPool.sol index b04699ac..710546f1 100644 --- a/contracts/z_mocks/shared/MockCurveMetaPool.sol +++ b/contracts/z_mocks/shared/MockCurveMetaPool.sol @@ -11,6 +11,9 @@ contract MockCurveMetaPool is ICurveMetaPool { address[] public coins; address mUSD; + // number of out per in (scaled) + uint256 ratio = 98e16; + constructor(address[] memory _coins, address _mUSD) public { require(_coins[0] == _mUSD, "Coin 0 must be mUSD"); @@ -18,15 +21,20 @@ contract MockCurveMetaPool is ICurveMetaPool { mUSD = _mUSD; } + function setRatio(uint256 _newRatio) external { + ratio = _newRatio; + } + // takes dx i from sender, returns j - function exchange_underlying(int128 i, int128 j, uint256 dx, uint256 /*min_dy*/) + function exchange_underlying(int128 i, int128 j, uint256 dx, uint256 min_dy) external returns (uint256) { require(j == 0, "Output must be mUSD"); address in_tok = coins[uint256(i)]; uint256 decimals = IBasicToken(in_tok).decimals(); - uint256 out_amt = dx * (10 ** (18 - decimals)); + uint256 out_amt = dx * (10 ** (18 - decimals)) * ratio / 1e18; + require(out_amt >= min_dy, "CRV: Output amount not enough"); IERC20(in_tok).transferFrom(msg.sender, address(this), dx); IERC20(mUSD).transfer(msg.sender, out_amt); return out_amt; diff --git a/contracts/z_mocks/shared/MockTrigger.sol b/contracts/z_mocks/shared/MockTrigger.sol new file mode 100644 index 00000000..055689e9 --- /dev/null +++ b/contracts/z_mocks/shared/MockTrigger.sol @@ -0,0 +1,11 @@ +pragma solidity 0.5.16; + +import { ILiquidator } from "../../masset/liquidator/ILiquidator.sol"; + + +contract MockTrigger { + + function trigger(ILiquidator _liq, address _integration) external { + _liq.triggerLiquidation(_integration); + } +} \ No newline at end of file diff --git a/contracts/z_mocks/shared/MockUniswap.sol b/contracts/z_mocks/shared/MockUniswap.sol index 2d13302c..bf404c7f 100644 --- a/contracts/z_mocks/shared/MockUniswap.sol +++ b/contracts/z_mocks/shared/MockUniswap.sol @@ -10,11 +10,17 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; // out token has 18 decimals contract MockUniswap is IUniswapV2Router02 { + // how many tokens to give out for 1 in + uint256 ratio = 106; + + function setRatio(uint256 _outRatio) external { + ratio = _outRatio; + } // takes input from sender, produces output function swapExactTokensForTokens( uint amountIn, - uint /*amountOutMin*/, + uint amountOutMin, address[] calldata path, address /*to*/, uint /*deadline*/ @@ -28,7 +34,9 @@ contract MockUniswap is IUniswapV2Router02 { amounts[0] = amountIn; IERC20(path[0]).transferFrom(msg.sender, address(this), amountIn); - uint256 output = amountIn * 106; + uint256 output = amountIn * ratio; + require(output >= amountOutMin, "UNI: Output amount not enough"); + amounts[len-1] = output; IERC20(path[len-1]).transfer(msg.sender, output); } @@ -41,7 +49,7 @@ contract MockUniswap is IUniswapV2Router02 { view returns (uint[] memory amounts) { - uint256 amountIn = amountOut / 106; + uint256 amountIn = amountOut / ratio; uint256 len = path.length; amounts = new uint[](len); amounts[0] = amountIn; diff --git a/test/masset/liquidator/TestLiquidatorContract.spec.ts b/test/masset/liquidator/TestLiquidatorContract.spec.ts index 080cf028..615d1160 100644 --- a/test/masset/liquidator/TestLiquidatorContract.spec.ts +++ b/test/masset/liquidator/TestLiquidatorContract.spec.ts @@ -8,6 +8,7 @@ import * as t from "types/generated"; import shouldBehaveLikeModule from "../../shared/behaviours/Module.behaviour"; +const MockTrigger = artifacts.require("MockTrigger"); const Liquidator = artifacts.require("Liquidator"); const MockCompoundIntegration = artifacts.require("MockCompoundIntegration1"); const SavingsManager = artifacts.require("SavingsManager"); @@ -40,6 +41,7 @@ contract("Liquidator", async (accounts) => { uniswapPath?: string[]; lastTriggered: BN; sellTranche: BN; + minReturn: BN; } interface Balance { @@ -124,12 +126,14 @@ contract("Liquidator", async (accounts) => { const getLiquidation = async (addr: string): Promise => { const liquidation = await liquidator.liquidations(addr); + const minReturn = await liquidator.minReturn(addr); return { sellToken: liquidation[0], bAsset: liquidation[1], curvePosition: liquidation[2], lastTriggered: liquidation[3], sellTranche: liquidation[4], + minReturn, }; }; const snapshotData = async (): Promise => { @@ -157,6 +161,7 @@ contract("Liquidator", async (accounts) => { 1, [compToken.address, ZERO_ADDRESS, bAsset.address], simpleToExactAmount(1000, 18), + simpleToExactAmount(70, 18), { from: sa.governor }, ); const liquidation = await getLiquidation(compIntegration.address); @@ -165,6 +170,7 @@ contract("Liquidator", async (accounts) => { expect(liquidation.curvePosition).bignumber.eq(new BN(1)); expect(liquidation.lastTriggered).bignumber.eq(new BN(0)); expect(liquidation.sellTranche).bignumber.eq(simpleToExactAmount(1000, 18)); + expect(liquidation.minReturn).bignumber.eq(simpleToExactAmount(70, 18)); }); }); describe("triggering a liquidation", () => { @@ -211,6 +217,7 @@ contract("Liquidator", async (accounts) => { 1, [compToken.address, ZERO_ADDRESS, bAsset.address], simpleToExactAmount(1, 18), + simpleToExactAmount(70, 18), { from: sa.governor }, ), "Invalid inputs", @@ -225,6 +232,7 @@ contract("Liquidator", async (accounts) => { 1, [compToken.address, ZERO_ADDRESS, bAsset2.address], simpleToExactAmount(1, 18), + simpleToExactAmount(70, 18), { from: sa.governor }, ), "Invalid uniswap path", @@ -238,6 +246,7 @@ contract("Liquidator", async (accounts) => { 1, [compToken.address, ZERO_ADDRESS], simpleToExactAmount(1, 18), + simpleToExactAmount(70, 18), { from: sa.governor }, ), "Invalid uniswap path", @@ -251,6 +260,7 @@ contract("Liquidator", async (accounts) => { 1, [compToken.address, ZERO_ADDRESS, bAsset.address], simpleToExactAmount(1000, 18), + simpleToExactAmount(70, 18), { from: sa.governor }, ); const liquidation = await getLiquidation(compIntegration.address); @@ -259,6 +269,7 @@ contract("Liquidator", async (accounts) => { expect(liquidation.curvePosition).bignumber.eq(new BN(1)); expect(liquidation.lastTriggered).bignumber.eq(new BN(0)); expect(liquidation.sellTranche).bignumber.eq(simpleToExactAmount(1000, 18)); + expect(liquidation.minReturn).bignumber.eq(simpleToExactAmount(70, 18)); await expectRevert( liquidator.createLiquidation( compIntegration.address, @@ -267,6 +278,7 @@ contract("Liquidator", async (accounts) => { 1, [compToken.address, ZERO_ADDRESS, bAsset.address], simpleToExactAmount(1000, 18), + simpleToExactAmount(70, 18), { from: sa.governor }, ), "Liquidation exists for this bAsset", @@ -283,6 +295,7 @@ contract("Liquidator", async (accounts) => { 1, [compToken.address, ZERO_ADDRESS, bAsset.address], simpleToExactAmount(1000, 18), + simpleToExactAmount(70, 18), { from: sa.governor }, ); }); @@ -295,6 +308,7 @@ contract("Liquidator", async (accounts) => { 1, [], simpleToExactAmount(1, 18), + simpleToExactAmount(70, 18), { from: sa.governor, }, @@ -310,6 +324,7 @@ contract("Liquidator", async (accounts) => { 1, [], simpleToExactAmount(1, 18), + simpleToExactAmount(70, 18), { from: sa.governor, }, @@ -325,6 +340,7 @@ contract("Liquidator", async (accounts) => { 1, [bAsset2.address], simpleToExactAmount(1, 18), + simpleToExactAmount(70, 18), { from: sa.governor, }, @@ -340,6 +356,7 @@ contract("Liquidator", async (accounts) => { 2, [compToken.address, ZERO_ADDRESS, bAsset2.address], simpleToExactAmount(123, 18), + simpleToExactAmount(70, 18), { from: sa.governor }, ); expectEvent(tx.receipt, "LiquidationModified", { @@ -385,10 +402,18 @@ contract("Liquidator", async (accounts) => { 1, [compToken.address, ZERO_ADDRESS, bAsset.address], simpleToExactAmount(1000, 18), + simpleToExactAmount(70, 18), { from: sa.governor }, ); await compIntegration.approveRewardToken({ from: sa.governor }); }); + it("should fail if called via contract", async () => { + const mock = await MockTrigger.new(); + await expectRevert( + mock.trigger(liquidator.address, compIntegration.address), + "Must be EOA", + ); + }); it("should fail if liquidation does not exist", async () => { await expectRevert( liquidator.triggerLiquidation(sa.dummy2), @@ -402,6 +427,25 @@ contract("Liquidator", async (accounts) => { "Must wait for interval", ); }); + it("should fail if Uniswap price is below the floor", async () => { + await uniswap.setRatio(69); + await expectRevert( + liquidator.triggerLiquidation(compIntegration.address), + "UNI: Output amount not enough", + ); + await uniswap.setRatio(71); + await liquidator.triggerLiquidation(compIntegration.address); + }); + + it("should fail if Curve price is below the floor", async () => { + await curve.setRatio(simpleToExactAmount(9, 17)); + await expectRevert( + liquidator.triggerLiquidation(compIntegration.address), + "CRV: Output amount not enough", + ); + await curve.setRatio(simpleToExactAmount(96, 16)); + await liquidator.triggerLiquidation(compIntegration.address); + }); it("should sell everything if the liquidator has less balance than tranche size", async () => { const s0 = await snapshotData(); await liquidator.updateBasset( @@ -410,6 +454,7 @@ contract("Liquidator", async (accounts) => { 1, [compToken.address, ZERO_ADDRESS, bAsset.address], simpleToExactAmount(1, 30), + simpleToExactAmount(70, 18), { from: sa.governor }, ); // set tranche size to 1e30 @@ -434,6 +479,7 @@ contract("Liquidator", async (accounts) => { 1, [compToken.address, ZERO_ADDRESS, bAsset.address], new BN(0), + simpleToExactAmount(70, 18), { from: sa.governor }, ); await expectRevert(