diff --git a/packages/disputer/index.js b/packages/disputer/index.js index 91d16c8a7b..2dbd01bb2f 100755 --- a/packages/disputer/index.js +++ b/packages/disputer/index.js @@ -8,6 +8,7 @@ const { SUPPORTED_CONTRACT_VERSIONS } = require("@uma/common"); // JS libs const { Disputer } = require("./src/disputer"); +const { ProxyTransactionWrapper } = require("./src/proxyTransactionWrapper"); const { multicallAddressMap, FinancialContractClient, @@ -18,10 +19,11 @@ const { waitForLogger, createReferencePriceFeedForFinancialContract, setAllowance, + DSProxyManager, } = require("@uma/financial-templates-lib"); // Truffle contracts. -const { getAbi, findContractVersion } = require("@uma/core"); +const { getAbi, findContractVersion, getAddress } = require("@uma/core"); const { getWeb3, PublicNetworks } = require("@uma/common"); /** @@ -45,6 +47,7 @@ async function run({ priceFeedConfig, disputerConfig, disputerOverridePrice, + proxyTransactionWrapperConfig, }) { try { const getTime = () => Math.round(new Date().getTime() / 1000); @@ -145,9 +148,36 @@ async function run({ const gasEstimator = new GasEstimator(logger); await gasEstimator.update(); + let dsProxyManager; + if (proxyTransactionWrapperConfig?.useDsProxyToDispute) { + dsProxyManager = new DSProxyManager({ + logger, + web3, + gasEstimator, + account: accounts[0], + dsProxyFactoryAddress: + proxyTransactionWrapperConfig?.dsProxyFactoryAddress || getAddress("DSProxyFactory", networkId), + dsProxyFactoryAbi: getAbi("DSProxyFactory"), + dsProxyAbi: getAbi("DSProxy"), + }); + + // Load in an existing DSProxy for the account EOA if one already exists or create a new one for the user. + await dsProxyManager.initializeDSProxy(); + } + + const proxyTransactionWrapper = new ProxyTransactionWrapper({ + web3, + financialContract, + gasEstimator, + account: accounts[0], + dsProxyManager, + proxyTransactionWrapperConfig, + }); + const disputer = new Disputer({ logger, financialContractClient, + proxyTransactionWrapper, gasEstimator, priceFeed, account: accounts[0], @@ -260,6 +290,16 @@ async function Poll(callback) { // If there is a DISPUTER_OVERRIDE_PRICE environment variable then the disputer will disregard the price from the // price feed and preform disputes at this override price. Use with caution as wrong input could cause invalid disputes. disputerOverridePrice: process.env.DISPUTER_OVERRIDE_PRICE, + // If there is a dsproxy config, the bot can be configured to send transactions via a smart contract wallet (DSProxy). + // This enables the bot to preform swap, dispute, enabling a single reserve currency. + // Note that the DSProxy will be deployed on the first run of the bot. Subsequent runs will re-use the proxy. example: + // { "useDsProxyToLiquidate": "true", If enabled, the bot will send liquidations via a DSProxy. + // "dsProxyFactoryAddress": "0x123..." -> Will default to an UMA deployed version if non provided. + // "disputerReserveCurrencyAddress": "0x123..." -> currency DSProxy will trade from when liquidating. + // "uniswapRouterAddress": "0x123..." -> uniswap router address to enable reserve trading. Defaults to mainnet router. + // "maxReserverTokenSpent": "10000000000"} -> max amount to spend in reserve currency. scaled by reserve currency + // decimals. defaults to MAX_UINT (no limit). + proxyTransactionWrapperConfig: process.env.DSPROXY_CONFIG ? JSON.parse(process.env.DSPROXY_CONFIG) : {}, }; await run({ logger: Logger, web3: getWeb3(), ...executionParameters }); diff --git a/packages/disputer/src/disputer.js b/packages/disputer/src/disputer.js index 591cb714c3..3a8d3c6748 100644 --- a/packages/disputer/src/disputer.js +++ b/packages/disputer/src/disputer.js @@ -9,6 +9,7 @@ class Disputer { * @notice Constructs new Disputer bot. * @param {Object} logger Winston module used to send logs. * @param {Object} financialContractClient Module used to query Financial Contract information on-chain. + * @param {Object} proxyTransactionWrapper Module enable the disputer to send transactions via a DSProxy. * @param {Object} gasEstimator Module used to estimate optimal gas price with which to send txns. * @param {Object} priceFeed Module used to get the current or historical token price. * @param {String} account Ethereum account from which to send txns. @@ -19,6 +20,7 @@ class Disputer { constructor({ logger, financialContractClient, + proxyTransactionWrapper, gasEstimator, priceFeed, account, @@ -28,6 +30,8 @@ class Disputer { this.logger = logger; this.account = account; + this.proxyTransactionWrapper = proxyTransactionWrapper; + // Expiring multiparty contract to read contract state this.financialContractClient = financialContractClient; this.web3 = this.financialContractClient.web3; @@ -154,7 +158,6 @@ class Disputer { price: price.toString(), liquidation: JSON.stringify(liquidation), }); - return { ...liquidation, price: price.toString() }; } @@ -172,54 +175,35 @@ class Disputer { } for (const disputeableLiquidation of disputableLiquidationsWithPrices) { - // Create the transaction. - const dispute = this.financialContract.methods.dispute(disputeableLiquidation.id, disputeableLiquidation.sponsor); - this.logger.debug({ at: "Disputer", message: "Disputing liquidation", liquidation: disputeableLiquidation, }); - try { - // Get successful transaction receipt and return value or error. - const transactionResult = await runTransaction({ - transaction: dispute, - config: { - gasPrice: this.gasEstimator.getCurrentFastPrice(), - from: this.account, - nonce: await this.web3.eth.getTransactionCount(this.account), - }, - }); - const receipt = transactionResult.receipt; - const returnValue = transactionResult.returnValue.toString(); - const logResult = { - tx: receipt.transactionHash, - sponsor: receipt.events.LiquidationDisputed.returnValues.sponsor, - liquidator: receipt.events.LiquidationDisputed.returnValues.liquidator, - id: receipt.events.LiquidationDisputed.returnValues.liquidationId, - disputeBondPaid: receipt.events.LiquidationDisputed.returnValues.disputeBondAmount, - }; - this.logger.info({ + + // Submit the dispute transaction. This will use the DSProxy if configured or will send the tx with the unlocked EOA. + const logResult = await this.proxyTransactionWrapper.submitDisputeTransaction([ + disputeableLiquidation.id, + disputeableLiquidation.sponsor, + ]); + + if (logResult instanceof Error || !logResult) + this.logger.error({ at: "Disputer", - message: "Position has been disputed!👮‍♂️", + message: + logResult.type === "call" + ? "Cannot dispute liquidation: not enough collateral (or large enough approval) to initiate dispute✋" + : "Failed to dispute liquidation🚨", liquidation: disputeableLiquidation, - disputeResult: logResult, - totalPaid: returnValue, + logResult, }); - } catch (error) { - const message = - error.type === "call" - ? "Cannot dispute liquidation: not enough collateral (or large enough approval) to initiate dispute✋" - : "Failed to dispute liquidation🚨"; - this.logger.error({ + else + this.logger.info({ at: "Disputer", - message, - disputer: this.account, + message: "Liquidation has been disputed!👮‍♂️", liquidation: disputeableLiquidation, - error, + logResult, }); - continue; - } } } diff --git a/packages/disputer/src/proxyTransactionWrapper.js b/packages/disputer/src/proxyTransactionWrapper.js new file mode 100644 index 0000000000..4050166df7 --- /dev/null +++ b/packages/disputer/src/proxyTransactionWrapper.js @@ -0,0 +1,183 @@ +const assert = require("assert"); + +const { createObjectFromDefaultProps, runTransaction, blockUntilBlockMined, MAX_UINT_VAL } = require("@uma/common"); +const { getAbi, getTruffleContract } = require("@uma/core"); + +class ProxyTransactionWrapper { + /** + * @notice Constructs new ProxyTransactionWrapper. This adds support DSProxy atomic dispute support to the bots. + * @param {Object} web3 Provider from Truffle instance to connect to Ethereum network. + * @param {Object} financialContract instance of a financial contract. Either a EMP or a perp. Used to send disputes. + * @param {Object} gasEstimator Module used to estimate optimal gas price with which to send txns. + * @param {String} account Ethereum account from which to send txns. + * @param {Object} dsProxyManager Module to send transactions via DSProxy. If null will use the unlocked account EOA. + * @param {Boolean} useDsProxyToDispute Toggles the mode Disputes will be sent with. If true then then Disputes. + * are sent from the DSProxy. Else, Transactions are sent from the EOA. If true dsProxyManager must not be null. + * @param {Object} proxyTransactionWrapperConfig configuration object used to paramaterize how the DSProxy is used. Expected: + * { uniswapRouterAddress: 0x123..., // uniswap router address. Defaults to mainnet router + disputerReserveCurrencyAddress: 0x123... // address of the reserve currency for the bot to trade against + maxReserverTokenSpent: "10000" // define the maximum amount of reserve currency the bot should use in 1tx. } + * */ + constructor({ + web3, + financialContract, + gasEstimator, + account, + dsProxyManager = undefined, + proxyTransactionWrapperConfig, + }) { + this.web3 = web3; + this.financialContract = financialContract; + this.gasEstimator = gasEstimator; + this.account = account; + this.dsProxyManager = dsProxyManager; + + // Helper functions from web3. + this.toBN = this.web3.utils.toBN; + this.toWei = this.web3.utils.toWei; + this.toChecksumAddress = this.web3.utils.toChecksumAddress; + + this.tradeDeadline = 10 * 60 * 60; + + // TODO: refactor the router to pull from a constant file. + const defaultConfig = { + useDsProxyToDispute: { + value: false, + isValid: (x) => { + return typeof x == "boolean"; + }, + }, + uniswapRouterAddress: { + value: "0x7a250d5630b4cf539739df2c5dacb4c659f2488d", + isValid: (x) => { + return this.web3.utils.isAddress(x); + }, + }, + disputerReserveCurrencyAddress: { + value: "", + isValid: (x) => { + return this.web3.utils.isAddress(x) || x === ""; + }, + }, + maxReserverTokenSpent: { + value: MAX_UINT_VAL, + isValid: (x) => { + return typeof x == "string"; + }, + }, + }; + + // Validate and set config settings to class state. + const configWithDefaults = createObjectFromDefaultProps(proxyTransactionWrapperConfig, defaultConfig); + Object.assign(this, configWithDefaults); + + // Preform some basic initalization sanity checks. + if (this.useDsProxyToDispute) { + assert( + this.dsProxyManager && this.dsProxyManager.getDSProxyAddress(), + "DSProxy Manger has not yet been initialized!" + ); + assert(this.dsProxyManager != undefined, "Cant use dsProxy to dispute if the client is set to undefined!"); + assert( + this.web3.utils.isAddress(this.disputerReserveCurrencyAddress), + "Must provide a reserve currency address to use the proxy transaction wrapper!" + ); + } + + this.reserveToken = new this.web3.eth.Contract(getAbi("ExpandedERC20"), this.disputerReserveCurrencyAddress); + this.ReserveCurrencyDisputer = getTruffleContract("ReserveCurrencyDisputer", this.web3); + } + + // Main entry point for submitting a dispute. If the bot is not using a DSProxy then simply send a normal EOA tx. + // If the bot is using a DSProxy then route the tx via it. + async submitDisputeTransaction(disputeArgs) { + // If the liquidator is not using a DSProxy, use the old method of liquidating + if (!this.useDsProxyToDispute) return await this._executeDisputeWithoutDsProxy(disputeArgs); + else return await this._executeDisputeWithDsProxy(disputeArgs); + } + + async _executeDisputeWithoutDsProxy(disputeArgs) { + // Dispute strategy will control how much to liquidate + // Create the transaction. + const dispute = this.financialContract.methods.dispute(...disputeArgs); + + // Send the transaction or report failure. + let receipt, returnValue; + try { + // Get successful transaction receipt and return value or error. + const transactionResult = await runTransaction({ + transaction: dispute, + config: { + gasPrice: this.gasEstimator.getCurrentFastPrice(), + from: this.account, + nonce: await this.web3.eth.getTransactionCount(this.account), + }, + }); + receipt = transactionResult.receipt; + returnValue = transactionResult.returnValue.toString(); + } catch (error) { + return error; + } + + return { + type: "Standard EOA Dispute", + tx: receipt && receipt.transactionHash, + sponsor: receipt.events.LiquidationDisputed.returnValues.sponsor, + liquidator: receipt.events.LiquidationDisputed.returnValues.liquidator, + id: receipt.events.LiquidationDisputed.returnValues.liquidationId, + disputeBondPaid: receipt.events.LiquidationDisputed.returnValues.disputeBondAmount, + totalPaid: returnValue, + txnConfig: { + gasPrice: this.gasEstimator.getCurrentFastPrice(), + from: this.account, + }, + }; + } + + async _executeDisputeWithDsProxy(disputeArgs) { + const reserveCurrencyDisputer = new this.web3.eth.Contract(this.ReserveCurrencyDisputer.abi); + + const callData = reserveCurrencyDisputer.methods + .swapDispute( + this.uniswapRouterAddress, // uniswapRouter + this.financialContract._address, // financialContract + this.reserveToken._address, // reserveCurrency + disputeArgs[0], // liquidationId + disputeArgs[1], // sponsor + this.maxReserverTokenSpent, // maxReserverTokenSpent + Number((await this.web3.eth.getBlock("latest")).timestamp) + this.tradeDeadline + ) + .encodeABI(); + const callCode = this.ReserveCurrencyDisputer.bytecode; + + const dsProxyCallReturn = await this.dsProxyManager.callFunctionOnNewlyDeployedLibrary(callCode, callData); + const blockAfterDispute = await this.web3.eth.getBlockNumber(); + + // Wait exactly one block to fetch events. This ensures that the events have been indexed by your node. + await blockUntilBlockMined(this.web3, blockAfterDispute + 1); + + const DisputeEvent = ( + await this.financialContract.getPastEvents("LiquidationDisputed", { + fromBlock: blockAfterDispute - 1, + filter: { disputer: this.dsProxyManager.getDSProxyAddress() }, + }) + )[0]; + + // Return the same data sent back from the EOA Dispute. + return { + type: "DSProxy Swap and dispute transaction", + tx: dsProxyCallReturn.transactionHash, + sponsor: DisputeEvent.returnValues.sponsor, + liquidator: DisputeEvent.returnValues.liquidator, + disputer: DisputeEvent.returnValues.disputer, + liquidationId: DisputeEvent.returnValues.liquidationId, + disputeBondAmount: DisputeEvent.returnValues.disputeBondAmount, + txnConfig: { + from: dsProxyCallReturn.from, + gas: dsProxyCallReturn.gasUsed, + }, + }; + } +} + +module.exports = { ProxyTransactionWrapper }; diff --git a/packages/disputer/test/Disputer.js b/packages/disputer/test/Disputer.js index 60d502810d..e3068b1e19 100644 --- a/packages/disputer/test/Disputer.js +++ b/packages/disputer/test/Disputer.js @@ -1,6 +1,7 @@ -const { toWei, toBN, utf8ToHex, padRight } = web3.utils; +const { toWei, toBN, utf8ToHex, padRight, isAddress } = web3.utils; const winston = require("winston"); const sinon = require("sinon"); +const truffleContract = require("@truffle/contract"); const { PostWithdrawLiquidationRewardsStatusTranslations, LiquidationStatesEnum, @@ -16,9 +17,16 @@ const { getTruffleContract } = require("@uma/core"); // Script to test const { Disputer } = require("../src/disputer.js"); +const { ProxyTransactionWrapper } = require("../src/proxyTransactionWrapper"); // Helper clients and custom winston transport module to monitor winston log outputs -const { FinancialContractClient, GasEstimator, PriceFeedMock, SpyTransport } = require("@uma/financial-templates-lib"); +const { + FinancialContractClient, + GasEstimator, + PriceFeedMock, + SpyTransport, + DSProxyManager, +} = require("@uma/financial-templates-lib"); // Run the tests against 3 different kinds of token/synth decimal combinations: // 1) matching 18 & 18 for collateral for most token types with normal tokens. @@ -63,6 +71,8 @@ let convertPrice; let gasEstimator; let financialContractClient; let disputer; +let dsProxyManager; +let proxyTransactionWrapper; // Set the funding rate and advances time by 10k seconds. const _setFundingRateAndAdvanceTime = async (fundingRate) => { @@ -273,9 +283,22 @@ contract("Disputer.js", function (accounts) { // Create price feed mock. priceFeedMock = new PriceFeedMock(undefined, undefined, undefined, undefined, testConfig.collateralDecimals); + // Set the proxyTransaction wrapper to act without the DSProxy by setting useDsProxyToLiquidate to false. + // This will treat all disputes in the "normal" way, executed from the bots's EOA. + proxyTransactionWrapper = new ProxyTransactionWrapper({ + web3, + financialContract: financialContract.contract, + gasEstimator, + account: accounts[0], + dsProxyManager: null, + useDsProxyToLiquidate: false, + proxyTransactionWrapperConfig: {}, + }); + disputer = new Disputer({ logger: spyLogger, - financialContractClient: financialContractClient, + financialContractClient, + proxyTransactionWrapper, gasEstimator, priceFeed: priceFeedMock, account: accounts[0], @@ -395,7 +418,7 @@ contract("Disputer.js", function (accounts) { ); assert.equal(spy.callCount, 3); // No info level logs should be sent. - // Now, set lookback such that the liquidation timestamp is captured and the dispute should go through: + // Now, set lookback such that the liquidation timestamp is captured and the dispute should go through. priceFeedMock.setLookback(2); await disputer.update(); await disputer.dispute(); @@ -420,7 +443,6 @@ contract("Disputer.js", function (accounts) { assert.equal((await financialContract.getLiquidations(sponsor3))[0].disputer, disputeBot); } ); - versionedIt([{ contractType: "any", contractVersion: "any" }])( "Detect disputable withdraws and send disputes", async function () { @@ -681,7 +703,8 @@ contract("Disputer.js", function (accounts) { disputerConfig = { ...disputerConfig, disputeDelay: -1 }; disputer = new Disputer({ logger: spyLogger, - financialContractClient: financialContractClient, + financialContractClient, + proxyTransactionWrapper, gasEstimator, priceFeed: priceFeedMock, account: accounts[0], @@ -702,7 +725,8 @@ contract("Disputer.js", function (accounts) { disputerConfig = { ...disputerConfig, disputeDelay: 60 }; disputer = new Disputer({ logger: spyLogger, - financialContractClient: financialContractClient, + financialContractClient, + proxyTransactionWrapper, gasEstimator, priceFeed: priceFeedMock, account: accounts[0], @@ -1012,6 +1036,433 @@ contract("Disputer.js", function (accounts) { ); }); }); + describe("Dispute via DSProxy", () => { + // Imports specific to the DSProxy wallet implementation. + const DSProxyFactory = getTruffleContract("DSProxyFactory", web3); + const DSProxy = getTruffleContract("DSProxy", web3); + const UniswapV2Factory = require("@uniswap/v2-core/build/UniswapV2Factory.json"); + const IUniswapV2Pair = require("@uniswap/v2-core/build/IUniswapV2Pair.json"); + const UniswapV2Router02 = require("@uniswap/v2-periphery/build/UniswapV2Router02.json"); + + let reserveToken; + let uniswapFactory; + let uniswapRouter; + let pairAddress; + let pair; + let dsProxyFactory; + let dsProxy; + + // Takes in a json object from a compiled contract and returns a truffle contract instance that can be deployed. + // TODO: refactor this to be from a common file + const createContractObjectFromJson = (contractJsonObject) => { + let truffleContractCreator = truffleContract(contractJsonObject); + truffleContractCreator.setProvider(web3.currentProvider); + return truffleContractCreator; + }; + + beforeEach(async () => { + // Create the reserve currency for the liquidator to hold. + reserveToken = await Token.new("reserveToken", "DAI", 18, { from: contractCreator }); + await reserveToken.addMember(1, contractCreator, { from: contractCreator }); + + // deploy Uniswap V2 Factory & router. + uniswapFactory = await createContractObjectFromJson(UniswapV2Factory).new(contractCreator, { + from: contractCreator, + }); + uniswapRouter = await createContractObjectFromJson(UniswapV2Router02).new( + uniswapFactory.address, + collateralToken.address, + { from: contractCreator } + ); + + // initialize the pair between the reserve and collateral token. + await uniswapFactory.createPair(reserveToken.address, collateralToken.address, { + from: contractCreator, + }); + pairAddress = await uniswapFactory.getPair(reserveToken.address, collateralToken.address); + pair = await createContractObjectFromJson(IUniswapV2Pair).at(pairAddress); + + // Seed the market. This sets up the initial price to be 1/1 reserve to collateral token. As the collateral + // token is Dai this starts off the uniswap market at 1 reserve/collateral. Note the amount of collateral + // is scaled according to the collateral decimals. + await reserveToken.mint(pairAddress, toBN(toWei("1000")).muln(10000000), { + from: contractCreator, + }); + await collateralToken.mint(pairAddress, toBN(convertCollateral("1000")).muln(10000000), { + from: contractCreator, + }); + await pair.sync({ from: contractCreator }); + + dsProxyFactory = await DSProxyFactory.new({ from: contractCreator }); + + // Create the DSProxy manager and proxy transaction wrapper for the liquidator instance. + dsProxyManager = new DSProxyManager({ + logger: spyLogger, + web3, + gasEstimator, + account: disputeBot, + dsProxyFactoryAddress: dsProxyFactory.address, + dsProxyFactoryAbi: DSProxyFactory.abi, + dsProxyAbi: DSProxy.abi, + }); + // Initialize the DSProxy manager. This will deploy a new DSProxy contract as the liquidator bot EOA does not + // yet have one deployed. + dsProxy = await DSProxy.at(await dsProxyManager.initializeDSProxy()); + + proxyTransactionWrapper = new ProxyTransactionWrapper({ + web3, + financialContract: financialContract.contract, + gasEstimator, + account: accounts[0], + dsProxyManager, + proxyTransactionWrapperConfig: { + useDsProxyToDispute: true, + uniswapRouterAddress: uniswapRouter.address, + disputerReserveCurrencyAddress: reserveToken.address, + }, + }); + + disputer = new Disputer({ + logger: spyLogger, + financialContractClient, + proxyTransactionWrapper, + gasEstimator, + priceFeed: priceFeedMock, + account: accounts[0], + financialContractProps, + disputerConfig, + }); + }); + versionedIt([{ contractType: "any", contractVersion: "any" }])( + "Can correctly detect initialized DSProxy and ProxyTransactionWrapper", + async function () { + // The initialization in the before-each should be correct. + assert.isTrue(isAddress(dsProxy.address)); + assert.equal(await dsProxy.owner(), disputeBot); + assert.isTrue(disputer.proxyTransactionWrapper.useDsProxyToDispute); + assert.equal(disputer.proxyTransactionWrapper.uniswapRouterAddress, uniswapRouter.address); + assert.equal(disputer.proxyTransactionWrapper.dsProxyManager.getDSProxyAddress(), dsProxy.address); + assert.equal(disputer.proxyTransactionWrapper.disputerReserveCurrencyAddress, reserveToken.address); + assert.isTrue(spy.getCall(-1).lastArg.message.includes("DSProxy deployed for your EOA")); + } + ); + versionedIt([{ contractType: "any", contractVersion: "any" }])( + "Rejects invalid invocation of proxy transaction wrapper", + async function () { + // Invalid invocation should reject. Missing reserve currency. + assert.throws(() => { + new ProxyTransactionWrapper({ + web3, + financialContract: financialContract.contract, + gasEstimator, + account: accounts[0], + dsProxyManager, + proxyTransactionWrapperConfig: { + useDsProxyToDispute: true, + uniswapRouterAddress: uniswapRouter.address, + disputerReserveCurrencyAddress: null, + }, + }); + }); + + // Invalid invocation should reject. Missing reserve currency. + assert.throws(() => { + new ProxyTransactionWrapper({ + web3, + financialContract: financialContract.contract, + gasEstimator, + account: accounts[0], + dsProxyManager, + proxyTransactionWrapperConfig: { + useDsProxyToDispute: true, + uniswapRouterAddress: "not-an-address", + disputerReserveCurrencyAddress: reserveToken.address, + }, + }); + }); + // Invalid invocation should reject. Requests to use DSProxy to Dispute but does not provide DSProxy manager. + assert.throws(() => { + new ProxyTransactionWrapper({ + web3, + financialContract: financialContract.contract, + gasEstimator, + account: accounts[0], + dsProxyManager: null, + proxyTransactionWrapperConfig: { + useDsProxyToDispute: true, + uniswapRouterAddress: uniswapRouter.address, + disputerReserveCurrencyAddress: reserveToken.address, + }, + }); + }); + // Invalid invocation should reject. DSProxy Manager not yet initalized. + dsProxyFactory = await DSProxyFactory.new({ from: contractCreator }); + + dsProxyManager = new DSProxyManager({ + logger: spyLogger, + web3, + gasEstimator, + account: disputeBot, + dsProxyFactoryAddress: dsProxyFactory.address, + dsProxyFactoryAbi: DSProxyFactory.abi, + dsProxyAbi: DSProxy.abi, + }); + assert.throws(() => { + new ProxyTransactionWrapper({ + web3, + financialContract: financialContract.contract, + gasEstimator, + account: accounts[0], + dsProxyManager, + proxyTransactionWrapperConfig: { + useDsProxyToDispute: true, + uniswapRouterAddress: uniswapRouter.address, + disputerReserveCurrencyAddress: reserveToken.address, + }, + }); + }); + } + ); + versionedIt([{ contractType: "any", contractVersion: "any" }])( + "Correctly disputes positions using DSProxy", + async function () { + // Seed the dsProxy with some reserve tokens so it can buy collateral to execute the dispute. + await reserveToken.mint(dsProxy.address, toWei("10000"), { from: contractCreator }); + + // Create three positions for each sponsor and one for the liquidator. Liquidate all positions. + await financialContract.create( + { rawValue: convertCollateral("125") }, + { rawValue: convertSynthetic("100") }, + { from: sponsor1 } + ); + + await financialContract.create( + { rawValue: convertCollateral("150") }, + { rawValue: convertSynthetic("100") }, + { from: sponsor2 } + ); + + await financialContract.create( + { rawValue: convertCollateral("175") }, + { rawValue: convertSynthetic("100") }, + { from: sponsor3 } + ); + + await financialContract.create( + { rawValue: convertCollateral("1000") }, + { rawValue: convertSynthetic("500") }, + { from: liquidator } + ); + + await financialContract.createLiquidation( + sponsor1, + { rawValue: "0" }, + { rawValue: convertPrice("1.75") }, + { rawValue: convertSynthetic("100") }, + unreachableDeadline, + { from: liquidator } + ); + await financialContract.createLiquidation( + sponsor2, + { rawValue: "0" }, + { rawValue: convertPrice("1.75") }, + { rawValue: convertSynthetic("100") }, + unreachableDeadline, + { from: liquidator } + ); + await financialContract.createLiquidation( + sponsor3, + { rawValue: "0" }, + { rawValue: convertPrice("1.75") }, + { rawValue: convertSynthetic("100") }, + unreachableDeadline, + { from: liquidator } + ); + + // Start with a mocked price of 1.75 usd per token. + // This makes all sponsors undercollateralized, meaning no disputes are issued. + priceFeedMock.setHistoricalPrice(convertPrice("1.75")); + await disputer.update(); + await disputer.dispute(); + + // There should be no disputes created from any sponsor account + assert.equal( + (await financialContract.getLiquidations(sponsor1))[0].state, + LiquidationStatesEnum.PRE_DISPUTE + ); + assert.equal( + (await financialContract.getLiquidations(sponsor2))[0].state, + LiquidationStatesEnum.PRE_DISPUTE + ); + assert.equal( + (await financialContract.getLiquidations(sponsor3))[0].state, + LiquidationStatesEnum.PRE_DISPUTE + ); + assert.equal(spy.callCount, 1); // No info level logs should be sent. + + // With a price of 1.1, two sponsors should be correctly collateralized, so disputes should be issued against sponsor2 and sponsor3's liquidations. + priceFeedMock.setHistoricalPrice(convertPrice("1.1")); + + // Set lookback such that the liquidation timestamp is captured and the dispute should go through. + priceFeedMock.setLookback(2); + await disputer.update(); + await disputer.dispute(); + assert.equal(spy.callCount, 5); // info level logs should be sent at the conclusion of the disputes. + + // Sponsor2 and sponsor3 should be disputed. + assert.equal( + (await financialContract.getLiquidations(sponsor1))[0].state, + LiquidationStatesEnum.PRE_DISPUTE + ); + assert.equal( + (await financialContract.getLiquidations(sponsor2))[0].state, + LiquidationStatesEnum.PENDING_DISPUTE + ); + assert.equal( + (await financialContract.getLiquidations(sponsor3))[0].state, + LiquidationStatesEnum.PENDING_DISPUTE + ); + + // The dsProxy should be the disputer in sponsor2 and sponsor3's liquidations. + assert.equal((await financialContract.getLiquidations(sponsor2))[0].disputer, dsProxy.address); + assert.equal((await financialContract.getLiquidations(sponsor3))[0].disputer, dsProxy.address); + } + ); + versionedIt([{ contractType: "any", contractVersion: "any" }])( + "Correctly deals with reserve being the same as collateral currency using DSProxy", + async function () { + // Create a new disputer set to use the same collateral as the reserve currency. + proxyTransactionWrapper = new ProxyTransactionWrapper({ + web3, + financialContract: financialContract.contract, + gasEstimator, + account: accounts[0], + dsProxyManager, + proxyTransactionWrapperConfig: { + useDsProxyToDispute: true, + uniswapRouterAddress: uniswapRouter.address, + disputerReserveCurrencyAddress: collateralToken.address, + }, + }); + + disputer = new Disputer({ + logger: spyLogger, + financialContractClient, + proxyTransactionWrapper, + gasEstimator, + priceFeed: priceFeedMock, + account: accounts[0], + financialContractProps, + disputerConfig, + }); + + // Seed the dsProxy with some reserve tokens so it can buy collateral to execute the dispute. + await collateralToken.mint(dsProxy.address, toWei("10000"), { from: contractCreator }); + + // Create 1 positions for the first sponsor sponsor and one for the liquidator. Liquidate the position. + await financialContract.create( + { rawValue: convertCollateral("150") }, + { rawValue: convertSynthetic("100") }, + { from: sponsor1 } + ); + + await financialContract.create( + { rawValue: convertCollateral("1000") }, + { rawValue: convertSynthetic("500") }, + { from: liquidator } + ); + + await financialContract.createLiquidation( + sponsor1, + { rawValue: "0" }, + { rawValue: convertPrice("1.75") }, + { rawValue: convertSynthetic("100") }, + unreachableDeadline, + { from: liquidator } + ); + + // With a price of 1.1, the sponsors should be correctly collateralized, so a dispute should be issued against sponsor1. + priceFeedMock.setHistoricalPrice(convertPrice("1.1")); + + // Seed the dsProxy with some reserve tokens so it can buy collateral to execute the dispute. + await reserveToken.mint(dsProxy.address, toWei("10000"), { from: contractCreator }); + + // Set lookback such that the liquidation timestamp is captured and the dispute should go through. + priceFeedMock.setLookback(2); + await disputer.update(); + await disputer.dispute(); + + // Sponsor1 and should be disputed. + assert.equal( + (await financialContract.getLiquidations(sponsor1))[0].state, + LiquidationStatesEnum.PENDING_DISPUTE + ); + + // The dsProxy should be the disputer in sponsor1 liquidations. + assert.equal((await financialContract.getLiquidations(sponsor1))[0].disputer, dsProxy.address); + + // There should be no swap events as the DSProxy already had enough collateral to dispute + assert.equal((await pair.getPastEvents("Swap")).length, 0); + } + ); + versionedIt([{ contractType: "any", contractVersion: "any" }])( + "Correctly respects existing collateral balances when using DSProxy", + async function () { + // Seed the dsProxy with a few collateral but not enough to finish the dispute. All collateral available + // should be spent and the shortfall should be purchased. + await collateralToken.mint(dsProxy.address, convertCollateral("0.5"), { from: contractCreator }); + await reserveToken.mint(dsProxy.address, toWei("10000"), { from: contractCreator }); + + // Set the final fee to 1 unit collateral. The total collateral needed for the dispute will be final fee + dispute bond. + await store.setFinalFee(collateralToken.address, { rawValue: convertCollateral("1") }); + + // Create 1 positions for the first sponsor sponsor and one for the liquidator. Liquidate the position. + await financialContract.create( + { rawValue: convertCollateral("150") }, + { rawValue: convertSynthetic("100") }, + { from: sponsor1 } + ); + + await financialContract.create( + { rawValue: convertCollateral("1000") }, + { rawValue: convertSynthetic("500") }, + { from: liquidator } + ); + + await financialContract.createLiquidation( + sponsor1, + { rawValue: "0" }, + { rawValue: convertPrice("1.75") }, + { rawValue: convertSynthetic("100") }, + unreachableDeadline, + { from: liquidator } + ); + + // With a price of 1.1, the sponsors should be correctly collateralized, so a dispute should be issued against sponsor1. + priceFeedMock.setHistoricalPrice(convertPrice("1.1")); + + // Set lookback such that the liquidation timestamp is captured and the dispute should go through. + priceFeedMock.setLookback(2); + await disputer.update(); + await disputer.dispute(); + + // Sponsor1 and should be disputed. + assert.equal( + (await financialContract.getLiquidations(sponsor1))[0].state, + LiquidationStatesEnum.PENDING_DISPUTE + ); + + // The dsProxy should be the disputer in sponsor1 liquidations. + assert.equal((await financialContract.getLiquidations(sponsor1))[0].disputer, dsProxy.address); + + // There should be 1 swap events as the DSProxy had to buy the token shortfall in collateral. + assert.equal((await pair.getPastEvents("Swap")).length, 1); + + // There should be no collateral left as it was all used in the dispute. + assert.equal((await collateralToken.balanceOf(dsProxy.address)).toString(), "0"); + } + ); + }); }); } }); diff --git a/packages/disputer/test/index.js b/packages/disputer/test/index.js index 803948302c..7a4997d2bc 100644 --- a/packages/disputer/test/index.js +++ b/packages/disputer/test/index.js @@ -1,4 +1,5 @@ -const { toWei, utf8ToHex, padRight } = web3.utils; +const { toWei, utf8ToHex, padRight, toBN } = web3.utils; +const truffleContract = require("@truffle/contract"); const { MAX_UINT_VAL, ZERO_ADDRESS, @@ -9,6 +10,22 @@ const { } = require("@uma/common"); const { getTruffleContract } = require("@uma/core"); +// Custom winston transport module to monitor winston log outputs +const winston = require("winston"); +const sinon = require("sinon"); +const { SpyTransport, spyLogLevel, spyLogIncludes } = require("@uma/financial-templates-lib"); + +// Uniswap related contracts +const UniswapV2Factory = require("@uniswap/v2-core/build/UniswapV2Factory.json"); +const IUniswapV2Pair = require("@uniswap/v2-core/build/IUniswapV2Pair.json"); +const UniswapV2Router02 = require("@uniswap/v2-periphery/build/UniswapV2Router02.json"); + +const createContractObjectFromJson = (contractJsonObject) => { + let truffleContractCreator = truffleContract(contractJsonObject); + truffleContractCreator.setProvider(web3.currentProvider); + return truffleContractCreator; +}; + // Script to test const Poll = require("../index.js"); @@ -27,6 +44,7 @@ let defaultPriceFeedConfig; let constructorParams; let spy; let spyLogger; +let dsProxyFactory; let pollingDelay = 0; // 0 polling delay creates a serverless bot that yields after one full execution. let errorRetries = 1; @@ -34,13 +52,9 @@ let errorRetriesTimeout = 0.1; // 100 milliseconds between preforming retries let identifier = "TEST_IDENTIFIER"; let fundingRateIdentifier = "TEST_FUNDING_IDENTIFIER"; -// Custom winston transport module to monitor winston log outputs -const winston = require("winston"); -const sinon = require("sinon"); -const { SpyTransport, spyLogLevel, spyLogIncludes } = require("@uma/financial-templates-lib"); - contract("index.js", function (accounts) { const contractCreator = accounts[0]; + const disputer = contractCreator; TESTED_CONTRACT_VERSIONS.forEach(function (contractVersion) { // Import the tested versions of contracts. note that financialContract is either an ExpiringMultiParty or the @@ -56,6 +70,7 @@ contract("index.js", function (accounts) { const Store = getTruffleContract("Store", web3, contractVersion.contractVersion); const ConfigStore = getTruffleContract("ConfigStore", web3); const OptimisticOracle = getTruffleContract("OptimisticOracle", web3); + const DSProxyFactory = getTruffleContract("DSProxyFactory", web3); describe(`Smart contract version ${contractVersion.contractType} @ ${contractVersion.contractVersion}`, function () { before(async function () { @@ -78,6 +93,9 @@ contract("index.js", function (accounts) { store = await Store.new({ rawValue: "0" }, { rawValue: "0" }, timer.address); await finder.changeImplementationAddress(utf8ToHex(interfaceName.Store), store.address); + + dsProxyFactory = await DSProxyFactory.new(); + addGlobalHardhatTestingAddress("DSProxyFactory", dsProxyFactory.address); }); beforeEach(async function () { @@ -91,6 +109,7 @@ contract("index.js", function (accounts) { // Create a new synthetic token syntheticToken = await SyntheticToken.new("Test Synthetic Token", "SYNTH", 18, { from: contractCreator }); collateralToken = await Token.new("Wrapped Ether", "WETH", 18, { from: contractCreator }); + await collateralToken.addMember(1, contractCreator, { from: contractCreator }); collateralWhitelist = await AddressWhitelist.new(); await finder.changeImplementationAddress( @@ -184,6 +203,105 @@ contract("index.js", function (accounts) { assert.isTrue(spyLogIncludes(spy, 7, '"syntheticDecimals":18')); assert.isTrue(spyLogIncludes(spy, 7, '"priceFeedDecimals":8')); }); + it("Can correctly initialize using a DSProxy", async function () { + // Deploy a reserve currency token. + const reserveToken = await Token.new("Reserve Token", "RTKN", 18, { from: contractCreator }); + await reserveToken.addMember(1, contractCreator, { from: contractCreator }); + // deploy Uniswap V2 Factory & router. + const factory = await createContractObjectFromJson(UniswapV2Factory).new(contractCreator, { + from: contractCreator, + }); + const router = await createContractObjectFromJson(UniswapV2Router02).new( + factory.address, + collateralToken.address, + { from: contractCreator } + ); + + // initialize the pair + await factory.createPair(reserveToken.address, collateralToken.address); + const pairAddress = await factory.getPair(reserveToken.address, collateralToken.address); + const pair = await createContractObjectFromJson(IUniswapV2Pair).at(pairAddress); + + await reserveToken.mint(pairAddress, toBN(toWei("1000")).muln(10000000), { from: contractCreator }); + await collateralToken.mint(pairAddress, toBN(toWei("1")).muln(10000000), { from: contractCreator }); + await pair.sync(); + + spy = sinon.spy(); + spyLogger = winston.createLogger({ + level: "debug", + transports: [new SpyTransport({ level: "debug" }, { spy: spy })], + }); + + await Poll.run({ + logger: spyLogger, + web3, + financialContractAddress: financialContract.address, + pollingDelay, + errorRetries, + errorRetriesTimeout, + priceFeedConfig: defaultPriceFeedConfig, + proxyTransactionWrapperConfig: { + useDsProxyToDispute: true, + disputerReserveCurrencyAddress: reserveToken.address, + uniswapRouterAddress: router.address, + }, + }); + + for (let i = 0; i < spy.callCount; i++) { + assert.notEqual(spyLogLevel(spy, i), "error"); + } + + // A log of a deployed DSProxy should be included. + assert.isTrue(spyLogIncludes(spy, 6, "No DSProxy found for EOA. Deploying new DSProxy")); + assert.isTrue(spyLogIncludes(spy, 8, "DSProxy deployed for your EOA")); + const createdEvents = await dsProxyFactory.getPastEvents("Created", { fromBlock: 0 }); + + assert.equal(createdEvents.length, 1); + assert.equal(createdEvents[0].returnValues.owner, disputer); + // To verify contract type detection is correct for a standard feed, check the fifth log to see it matches expected. + assert.isTrue(spyLogIncludes(spy, 9, '"collateralDecimals":18')); + assert.isTrue(spyLogIncludes(spy, 9, '"syntheticDecimals":18')); + assert.isTrue(spyLogIncludes(spy, 9, '"priceFeedDecimals":18')); + + spy = sinon.spy(); // Create a new spy for each test. + spyLogger = winston.createLogger({ + level: "debug", + transports: [new SpyTransport({ level: "debug" }, { spy: spy })], + }); + + collateralToken = await Token.new("BTC", "BTC", 8, { from: contractCreator }); + syntheticToken = await SyntheticToken.new("Test Synthetic Token", "SYNTH", 18, { from: contractCreator }); + // For this test we are using a lower decimal identifier, USDBTC. First we need to add it to the whitelist. + await identifierWhitelist.addSupportedIdentifier(padRight(utf8ToHex("USDBTC"), 64)); + const decimalTestConstructorParams = JSON.parse( + JSON.stringify({ + ...constructorParams, + collateralAddress: collateralToken.address, + tokenAddress: syntheticToken.address, + priceFeedIdentifier: padRight(utf8ToHex("USDBTC"), 64), + }) + ); + financialContract = await FinancialContract.new(decimalTestConstructorParams); + await syntheticToken.addMinter(financialContract.address); + await syntheticToken.addBurner(financialContract.address); + + // Note the execution below does not have a price feed included. It should be pulled from the default USDBTC config. + await Poll.run({ + logger: spyLogger, + web3, + financialContractAddress: financialContract.address, + pollingDelay, + errorRetries, + errorRetriesTimeout, + }); + + // Seventh log, which prints the decimal info, should include # of decimals for the price feed, collateral and synthetic. + // The "7th" log is pretty arbitrary. This is simply the log message that is produced at the end of initialization + // under `Liquidator initialized`. It does however contain the decimal info, which is what we really care about. + assert.isTrue(spyLogIncludes(spy, 7, '"collateralDecimals":8')); + assert.isTrue(spyLogIncludes(spy, 7, '"syntheticDecimals":18')); + assert.isTrue(spyLogIncludes(spy, 7, '"priceFeedDecimals":8')); + }); it("Allowances are set", async function () { await Poll.run({ diff --git a/packages/liquidator/index.js b/packages/liquidator/index.js index 6d9ab081e1..59a9e5a349 100755 --- a/packages/liquidator/index.js +++ b/packages/liquidator/index.js @@ -381,7 +381,7 @@ async function Poll(callback) { // until. If either startingBlock or endingBlock is not sent, then the bot will search for event. endingBlock: process.env.ENDING_BLOCK_NUMBER, // If there is a dsproxy config, the bot can be configured to send transactions via a smart contract wallet (DSProxy). - // This enables the bot to preform swap, mint liquidate, enabling a single reserve currency. + // This enables the bot to preform swap, mint & liquidate, enabling a single reserve currency. // Note that the DSProxy will be deployed on the first run of the bot. Subsequent runs will re-use the proxy. example: // { "useDsProxyToLiquidate": "true", If enabled, the bot will send liquidations via a DSProxy. // "dsProxyFactoryAddress": "0x123..." -> Will default to an UMA deployed version if non provided. diff --git a/packages/liquidator/src/liquidator.js b/packages/liquidator/src/liquidator.js index 272a6e1df7..1af469a62d 100644 --- a/packages/liquidator/src/liquidator.js +++ b/packages/liquidator/src/liquidator.js @@ -12,6 +12,7 @@ class Liquidator { * @notice Constructs new Liquidator bot. * @param {Object} logger Module used to send logs. * @param {Object} financialContractClient Module used to query Financial Contract information on-chain. + * @param {Object} proxyTransactionWrapper Module enable the liquidator to send transactions via a DSProxy. * @param {Object} gasEstimator Module used to estimate optimal gas price with which to send txns. * @param {Object} syntheticToken Synthetic token (tokenCurrency). * @param {Object} priceFeed Module used to query the current token price. diff --git a/packages/liquidator/src/proxyTransactionWrapper.js b/packages/liquidator/src/proxyTransactionWrapper.js index 45cd747747..795b32e453 100644 --- a/packages/liquidator/src/proxyTransactionWrapper.js +++ b/packages/liquidator/src/proxyTransactionWrapper.js @@ -33,7 +33,6 @@ class ProxyTransactionWrapper { collateralToken, account, dsProxyManager = undefined, - useDsProxyToLiquidate = false, proxyTransactionWrapperConfig, }) { this.web3 = web3; @@ -49,8 +48,6 @@ class ProxyTransactionWrapper { this.toWei = this.web3.utils.toWei; this.toChecksumAddress = this.web3.utils.toChecksumAddress; - this.useDsProxyToLiquidate = useDsProxyToLiquidate; - // TODO: refactor the router to pull from a constant file. const defaultConfig = { useDsProxyToLiquidate: { diff --git a/packages/liquidator/test/Liquidator.js b/packages/liquidator/test/Liquidator.js index daa5a97b81..62ec567628 100644 --- a/packages/liquidator/test/Liquidator.js +++ b/packages/liquidator/test/Liquidator.js @@ -28,7 +28,6 @@ const { // Script to test const { Liquidator } = require("../src/liquidator.js"); const { ProxyTransactionWrapper } = require("../src/proxyTransactionWrapper"); -const { assert } = require("chai"); // Run the tests against 3 different kinds of token/synth decimal combinations: // 1) matching 18 & 18 for collateral for most token types with normal tokens. @@ -1722,15 +1721,11 @@ contract("Liquidator.js", function (accounts) { uniswapRouter = await createContractObjectFromJson(UniswapV2Router02).new( uniswapFactory.address, collateralToken.address, - { - from: contractCreator, - } + { from: contractCreator } ); // initialize the pair between the reserve and collateral token. - await uniswapFactory.createPair(reserveToken.address, collateralToken.address, { - from: contractCreator, - }); + await uniswapFactory.createPair(reserveToken.address, collateralToken.address, { from: contractCreator }); pairAddress = await uniswapFactory.getPair(reserveToken.address, collateralToken.address); pair = await createContractObjectFromJson(IUniswapV2Pair).at(pairAddress); @@ -2097,7 +2092,6 @@ contract("Liquidator.js", function (accounts) { // Next, try another liquidation. This time around the bot does not have enough collateral to mint enough // to liquidate the min sponsor size. The bot should correctly report this without generating any errors // or throwing any txs. - priceFeedMock.setCurrentPrice(convertPrice("2")); await liquidator.update(); await liquidator.liquidatePositions();