Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,18 @@ yarn start liquidationBot --network mainnet_arbitrum \

#### Run the liquidation bot under PM2 process manager

It would restart the bot in case of crashed, provides a basic dashboard with
Note: this may not work on Windows machine because of pm2 Windows issues

It would restart the bot in case of crashes, provides a basic dashboard with
metrics, save logs into files and ensure that bot will not stop after you will
close the terminal

Unfortunately, passing parameters to the commands below like it has been done
in the command above will not work. The easiest way would be to add `NETWORK`
and `EXCHANGE_ADDRESS` to your `.env` file like it explained in
[Additional parameters](#additional-parameters) section above or to add them to
`pm2.config.js` file to `env` field e.g. replacing
in the command above will not work. The easiest way would be to add
`ACCOUNT_NUMBER`, `NETWORK`, `${NETWORK}_EXCHANGE`, `${NETWORK}_MNEMONIC`,
`${NETWORK}_CHAINID`, and `${NETWORK}_RPC_URL` to your `.env` file like it
explained in [Additional parameters](#additional-parameters) section above or
to add them to `pm2.config.js` file to `env` field e.g. replacing

```javascript
env: {
Expand All @@ -152,8 +155,12 @@ env: {
TS_NODE_FILES: true,
TS_NODE_TRANSPILE_ONLY: true,
REPORTING: "pm2",
NETWORK "mainnet_arbitrum",
EXCHANGE_ADDRESS" "0x1B5A08020E94066a3fB91Aff8B395De2d9cfaDd2"
ACCOUNT_NUMBER: 0,
NETWORK: "MAINNET_ARBITRUM",
MAINNET_ARBITRUM_MNEMONIC: "<your secret mnemonic phrase>",
MAINNET_ARBITRUM_CHAINID: 42161,
MAINNET_ARBITRUM_RPC_URL: "<your Infura or Alchemy JSON-RPC endpoint URL>",
MAINNET_ARBITRUM_EXCHANGE: "0x1B5A08020E94066a3fB91Aff8B395De2d9cfaDd2",
},
```

Expand Down
29 changes: 15 additions & 14 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"description": "",
"scripts": {
"test": "DOTENV_CONFIG_PATH=.env.test jest --detectOpenHandles",
"test:windows": "set DOTENV_CONFIG_PATH=.env.test && jest --detectOpenHandles",
"start": "npx ts-node src/index.ts",
"liquidationBot:pm2:start": "pm2 reload pm2.config.js",
"liquidationBot:pm2:stop": "pm2 stop pm2.config.js",
Expand All @@ -22,29 +23,29 @@
"lodash": "^4.17.21",
"node-input-validator": "^4.5.0",
"pm2": "^5.1.2",
"tx2": "^1.0.4",
"yargs": "^17.2.1"
"tx2": "^1.0.5",
"yargs": "^17.3.1"
},
"devDependencies": {
"@openzeppelin/contracts": "^4.3.2",
"@types/jest": "^27.0.2",
"@types/lodash": "^4.14.176",
"@types/node": "^16.11.6",
"@types/yargs": "17.0.5",
"@types/jest": "^27.0.3",
"@types/lodash": "^4.14.178",
"@types/node": "^16.11.17",
"@types/yargs": "17.0.8",
"@uniswap/v3-core": "^1.0.0",
"chai": "4.3.4",
"date-fns": "^2.25.0",
"date-fns": "^2.28.0",
"dot-only-hunter": "1.0.3",
"dotenv": "^10.0.0",
"ethers": "5.4.1",
"jest": "^27.3.1",
"jest-extended": "^1.1.0",
"prettier": "^2.4.1",
"prettier-plugin-solidity": "1.0.0-beta.18",
"jest": "^27.4.5",
"jest-extended": "^1.2.0",
"prettier": "^2.5.1",
"prettier-plugin-solidity": "1.0.0-beta.19",
"rimraf": "3.0.2",
"ts-jest": "^27.0.7",
"ts-jest": "^27.1.2",
"ts-node": "^10.4.0",
"tsconfig-paths": "^3.11.0",
"typescript": "^4.4.4"
"tsconfig-paths": "^3.12.0",
"typescript": "^4.5.4"
}
}
2 changes: 1 addition & 1 deletion pm2.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module.exports = {
apps: [
{
name: "Liquidation Bot",
script: "./src/index.ts",
script: path.resolve("./src/index.ts"),
args: "liquidationBot",
interpreter: path.resolve("./node_modules/.bin/ts-node"),
interpreter_args: `--project=${path.resolve("./tsconfig.json")}`,
Expand Down
135 changes: 94 additions & 41 deletions src/liquidationBot/__tests__/integration/liquidationBot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ const setupMocks = (
mockLiquidate: jest.MockedFunction<() => Promise<Symbol>>;
mockIsLiquidatable: jest.Mock;
start: () => void;
consts: {
exchangeLaunchBlock: number;
maxBlocksPerJsonRpcQuery: number;
fetcherRetryIntervalSec: number;
checkerRetryIntervalSec: number;
liquidatorRetryIntervalSec: number;
maxTradersPerLiquidationCheck: number;
};
} => {
// TODO `as any as Type` conversion is not safe. It would be nice to replace it with a more
// comprehensive mock. One that would through a meaningful error if an unexpected property is
Expand All @@ -50,29 +58,44 @@ const setupMocks = (
callStatic: { isLiquidatable: mockIsLiquidatable },
} as any as LiquidationBotApi;

const consts = {
exchangeLaunchBlock: 0,
maxBlocksPerJsonRpcQuery: 100,
fetcherRetryIntervalSec: 0.02,
checkerRetryIntervalSec: 0.01,
liquidatorRetryIntervalSec: 0.005,
maxTradersPerLiquidationCheck: 1000,
};

const mockProvider = {
getBlockNumber: () => 10,
} as any as Provider;

const start = () => {
// NOTE Timeouts here need to be very low, as we need to wait for a timeout to expire when when
// NOTE Timeouts here need to be very low, as we need to wait for a timeout to expire when
// are stopping our tests. So the shorter the timeouts are, the less time our tests will waste
// when stopping.
liquidationBot.start(
mockProvider,
mockExchange,
mockExchangeEvents,
mockLiquidationBotApi,
0,
100,
0.01,
0.005,
0.001,
1000
consts.exchangeLaunchBlock,
consts.maxBlocksPerJsonRpcQuery,
consts.fetcherRetryIntervalSec,
consts.checkerRetryIntervalSec,
consts.liquidatorRetryIntervalSec,
consts.maxTradersPerLiquidationCheck
);
};

return { mockChangePositionEvents, mockLiquidate, mockIsLiquidatable, start };
return {
mockChangePositionEvents,
mockLiquidate,
mockIsLiquidatable,
start,
consts,
};
};

describe("liquidationBot", () => {
Expand Down Expand Up @@ -103,15 +126,33 @@ describe("liquidationBot", () => {
})();
};

const onceBotEvent = async <EventType extends EventTypes>(
eventType: EventType
): Promise<LiquidationBotEvents & { type: EventType }> => {
type EventsByEventsTypes<EventsTypes extends EventTypes[]> =
EventsTypes extends [infer EventType, ...infer RestEventsTypes]
? RestEventsTypes extends EventTypes[]
? [
LiquidationBotEvents & { type: EventType },
...EventsByEventsTypes<RestEventsTypes>
]
: never
: [];

// `| [EventTypes]` is a hint to ensure that
// inferred type of EventsTypes would be tuple and not array.
// https://github.com/microsoft/TypeScript/issues/27179 - see last comments.
// Alternative with multiple `readonly` and `as const` in every call is much uglier.
const onceBotEvents = async <EventsTypes extends EventTypes[] | [EventTypes]>(
eventsTypes: EventsTypes
): Promise<EventsByEventsTypes<EventsTypes>> => {
const collectedEvents = [];
for await (const event of liquidationBot.getEventsIterator()) {
if (event.type === eventType) {
return event as any;
if (event.type === eventsTypes[collectedEvents.length]) {
collectedEvents.push(event);
if (collectedEvents.length == eventsTypes.length) {
break;
}
}
}
return undefined as never; // unreachable. Just for compiler
return collectedEvents as EventsByEventsTypes<EventsTypes>;
};

afterEach(async () => {
Expand All @@ -133,7 +174,7 @@ describe("liquidationBot", () => {
mockLiquidate.mockResolvedValueOnce(mockLiquidationResult);

start();
const { trader } = await onceBotEvent("traderLiquidated");
const [{ trader }] = await onceBotEvents(["traderLiquidated"]);

expect(trader).toEqual("trader1");
});
Expand All @@ -147,8 +188,7 @@ describe("liquidationBot", () => {

start();
collectBotEvents("traderLiquidated", "error");
await onceBotEvent("tradersChecked");
await onceBotEvent("tradersFetched");
await onceBotEvents(["tradersChecked", "tradersChecked"]);

expect(botEvents).toBeEmpty();
});
Expand All @@ -161,8 +201,7 @@ describe("liquidationBot", () => {
mockIsLiquidatable.mockResolvedValue([false]);

start();
await onceBotEvent("tradersChecked");
await onceBotEvent("tradersFetched");
await onceBotEvents(["tradersChecked", "tradersFetched"]);

closePositions(mockChangePositionEvents, ["trader1"]);
mockIsLiquidatable.mockResolvedValue([true]);
Expand All @@ -184,7 +223,7 @@ describe("liquidationBot", () => {
mockLiquidate.mockResolvedValueOnce(mockLiquidationResult);

start();
const { trader } = await onceBotEvent("traderLiquidated");
const [{ trader }] = await onceBotEvents(["traderLiquidated"]);

expect(trader).toEqual("trader2");
});
Expand All @@ -199,7 +238,7 @@ describe("liquidationBot", () => {

start();
collectBotEvents("tradersChecked", "traderLiquidated", "error");
await onceBotEvent("traderLiquidated");
await onceBotEvents(["traderLiquidated"]);

expect(botEvents).toEqual([
expect.objectContaining({ type: "tradersChecked" }),
Expand All @@ -224,9 +263,7 @@ describe("liquidationBot", () => {

start();
collectBotEvents("tradersChecked", "traderLiquidated", "error");
await onceBotEvent("tradersChecked");
await onceBotEvent("tradersChecked");
await onceBotEvent("tradersChecked");
await onceBotEvents(["tradersChecked", "tradersChecked", "tradersChecked"]);

expect(botEvents).toEqual([
expect.objectContaining({ type: "tradersChecked" }),
Expand All @@ -253,8 +290,10 @@ describe("liquidationBot", () => {

start();
collectBotEvents("tradersChecked", "traderLiquidated", "error");
await onceBotEvent("error"); // mock liquidate error
await onceBotEvent("traderLiquidated");
await onceBotEvents([
"error", // mock liquidate error
"traderLiquidated",
]);

expect(botEvents).toEqual([
expect.objectContaining({ type: "tradersChecked" }),
Expand Down Expand Up @@ -282,8 +321,10 @@ describe("liquidationBot", () => {

start();
collectBotEvents("traderLiquidated", "error");
await onceBotEvent("error"); // mock liquidate error
await onceBotEvent("tradersChecked");
await onceBotEvents([
"error", // mock liquidate error
"tradersChecked",
]);

expect(botEvents).toEqual([
{ type: "error", error: expect.any(LiquidationError) },
Expand All @@ -306,9 +347,11 @@ describe("liquidationBot", () => {

start();
collectBotEvents("traderLiquidated", "error");
await onceBotEvent("error"); // mock liquidate error 1
await onceBotEvent("error"); // mock liquidate error 2
await onceBotEvent("tradersChecked");
await onceBotEvents([
"error", // mock liquidate error 1
"error", // mock liquidate error 2
"tradersChecked",
]);

expect(botEvents).toEqual([
{ type: "error", error: expect.any(LiquidationError) },
Expand All @@ -317,31 +360,41 @@ describe("liquidationBot", () => {
});

it("should determine liquidatable traders when number of active traders exceeds the chunk size of liquidation bot api", async () => {
const activeTraders = Array.from({ length: 5_000 }, (_, i) => `trader${i}`);
const {
mockChangePositionEvents,
mockLiquidate,
mockIsLiquidatable,
start,
consts,
} = setupMocks(liquidationBot);

consts.maxTradersPerLiquidationCheck = 100;
const activeTraders = Array.from({ length: 500 }, (_, i) => `trader${i}`);
openPositions(mockChangePositionEvents, activeTraders);
mockIsLiquidatable.mockResolvedValue([false, true]);
const mockLiquidationResult = Symbol("mockLiquidationResult");
mockLiquidate.mockResolvedValueOnce(mockLiquidationResult);

start();
const { trader: trader1 } = await onceBotEvent("traderLiquidated");
const { trader: trader2 } = await onceBotEvent("traderLiquidated");
const { trader: trader3 } = await onceBotEvent("traderLiquidated");
const { trader: trader4 } = await onceBotEvent("traderLiquidated");
const { trader: trader5 } = await onceBotEvent("traderLiquidated");
const [
{ trader: trader1 },
{ trader: trader2 },
{ trader: trader3 },
{ trader: trader4 },
{ trader: trader5 },
] = await onceBotEvents([
"traderLiquidated",
"traderLiquidated",
"traderLiquidated",
"traderLiquidated",
"traderLiquidated",
]);

expect(trader1).toEqual("trader1");
expect(trader2).toEqual("trader1001");
expect(trader3).toEqual("trader2001");
expect(trader4).toEqual("trader3001");
expect(trader5).toEqual("trader4001");
expect(trader2).toEqual("trader101");
expect(trader3).toEqual("trader201");
expect(trader4).toEqual("trader301");
expect(trader5).toEqual("trader401");
});
});

Expand Down
4 changes: 2 additions & 2 deletions src/liquidationBot/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ export type LiquidationBot = Reportable & {
liquidationBotApi: LiquidationBotApi,
exchangeLaunchBlock: number,
maxBlocksPerJsonRpcQuery: number,
maxTradersPerLiquidationCheck: number,
fetcherRetryIntervalSec: number,
checkerRetryIntervalSec: number,
liquidatorRetryIntervalSec: number
liquidatorRetryIntervalSec: number,
maxTradersPerLiquidationCheck: number,
) => Promise<void>;
join: () => Promise<void>;
stop: () => Promise<void>;
Expand Down
8 changes: 3 additions & 5 deletions src/liquidationBot/services/liquidationBot/liquidationBot.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Trader } from "@liquidationBot/types";
import type { LiquidationBotApi } from "@generated/LiquidationBotApi";
import { chunk } from "lodash";
import { CheckError } from "@liquidationBot/errors";
import { LiquidationBotApi } from "@generated/LiquidationBotApi";

export type LiquidatableTradersCheckResult = Trader[] | CheckError;

Expand All @@ -21,10 +21,8 @@ export const constructFilterLiquidatableTraders: ConstructFilter = (
chunkSize: number
) =>
async function* (traders: Trader[]) {
for (const [chunkIndex, chunkOfTraders] of chunk(
traders,
chunkSize
).entries()) {
const chunksOfTraders = chunk(traders, chunkSize);
for (const [chunkIndex, chunkOfTraders] of chunksOfTraders.entries()) {
try {
const areLiquidatable =
await liquidationBotApi.callStatic.isLiquidatable(
Expand Down
Loading