Skip to content
This repository has been archived by the owner on Oct 14, 2022. It is now read-only.

Commit

Permalink
adding auto swapping and targeting specific markets
Browse files Browse the repository at this point in the history
  • Loading branch information
0xCactus committed Jul 18, 2022
1 parent 03a405b commit f613122
Show file tree
Hide file tree
Showing 9 changed files with 322 additions and 51 deletions.
71 changes: 51 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,85 @@
# Solend-liquidator-bot

open-source version of a liquidation bot running against Solend
Table of contents
* [Overview](#overview)
* [Basic Usage](#basic-usage)
* [FAQ](#faq)
+ [Enabling wallet rebalancing](#enabling-wallet-rebalancing)
+ [Rebalance padding](#rebalance-padding)
+ [Target specific markets](#target-specific-markets)
+ [Tweak throttling](#tweak-throttling)
* [Support](#support)

## Overview

The Solend liquidator bot identifies and liquidates overexposed obligations. Solend awards liquidators a 5% bonus on each liquidation. See [Solend params](https://docs.solend.fi/protocol/parameters) for the most up-to-date parameters on each asset. This repo is intended as a starting point for the Solend community to build their liquidator bots.
The Solend liquidator bot identifies and liquidates overexposed obligations. Solend awards liquidators a 5-20% bonus on each liquidation. Visit [Solend documentation](https://docs.solend.fi/protocol/parameters) for the parameters on each asset. This repo is intended as a starting point for the Solend community to build their liquidator bots.

## Usage
## Basic Usage

A file system wallet funded with SOL, USDC, ETH, SRM BTC is required to liquidate obligations. Users will need to manually rebalance wallet whenever a token is depleted.
A funded file system wallet is required to liquidate obligations. Users may choose to [enable auto rebalancing](#enabling-wallet-rebalancing) or manually rebalance after tokens have been used to repay a debt.

1. Install [docker engine](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/)
1. A private RPC is required as public RPCs have strict rate limiting and response size restrictions. You can get a private rpc set up in minutes through [Figment](https://www.figment.io/datahub/solana) which provides a free tier of 3m request/month and the PRO tier costs only $50/month. Set your private RPC in `docker-compose.yaml`
```
- RPC_ENDPOINT=<YOUR PRIVATE RPC ENDPOINT>
```

2. Update [file system wallet](https://docs.solana.com/wallet-guide/file-system-wallet) path in docker-compose.yaml.
2. Install [docker engine](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/)

3. Update [file system wallet](https://docs.solana.com/wallet-guide/file-system-wallet) path in docker-compose.yaml.

```
secrets:
keypair:
file: <PATH TO KEYPAIR WALLET THAT WILL BE LIQUIDATING UNDERWATER OBLIGATIONS>
```

3. Build and run liquidator for all pools
4. Build and run liquidator

```
docker-compose up --build
```

To run liquidator in background:
P.S. To run liquidator in background:
```
docker-compose up --build -d
```


## FAQ
1. How to target a specific market?
The liquidator by default checks the health of all markets (aka isolated pools) e.g main, turbo sol, dog, invictus, etc... If you have the necessary assets in your wallet, the liquidator will attempt to liquidate the unhealhty obligation, otherwise, it simply tells you "insufficient fund" and move on to check the next obligation. If you want to target a specific market, you just need to specify the MARKET param in `docker-compose.yaml` with the market address. You may find all the market address for solend in `https://api.solend.fi/v1/config?deployment=production`
### Enabling wallet rebalancing
The auto rebalancing has to be explicitly enabled by specifying the token distribution in `docker-compose.yaml`. Under the hood, the liquidator uses [Jupiter](https://docs.jup.ag/) to rebalance against the USDC<>token pair. Make sure your wallet has an excess amount of USDC to cover liquidation of USDC positions along with rebalancing of other assets. A rule of thumb of USDC to hold is (targeted USDC amount * 1.3). Note that since USDC is base token, we will only use USDC to purchase other tokens when required but not use other tokens to purchase USDC when USDC holdings is below target value. The rebalancer neglets the USDC target value and its only listed to ensure users deposit USDC. Nonetheless, do not remove USDC from the target distribution.

Steps:

1. In `docker-compose.yaml`, uncomment the following line
```
# - TARGETS=USDC:100 USDT:5 scnSOL:0.5 SOL:0.5
```

2. Specify the distribution you would like. The following format is required:
```
<TokenA>:<amount> <TokenB>:<amount> ... <TokenZ>:<amount>
```
The amount is in token units e.g `SOL:1` means 1 SOL and `ETH:2` mean 2 ETH. The distribution is set using token units instead of token price to keep rebalancer independent of price fluctuations but only after a liquidation transaction has been executed.

Example: Distribution where we expect the liquidator wallet to be holding 500 USDT, 10 SOL and 1 ETH. As mentioned above, the USDC target is ignored as it is the base token we trade to/from.
```
# - TARGETS=USDC:1000 USDT:500 SOL:10 ETH:1
```

### Rebalance padding
The env variable `REBALANCE_PADDING` is introduced in `docker-compose.yaml` to avoid unnecessary padding. If the targeted SOL amount is 10 and `REBALANCE_PADDING` is 0.2, we will only swap USDC for SOL when SOL holding is under 8 SOL = (10 SOL * (1 - REBALANCE_PADDING )) and only sell when SOL holding is over 12 SOL = (10 SOL * (1 + REBALANCE_PADDING)). Default padding is set to 0.2

### Target specific markets
BY default the liquidator runs against all solend created pools e.g main, TURBO SOL, dog, etc... If you want to target specific markets, you just need to specify the MARKETS param in `docker-compose.yaml` separated by commas. The following definition will configures the liquidator to only run against the main and coin98 pools.

2. How to change RPC network
By default we use the public solana rpc which is slow and highly rate limited. We strongly suggest using a custom RPC network e.g rpcpool, figment, etc.. so your bot may be more competitive and win some liquidations. Once you have your rpc url, you may specify it in `config.ts`
```
{
name: 'production',
endpoint: '<YOUR CUSTOM RPC NETWORK URL>',
},
MARKET=4UpD2fh7xH3VP9QQaXtsS1YY3bxzWhtfpks7FatyKvdY,7tiNvRHSjYDfc6usrWnSNPyuN68xQfKs1ZG2oqtR5F46
```

3. How to tweak throttling?
If you have a custom rpc network, you would want to disable the default throttling we have set up by specifying the THROTTLE environment variable in `docker-compose.yaml` to 0
### Tweak throttling
If you are getting rate limited by your RPC provider, you can use the following env variable to avoid getting rate limited. If you have a custom RPC provider, you will be fine without any throttling.
```
- THROTTLE=0 # Throttle not avoid rate limiting
- THROTTLE=1000 # Throttle not avoid rate limiting. In milliseconds
```

## Support
Expand Down
18 changes: 15 additions & 3 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,22 @@ services:
context: .
dockerfile: Dockerfile
environment:
# production or devnet
- APP=production
- THROTTLE=1900 # Throttle not avoid rate limiting
# Uncomment line below and specify lending market if you only want to target a single pool
# - MARKET=4UpD2fh7xH3VP9QQaXtsS1YY3bxzWhtfpks7FatyKvdY
# A private RPC provider is strongly recommended. A public rpc will often fail and crash the liquidator
- RPC_ENDPOINT=
# Throttle not avoid rate limiting
- THROTTLE=0
# Padding against each wallet rebalancing target to avoid unnecessary rebalancing.
# If wallet is expected to hold 2 ETH. The rebalancer will buy if wallet has less than 2 * (1-REBALANCE_PADDING) ETH and sell
# if wallet has over 2 * (1+REBALANCE_PADDING) ETH
- REBALANCE_PADDING=0.2
# Specify target below for wallet auto rebalancing. Ensure to keep the format "tokenA:amount tokenB:amount ..."
# USDC is the base currency that every other token will swap against so besure to allocate an access amount of USDC
# Note: the amount is in token unit so ETH:2 means we'll always rebalance to have 2 ETH
# - TARGETS=USDC:100 USDT:5 scnSOL:0.5 SOL:0.5
# For targeting specific markets. All markets can be found in https://api.solend.fi/v1/markets/configs?scope=all
# - MARKET=4UpD2fh7xH3VP9QQaXtsS1YY3bxzWhtfpks7FatyKvdY,7tiNvRHSjYDfc6usrWnSNPyuN68xQfKs1ZG2oqtR5F46,GktVYgkstojYd8nVXGXKJHi7SstvgZ6pkQqQhUPD7y7Q
secrets:
- keypair # secret to encrypte wallet details in container

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
],
"license": "ISC",
"dependencies": {
"@jup-ag/core": "=1.0.0-beta.22",
"@project-serum/sol-wallet-adapter": "^0.2.0",
"@pythnetwork/client": "^2.0.0",
"@solana/spl-token": "^0.1.4",
Expand Down
25 changes: 12 additions & 13 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,30 @@ import { MarketConfig } from 'global';
export const OBLIGATION_LEN = 1300;
export const RESERVE_LEN = 619;
export const LENDING_MARKET_LEN = 290;
export const ENDPOINTS = [
{
name: 'production',
endpoint: 'https://solana-api.projectserum.com',
},
{
name: 'devnet',
endpoint: 'https://api.devnet.solana.com',
},
];
const eligibleApps = ['production', 'devnet'];

function getApp() {
const app = process.env.APP;
if (!eligibleApps.includes(app!)) {
throw new Error(`Unrecognized env app provided: ${app}`);
throw new Error(`Unrecognized env app provided: ${app}. Must be production or devnet`);
}
return app;
}

function getMarketsUrl(): string {
// Only fetch the targeted markets if specified. Otherwise we fetch all solend pools
if (process.env.MARKET) {
return `https://api.solend.fi/v1/markets/configs?ids=${process.env.MARKET}`;
}

return `https://api.solend.fi/v1/markets/configs?scope=solend&deployment=${getApp()}`;
}

export async function getMarkets(): Promise<MarketConfig[]> {
let attemptCount = 0;
let backoffFactor = 1;
const maxAttempt = 10;
const marketUrl = getMarketsUrl();

do {
try {
Expand All @@ -37,7 +37,7 @@ export async function getMarkets(): Promise<MarketConfig[]> {
backoffFactor *= 2;
}
attemptCount += 1;
const resp = await got(`https://api.solend.fi/v1/markets/configs?scope=solend&deployment=${getApp()}`, { json: true });
const resp = await got(marketUrl, { json: true });
const data = resp.body as MarketConfig[];
return data;
} catch (error) {
Expand All @@ -49,4 +49,3 @@ export async function getMarkets(): Promise<MarketConfig[]> {
}

export const network = getApp();
export const clusterUrl = ENDPOINTS.find((env) => env.name === network);
5 changes: 5 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,8 @@ export interface Reserve {
liquidityFeeReceiverAddress: string;
userSupplyCap?: number;
}

export interface TokenCount {
symbol: String,
target: number,
}
106 changes: 106 additions & 0 deletions src/libs/rebalanceWallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/* eslint-disable no-lonely-if */
/* eslint-disable no-continue */
/* eslint-disable no-restricted-syntax */
/* eslint-disable no-param-reassign */
import { findWhere } from 'underscore';
import BigNumber from 'bignumber.js';
import { Token, TOKEN_PROGRAM_ID } from '@solana/spl-token';
import { PublicKey } from '@solana/web3.js';
import { TokenCount } from 'global';
import swap from './swap';

// Padding so we rebalance only when abs(target-actual)/target is greater than PADDING
const PADDING = Number(process.env.REBALANCE_PADDING) || 0.2;

export async function rebalanceWallet(connection, payer, jupiter, tokensOracle, walletBalances, target) {
const info = await aggregateInfo(tokensOracle, walletBalances, connection, payer, target);
// calculate token diff between current & target value
info.forEach((tokenInfo) => {
tokenInfo.diff = tokenInfo.balance - tokenInfo.target;
tokenInfo.diffUSD = tokenInfo.diff * tokenInfo.price;
});

// Sort in decreasing order so we sell first then buy
info.sort((a, b) => b.diffUSD - a.diffUSD);

for (const tokenInfo of info) {
// skip usdc since it is our base currency
if (tokenInfo.symbol === 'USDC') {
continue;
}

// skip if exchange amount is too little
if (Math.abs(tokenInfo.diff) <= PADDING * tokenInfo.target) {
continue;
}

let fromTokenInfo;
let toTokenInfo;
let amount;

const USDCTokenInfo = findWhere(info, { symbol: 'USDC' });
if (!USDCTokenInfo) {
console.error('failed to find USDC token info');
}

// negative diff means we need to buy
if (tokenInfo.diff < 0) {
fromTokenInfo = USDCTokenInfo;
toTokenInfo = tokenInfo;
amount = (new BigNumber(tokenInfo.diffUSD).multipliedBy(fromTokenInfo.decimals)).abs();

// positive diff means we sell
} else {
fromTokenInfo = tokenInfo;
toTokenInfo = USDCTokenInfo;
amount = new BigNumber(tokenInfo.diff).multipliedBy(fromTokenInfo.decimals);
}

try {
await swap(connection, payer, jupiter, fromTokenInfo, toTokenInfo, Math.floor(amount.toNumber()));
} catch (error) {
console.error({ error }, 'failed to swap tokens');
}
}
}

function aggregateInfo(tokensOracle, walletBalances, connection, wallet, target) {
const info: any = [];
target.forEach(async (tokenDistribution: TokenCount) => {
const { symbol, target } = tokenDistribution;
const tokenOracle = findWhere(tokensOracle, { symbol });
const walletBalance = findWhere(walletBalances, { symbol });

if (walletBalance) {
// -1 as sentinel value for account not available
if (walletBalance.balance === -1) {
const token = new Token(
connection,
new PublicKey(tokenOracle.mintAddress),
TOKEN_PROGRAM_ID,
wallet,
);

// create missing ATA for token
const ata = await token.createAssociatedTokenAccount(wallet.publicKey);
walletBalance.ata = ata.toString();
walletBalance.balance = 0;
}

const usdValue = new BigNumber(walletBalance.balance).multipliedBy(tokenOracle.price);
info.push({
symbol,
target,
mintAddress: tokenOracle.mintAddress,
ata: walletBalance.ata?.toString(),
balance: walletBalance.balance,
usdValue: usdValue.toNumber(),
price: tokenOracle.price.toNumber(),
decimals: tokenOracle.decimals,
reserveAddress: tokenOracle.reserveAddress,
});
}
});

return info;
}
69 changes: 69 additions & 0 deletions src/libs/swap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/* eslint-disable prefer-promise-reject-errors */
import { Jupiter } from '@jup-ag/core';
import {
Connection, Keypair, PublicKey,
} from '@solana/web3.js';

const SLIPPAGE = 2;
const SWAP_TIMEOUT_SEC = 20;

export default async function swap(connection: Connection, wallet: Keypair, jupiter: Jupiter, fromTokenInfo, toTokenInfo, amount: number) {
console.log({
fromToken: fromTokenInfo.symbol,
toToken: toTokenInfo.symbol,
amount: amount.toString(),
}, 'swapping tokens');

const inputMint = new PublicKey(fromTokenInfo.mintAddress);
const outputMint = new PublicKey(toTokenInfo.mintAddress);
const routes = await jupiter.computeRoutes({
inputMint, // Mint address of the input token
outputMint, // Mint address of the output token
inputAmount: amount, // raw input amount of tokens
slippage: SLIPPAGE, // The slippage in % terms
});

// Prepare execute exchange
const { execute } = await jupiter.exchange({
routeInfo: routes.routesInfos[0],
});

// Execute swap
await new Promise((resolve, reject) => {
// sometime jup hangs hence the timeout here.
let timedOut = false;
const timeoutHandle = setTimeout(() => {
timedOut = true;
console.error(`Swap took longer than ${SWAP_TIMEOUT_SEC} seconds to complete.`);
reject('Swap timed out');
}, SWAP_TIMEOUT_SEC * 1000);

execute().then((swapResult: any) => {
if (!timedOut) {
clearTimeout(timeoutHandle);

console.log({
tx: swapResult.txid,
inputAddress: swapResult.inputAddress.toString(),
outputAddress: swapResult.outputAddress.toString(),
inputAmount: swapResult.inputAmount / fromTokenInfo.decimals,
outputAmount: swapResult.outputAmount / toTokenInfo.decimals,
inputToken: fromTokenInfo.symbol,
outputToken: toTokenInfo.symbol,
}, 'successfully swapped token');
resolve(swapResult);
}
}).catch((swapError) => {
if (!timedOut) {
clearTimeout(timeoutHandle);
console.error({
err: swapError.error,
tx: swapError.txid,
fromToken: fromTokenInfo.symbol,
toToken: toTokenInfo.symbol,
}, 'error swapping');
resolve(swapError);
}
});
});
}
Loading

0 comments on commit f613122

Please sign in to comment.