Add liquidator

This commit is contained in:
Riordan Panayides 2021-11-09 22:37:10 +00:00
parent ecdb2dd569
commit ec78b01c5e
6 changed files with 3899 additions and 1 deletions

View File

@ -1 +1,39 @@
# liquidator-v3
# liquidator-v3
A script for liquidating undercollateralized account on Mango Markets
## Setup
To run the liquidator you will need:
* A Solana account with some SOL deposited to cover transaction fees
* A Mango Account with some collateral deposited
* Your wallet keypair saved as a JSON file
* `node` and `yarn`
* A clone of this repository
* Dependencies installed with `yarn install`
## Environment Variables
| Variable | Default | Description |
| -------- | ------- | ----------- |
| `CLUSTER` | `mainnet` | The Solana cluster to use |
| `ENDPOINT_URL` | `https://solana-api.projectserum.com` | Your RPC node endpoint |
| `KEYPAIR` | `${HOME}/.config/solana/id.json` | The location of your wallet keypair |
| `GROUP` | `mainnet.1` | Name of the group in ids.json to run the Liquidator against |
| `TARGETS` | `0 0 0 0 0 0 0 0` | Space separated list of the amount of each asset to maintain when rebalancing |
| `INTERVAL` | `3500` | Milliseconds to wait before checking for sick accounts |
| `INTERVAL_ACCOUNTS` | `120000` | Milliseconds to wait before reloading all Mango accounts |
| `INTERVAL_WEBSOCKET` | `300000` | Milliseconds to wait before reconnecting to the websocket |
| `LIQOR_PK` | N/A | Liqor Mango account Public Key, by default uses the largest value account owned by the keypair |
| `WEBHOOK_URL` | N/A | Discord webhook URL to post liquidation events and errors to |
You can add these varibles to a `.env` file in the project root to load automatically on liquidator startup. For example:
```bash
ENDPOINT_URL=https://solana-api.projectserum.com
KEYPAIR=${HOME}/.config/solana/my-keypair.json
TARGETS=500 0.1 0.75 0 0 0 0 0
```
## Rebalancing
The liquidator will attempt to close all perp positions, and balance the tokens in the liqor account after each liquidation. By default it will sell all token assets into USDC. You can choose to maintain a certain amount of each asset through this process by editing the value in the `TARGETS` environment variable at the position of the asset. You can find the order of the assets in the 'oracles' property of the group in [ids.json](https://github.com/blockworks-foundation/mango-client-v3/blob/main/src/ids.json#L81) The program will attempt to make buy/sell orders during balancing to maintain this level.
## Run
```
yarn liquidator
```

59
package.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "liquidator-v3",
"version": "1.0.0",
"repository": "git@github.com:blockworks-foundation/liquidator-v3.git",
"license": "MIT",
"main": "lib/src/index.js",
"types": "lib/src/index.d.ts",
"scripts": {
"liquidator": "ts-node src/liquidator.ts"
},
"devDependencies": {
"@tsconfig/recommended": "^1.0.1",
"@types/big.js": "^6.1.1",
"@types/bn.js": "^5.1.0",
"@types/chai": "^4.2.14",
"@types/jest": "^26.0.9",
"@types/mocha": "^8.2.0",
"@types/node": "^15.12.4",
"@types/yargs": "latest",
"@typescript-eslint/eslint-plugin": "^4.14.2",
"@typescript-eslint/parser": "^4.14.2",
"chai": "^4.3.4",
"cross-env": "^7.0.2",
"eslint": "^7.28.0",
"eslint-config-prettier": "^7.2.0",
"mocha": "^8.4.0",
"prettier": "^2.0.5",
"ts-node": "^9.1.1",
"typedoc": "^0.22.5",
"typescript": "^4.1.3"
},
"files": [
"lib"
],
"prettier": {
"singleQuote": true,
"trailingComma": "all"
},
"dependencies": {
"@blockworks-foundation/mango-client": "^3.2.4",
"@project-serum/anchor": "^0.16.2",
"@project-serum/serum": "0.13.55",
"@project-serum/sol-wallet-adapter": "^0.2.0",
"@solana/spl-token": "^0.1.6",
"@solana/web3.js": "1.21.0",
"axios": "^0.21.1",
"big.js": "^6.1.1",
"bigint-buffer": "^1.1.5",
"bn.js": "^5.2.0",
"buffer-layout": "^1.2.1",
"dotenv": "^10.0.0",
"dotenv-expand": "^5.1.0"
},
"resolutions": {
"bn.js": "5.1.3",
"@types/bn.js": "5.1.0",
"@solana/web3.js": "^1.21.0"
}
}

1164
src/liquidator.ts Normal file

File diff suppressed because it is too large Load Diff

325
test/liquidator.test.ts Normal file
View File

@ -0,0 +1,325 @@
// test is broken :(
import fs from 'fs';
import os from 'os';
import {
Cluster,
Config,
MangoClient,
sleep,
QUOTE_INDEX,
IDS,
} from '@blockworks-foundation/mango-client';
import {
Account,
Commitment,
Connection,
} from '@solana/web3.js';
import { Market } from '@project-serum/serum';
import { Token, TOKEN_PROGRAM_ID } from '@solana/spl-token';
import { spawn } from 'child_process';
async function testPerpLiquidationAndBankruptcy() {
const cluster = (process.env.CLUSTER || 'devnet') as Cluster;
const config = new Config(IDS);
const payer = new Account(
JSON.parse(
process.env.KEYPAIR ||
fs.readFileSync(os.homedir() + '/.config/solana/devnet.json', 'utf-8'),
),
);
const connection = new Connection(
config.cluster_urls[cluster],
'processed' as Commitment,
);
const groupIds = config.getGroup(cluster, 'devnet.3');
if (!groupIds) {
throw new Error(`Group not found`);
}
const client = new MangoClient(connection, groupIds.mangoProgramId);
const mangoGroup = await client.getMangoGroup(groupIds.publicKey);
console.log(mangoGroup.admin.toBase58());
const perpMarkets = await Promise.all(
groupIds.perpMarkets.map((perpMarket) => {
return mangoGroup.loadPerpMarket(
connection,
perpMarket.marketIndex,
perpMarket.baseDecimals,
perpMarket.quoteDecimals,
);
}),
);
const spotMarkets = await Promise.all(
groupIds.spotMarkets.map((spotMarket) => {
return Market.load(
connection,
spotMarket.publicKey,
undefined,
groupIds.serumProgramId,
);
}),
);
// Run keeper
const keeper = spawn('yarn', ['keeper'], {
env: {
CLUSTER: 'devnet',
GROUP: 'devnet.3',
PATH: process.env.PATH
},
});
// keeper.stdout.on('data', (data) => {
// console.log(`keeper stdout: ${data}`);
// });
keeper.stderr.on('data', (data) => {
console.error(`keeper stderr: ${data}`);
});
keeper.on('close', (code) => {
console.log(`keeper exited with code ${code}`);
});
// Run crank
const crank = spawn('yarn', ['crank'], {
env: {
CLUSTER: 'devnet',
GROUP: 'devnet.3',
PATH: process.env.PATH
},
});
crank.stderr.on('data', (data) => {
console.error(`crank stderr: ${data}`);
});
crank.on('close', (code) => {
console.log(`crank exited with code ${code}`);
});
let cache = await mangoGroup.loadCache(connection);
const rootBanks = await mangoGroup.loadRootBanks(connection);
const quoteRootBank = rootBanks[QUOTE_INDEX];
if (!quoteRootBank) {
throw new Error('Quote Rootbank Not Found');
}
const quoteNodeBanks = await quoteRootBank.loadNodeBanks(connection);
const quoteTokenInfo = mangoGroup.tokens[QUOTE_INDEX];
const quoteToken = new Token(
connection,
quoteTokenInfo.mint,
TOKEN_PROGRAM_ID,
payer,
);
const quoteWallet = await quoteToken.getOrCreateAssociatedAccountInfo(
payer.publicKey,
);
const btcToken = new Token(
connection,
mangoGroup.tokens[1].mint,
TOKEN_PROGRAM_ID,
payer,
);
const btcWallet = await btcToken.getOrCreateAssociatedAccountInfo(
payer.publicKey,
);
const liqorPk = await client.initMangoAccount(mangoGroup, payer);
const liqorAccount = await client.getMangoAccount(
liqorPk,
mangoGroup.dexProgramId,
);
console.log('Created Liqor:', liqorPk.toBase58());
const liqeePk = await client.initMangoAccount(mangoGroup, payer);
const liqeeAccount = await client.getMangoAccount(
liqeePk,
mangoGroup.dexProgramId,
);
console.log('Created Liqee:', liqeePk.toBase58());
const makerPk = await client.initMangoAccount(mangoGroup, payer);
const makerAccount = await client.getMangoAccount(
makerPk,
mangoGroup.dexProgramId,
);
console.log('Created Maker:', liqorPk.toBase58());
await client.setStubOracle(
mangoGroup.publicKey,
mangoGroup.oracles[1],
payer,
60000,
);
// await runKeeper();
console.log('Depositing for liqor');
await client.deposit(
mangoGroup,
liqorAccount,
payer,
quoteRootBank.publicKey,
quoteNodeBanks[0].publicKey,
quoteNodeBanks[0].vault,
quoteWallet.address,
100000,
);
console.log('Depositing for liqee');
await client.deposit(
mangoGroup,
liqeeAccount,
payer,
rootBanks[1]!.publicKey,
rootBanks[1]!.nodeBanks[0],
rootBanks[1]!.nodeBankAccounts[0].vault,
btcWallet.address,
1,
);
console.log('Depositing for maker');
await client.deposit(
mangoGroup,
makerAccount,
payer,
quoteRootBank.publicKey,
quoteNodeBanks[0].publicKey,
quoteNodeBanks[0].vault,
quoteWallet.address,
100000,
);
// await runKeeper();
console.log('Placing maker orders');
await client.placePerpOrder(
mangoGroup,
makerAccount,
mangoGroup.mangoCache,
perpMarkets[0],
payer,
'sell',
60000,
0.0111,
'limit',
);
await client.placePerpOrder(
mangoGroup,
makerAccount,
mangoGroup.mangoCache,
perpMarkets[0],
payer,
'buy',
1000,
10,
'limit',
);
await client.placeSpotOrder2(
mangoGroup,
makerAccount,
spotMarkets[1],
payer,
'buy',
100,
3,
'postOnly',
);
console.log('Placing taker order');
await client.placePerpOrder(
mangoGroup,
liqeeAccount,
mangoGroup.mangoCache,
perpMarkets[0],
payer,
'buy',
60000,
1,
'market',
);
// await runKeeper();
await liqeeAccount.reload(connection);
await liqorAccount.reload(connection);
console.log(
'Liqor base',
liqorAccount.perpAccounts[1].basePosition.toString(),
);
console.log(
'Liqor quote',
liqorAccount.perpAccounts[1].quotePosition.toString(),
);
console.log(
'Liqee base',
liqeeAccount.perpAccounts[1].basePosition.toString(),
);
console.log(
'Liqee quote',
liqeeAccount.perpAccounts[1].quotePosition.toString(),
);
await client.setStubOracle(
mangoGroup.publicKey,
mangoGroup.oracles[1],
payer,
100,
);
cache = await mangoGroup.loadCache(connection);
await liqeeAccount.reload(connection);
await liqorAccount.reload(connection);
console.log(
'Liqee Maint Health',
liqeeAccount.getHealthRatio(mangoGroup, cache, 'Maint').toString(),
);
console.log(
'Liqor Maint Health',
liqorAccount.getHealthRatio(mangoGroup, cache, 'Maint').toString(),
);
// Run the liquidator process for 60s
const liquidator = spawn('yarn', ['liquidator'], {
env: {
CLUSTER: 'devnet',
GROUP: 'devnet.3',
KEYPAIR: '/Users/riordan/.config/solana/devnet.json',
LIQOR_PK: liqorAccount.publicKey.toBase58(),
PATH: process.env.PATH
},
});
liquidator.stdout.on('data', (data) => {
console.log(`Liquidator stdout: ${data}`);
});
liquidator.stderr.on('data', (data) => {
console.error(`Liquidator stderr: ${data}`);
});
liquidator.on('close', (code) => {
console.log(`Liquidator exited with code ${code}`);
});
await sleep(60000);
liquidator.kill();
await liqeeAccount.reload(connection);
await liqorAccount.reload(connection);
console.log(
'Liqee Maint Health',
liqeeAccount.getHealthRatio(mangoGroup, cache, 'Maint').toString(),
);
console.log('Liqee Bankrupt', liqeeAccount.isBankrupt);
console.log(
'Liqor Maint Health',
liqorAccount.getHealthRatio(mangoGroup, cache, 'Maint').toString(),
);
}
testPerpLiquidationAndBankruptcy();

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/recommended/tsconfig.json",
"compilerOptions": {
"outDir": "./lib",
"target": "es6",
"lib": ["es2019"],
"allowJs": true,
"checkJs": true,
"declaration": true,
"declarationMap": true,
"noImplicitAny": false,
"resolveJsonModule": true,
"sourceMap": true,
"esModuleInterop": true
},
"include": ["./src/**/*"],
"exclude": ["./src/**/*.test.js", "node_modules", "**/node_modules"]
}

2293
yarn.lock Normal file

File diff suppressed because it is too large Load Diff