From 8459cdfcd8b1b12854b28c5ef2eaec2a1d711c00 Mon Sep 17 00:00:00 2001 From: Daniel Simon Date: Wed, 10 Feb 2021 19:47:13 +0700 Subject: [PATCH] Add liquidation bot example script that uses the SDK --- filter-repo/1-included-paths | 1 + packages/examples/package.json | 14 ++++ packages/examples/src/liqbot.js | 144 ++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 packages/examples/package.json create mode 100644 packages/examples/src/liqbot.js diff --git a/filter-repo/1-included-paths b/filter-repo/1-included-paths index 05045fc07..b08a9c9e6 100644 --- a/filter-repo/1-included-paths +++ b/filter-repo/1-included-paths @@ -4,6 +4,7 @@ docs/ packages/decimal/ packages/frontend/ packages/dev-frontend/ +packages/examples/ packages/providers/ packages/subgraph/ packages/lib/ diff --git a/packages/examples/package.json b/packages/examples/package.json new file mode 100644 index 000000000..119dc700f --- /dev/null +++ b/packages/examples/package.json @@ -0,0 +1,14 @@ +{ + "name": "@liquity/examples", + "version": "0.0.1", + "private": true, + "dependencies": { + "@liquity/lib-base": "^0.0.1", + "@liquity/lib-ethers": "^0.0.1", + "chalk": "^4.1.0", + "ethers": "^5.0.0" + }, + "scripts": { + "liqbot": "node src/liqbot.js" + } +} diff --git a/packages/examples/src/liqbot.js b/packages/examples/src/liqbot.js new file mode 100644 index 000000000..b00e3118f --- /dev/null +++ b/packages/examples/src/liqbot.js @@ -0,0 +1,144 @@ +const { red, blue, green, yellow, dim, bold } = require("chalk"); + +const { JsonRpcProvider } = require("@ethersproject/providers"); +const { Wallet } = require("@ethersproject/wallet"); + +const { Decimal, Trove, LUSD_LIQUIDATION_RESERVE } = require("@liquity/lib-base"); +const { EthersLiquity, EthersLiquityWithStore } = require("@liquity/lib-ethers"); + +function log(message) { + console.log(`${dim(`[${new Date().toLocaleTimeString()}]`)} ${message}`); +} + +const info = message => log(`${blue("ℹ")} ${message}`); +const warn = message => log(`${yellow("‼")} ${message}`); +const error = message => log(`${red("✖")} ${message}`); +const success = message => log(`${green("✔")} ${message}`); + +async function main() { + // Replace URL if not using a local node + const provider = new JsonRpcProvider("http://localhost:8545"); + const wallet = new Wallet(process.env.PRIVATE_KEY).connect(provider); + const liquity = await EthersLiquity.connect(wallet, { useStore: "blockPolled" }); + + liquity.store.onLoaded = () => { + info("Waiting for price drops..."); + tryToLiquidate(liquity); + }; + + liquity.store.subscribe(({ newState, oldState }) => { + // Try to liquidate whenever the price drops + if (newState.price.lt(oldState.price)) { + tryToLiquidate(liquity); + } + }); + + liquity.store.start(); +} + +/** + * @param {Decimal} [price] + * @returns {(trove: [string, Trove]) => boolean} + */ +const underCollateralized = price => ([, trove]) => trove.collateralRatioIsBelowMinimum(price); + +/** + * @param {[string, Trove]} + * @param {[string, Trove]} + */ +const byDescendingCollateral = ([, { collateral: a }], [, { collateral: b }]) => + b.gt(a) ? 1 : b.lt(a) ? -1 : 0; + +/** + * @param {[string[], Trove[]]} + * @param {[string, Trove]} + * @returns {[string[], Trove[]]} + */ +const unzip = ([addresses, troves], [address, trove]) => [ + addresses.concat(address), + troves.concat(trove) +]; + +/** + * @param {EthersLiquityWithStore} [liquity] + */ +async function tryToLiquidate(liquity) { + const { store } = liquity; + + const [gasPrice, riskiestTroves] = await Promise.all([ + liquity.connection.provider + .getGasPrice() + .then(bn => Decimal.fromBigNumberString(bn.toHexString())), + + liquity.getTroves({ + first: 1000, + sortedBy: "ascendingCollateralRatio" + }) + ]); + + const [addresses, troves] = riskiestTroves + .filter(underCollateralized(store.state.price)) + .sort(byDescendingCollateral) + .slice(0, 40) + .reduce(unzip, [[], []]); + + if (troves.length === 0) { + // Nothing to liquidate + return; + } + + try { + const liquidation = await liquity.populate.liquidate(addresses, { gasPrice: gasPrice.hex }); + const gasLimit = liquidation.rawPopulatedTransaction.gasLimit.toNumber(); + const expectedCost = gasPrice.mul(gasLimit).mul(store.state.price); + + const total = troves.reduce((a, b) => a.add(b)); + const expectedCompensation = total.collateral + .mul(0.005) + .mul(store.state.price) + .add(LUSD_LIQUIDATION_RESERVE.mul(troves.length)); + + if (expectedCost.gt(expectedCompensation)) { + // In reality, the TX cost will be lower than this thanks to storage refunds, but let's be + // on the safe side. + warn( + "Skipping liquidation due to high TX cost " + + `($${expectedCost.toString(2)} > $${expectedCompensation.toString(2)}).` + ); + return; + } + + info(`Attempting to liquidate ${troves.length} Trove(s)...`); + + const tx = await liquidation.send(); + const receipt = await tx.waitForReceipt(); + + if (receipt.status === "failed") { + error(`TX ${receipt.rawReceipt.transactionHash} failed.`); + return; + } + + const { collateralGasCompensation, lusdGasCompensation, liquidatedAddresses } = receipt.details; + const gasCost = gasPrice.mul(receipt.rawReceipt.gasUsed.toNumber()).mul(store.state.price); + const totalCompensation = collateralGasCompensation + .mul(store.state.price) + .add(lusdGasCompensation); + + success( + `Received ${bold(`${collateralGasCompensation.toString(4)} ETH`)} + ` + + `${bold(`${lusdGasCompensation.toString(2)} LUSD`)} compensation (` + + (totalCompensation.gte(gasCost) + ? `${green(`$${totalCompensation.sub(gasCost).toString(2)}`)} profit` + : `${red(`$${gasCost.sub(totalCompensation).toString(2)}`)} loss`) + + `) for liquidating ${liquidatedAddresses.length} Trove(s).` + ); + } catch (err) { + error("Unexpected error:"); + console.error(err); + } +} + +main().catch(err => { + console.error(err); + process.exit(1); +});