diff --git a/implementation/contracts/deposit/DepositLiquidation.sol b/implementation/contracts/deposit/DepositLiquidation.sol index 9506510da..141debd69 100644 --- a/implementation/contracts/deposit/DepositLiquidation.sol +++ b/implementation/contracts/deposit/DepositLiquidation.sol @@ -9,6 +9,8 @@ import {TBTCConstants} from "./TBTCConstants.sol"; import {IBondedECDSAKeep} from "../external/IBondedECDSAKeep.sol"; import {OutsourceDepositLogging} from "./OutsourceDepositLogging.sol"; import {TBTCToken} from "../system/TBTCToken.sol"; +import {IUniswapExchange} from "../external/IUniswapExchange.sol"; +import {ITBTCSystem} from "../interfaces/ITBTCSystem.sol"; library DepositLiquidation { @@ -20,13 +22,6 @@ library DepositLiquidation { using DepositStates for DepositUtils.Deposit; using OutsourceDepositLogging for DepositUtils.Deposit; - /// @notice Tries to liquidate the position on-chain using the signer bond - /// @dev Calls out to other contracts, watch for re-entrance - /// @return True if Liquidated, False otherwise - function attemptToLiquidateOnchain() public pure returns (bool) { - return false; - } - /// @notice Notifies the keep contract of fraud /// @dev Calls out to the keep contract. this could get expensive if preimage is large /// @param _d deposit storage pointer @@ -92,7 +87,7 @@ library DepositLiquidation { return; } - bool _liquidated = attemptToLiquidateOnchain(); + bool _liquidated = attemptToLiquidateOnchain(_d); if (_liquidated) { _d.distributeBeneficiaryReward(); @@ -115,7 +110,7 @@ library DepositLiquidation { _d.redemptionTeardown(); _d.seizeSignerBonds(); - bool _liquidated = attemptToLiquidateOnchain(); + bool _liquidated = attemptToLiquidateOnchain(_d); if (_liquidated) { _d.distributeBeneficiaryReward(); @@ -329,4 +324,33 @@ library DepositLiquidation { _d.logCourtesyCalled(); _d.courtesyCallInitiated = block.timestamp; } + + /// @notice Tries to liquidate the position on-chain using the signer bond + /// @dev Calls out to other contracts, watch for re-entrance + /// @return True if Liquidated, False otherwise + // TODO(liamz): check for re-entry + function attemptToLiquidateOnchain( + DepositUtils.Deposit storage _d + ) internal returns (bool) { + // Return early if there is no Uniswap TBTC Exchange. + IUniswapExchange exchange = IUniswapExchange(ITBTCSystem(_d.TBTCSystem).getTBTCUniswapExchange()); + if(address(exchange) == address(0x0)) { + return false; + } + + // Only liquidate if we can buy up enough TBTC to burn, + // otherwise go 100% for the falling-price auction + uint tbtcAmount = _d.liquidationTBTCAmount(); + uint ethAmount = exchange.getEthToTokenOutputPrice(tbtcAmount); + + if(address(this).balance < ethAmount) { + return false; + } + + // Leverage uniswap’s frontrunning mitigation functionality. + uint deadline = block.timestamp; + exchange.ethToTokenSwapOutput.value(ethAmount)(tbtcAmount, deadline); + + return true; + } } diff --git a/implementation/contracts/deposit/DepositUtils.sol b/implementation/contracts/deposit/DepositUtils.sol index e3a4c2eee..b6b963e6c 100644 --- a/implementation/contracts/deposit/DepositUtils.sol +++ b/implementation/contracts/deposit/DepositUtils.sol @@ -241,6 +241,12 @@ library DepositUtils { } } + /// @notice Determines the threshold amount of TBTC necessary to liquidate + /// @return The amount of TBTC to buy during liquidation + function liquidationTBTCAmount(Deposit storage _d) public view returns (uint256) { + return TBTCConstants.getLotSize().add(beneficiaryReward()); + } + /// @notice Determines the amount of TBTC accepted in the auction /// @dev If requesterAddress is non-0, that means we came from redemption, and no auction should happen /// @return The amount of TBTC that must be paid at auction for the signer's bond diff --git a/implementation/contracts/interfaces/ITBTCSystem.sol b/implementation/contracts/interfaces/ITBTCSystem.sol index e3ed1fbf8..8319555e7 100644 --- a/implementation/contracts/interfaces/ITBTCSystem.sol +++ b/implementation/contracts/interfaces/ITBTCSystem.sol @@ -15,4 +15,5 @@ interface ITBTCSystem { function fetchRelayCurrentDifficulty() external view returns (uint256); function fetchRelayPreviousDifficulty() external view returns (uint256); + function getTBTCUniswapExchange() external view returns (address); } diff --git a/implementation/test/DepositLiquidationTest.js b/implementation/test/DepositLiquidationTest.js index 0510400f4..6aac97afc 100644 --- a/implementation/test/DepositLiquidationTest.js +++ b/implementation/test/DepositLiquidationTest.js @@ -1,4 +1,9 @@ import expectThrow from './helpers/expectThrow' +import { + createSnapshot, + restoreSnapshot, +} from './helpers/snapshot' +import { AssertBalance } from './helpers/assertBalance' const BytesLib = artifacts.require('BytesLib') const BTCUtils = artifacts.require('BTCUtils') @@ -13,6 +18,7 @@ const DepositRedemption = artifacts.require('DepositRedemption') const DepositLiquidation = artifacts.require('DepositLiquidation') const ECDSAKeepStub = artifacts.require('ECDSAKeepStub') +const KeepRegistryStub = artifacts.require('KeepRegistryStub') const TestToken = artifacts.require('TestToken') const TBTCSystemStub = artifacts.require('TBTCSystemStub') @@ -20,6 +26,8 @@ const TestTBTCConstants = artifacts.require('TestTBTCConstants') const TestDeposit = artifacts.require('TestDeposit') const TestDepositUtils = artifacts.require('TestDepositUtils') +const UniswapExchangeStub = artifacts.require('UniswapExchangeStub') + const BN = require('bn.js') const utils = require('./utils') const chai = require('chai') @@ -58,6 +66,15 @@ contract('DepositLiquidation', (accounts) => { let beneficiary let tbtcToken let tbtcSystemStub + let uniswapExchange + + before(async () => { + await createSnapshot() + }) + + after(async () => { + await restoreSnapshot() + }) before(async () => { beneficiary = accounts[4] @@ -73,11 +90,24 @@ contract('DepositLiquidation', (accounts) => { testInstance.setExteriorAddresses(tbtcSystemStub.address, tbtcToken.address) tbtcSystemStub.forceMint(beneficiary, web3.utils.toBN(deployed.TestDeposit.address)) + + + const keepRegistry = await KeepRegistryStub.new() + uniswapExchange = await UniswapExchangeStub.new(tbtcToken.address) + await tbtcSystemStub.initialize( + keepRegistry.address, + uniswapExchange.address + ) }) beforeEach(async () => { await testInstance.reset() await testInstance.setKeepAddress(deployed.ECDSAKeepStub.address) + await createSnapshot() + }) + + afterEach(async () => { + await restoreSnapshot() }) describe('purchaseSignerBondsAtAuction', async () => { @@ -149,7 +179,6 @@ contract('DepositLiquidation', (accounts) => { const finalTokenBalance = await tbtcToken.balanceOf(beneficiary) const tokenCheck = new BN(initialTokenBalance).add(new BN(beneficiaryReward)) - expect(finalTokenBalance, 'tokens not returned to beneficiary correctly').to.eq.BN(tokenCheck) }) @@ -489,4 +518,62 @@ contract('DepositLiquidation', (accounts) => { ) }) }) + + describe('#attemptToLiquidateOnchain', async () => { + let assertBalance + let deposit + + beforeEach(async () => { + deposit = testInstance + + /* eslint-disable no-multi-spaces */ + const ethSupply = web3.utils.toWei('0.2', 'ether') // 0.2 ETH + const tbtcSupply = new BN('1000000000') // 10 TBTC + /* eslint-enable */ + await uniswapExchange.addLiquidity( + ethSupply, tbtcSupply, '0', + { from: accounts[0], value: ethSupply } + ) + + // Helpers + assertBalance = new AssertBalance(tbtcToken) + }) + + it('returns false if address(exchange) = 0x0', async () => { + await tbtcSystemStub.reinitialize('0x0000000000000000000000000000000000000000') + + const retval = await deposit.attemptToLiquidateOnchain.call() + expect(retval).to.be.false + }) + + it('liquidates using Uniswap successfully', async () => { + const minTbtcAmount = '100100000' + const expectedPrice = new BN('100000000') + + await assertBalance.eth(deposit.address, '0') + await assertBalance.tbtc(deposit.address, '0') + await deposit.send(expectedPrice, { from: accounts[0] }) + await assertBalance.eth(deposit.address, expectedPrice.toString()) + + const retval = await deposit.attemptToLiquidateOnchain.call() + expect(retval).to.be.true + await deposit.attemptToLiquidateOnchain() + + await assertBalance.tbtc(deposit.address, minTbtcAmount) + await assertBalance.eth(deposit.address, '0') + }) + + it('returns false if cannot buy up enough tBTC', async () => { + const expectedPrice = new BN('100000000') + const depositEthFunding = expectedPrice.sub(new BN(100)) + + await assertBalance.eth(deposit.address, '0') + await assertBalance.tbtc(deposit.address, '0') + await deposit.send(depositEthFunding, { from: accounts[0] }) + await assertBalance.eth(deposit.address, depositEthFunding.toString()) + + const retval = await deposit.attemptToLiquidateOnchain.call() + expect(retval).to.be.false + }) + }) }) diff --git a/implementation/test/contracts/deposit/TBTCSystemStub.sol b/implementation/test/contracts/deposit/TBTCSystemStub.sol index 3d703a7fd..4618c080e 100644 --- a/implementation/test/contracts/deposit/TBTCSystemStub.sol +++ b/implementation/test/contracts/deposit/TBTCSystemStub.sol @@ -13,6 +13,12 @@ contract TBTCSystemStub is TBTCSystem { // solium-disable-previous-line no-empty-blocks } + // Bypasses the ACL on TBTCSystem.initialize + // and allows repeat initialization. + function reinitialize(address _tbtcUniswapExchange) external { + tbtcUniswapExchange = _tbtcUniswapExchange; + } + function setOraclePrice(uint256 _oraclePrice) external { oraclePrice = _oraclePrice; } diff --git a/implementation/test/contracts/deposit/TestDeposit.sol b/implementation/test/contracts/deposit/TestDeposit.sol index 37d116715..e85c8e644 100644 --- a/implementation/test/contracts/deposit/TestDeposit.sol +++ b/implementation/test/contracts/deposit/TestDeposit.sol @@ -143,4 +143,8 @@ contract TestDeposit is Deposit { function pushFundsToKeepGroup(uint256 _ethValue) public returns (bool) { return self.pushFundsToKeepGroup(_ethValue); } + + function attemptToLiquidateOnchain() public returns (bool) { + return self.attemptToLiquidateOnchain(); + } } diff --git a/implementation/test/contracts/uniswap/UniswapExchangeStub.sol b/implementation/test/contracts/uniswap/UniswapExchangeStub.sol new file mode 100644 index 000000000..69943c930 --- /dev/null +++ b/implementation/test/contracts/uniswap/UniswapExchangeStub.sol @@ -0,0 +1,50 @@ +pragma solidity ^0.5.10; + +import {TestToken} from '../deposit/TestToken.sol'; +import {IUniswapExchange} from '../../../contracts/external/IUniswapExchange.sol'; + +contract UniswapExchangeStub is IUniswapExchange { + TestToken tbtc; + + // The below returns an absurdly large price for tBTC + // such that attemptToLiquidateOnchain will return early, from not being funded enough + uint256 ethPrice = 10**8; + + constructor(address _tbtc) public { + tbtc = TestToken(_tbtc); + } + + function setEthPrice(uint256 _ethPrice) public { + ethPrice = _ethPrice; + } + + function addLiquidity(uint256 min_liquidity, uint256 max_tokens, uint256 deadline) + external payable + returns (uint256) + { + require(msg.value > 0, "ETH missing from addLiquidity"); + tbtc.forceMint(address(this), max_tokens); + // Stub doesn't implement the internal Uniswap token (UNI), + // so return 0 here for total minted UNI. + return 0; + } + + function getEthToTokenOutputPrice(uint256 tokens_sold) + external view + returns (uint256) + { + tokens_sold; + return ethPrice; + } + + function ethToTokenSwapOutput(uint256 tokens_bought, uint256 deadline) + external payable + returns (uint256 eth_sold) + { + deadline; + require(msg.value == ethPrice, "incorrect eth sent"); + require(tbtc.balanceOf(address(this)) >= tokens_bought, "not enough TBTC liquidity mocked"); + tbtc.transfer(msg.sender, tokens_bought); + return msg.value; + } +} \ No newline at end of file diff --git a/implementation/test/helpers/assertBalance.js b/implementation/test/helpers/assertBalance.js new file mode 100644 index 000000000..735862b88 --- /dev/null +++ b/implementation/test/helpers/assertBalance.js @@ -0,0 +1,21 @@ +const BN = require('bn.js') +const chai = require('chai') +const expect = chai.expect +const bnChai = require('bn-chai') +chai.use(bnChai(BN)) + +export class AssertBalance { + constructor(tbtc) { + this.tbtcInstance = tbtc + } + + async tbtc(account, amount) { + const balance = await this.tbtcInstance.balanceOf(account) + expect(balance).to.eq.BN(amount) + } + + async eth(account, amount) { + const balance = await web3.eth.getBalance(account) + expect(balance).to.equal(amount) + } +} diff --git a/implementation/test/helpers/snapshot.js b/implementation/test/helpers/snapshot.js new file mode 100644 index 000000000..78d3e6311 --- /dev/null +++ b/implementation/test/helpers/snapshot.js @@ -0,0 +1,78 @@ +/** + * Snapshots are a feature of some EVM implementations ([1]) for improved dev UX. + * They allow us to snapshot the entire state of the chain, and restore it at a + * later point. + * + * We can use snapshots to create state for an `it` test, perform some transactions, + * and then revert that state after. However, **Ganache snapshots can only be restored ONCE.** + * + * What this means in practice, is that snapshots must be created before EVERY test in + * a `beforeEach` block, and thereafter reverted in `afterEach`. + * + * Example usage: + * ```js + * before(async () => { + * await createSnapshot() + * }) + * + * after(async () => { + * await restoreSnapshot() + * }) + * + * beforeEach(async () => { + * await createSnapshot() + * }) + * + * afterEach(async () => { + * await restoreSnapshot() + * }) + * + * it('#something', async () => { ... }) + * ``` + * + * This pattern is adopted from the 0x codebase - see their BlockchainLifecycle [2], which + * implements it for Ganache and Geth. And in their tests [3], running it in both before + * and beforeEach blocks. + * + * [1]: https://github.com/trufflesuite/ganache-core/blob/master/README.md#custom-methods + * [2]: https://sourcegraph.com/github.com/0xProject/0x-monorepo@ec92cea5982375fa2fa7ba8445b5e8af589b75bd/-/blob/packages/dev-utils/src/blockchain_lifecycle.ts#L21 + * [3]: https://sourcegraph.com/github.com/0xProject/0x-monorepo@ec92cea/-/blob/contracts/asset-proxy/test/authorizable.ts#L27 + */ + + +const snapshotIdsStack = [] + +export async function createSnapshot() { + const snapshotId = await new Promise((res, rej) => { + web3.currentProvider.send({ + jsonrpc: '2.0', + method: 'evm_snapshot', + id: 12345, + }, function(err, result) { + if (err) rej(err) + else res(result.result) + }) + }) + + snapshotIdsStack.push(snapshotId) +} + +export async function restoreSnapshot() { + const snapshotId = snapshotIdsStack.pop() + + try { + await new Promise((res, rej) => { + web3.currentProvider.send({ + jsonrpc: '2.0', + method: 'evm_revert', + id: 12345, + params: [snapshotId], + }, function(err, result) { + if (err) rej(err) + else res(result.result) + }) + }) + } catch (ex) { + throw new Error(`Snapshot with id #${snapshotId} failed to revert`) + } +} diff --git a/implementation/test/integration/UniswapTest.js b/implementation/test/integration/UniswapTest.js index ed18591db..e80b9eb20 100644 --- a/implementation/test/integration/UniswapTest.js +++ b/implementation/test/integration/UniswapTest.js @@ -4,11 +4,38 @@ const IUniswapFactory = artifacts.require('IUniswapFactory') const IUniswapExchange = artifacts.require('IUniswapExchange') const TBTCSystem = artifacts.require('TBTCSystem') -import { UniswapFactoryAddress } from '../../migrations/externals' +const OutsourceDepositLogging = artifacts.require('OutsourceDepositLogging') +const DepositStates = artifacts.require('DepositStates') +const DepositUtils = artifacts.require('DepositUtils') +const DepositFunding = artifacts.require('DepositFunding') +const DepositRedemption = artifacts.require('DepositRedemption') +const DepositLiquidation = artifacts.require('DepositLiquidation') + +const ECDSAKeepStub = artifacts.require('ECDSAKeepStub') +const TBTCSystemStub = artifacts.require('TBTCSystemStub') + +const TestTBTCConstants = artifacts.require('TestTBTCConstants') +const TestDeposit = artifacts.require('TestDeposit') +const TestDepositUtils = artifacts.require('TestDepositUtils') + +const TEST_DEPOSIT_DEPLOY = [ + { name: 'TBTCConstants', contract: TestTBTCConstants }, // note the name + { name: 'OutsourceDepositLogging', contract: OutsourceDepositLogging }, + { name: 'DepositStates', contract: DepositStates }, + { name: 'DepositUtils', contract: DepositUtils }, + { name: 'DepositFunding', contract: DepositFunding }, + { name: 'DepositRedemption', contract: DepositRedemption }, + { name: 'DepositLiquidation', contract: DepositLiquidation }, + { name: 'TestDeposit', contract: TestDeposit }, + { name: 'TestDepositUtils', contract: TestDepositUtils }, + { name: 'ECDSAKeepStub', contract: ECDSAKeepStub }] -import { UniswapHelpers } from './helpers/uniswap' -import { integration } from './helpers/integration' +import { UniswapFactoryAddress } from '../../migrations/externals' import expectThrow from '../helpers/expectThrow' +import utils from '../utils' +import { integration } from './helpers/integration' +import { UniswapHelpers } from './helpers/uniswap' +import { AssertBalance } from '../helpers/assertBalance' const BN = require('bn.js') const chai = require('chai') @@ -141,4 +168,83 @@ integration('Uniswap', (accounts) => { expect(balance).to.eq.BN(TBTC_BUY_AMOUNT) }) }) + + describe('DepositLiquidation', async () => { + let deployed + let tbtcSystem + + before(async () => { + deployed = await utils.deploySystem(TEST_DEPOSIT_DEPLOY) + }) + + describe('#attemptToLiquidateOnchain', async () => { + let assertBalance + let deposit + let uniswapExchange + let tbtcToken + + beforeEach(async () => { + deposit = deployed.TestDeposit + tbtcSystem = await TBTCSystemStub.new(utils.address0) + tbtcToken = await TestToken.new(tbtcSystem.address) + + // create a uniswap exchange for our TestToken + const uniswapFactory = await IUniswapFactory.at(UniswapFactoryAddress) + await uniswapFactory.createExchange(tbtcToken.address) + const uniswapExchangeAddress = await uniswapFactory.getExchange(tbtcToken.address) + uniswapExchange = await IUniswapExchange.at(uniswapExchangeAddress) + + await tbtcSystem.reinitialize(uniswapExchangeAddress) + + deposit.setExteriorAddresses(tbtcSystem.address, tbtcToken.address) + tbtcSystem.forceMint(accounts[0], web3.utils.toBN(deposit.address)) + + // Helpers + assertBalance = new AssertBalance(tbtcToken) + }) + + it('liquidates using Uniswap successfully', async () => { + const ethAmount = web3.utils.toWei('0.2', 'ether') + const tbtcAmount = '100000000' + await UniswapHelpers.addLiquidity(accounts[0], uniswapExchange, tbtcToken, ethAmount, tbtcAmount) + + const minTbtcAmount = '100100000' + // hard-coded from previous run + const expectedPrice = new BN('223138580092984811') + + await assertBalance.eth(deposit.address, '0') + await assertBalance.tbtc(deposit.address, '0') + await deposit.send(expectedPrice, { from: accounts[0] }) + await assertBalance.eth(deposit.address, expectedPrice.toString()) + + const price = await uniswapExchange.getEthToTokenOutputPrice.call(minTbtcAmount) + expect(price).to.eq.BN(expectedPrice) + + const retval = await deposit.attemptToLiquidateOnchain.call() + expect(retval).to.be.true + await deposit.attemptToLiquidateOnchain() + + await assertBalance.tbtc(deposit.address, minTbtcAmount) + await assertBalance.eth(deposit.address, '0') + }) + + it('returns false if cannot buy up enough TBTC', async () => { + const ethAmount = web3.utils.toWei('0.2', 'ether') + const tbtcAmount = '100000000' + await UniswapHelpers.addLiquidity(accounts[0], uniswapExchange, tbtcToken, ethAmount, tbtcAmount) + + // hard-coded from previous run + const expectedPrice = new BN('223138580092984811') + const depositEthFunding = expectedPrice.sub(new BN(100)) + + await assertBalance.eth(deposit.address, '0') + await assertBalance.tbtc(deposit.address, '0') + await deposit.send(depositEthFunding, { from: accounts[0] }) + await assertBalance.eth(deposit.address, depositEthFunding.toString()) + + const retval = await deposit.attemptToLiquidateOnchain.call() + expect(retval).to.be.false + }) + }) + }) }) diff --git a/implementation/test/integration/helpers/uniswap.js b/implementation/test/integration/helpers/uniswap.js index d26829698..dcdab87db 100644 --- a/implementation/test/integration/helpers/uniswap.js +++ b/implementation/test/integration/helpers/uniswap.js @@ -1,3 +1,6 @@ + +import BN from 'bn.js' + export class UniswapHelpers { static async getDeadline(web3) { const block = await web3.eth.getBlock('latest') @@ -5,4 +8,33 @@ export class UniswapHelpers { const deadline = block.timestamp + DEADLINE_FROM_NOW return deadline } + + /* + * Add TBTC and ETH liquidity to the Uniswap exchange. + */ + static async addLiquidity(account, uniswapExchange, tbtcToken, ethAmount, tbtcAmount) { + // Mint tBTC, mock liquidity + // supply the equivalent of 10 actors posting liquidity + const supplyFactor = new BN(10) + + const tbtcSupply = new BN(tbtcAmount).mul(supplyFactor) + await tbtcToken.forceMint(account, tbtcSupply, { from: account }) + await tbtcToken.approve(uniswapExchange.address, tbtcSupply, { from: account }) + + // Uniswap requires a minimum of 1000000000 wei for the initial addLiquidity call. + // See https://github.com/Uniswap/contracts-vyper/blob/c10c08d81d6114f694baa8bd32f555a40f6264da/contracts/uniswap_exchange.vy#L65 + // for more context on this undocumented constant. + const UNISWAP_MINIMUM_INITIAL_LIQUIDITY_WEI = new BN('1000000000') + const ethSupply = new BN(ethAmount).add(UNISWAP_MINIMUM_INITIAL_LIQUIDITY_WEI).mul(supplyFactor) + + const deadline = await this.getDeadline(web3) + /* eslint-disable no-multi-spaces */ + await uniswapExchange.addLiquidity( + '0', // min_liquidity + tbtcSupply, // max_tokens + deadline, // deadline + { from: account, value: ethSupply } + ) + /* eslint-enable no-multi-spaces */ + } }