Multi-Cloud Deployment (#1)

* Introduce instances list to scale bots and add config templating

* Use env var for wallet address if not provided as path param

* Expose SOL price as metric

* Update docs

* Add auto-swap

* Add Panopticon

* implement backoff stategy in autoswap

* Add retry logic for withdraw and swap

* bump drift-sdk, update ctl.sh and docs

* Update filler bot, add tx metrics

* Add user-metrics

* Update build and dashboard
This commit is contained in:
Patrick Balsiger
2024-04-28 07:25:08 +02:00
committed by GitHub
parent 6bd2c3367b
commit f05fac48ca
58 changed files with 9783 additions and 319 deletions

7
.gitignore vendored
View File

@@ -1,9 +1,12 @@
# Bot
.env
.env*
.build/
# Terraform
.terraform/
terraform.tfstate
terraform.tfstate.backup
values.auto.tfvars
values.auto.tfvars
# Ansible
inventory.cfg

22
.terraform.lock.hcl generated
View File

@@ -24,3 +24,25 @@ provider "registry.terraform.io/digitalocean/digitalocean" {
"zh:fbe5edf5337adb7360f9ffef57d02b397555b6a89bba68d1b60edfec6e23f02c",
]
}
provider "registry.terraform.io/linode/linode" {
version = "2.13.0"
constraints = "2.13.0"
hashes = [
"h1:bsdPJRav766kmjV62GdSnOmaZTfmSMcLNbKyWS9+5Ms=",
"zh:25776adaba8c01251f02326b356e960f5e7ea1bf5f7e60b3b598792a0a3b5b4f",
"zh:30bc848498bcf2ad1366ea32068c2983d982140669aa507bb7a21714fe5f6beb",
"zh:32b5078f3defe920d611da19ff53139cbdff7de74a19238a9611d206255d5eb8",
"zh:32b69e72ed06273550888b5fa6c53b05f37d6fb42aa898b1981e0e40d5332cd3",
"zh:7b1df46d734b461b006f2fc92a8f8e4e810afa5458c50f2016ee99e1541f6a4b",
"zh:7eda947ae4aefd486e758d6f86985607c9764ea55556aacf8a9fcc78780fa6d0",
"zh:832ec3adf887bcbbff99021ca1518e44f51e1c6af0d7fe639ceddc92921df130",
"zh:8cd1dfd9edcdd9432ce567981dc653cc2cdedf6349513614493c37485533d519",
"zh:a5cf20563230d2180fd48a1716315f7ccfb20d8e12eceb29609135122a2a07db",
"zh:b8d66ae8c6fbd31cea8cfc15f8160e3e00b6d79f8afcdd2b88ccbe63c3bbc34e",
"zh:da0a28bde0bf5ac818fc07c7ab1136e7dfb46efa98fd38e1450104582d629f96",
"zh:dc611263577f1ee319a229fb2e47d1ab5ad99cfab8eb4d43c09afcafefb00f1d",
"zh:e23355243d5c024caec26f072035b67707f09ee1231361168919023d2ca15c65",
"zh:e51d4813a58e79bc953c77db295e23a8abf0f8c21afcfc6401dc1010b9613a48",
]
}

View File

@@ -1,15 +1,14 @@
FROM public.ecr.aws/bitnami/node:16
FROM public.ecr.aws/bitnami/node:18
RUN apt-get install git
ENV NODE_ENV=production
RUN npm install -g yarn
RUN npm install -g typescript
RUN npm install -g ts-node
WORKDIR /app
COPY package.json ./
RUN yarn install
COPY . .
RUN yarn install
RUN yarn build
RUN yarn install --production

225
README.md
View File

@@ -2,13 +2,111 @@
> Keeper Bots in the Drift Protocol keep the protocol operational by performing automated actions as autonomous off-chain agents. Keepers are rewarded depending on the duties that they perform.
This repository contains tools to build, run and monitor Keeper bots for Drift on Solana.
This repository contains tools to build, run and monitor Drift Keeper bots on Solana.
Checkout my article about all that stuff: https://nitro.dcentral.systems/drift-keeper-bot
More information:
- https://github.com/drift-labs/keeper-bots-v2/
- https://docs.drift.trade/keeper-bots
- https://docs.drift.trade/tutorial-order-matching-bot
---
## Components
This repository contains several components for automation and monitoring of Drift Keeper bots:
- Keeper Bot
- Wallet-Tracker
- User-Tracker
- Auto-Swap
- Panopticon
### Keeper-Bot
The Keeper-Bot is the core component of this whole thing.
It performs off-chain actions for the Drift Protocol and receives rewards in USDC. A portion of SOL is required to pay for transaction fees.
While there are several types of bots for Drift, this repository is focused on order matching a.k.a filler bots that tries to match orders in the decentralized orderbook.
This repository only serves as a build and deploy stack for the official bot of this source repository: https://github.com/drift-labs/keeper-bots-v2/.
```mermaid
stateDiagram-v2
Terraform --> Linode
Terraform --> DigitalOcean
Linode --> Keeper_1
Linode --> Keeper_2
DigitalOcean --> Keeper_3
DigitalOcean --> Keeper_4
```
### Wallet-Tracker
As the name suggests, this component tracks the current SOL and USDC onchain balance of a given wallet, as well as the current SOL price from Jupiter.
Everything is conveniently exportes as Prometheus metrics and will be scraped by the `Panopticon`.
```mermaid
stateDiagram-v2
WalletTracker --> Solana
WalletTracker --> Jupiter
Solana --> USDC_Balance
Solana --> SOL_Balance
Jupiter --> SOL_Price
```
### User-Tracker
This component tracks the current SOL and USDC balance in the wallet as well as total collateral and unrealized PNL on Drift.
```mermaid
stateDiagram-v2
UserTracker --> Solana
UserTracker --> Drift
Solana --> USDC_Balance
Solana --> SOL_Balance
Drift --> Total_Collateral
Drift --> Unrealized_PNL
```
### Auto-Swap
The Auto-Swap is resposible of keeping the bot afloat by automatically swapping a portion of the profits to SOL in order to pay for transaction fees.
It periodically checks if a configurable amount of USDC collateral is available on your Drift account. If this amount is reached,it withdraws all collateral and swaps a configurable portion of the profits to SOL using Jupiter.
Altough it is possible to configure the swap ratio so that you can make profits in both SOL and USDC. this only seems to work on "slow days". With crazy market fluctuation and high trading volume, consider that you might pay a lot of fees.
```mermaid
stateDiagram-v2
[*] --> checkBalance
checkBalance --> [*]:threshold\nnot reached
checkBalance --> withdraw: threshold\nreached
withdraw --> withdraw: try 3x
withdraw --> [*]: backoff
withdraw --> swap
swap --> swap: try 3x
swap --> [*]
```
Known problem: when Solana is congested, the autoswap routine fails a lot, as the `withdraw` method seems to be somehow unstable.
### Panopticon
This is basically the monitoring stack consisting of a Prometheus and Grafana instance.
As every bot is accompanied by a Prometheus instance, the Prometheus of the Panopticon is configured to scrape the `/federate` endpoint of all available instances to centralize monitoring of all servers, bots and wallets.
```mermaid
stateDiagram-v2
Panopticon --> WalletTracker
Panopticon --> /federate
/federate --> NodeExporter
/federate --> Keeper
```
---
## Prerequisites
- Docker, Docker-Compose
@@ -17,58 +115,141 @@ More information:
- Jito Private Key for auth to block engine API (optional)
- Terraform (optional)
- DigitalOcean API Key (optional)
- Linode API Key (optional)
Drift account is setup according to: https://docs.drift.trade/keeper-bots.
The account can also be set up using the DEX app: https://app.drift.trade/.
## Configure
## Build
In order to build all components and push them to the Docker registry, simply run:
```
./ctl.sh build all
./ctl.sh push all
```
## Run Locally
Create .env file from example and configure all environment variables.
```
cp example.env .env
cp example.env.monitoring .env.monitoring
cp example.env.autoswap .env.autoswap
cp example.env.user-metrics .env.user-metrics
```
Adjust `config.yaml` as you please.
## Build
Clone the [keeper-bots-v2](https://github.com/drift-labs/keeper-bots-v2/) repository and build the Docker image.
Adjust `config.yaml` as you please (e.g. configure Jito).
Then just run the bot.
```
./ctl.sh keeper build
```
## Run
Run the bot.
```
docker compose up
./ctl.sh run
```
## Deploy
Provision a DigitalOcean Droplet and deploy Keeper Bot with current configuration (.env and config.yaml).
By default ~/.ssh/id_rsa.pub is added to DigitalOcean and the Droplet.
Provision DigitalOcean and Linode instances using Terraform.
By default ~/.ssh/id_rsa.pub is added to the root account of each server.
First, create a `values.auto.tfvars` with all your secrets:
```
./ctl.sh droplet provision
do_token = "your-token"
linode_token = "your-token"
bot = {
wallet_address = "your-wallet-address"
rpc_endpoint = "https://your-endpoint"
ws_endpoint = "wss://your-ws-endpoint"
keeper_private_key = "[123,456...789]"
jito_private_key = "[123,456...789]"
}
monitoring = {
grafana_user = "admin"
grafana_password = "grafana"
prometheus_password = "prompass"
}
```
Wait until Droplet is up and the `droplet_ip` is printed. You may connect to the Droplet using
If you want to configure custom servers, you may also add them to your values file:
```
./ctl.sh droplet connect
linode_instances = [
{
label = "DK-LN-AMS"
group = "keeper"
image = "linode/ubuntu23.10"
region = "nl-ams"
type = "g6-standard-1"
ntp_server = "ntp.amsterdam.jito.wtf"
jito_block_engine_url = "amsterdam.mainnet.block-engine.jito.wtf"
use_jito = true
}
]
digitalocean_instances = [
{
label = "DK-DO-FRA"
image = "ubuntu-23-10-x64"
region = "fra1"
type = "s-1vcpu-1gb"
ntp_server = "ntp.frankfurt.jito.wtf"
jito_block_engine_url = "frankfurt.mainnet.block-engine.jito.wtf"
use_jito = true
}
]
```
If no custom instances are provided, the default set from `variables.tf` will be used:
- Linode g6-nanode-1 in Amsterdam (NL)
- Linode g6-nanode-1 in Osaka (JP)
- DigitalOcean s-1vcpu-1gb in Frankfurt (DE)
- DigitalOcean s-1vcpu-1gb in New York (US)
At the time of writing, this setup will cost 22$.
Provision the infrastructure:
```
./ctl.sh infra provision
```
Wait until all instances are up and the `instances` output is printed. You may connect to any server using
```
./ctl.sh infra connect
```
In case somethin went wrong with the provisioning, check the cloud-init-output log at `/var/log/cloud-init-output.log`.
## RPC Providers
In order for Keeper bots to run smoothly, you need to choose a suitable RPC provider that allows all method calls (e.g. getProgramAccounts, etc.).
Checkout https://solana.com/rpc
Helius and ExtrNode are quite suitable with reasonable pricing.
## Geo-Locating Nodes
When deploying multiple bots all around the globe, consider placing the machines near your RPC provider and other Solana nodes:
https://solanacompass.com/statistics/decentralization
Keep in mind, not all Linode datacenters support the `Metadata Service` required to apply the cloud-init config. Check the availability here: https://www.linode.com/docs/products/compute/compute-instances/guides/metadata/#availability
## Maintenance
There are several Ansible playbooks to maintain the servers and the app that can be selected and run through the ctl.sh.
```
./ctl.sh infra playbook
```
## Metrics
There are several metrics endpoints available that are periodically scraped by Prometheus:
- http://keeper:9464/metrics
- http://wallet-tracker:3000/metrics
- http://user-tracker:3000/metrics
- http://node-exporter:9100/metrics
A Grafana dashboard is exposed on http://localhost:3000 with default username/password: admin/grafana.

View File

@@ -0,0 +1,24 @@
- hosts: bots
remote_user: root
become: true
tasks:
- name: Stop Containers
shell: |
cd /app/bot
docker-compose down
- name: Configure Grafana
copy:
src: ../grafana
dest: /app/bot
#- name: Configure Prometheus
# copy:
# src: ../prometheus
# dest: /app/bot
#- name: Configure Bot
# copy:
# src: ../config.yaml
# dest: /app/bot
- name: Start Containers
shell: |
cd /app/bot
sudo -u keeper -g bot -- docker-compose up -d

9
ansible/app-reset.yaml Normal file
View File

@@ -0,0 +1,9 @@
- hosts: bots
remote_user: root
become: true
tasks:
- name: Reset Containers
shell: |
cd /app/bot
docker-compose down -v
sudo -u keeper -g bot -- docker-compose up -d

10
ansible/app-upgrade.yaml Normal file
View File

@@ -0,0 +1,10 @@
- hosts: bots
remote_user: root
become: true
tasks:
- name: Upgrade Containers
shell: |
cd /app/bot
docker-compose down
docker-compose pull
sudo -u keeper -g bot -- docker-compose up -d

View File

@@ -0,0 +1,6 @@
- hosts: bots
remote_user: root
become: true
tasks:
- name: Reboot
reboot:

View File

@@ -0,0 +1,10 @@
- hosts: bots
remote_user: root
become: true
tasks:
- name: Upgrade System
shell: |
apt update
apt upgrade -y
- name: Reboot
reboot:

2
auto-swap/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
node_modules/

7
auto-swap/Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM node:18
WORKDIR /app
COPY package.json ./
RUN npm install
COPY src src
CMD ["npm", "start"]

18
auto-swap/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Auto-Swap
## Configuration
- PRIVATE_KEY: Either use the PRIVATE_KEY or PRIVATE_KEY_FILE variable to set your Solana private key
- SWAP_THRESHOLD: min amount of USDC in Drift account to initiate the swap. Default: 10
- SWAP_RATION: ratio to which the swap should be executed. E.g. 0.5 = 50% of USDC will be swapped to SOL. Default: 0.5
- AUTOSWA_INTERVAL: Time in ms of the interval to check if SWAP_THRESHOLD is reached. Default: 60000
Example: `.env` file:
```
RPC_ENDPOINT=https://your-rpc-endpoint
PRIVATE_KEY="[123,456,...789]"
#PRIVATE_KEY_FILE=~/.config/solana/bot.json
SWAP_THRESHOLD=2
SWAP_RATIO=0.6
AUTOSWAP_INTERVAL=60000
```

1694
auto-swap/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
auto-swap/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "auto-swap",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node --no-warnings=ExperimentalWarning src/main.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"@drift-labs/sdk": "2.78.0-beta.0",
"@solana/web3.js": "^1.89.1",
"bigint-buffer": "^1.1.5",
"dotenv": "^16.4.4"
}
}

310
auto-swap/src/_main.js Normal file
View File

@@ -0,0 +1,310 @@
require('dotenv').config()
const web3 = require("@solana/web3.js");
const drift = require("@drift-labs/sdk");
const LAMPORTS_PER_SOL = 1000000000;
const AUTOSWAP_INTERVAL = process.env.AUTOSWAP_INTERVAL || 1000 * 60;
const USDC_MARKET = 0;
const SOL_MARKET = 1;
const SWAP_RATIO = process.env.SWAP_RATIO || 0.5;
const SWAP_THRESHOLD = process.env.SWAP_THRESHOLD || 10;
const USDC_INT = 1000000;
const SOL_MINT_ADDRESS = 'So11111111111111111111111111111111111111112';
const USDC_MINT_ADDRESS = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
const USDC_MINT_PUBLIC_KEY = new web3.PublicKey(USDC_MINT_ADDRESS);
const keyPairFile = process.env.PRIVATE_KEY || process.env.PRIVATE_KEY_FILE;
const wallet = new drift.Wallet(drift.loadKeypair(keyPairFile));
const connection = new web3.Connection(process.env.RPC_ENDPOINT);
const thresholdReached = (amount) => amount / USDC_INT > SWAP_THRESHOLD;
const driftClient = new drift.DriftClient({
connection,
wallet,
env: 'mainnet-beta',
activeSubAccountId: 0,
subAccountIds: [0],
});
const sleep = (ms) => {
return new Promise(resolve => setTimeout(resolve, ms));
}
const log = (msg) => {
console.log(`[${new Date().toISOString()}] ${msg}`)
};
const quoteNumber = (val) => {
return drift.convertToNumber(val, drift.QUOTE_PRECISION);
}
const baseNumber = (val) => {
return drift.convertToNumber(val, drift.BASE_PRECISION);
}
const quoteUsdcSol = async (amount) => {
const quoteUrl = `https://quote-api.jup.ag/v6/quote?inputMint=${USDC_MINT_ADDRESS}&outputMint=${SOL_MINT_ADDRESS}&amount=${amount}&slippageBps=50`;
const quoteResponse = await (
await fetch(quoteUrl)
).json();
return quoteResponse;
}
const getWalletBalance = async (connection, publicKey) => {
const lamportsBalance = await connection.getBalance(publicKey);
return lamportsBalance / LAMPORTS_PER_SOL;
};
const getUsdcBalance = async (connection, publicKey) => {
const balance = await connection.getParsedTokenAccountsByOwner(
publicKey, { mint: USDC_MINT_PUBLIC_KEY }
);
return balance.value[0]?.account.data.parsed.info.tokenAmount.uiAmount;
};
const printInfo = async (user) => {
let usdcInAccount = quoteNumber(user.getTokenAmount(USDC_MARKET));
let solInWallet = await getWalletBalance(connection, wallet.publicKey);
let usdcWallet = await getUsdcBalance(connection, wallet.publicKey)
log(`USDC in account: ${usdcInAccount}`);
log(`USDC in wallet: ${usdcWallet}`);
log(`SOL in wallet: ${solInWallet}`);
};
const calcSwapAmount = (usdcInAccount) => {
return usdcInAccount * SWAP_RATIO;
};
const getSerializedTransaction = async (quote) => {
return await (
await fetch('https://quote-api.jup.ag/v6/swap', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
quoteResponse: quote,
userPublicKey: wallet.publicKey.toString(),
wrapAndUnwrapSol: true,
dynamicComputeUnitLimit: true, // allow dynamic compute limit instead of max 1,400,000
prioritizationFeeLamports: 'auto' // capped at 5,000,000 lamports / 0.005 SOL.
})
})
).json();
};
const signTransaction = (swapTransaction) => {
const swapTransactionBuf = Buffer.from(swapTransaction, 'base64');
var transaction = web3.VersionedTransaction.deserialize(swapTransactionBuf);
transaction.sign([wallet.payer]);
return transaction;
};
const sendTransaction = async (transaction) => {
const rawTransaction = transaction.serialize()
return connection.sendRawTransaction(rawTransaction, {
skipPreflight: false,
preflightCommitment: 'confirmed',
// Maximum number of times for the RPC node to retry sending the transaction to the leader.
// If this parameter is not provided, the RPC node will retry the transaction until it is finalized or until the blockhash expires.
//maxRetries: 0
})
};
const withdraw = async (marketIndex, withdrawAmount) => {
const amount = driftClient.convertToSpotPrecision(marketIndex, withdrawAmount);
const associatedTokenAccount = await driftClient.getAssociatedTokenAccount(marketIndex);
const reduceOnly = true;
return driftClient.withdraw(
amount,
marketIndex,
associatedTokenAccount,
reduceOnly
);
};
const signAndSendTx = async (quote) => {
let tx = await getSerializedTransaction(quote);
let signedTx = signTransaction(tx.swapTransaction);
return sendTransaction(signedTx);
};
const confirmTx = async (txSig) => {
const latestBlockHash = await connection.getLatestBlockhash();
return connection.confirmTransaction({
blockhash: latestBlockHash.blockhash,
lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
signature: txSig,
}, 'confirmed');
};
const run2 = async () => {
await driftClient.subscribe();
const user = driftClient.getUser();
// force markets to be included
driftClient.perpMarketLastSlotCache.set(SOL_MARKET, Number.MAX_SAFE_INTEGER);
driftClient.perpMarketLastSlotCache.set(USDC_MARKET, Number.MAX_SAFE_INTEGER);
log('---------------------------------')
log("DriftClient initialized")
log(`Swap Ratio: ${SWAP_RATIO}`);
log(`Swap Threshold: ${SWAP_THRESHOLD} USDC`);
printInfo(user);
let swapInProgress = false;
let lastUsdcInAccount = await connection.getBalance(publicKey);
let lastSolInWallet = await getWalletBalance(connection, wallet.publicKey);
setInterval(async () => {
if (!swapInProgress) {
let currentUsdcInAccount = await connection.getBalance(publicKey);
let currentSolInWallet = await getWalletBalance(connection, wallet.publicKey);
let differenceSol = lastSolInWallet - currentSolInWallet;
let differenceUsdc = currentUsdcInAccount - lastUsdcInAccount;
await quoteUsdcSol(differenceUsdc)
.then(quote => {
log(`Swap: ${differenceUsdc / USDC_INT}$ to ${quote.outAmount / LAMPORTS_PER_SOL} SOL`);
//return signAndSendTx(quote);
})
lastUsdcInAccount = currentUsdcInAccount;
}
}, AUTOSWAP_INTERVAL);
};
const run = async () => {
await driftClient.subscribe();
const user = driftClient.getUser();
// force markets to be included
driftClient.perpMarketLastSlotCache.set(SOL_MARKET, Number.MAX_SAFE_INTEGER);
driftClient.perpMarketLastSlotCache.set(USDC_MARKET, Number.MAX_SAFE_INTEGER);
log('---------------------------------')
log("DriftClient initialized")
log(`Swap Ratio: ${SWAP_RATIO}`);
log(`Swap Threshold: ${SWAP_THRESHOLD} USDC`);
printInfo(user);
let swapInProgress = false;
setInterval(async () => {
if (!swapInProgress) {
try {
let usdcInAccount = user.getTokenAmount(USDC_MARKET);
let swapAmount = Math.floor(calcSwapAmount(usdcInAccount));
if (thresholdReached(usdcInAccount)) {
log('---------------------------------')
printInfo(user);
swapInProgress = true;
let withdrawSuccessful = false;
let withdrawRetries = 0;
let maxWithdrawRetries = 3;
while (!withdrawSuccessful && withdrawRetries < maxWithdrawRetries) {
log(`Withdraw from exchange`);
await withdraw(USDC_MARKET, quoteNumber(user.getTokenAmount(USDC_MARKET)))
.then(async (txSig) => {
log(`Confirm withdraw: https://solscan.io/tx/${txSig}`);
return confirmTx(txSig);
}, (error) => {
throw `Withdraw TX failed: ${error}`;
})
.then((confirmResult) => {
if (confirmResult.value.err) {
throw `Withdraw not confirmed`;
} else {
log(`Withdraw confirmed`);
withdrawSuccessful = true;
}
})
.catch(error => {
withdrawRetries++;
log(`Withdraw failed (${withdrawRetries}/${maxWithdrawRetries}): ${error}`);
// TODO check if withdraw was still somehow successfull
// it sometimes happens and we're then stuck in the loop
});
}
//backoff & try again in next iteration
if (withdrawRetries === maxWithdrawRetries) {
log('Withdraw failed - check if account balance changed and the withdraw actualy did not fail');
let currentUsdcInAccount = user.getTokenAmount(USDC_MARKET);
if(currentUsdcInAccount < usdcInAccount) {
log('Withdraw seemed to went through - continue with swap');
} else {
log('Withdraw really failed - backoff & try again in next iteration');
swapInProgress = false;
return;
}
}
let swapSuccessful = false
let swapRetries = 0;
let maxSwapRetries = 3;
while (!swapSuccessful && swapRetries < maxSwapRetries) {
// TODO calculate fresh swapAmount every iteration as collateral may has increased in the mean time
await quoteUsdcSol(swapAmount)
.then(quote => {
log(`Swap: ${swapAmount / USDC_INT}$ to ${quote.outAmount / LAMPORTS_PER_SOL} SOL`);
return signAndSendTx(quote);
})
.then(async txSig => {
log(`Confirm swap: https://solscan.io/tx/${txSig}`);
return confirmTx(txSig);
})
.then(confirmResult => {
if (confirmResult.value.err) {
throw `Confirm swap failed ${confirmResult.value.err}`;
} else {
log('Swap successful')
log('---------------------------------')
swapInProgress = false;
swapSuccessful = true;
}
})
.catch(error => {
swapRetries++;
log(`FAAAAAIL (${swapRetries}/${maxSwapRetries}): ${error}`);
})
sleep(1000);
}
//backoff & try again in next iteration
if (swapRetries === maxSwapRetries) {
log('Swap failed - backoff & try again in next iteration');
swapInProgress = false;
return;
}
} else {
log(`Waiting to reach threshold - current balance on DEX: ${usdcInAccount / USDC_INT} USDC`);
swapInProgress = false;
}
} catch (error) {
log(error);
swapInProgress = false;
}
} else {
log('Swap in progress')
}
}, AUTOSWAP_INTERVAL);
};
run2();

272
auto-swap/src/main.js Normal file
View File

@@ -0,0 +1,272 @@
require('dotenv').config()
const web3 = require("@solana/web3.js");
const drift = require("@drift-labs/sdk");
const LAMPORTS_PER_SOL = 1000000000;
const AUTOSWAP_INTERVAL = process.env.AUTOSWAP_INTERVAL || 1000 * 60;
const USDC_MARKET = 0;
const SOL_MARKET = 1;
const SWAP_RATIO = process.env.SWAP_RATIO || 0.5;
const SWAP_THRESHOLD = process.env.SWAP_THRESHOLD || 10;
const USDC_INT = 1000000;
const SOL_MINT_ADDRESS = 'So11111111111111111111111111111111111111112';
const USDC_MINT_ADDRESS = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
const USDC_MINT_PUBLIC_KEY = new web3.PublicKey(USDC_MINT_ADDRESS);
const keyPairFile = process.env.PRIVATE_KEY || process.env.PRIVATE_KEY_FILE;
const wallet = new drift.Wallet(drift.loadKeypair(keyPairFile));
const connection = new web3.Connection(process.env.RPC_ENDPOINT);
const thresholdReached = (amount) => amount / USDC_INT > SWAP_THRESHOLD;
const driftClient = new drift.DriftClient({
connection,
wallet,
env: 'mainnet-beta',
activeSubAccountId: 0,
subAccountIds: [0],
});
const sleep = (ms) => {
return new Promise(resolve => setTimeout(resolve, ms));
}
const log = (msg) => {
console.log(`[${new Date().toISOString()}] ${msg}`)
};
const quoteNumber = (val) => {
return drift.convertToNumber(val, drift.QUOTE_PRECISION);
}
const baseNumber = (val) => {
return drift.convertToNumber(val, drift.BASE_PRECISION);
}
const quoteUsdcSol = async (amount) => {
const quoteUrl = `https://quote-api.jup.ag/v6/quote?inputMint=${USDC_MINT_ADDRESS}&outputMint=${SOL_MINT_ADDRESS}&amount=${amount}&slippageBps=50`;
const quoteResponse = await (
await fetch(quoteUrl)
).json();
return quoteResponse;
}
const getWalletBalance = async (connection, publicKey) => {
const lamportsBalance = await connection.getBalance(publicKey);
return lamportsBalance / LAMPORTS_PER_SOL;
};
const getUsdcBalance = async (connection, publicKey) => {
const balance = await connection.getParsedTokenAccountsByOwner(
publicKey, { mint: USDC_MINT_PUBLIC_KEY }
);
return balance.value[0]?.account.data.parsed.info.tokenAmount.uiAmount;
};
const printInfo = async (user) => {
let usdcInAccount = quoteNumber(user.getTokenAmount(USDC_MARKET));
let solInWallet = await getWalletBalance(connection, wallet.publicKey);
let usdcWallet = await getUsdcBalance(connection, wallet.publicKey)
log(`USDC in account: ${usdcInAccount}`);
log(`USDC in wallet: ${usdcWallet}`);
log(`SOL in wallet: ${solInWallet}`);
};
const calcSwapAmount = (usdcInAccount) => {
return usdcInAccount * SWAP_RATIO;
};
const getSerializedTransaction = async (quote) => {
return await (
await fetch('https://quote-api.jup.ag/v6/swap', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
quoteResponse: quote,
userPublicKey: wallet.publicKey.toString(),
wrapAndUnwrapSol: true,
dynamicComputeUnitLimit: true, // allow dynamic compute limit instead of max 1,400,000
prioritizationFeeLamports: 'auto' // capped at 5,000,000 lamports / 0.005 SOL.
})
})
).json();
};
const signTransaction = (swapTransaction) => {
const swapTransactionBuf = Buffer.from(swapTransaction, 'base64');
var transaction = web3.VersionedTransaction.deserialize(swapTransactionBuf);
transaction.sign([wallet.payer]);
return transaction;
};
const sendTransaction = async (transaction) => {
const rawTransaction = transaction.serialize()
return connection.sendRawTransaction(rawTransaction, {
skipPreflight: false,
preflightCommitment: 'confirmed',
// Maximum number of times for the RPC node to retry sending the transaction to the leader.
// If this parameter is not provided, the RPC node will retry the transaction until it is finalized or until the blockhash expires.
//maxRetries: 0
})
};
const withdraw = async (marketIndex, withdrawAmount) => {
const amount = driftClient.convertToSpotPrecision(marketIndex, withdrawAmount);
const associatedTokenAccount = await driftClient.getAssociatedTokenAccount(marketIndex);
const reduceOnly = true;
return driftClient.withdraw(
amount,
marketIndex,
associatedTokenAccount,
reduceOnly
);
};
const signAndSendTx = async (quote) => {
let tx = await getSerializedTransaction(quote);
let signedTx = signTransaction(tx.swapTransaction);
return sendTransaction(signedTx);
};
const confirmTx = async (txSig) => {
const latestBlockHash = await connection.getLatestBlockhash();
return connection.confirmTransaction({
blockhash: latestBlockHash.blockhash,
lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
signature: txSig,
}, 'confirmed');
};
const run = async () => {
await driftClient.subscribe();
const user = driftClient.getUser();
// force markets to be included
driftClient.perpMarketLastSlotCache.set(SOL_MARKET, Number.MAX_SAFE_INTEGER);
driftClient.perpMarketLastSlotCache.set(USDC_MARKET, Number.MAX_SAFE_INTEGER);
log('---------------------------------')
log("DriftClient initialized")
log(`Swap Ratio: ${SWAP_RATIO}`);
log(`Swap Threshold: ${SWAP_THRESHOLD} USDC`);
printInfo(user);
let swapInProgress = false;
setInterval(async () => {
if (!swapInProgress) {
try {
let usdcInAccount = user.getTokenAmount(USDC_MARKET);
let swapAmount = Math.floor(calcSwapAmount(usdcInAccount));
if (thresholdReached(usdcInAccount)) {
log('---------------------------------')
printInfo(user);
swapInProgress = true;
let withdrawSuccessful = false;
let withdrawRetries = 0;
let maxWithdrawRetries = 3;
while (!withdrawSuccessful && withdrawRetries < maxWithdrawRetries) {
log(`Withdraw from exchange`);
await withdraw(USDC_MARKET, quoteNumber(user.getTokenAmount(USDC_MARKET)))
.then(async (txSig) => {
log(`Confirm withdraw: https://solscan.io/tx/${txSig}`);
return confirmTx(txSig);
}, (error) => {
throw `Withdraw TX failed: ${error}`;
})
.then((confirmResult) => {
if (confirmResult.value.err) {
throw `Withdraw not confirmed`;
} else {
log(`Withdraw confirmed`);
withdrawSuccessful = true;
}
})
.catch(error => {
withdrawRetries++;
log(`Withdraw failed (${withdrawRetries}/${maxWithdrawRetries}): ${error}`);
// TODO check if withdraw was still somehow successfull
// it sometimes happens and we're then stuck in the loop
});
}
//backoff & try again in next iteration
if (withdrawRetries === maxWithdrawRetries) {
log('Withdraw failed - check if account balance changed and the withdraw actualy did not fail');
let currentUsdcInAccount = user.getTokenAmount(USDC_MARKET);
if(currentUsdcInAccount < usdcInAccount) {
log('Withdraw seemed to went through - continue with swap');
} else {
log('Withdraw really failed - backoff & try again in next iteration');
swapInProgress = false;
return;
}
}
let swapSuccessful = false
let swapRetries = 0;
let maxSwapRetries = 3;
while (!swapSuccessful && swapRetries < maxSwapRetries) {
// TODO calculate fresh swapAmount every iteration as collateral may has increased in the mean time
await quoteUsdcSol(swapAmount)
.then(quote => {
log(`Swap: ${swapAmount / USDC_INT}$ to ${quote.outAmount / LAMPORTS_PER_SOL} SOL`);
return signAndSendTx(quote);
})
.then(async txSig => {
log(`Confirm swap: https://solscan.io/tx/${txSig}`);
return confirmTx(txSig);
})
.then(confirmResult => {
if (confirmResult.value.err) {
throw `Confirm swap failed ${confirmResult.value.err}`;
} else {
log('Swap successful')
log('---------------------------------')
swapInProgress = false;
swapSuccessful = true;
}
})
.catch(error => {
swapRetries++;
log(`FAAAAAIL (${swapRetries}/${maxSwapRetries}): ${error}`);
})
sleep(1000);
}
//backoff & try again in next iteration
if (swapRetries === maxSwapRetries) {
log('Swap failed - backoff & try again in next iteration');
swapInProgress = false;
return;
}
} else {
log(`Waiting to reach threshold - current balance on DEX: ${usdcInAccount / USDC_INT} USDC`);
swapInProgress = false;
}
} catch (error) {
log(error);
swapInProgress = false;
}
} else {
log('Swap in progress')
}
}, AUTOSWAP_INTERVAL);
};
run();

View File

@@ -1,49 +0,0 @@
#cloud-config
groups:
- ubuntu: [root,sys]
- docker
- bot
users:
- default
- name: keeper
gecos: keeper
shell: /bin/bash
primary_group: bot
sudo: ALL=(ALL) NOPASSWD:ALL
groups: users, admin, docker
lock_passwd: false
packages:
- apt-transport-https
- ca-certificates
- curl
- gnupg-agent
- software-properties-common
- chrony
- docker.io
- docker-compose
ntp:
enabled: true
ntp_client: chrony
servers:
- ${ntp_server}
runcmd:
- git clone https://github.com/0x1d/drift-keeper /app/bot
- mv /app/.env /app/bot/.env
- mv /app/config.yaml /app/bot/config.yaml
- cd /app/bot && sudo -u keeper -g bot -- docker-compose up -d
write_files:
- path: /app/.env
encoding: b64
owner: root:root
permissions: '0750'
content: ${env_file}
- path: /app/config.yaml
encoding: b64
owner: root:root
permissions: '0750'
content: ${config_file}

View File

@@ -0,0 +1,71 @@
#cloud-config
groups:
- ubuntu: [root,sys]
- docker
- bot
users:
- default
- name: keeper
gecos: keeper
shell: /bin/bash
primary_group: bot
sudo: ALL=(ALL) NOPASSWD:ALL
groups: users, admin, docker
lock_passwd: false
packages:
- apt-transport-https
- ca-certificates
- curl
- gnupg-agent
- software-properties-common
- chrony
- docker.io
- docker-compose
ntp:
enabled: true
ntp_client: chrony
servers:
- ${ntp_server}
runcmd:
- systemctl stop snapd && systemctl disable snapd
- git clone -b feature/scaling https://github.com/0x1d/drift-keeper /app/bot
- cp -rT /transfer /app/bot && rm -rf /transfer
- chown -R keeper:bot /app/bot
- cd /app/bot && sudo -u keeper -g bot -- docker-compose up -d
write_files:
- path: /transfer/.env
encoding: b64
owner: root:root
permissions: '0750'
content: ${env_file}
- path: /transfer/.env.monitoring
encoding: b64
owner: root:root
permissions: '0750'
content: ${env_monitoring_file}
- path: /transfer/config.yaml
encoding: b64
owner: root:root
permissions: '0750'
content: ${config_file}
- path: /transfer/prometheus/prometheus.yml
encoding: b64
owner: root:root
permissions: '0750'
content: ${prometheus_config_file}
- path: /transfer/prometheus/web.yml
encoding: b64
owner: root:root
permissions: '0750'
content: ${prometheus_web_file}
- path: /transfer/docker-compose.yaml
encoding: b64
owner: root:root
permissions: '0750'
content: ${docker_compose_file}

View File

@@ -56,7 +56,7 @@ global:
eventSubscriberPollingInterval: 5000
bulkAccountLoaderPollingInterval: 5000
useJito: false
useJito: true
jitoBlockEngineUrl:
jitoAuthPrivateKey:

149
ctl.sh
View File

@@ -5,38 +5,143 @@ API_ENDPOINT=https://api.mainnet-beta.solana.com/
source .env
function keeper {
function build {
##
## Usage: ./ctl.sh COMMAND SUBCOMMAND
##
## ~> build
## all Build all images
## keeper Build bot image
## tracker Build wallet-tracker image
## autoswap Build auto-swap image
##
## ~> push
## all Push all images to Docker registry
## keeper Push bot image to Docker registry
## tracker Push tracker image to Docker registry
## autoswap Push auto-swaü image to Docker registry
##
## ~> run
## all Run the complete stack locally
## autoswap Run Auto-Swap locally
##
## ~> infra
## plan Plan infrastructure change
## provision Provision infrastructure
## hosts Show list of servers
## connect Connect to a server
## playbook Run a maintenance playbook
##
## ~> balance
## sol Show SOL balance
## usdc Show USDC balance
##
RED="31"
GREEN="32"
GREENBLD="\e[1;${GREEN}m"
REDBOLD="\e[1;${RED}m"
REDITALIC="\e[3;${RED}m"
EC="\e[0m"
function info {
printf "\n${GREENBLD}Wallet Address:\t$WALLET_ADDRESS${EC}\n"
printf "${GREENBLD}Environment:\t$ENV${EC}\n"
sed -n 's/^##//p' ctl.sh
}
function build {
function all {
build keeper
build tracker
}
function keeper {
mkdir -p .build
git clone https://github.com/drift-labs/keeper-bots-v2 -b mainnet-beta .build/keeper-bots-v2
#pushd .build/keeper-bots-v2
# git checkout 21fd791d142490fe033b5e25719927c106a0aaf2
#popd
docker build -f Dockerfile -t ${DOCKER_IMAGE} .build/keeper-bots-v2
rm -rf .build
}
function push {
docker push ${DOCKER_IMAGE}
}
${@:-}
}
function tracker {
function build {
function tracker {
pushd wallet-tracker
docker build -t ${DOCKER_IMAGE_WALLET_TRACKER} .
popd
}
function push {
docker push ${DOCKER_IMAGE_WALLET_TRACKER}
function autoswap {
pushd auto-swap
docker build -t ${DOCKER_IMAGE_AUTO_SWAP} .
popd
}
function metrics {
pushd user-metrics
docker build -t ${DOCKER_IMAGE_USER_METRICS} .
popd
}
${@:-}
}
function droplet {
function push {
function all {
push keeper
push tracker
}
function keeper {
docker push ${DOCKER_IMAGE}
}
function tracker {
docker push ${DOCKER_IMAGE_WALLET_TRACKER}
}
function autoswap {
docker push ${DOCKER_IMAGE_AUTO_SWAP}
}
function metrics {
docker push ${DOCKER_IMAGE_USER_METRICS}
}
${@:-}
}
function run {
function all {
docker compose up
}
function autoswap {
pushd auto-swap
npm start
popd
}
function metircs {
pushd user-metrics
npm start
popd
}
${@:-}
}
function infra {
function plan {
terraform init
terraform plan
}
function provision {
terraform init
terraform apply
echo "[bots]" > inventory.cfg
terraform output -json | jq --raw-output ' .instances.value | to_entries[] | .value' >> inventory.cfg
}
function hosts {
terraform output -json | jq --raw-output '.instances.value | to_entries[] | [.key, .value] | @tsv'
}
function connect {
ssh root@$(terraform output -raw droplet_ip)
infra hosts \
| fzf --height=~50 \
| awk '{print $2}' \
| xargs -o ssh -l root $@
}
function playbook {
pushd ansible
ansible-playbook --ssh-common-args='-o StrictHostKeyChecking=accept-new' -i ../inventory.cfg $(fzf --height=~10)
popd
}
${@:-}
}
@@ -77,4 +182,18 @@ function balance {
${@:-}
}
${@:-}
function repl {
clear
cat motd
info
echo -e "\n${REDBOLD}Enter command...${EC}"
read -p '~> ';
clear
cat motd
./ctl.sh ${REPLY}
printf "\n"
read -p "Press any key to continue."
repl
}
${@:-info}

View File

@@ -2,25 +2,29 @@ version: '3'
services:
# ---------------------------------------------------
# Bot
# ---------------------------------------------------
keeper:
image: ${DOCKER_IMAGE}
restart: unless-stopped
env_file: .env
volumes:
- ./config.yaml:/app/config.yaml
# ---------------------------------------------------
# Monitoring
# ---------------------------------------------------
auto-swap:
image: ${DOCKER_IMAGE_AUTO_SWAP}
build:
context: auto-swap
env_file: .env.autoswap
restart: unless-stopped
wallet-tracker:
image: ${DOCKER_IMAGE_WALLET_TRACKER}
build:
context: wallet-tracker
env_file: .env
restart: unless-stopped
user-metrics:
image: wirelos/user-metrics:0.1.0
env_file: .env.user-metrics
restart: unless-stopped
prometheus:
image: prom/prometheus
container_name: prometheus
@@ -51,12 +55,10 @@ services:
grafana:
image: grafana/grafana
container_name: grafana
env_file: .env.monitoring
ports:
- 3000:3000
restart: unless-stopped
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning
- ./grafana/dashboards:/var/lib/grafana/dashboards

View File

@@ -1,12 +1,11 @@
# Build Settings
DOCKER_IMAGE=wirelos/drift-keeper:mainnet-beta
DOCKER_IMAGE_AUTO_SWAP=wirelos/auto-swap:0.1.0
DOCKER_IMAGE_WALLET_TRACKER=wirelos/solana-wallet-tracker:0.1.0
# Shell Utils
WALLET_ADDRESS=h5XjtA.....
# Grafana Settings
GRAFANA_ADMIN_PASSWORD=grafana
# Drift Config
ENV=mainnet-beta
KEEPER_PRIVATE_KEY="[123,345,...]"
@@ -24,7 +23,6 @@ ENDPOINT=http://178.63.126.77:8899
# Teraswitch2 US
#ENDPOINT=http://74.118.139.68:8899
# ExtrNode
#ENDPOINT=https://solana-mainnet.rpc.extrnode.com/your-api-key
#WS_ENDPOINT=wss://solana-mainnet.rpc.extrnode.com/your-api-key

6
example.env.autoswap Normal file
View File

@@ -0,0 +1,6 @@
RPC_ENDPOINT=https://your-rpc-endpoint
PRIVATE_KEY="[123,456,...789]"
#PRIVATE_KEY_FILE=~/.config/solana/bot.json
SWAP_THRESHOLD=2
SWAP_RATIO=0.6
AUTOSWAP_INTERVAL=60000

3
example.env.monitoring Normal file
View File

@@ -0,0 +1,3 @@
GF_SECURITY_ADMIN_USER=admin
GF_SECURITY_ADMIN_PASSWORD=grafana
PROMETHEUS_PASSWORD=prompass

3
example.env.user-metrics Normal file
View File

@@ -0,0 +1,3 @@
RPC_ENDPOINT=https://your-rpc-endpoint
PRIVATE_KEY="[123,456,...789]"
#PRIVATE_KEY_FILE=~/.config/solana/bot.json

File diff suppressed because it is too large Load Diff

View File

@@ -10,4 +10,4 @@ datasources:
basicAuth: true
basicAuthUser: prom
secureJsonData:
basicAuthPassword: prompass
basicAuthPassword: ${PROMETHEUS_PASSWORD}

119
main.tf
View File

@@ -1,5 +1,9 @@
terraform {
required_providers {
linode = {
source = "linode/linode"
version = "2.13.0"
}
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.0"
@@ -7,41 +11,110 @@ terraform {
}
}
variable "do_token" {}
variable "config" {
default = {
region = "ams3"
ntp_server = "ntp.amsterdam.jito.wtf"
docker_image = "wirelos/drift-keeper:mainnet-beta"
}
}
provider "digitalocean" {
token = var.do_token
}
provider "linode" {
token = var.linode_token
}
locals {
user_data = templatefile("cloud-config.yaml", {
ntp_server = var.config.ntp_server
env_file = base64encode(file(".env"))
config_file = base64encode(file("config.yaml"))
})
monitoring_config = {
env = base64encode(templatefile("templates/monitoring/env.monitoring.tpl", var.monitoring))
prometheus = base64encode(templatefile("templates/monitoring/prometheus/prometheus.yml.tpl", {
wallet_address = var.bot.wallet_address
}))
prometheus_web = base64encode(templatefile("templates/monitoring/prometheus/web.yml.tpl", {
prometheus_password_bcrypt = bcrypt(var.monitoring.prometheus_password)
}))
}
cloud_config = { for s in concat(var.linode_instances, var.digitalocean_instances) : s.label => templatefile("cloud-init/cloud-config.yaml", {
ntp_server = s.ntp_server
env_file = base64encode(templatefile("templates/bot/env.tpl", merge(var.bot, {
jito_block_engine_url = s.jito_block_engine_url
})))
config_file = base64encode(templatefile("templates/bot/config.yaml.tpl", {
use_jito = s.use_jito
}))
env_monitoring_file = local.monitoring_config.env
prometheus_config_file = local.monitoring_config.prometheus
prometheus_web_file = local.monitoring_config.prometheus_web
docker_compose_file = base64encode(templatefile("templates/bot/docker-compose.yaml.tpl", {
docker_image = var.bot.docker_image
docker_image_wallet_tracker = var.bot.docker_image_wallet_tracker
}))
}) }
}
resource "linode_sshkey" "master" {
label = "master-key"
ssh_key = chomp(file("~/.ssh/id_rsa.pub"))
}
resource "digitalocean_ssh_key" "default" {
name = "Keeper Key"
public_key = file("~/.ssh/id_rsa.pub")
name = "master-key"
public_key = chomp(file("~/.ssh/id_rsa.pub"))
}
resource "linode_instance" "keeper" {
for_each = { for s in var.linode_instances : s.label => s }
label = each.key
image = each.value.image
group = each.value.group
region = each.value.region
type = each.value.type
authorized_keys = [linode_sshkey.master.ssh_key]
metadata {
user_data = base64encode(local.cloud_config[each.key])
}
lifecycle {
ignore_changes = [
metadata
]
}
}
resource "digitalocean_droplet" "keeper" {
image = "ubuntu-23-10-x64"
name = "drift-keeper"
region = var.config.region
size = "s-1vcpu-1gb-intel"
for_each = { for s in var.digitalocean_instances : s.label => s }
image = each.value.image
name = each.key
region = each.value.region
size = each.value.type
ssh_keys = [digitalocean_ssh_key.default.fingerprint]
user_data = local.user_data
user_data = local.cloud_config[each.key]
lifecycle {
ignore_changes = [
user_data
]
}
}
output "droplet_ip" {
value = digitalocean_droplet.keeper.ipv4_address
output "instances" {
value = merge(
tomap({ for k, v in linode_instance.keeper : k => v.ip_address }),
tomap({ for k, v in digitalocean_droplet.keeper : k => v.ipv4_address })
)
}
output "panopticonf" {
value = templatefile("templates/monitoring/prometheus/panopticon.yml.tpl", {
user = "prom"
password = var.monitoring.prometheus_password
targets = <<-EOT
%{for k, v in merge(
tomap({ for k, v in linode_instance.keeper : k => v.ip_address }),
tomap({ for k, v in digitalocean_droplet.keeper : k => v.ipv4_address })
)}
- targets: ['${v}:9090']
labels:
server: ${k}
%{endfor}
EOT
})
}
output "configurations" {
value = local.cloud_config
}

11
motd Normal file
View File

@@ -0,0 +1,11 @@
▓█████▄ ██▀███ ██▓ █████▒▄▄▄█████▓ ██ ▄█▀▓█████ ▓█████ ██▓███ ▓█████ ██▀███
▒██▀ ██▌▓██ ▒ ██▒▓██▒▓██ ▒ ▓ ██▒ ▓▒ ██▄█▒ ▓█ ▀ ▓█ ▀ ▓██░ ██▒▓█ ▀ ▓██ ▒ ██▒
░██ █▌▓██ ░▄█ ▒▒██▒▒████ ░ ▒ ▓██░ ▒░ ▓███▄░ ▒███ ▒███ ▓██░ ██▓▒▒███ ▓██ ░▄█ ▒
░▓█▄ ▌▒██▀▀█▄ ░██░░▓█▒ ░ ░ ▓██▓ ░ ▓██ █▄ ▒▓█ ▄ ▒▓█ ▄ ▒██▄█▓▒ ▒▒▓█ ▄ ▒██▀▀█▄
░▒████▓ ░██▓ ▒██▒░██░░▒█░ ▒██▒ ░ ▒██▒ █▄░▒████▒░▒████▒▒██▒ ░ ░░▒████▒░██▓ ▒██▒
▒▒▓ ▒ ░ ▒▓ ░▒▓░░▓ ▒ ░ ▒ ░░ ▒ ▒▒ ▓▒░░ ▒░ ░░░ ▒░ ░▒▓▒░ ░ ░░░ ▒░ ░░ ▒▓ ░▒▓░
░ ▒ ▒ ░▒ ░ ▒░ ▒ ░ ░ ░ ░ ░▒ ▒░ ░ ░ ░ ░ ░ ░░▒ ░ ░ ░ ░ ░▒ ░ ▒░
░ ░ ░ ░░ ░ ▒ ░ ░ ░ ░ ░ ░░ ░ ░ ░ ░░ ░ ░░ ░
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░

View File

@@ -0,0 +1,27 @@
version: '3'
services:
prometheus:
image: prom/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--web.config.file=/etc/prometheus/web.yml'
ports:
- 9090:9090
restart: unless-stopped
volumes:
- ./prometheus:/etc/prometheus
- prom_data:/prometheus
grafana:
image: grafana/grafana
env_file: .env.monitoring
ports:
- 3000:3000
restart: unless-stopped
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning
- ./grafana/dashboards:/var/lib/grafana/dashboards
volumes:
prom_data:

View File

@@ -0,0 +1,3 @@
GF_SECURITY_ADMIN_USER=admin
GF_SECURITY_ADMIN_PASSWORD=grafana
PROMETHEUS_PASSWORD=prompass

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
apiVersion: 1
providers:
# <string> an unique provider name. Required
- name: 'a unique provider name'
# <int> Org id. Default to 1
orgId: 1
# <string> name of the dashboard folder.
folder: ''
# <string> folder UID. will be automatically generated if not specified
folderUid: ''
# <string> provider type. Default to 'file'
type: file
# <bool> disable dashboard deletion
disableDeletion: false
# <int> how often Grafana will scan for changed dashboards
updateIntervalSeconds: 10
# <bool> allow updating provisioned dashboards from the UI
allowUiUpdates: true
options:
# <string, required> path to dashboard files on disk. Required when using the 'file' type
path: /var/lib/grafana/dashboards
# <bool> use folder names from filesystem to create folders in Grafana
foldersFromFilesStructure: true

View File

@@ -0,0 +1,13 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
url: http://prometheus:9090
isDefault: true
access: proxy
editable: true
basicAuth: true
basicAuthUser: prom
secureJsonData:
basicAuthPassword: ${PROMETHEUS_PASSWORD}

View File

@@ -0,0 +1,29 @@
global:
scrape_interval: 60s
scrape_timeout: 10s
evaluation_interval: 15s
alerting:
alertmanagers:
- static_configs:
- targets: []
scheme: http
timeout: 10s
api_version: v1
scrape_configs:
- job_name: 'federate'
scrape_interval: 60s
honor_labels: true
metrics_path: '/federate'
basic_auth:
username: '${user}'
password: '${password}'
params:
'match[]':
- '{instance="node-exporter:9100"}'
- '{__name__=~"up:.*"}'
static_configs:
${targets}

View File

@@ -0,0 +1,4 @@
basic_auth_users:
# bcrypt "prompass"
# e.g. htpasswd -bnBC 10 "" prompass | tr -d ':\n'
prom: $2y$10$oWtHm79bh0D1CnNC4brGiOU7y6MbYa6cgklF/g6ek9YZYkgXfeOIu

View File

@@ -19,6 +19,12 @@ scrape_configs:
static_configs:
- targets:
- wallet-tracker:3000
- job_name: user
scrape_interval: 60s
metrics_path: /metrics
static_configs:
- targets:
- user-metrics:3000
- job_name: keeper
honor_timestamps: true
scrape_interval: 15s

11
shell.nix Normal file
View File

@@ -0,0 +1,11 @@
let
unstable = import (fetchTarball https://nixos.org/channels/nixos-unstable/nixexprs.tar.xz) { };
in
{ pkgs ? import <nixpkgs> { } }:
pkgs.mkShell {
buildInputs = with pkgs; [
ansible
terraform
solana-cli
];
}

View File

@@ -0,0 +1,233 @@
global:
# devnet or mainnet-beta
driftEnv: mainnet-beta
# RPC endpoint to use
endpoint:
# Custom websocket endpoint to use (if null will be determined from `endpoint``)
# Note: the default wsEndpoint value simply replaces http(s) with ws(s), so if
# your RPC provider requires a special path (i.e. /ws) for websocket connections
# you must set this.
wsEndpoint:
# optional if you want to use helius' global priority fee method AND `endpoint` is not
# already a helius url.
heliusEndpoint:
# `solana` or `helius`. If `helius` `endpoint` must be a helius RPC, or `heliusEndpoint`
# must be set
# solana: uses https://solana.com/docs/rpc/http/getrecentprioritizationfees
# helius: uses https://docs.helius.dev/solana-rpc-nodes/alpha-priority-fee-api
priorityFeeMethod: solana
# skips preflight checks on sendTransaciton, default is false.
# this will speed up tx sending, but may increase SOL paid due to failed txs landing
# on chain
txSkipPreflight: false
# max priority fee to use, in micro lamports
# i.e. a fill that uses 500_000 CUs will spend:
# 500_000 * 10_000 * 1e-6 * 1e-9 = 0.000005 SOL on priority fees
# this is on top of the 0.000005 SOL base fee, so 0.000010 SOL total
maxPriorityFeeMicroLamports: 10000
# Private key to use to sign transactions.
# will load from KEEPER_PRIVATE_KEY env var if null
keeperPrivateKey:
initUser: false # initialize user on startup
testLiveness: false # test liveness, by failing liveness test after 1 min
# Force deposit this amount of USDC to collateral account, the program will
# end after the deposit transaction is sent
#forceDeposit: 1000
websocket: false # use websocket for account loading and events (limited support)
eventSubscriber: false # disables event subscriber (heavy RPC demand), this is primary used for counting fills
runOnce: false # Set true to run once and exit, useful for testing or one off bot runs
debug: false # Enable debug logging
txSenderType: "fast"
# subaccountIDs to load, if null will load subaccount 0 (default).
# Even if bot specific configs requires subaccountIDs, you should still
# specify it here, since we load the subaccounts before loading individual
# bots.
# subaccounts:
# - 0
# - 1
# - 2
eventSubscriberPollingInterval: 5000
bulkAccountLoaderPollingInterval: 5000
useJito: ${use_jito}
# one of: ['non-jito-only', 'jito-only', 'hybrid'].
# * non-jito-only: will only send txs to RPC when there is no active jito leader
# * jito-only: will only send txs via bundle when there is an active jito leader
# * hybrid: will attempt to send bundles when active jito leader, and use RPC when not
# hybrid may not work well if using high throughput bots such as a filler depending on infra limitations.
jitoStrategy: jito-only
# the minimum tip to pay
jitoMinBundleTip: 10000
# the maximum tip to pay (will pay this once jitoMaxBundleFailCount is hit)
jitoMaxBundleTip: 100000
# the number of failed bundles (accepted but not landed) before tipping the max tip
jitoMaxBundleFailCount: 200
# the tip multiplier to use when tipping the max tip
# controls superlinearity (1 = linear, 2 = slightly-superlinear, 3 = more-superlinear, ...)
jitoTipMultiplier: 3
jitoBlockEngineUrl:
jitoAuthPrivateKey:
# which subaccounts to load, if null will load subaccount 0 (default)
subaccounts:
# perpMarketIndexes:
# - 1
# Which bots to run, be careful with this, running multiple bots in one instance
# might use more resources than expected.
# Bot specific configs are below
enabledBots:
# Perp order filler bot
- fillerLite
# Spot order filler bot
#- spotFiller
# Trigger bot (triggers trigger orders)
#- trigger
# Liquidator bot, liquidates unhealthy positions by taking over the risk (caution, you should manage risk here)
# - liquidator
# Example maker bot that participates in JIT auction (caution: you will probably lose money)
# - jitMaker
# Example maker bot that posts floating oracle orders
#- floatingMaker
# settles PnLs for the insurance fund (may want to run with runOnce: true)
# - ifRevenueSettler
# settles negative PnLs for users (may want to run with runOnce: true)
# - userPnlSettler
# - markTwapCrank
# below are bot configs
botConfigs:
fillerLite:
botId: "fillerLite"
dryRun: false
fillerPollingInterval: 6000
metricsPort: 9464
revertOnFailure: true
simulateTxForCUEstimate: true
filler:
botId: "filler"
dryRun: false
fillerPollingInterval: 6000
metricsPort: 9464
# will revert a transaction during simulation if a fill fails, this will save on tx fees,
# and be friendlier for use with services like Jito.
# Default is true
revertOnFailure: true
# calls simulateTransaction before sending to get an accurate CU estimate
# as well as stop before sending the transaction (Default is true)
simulateTxForCUEstimate: true
spotFiller:
botId: "spot-filler"
dryRun: false
fillerPollingInterval: 6000
metricsPort: 9465
revertOnFailure: true
simulateTxForCUEstimate: true
liquidator:
botId: "liquidator"
dryRun: false
metricsPort: 9466
# if true will NOT attempt to sell off any inherited positions
disableAutoDerisking: false
# if true will swap spot assets on jupiter if the price is better
useJupiter: true
# null will handle all markets
perpMarketIndicies:
spotMarketIndicies:
# this replaces perpMarketIndicies and spotMarketIndicies by allowing you to specify
# which subaccount is responsible for liquidating markets
# Markets are defined on perpMarkets.ts and spotMarkets.ts on the protocol codebase
# Note: you must set global.subaccounts with all the subaccounts you want to load
perpSubAccountConfig:
0:
# subaccount 0 will watch perp markets 0 and 1
- 0
- 1
spotSubAccountConfig:
0:
# subaccount 0 will watch all spot markets
# max slippage (from oracle price) to incur allow when derisking
maxSlippagePct: 0.05
# what algo to use for derisking. Options are "market" or "twap"
deriskAlgo: "market"
# if deriskAlgo == "twap", must supply these as well
# twapDurationSec: 300 # overall duration of to run the twap algo. Aims to derisk the entire position over this duration
# Minimum deposit amount to try to liquidiate, per spot market, in lamports.
# If null, or a spot market isn't here, it will liquidate any amount
# See perpMarkets.ts on the protocol codebase for the indices
minDepositToLiq:
1: 10
2: 1000
# Filter out un-liquidateable accounts that just create log noise
excludedAccounts:
- 9CJLgd5f9nmTp7KRV37RFcQrfEmJn6TU87N7VQAe2Pcq
- Edh39zr8GnQFNYwyvxhPngTJHrr29H3vVup8e8ZD4Hwu
# max % of collateral to spend when liquidating a user. In percentage terms (0.5 = 50%)
maxPositionTakeoverPctOfCollateral: 0.5
# sends a webhook notification (slack, discord, etc.) when a liquidation is attempted (can be noisy due to partial liquidations)
notifyOnLiquidation: true
trigger:
botId: "trigger"
dryRun: false
metricsPort: 9465
markTwapCrank:
botId: "mark-twap-cranker"
dryRun: false
metricsPort: 9465
crankIntervalToMarketIndicies:
15000:
- 0
- 1
- 2
60000:
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16

View File

@@ -0,0 +1,60 @@
version: '3'
services:
keeper:
image: ${docker_image}
restart: unless-stopped
env_file: .env
volumes:
- ./config.yaml:/app/config.yaml
wallet-tracker:
image: ${docker_image_wallet_tracker}
build:
context: wallet-tracker
env_file: .env
restart: unless-stopped
user-metrics:
image: wirelos/user-metrics:0.1.0
env_file: .env
restart: unless-stopped
prometheus:
image: prom/prometheus
container_name: prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--web.config.file=/etc/prometheus/web.yml'
ports:
- 9090:9090
restart: unless-stopped
volumes:
- ./prometheus:/etc/prometheus
- prom_data:/prometheus
node-exporter:
image: prom/node-exporter:latest
container_name: node-exporter
restart: unless-stopped
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.rootfs=/rootfs'
- '--path.sysfs=/host/sys'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
expose:
- 9100
grafana:
image: grafana/grafana
container_name: grafana
env_file: .env.monitoring
ports:
- 3000:3000
restart: unless-stopped
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning
- ./grafana/dashboards:/var/lib/grafana/dashboards
volumes:
prom_data:

View File

@@ -0,0 +1,9 @@
version: '3'
services:
keeper:
image: ${docker_image}
restart: unless-stopped
env_file: .env
volumes:
- ./config.yaml:/app/config.yaml

13
templates/bot/env.tpl Normal file
View File

@@ -0,0 +1,13 @@
DOCKER_IMAGE=wirelos/drift-keeper:mainnet-beta
DOCKER_IMAGE_WALLET_TRACKER=wirelos/solana-wallet-tracker:latest
ENV=mainnet-beta
ENDPOINT=${rpc_endpoint}
RPC_ENDPOINT=${rpc_endpoint}
WS_ENDPOINT=${ws_endpoint}
WALLET_ADDRESS=${wallet_address}
KEEPER_PRIVATE_KEY="${keeper_private_key}"
PRIVATE_KEY="${keeper_private_key}"
JITO_BLOCK_ENGINE_URL=${jito_block_engine_url}
JITO_AUTH_PRIVATE_KEY="${jito_private_key}"

View File

@@ -0,0 +1,3 @@
GF_SECURITY_ADMIN_USER=${grafana_user}
GF_SECURITY_ADMIN_PASSWORD=${grafana_password}
PROMETHEUS_PASSWORD=${prometheus_password}

View File

@@ -0,0 +1,29 @@
global:
scrape_interval: 60s
scrape_timeout: 10s
evaluation_interval: 15s
alerting:
alertmanagers:
- static_configs:
- targets: []
scheme: http
timeout: 10s
api_version: v1
scrape_configs:
- job_name: 'federate'
scrape_interval: 60s
honor_labels: true
metrics_path: '/federate'
basic_auth:
username: '${user}'
password: '${password}'
params:
'match[]':
- '{instance="node-exporter:9100"}'
- '{__name__=~"up:.*"}'
static_configs:
${targets}

View File

@@ -0,0 +1,42 @@
global:
scrape_interval: 60s
scrape_timeout: 10s
evaluation_interval: 15s
alerting:
alertmanagers:
- static_configs:
- targets: []
scheme: http
timeout: 10s
api_version: v1
scrape_configs:
- job_name: node
static_configs:
- targets: ['node-exporter:9100']
- job_name: keeper
honor_timestamps: true
scrape_interval: 15s
scrape_timeout: 10s
metrics_path: /metrics
scheme: http
static_configs:
- targets:
- keeper:9464
- job_name: wallet
honor_timestamps: true
scrape_interval: 15s
scrape_timeout: 10s
metrics_path: /metrics/${wallet_address}
scheme: http
static_configs:
- targets:
- wallet-tracker:3000
- job_name: user
honor_timestamps: true
scrape_interval: 15s
scrape_timeout: 10s
metrics_path: /metrics
scheme: http
static_configs:
- targets:
- user-metrics:3000

View File

@@ -0,0 +1,2 @@
basic_auth_users:
prom: ${prometheus_password_bcrypt}

1
user-metrics/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

7
user-metrics/Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM node:18
WORKDIR /app
COPY package.json ./
RUN npm install
COPY src src
CMD ["npm", "start"]

2278
user-metrics/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
user-metrics/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "dex-metrics",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node --no-warnings=ExperimentalWarning src/main.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2",
"prom-client": "^15.1.0",
"@drift-labs/sdk": "2.78.0-beta.0",
"@solana/web3.js": "^1.89.1",
"bigint-buffer": "^1.1.5",
"dotenv": "^16.4.4"
}
}

94
user-metrics/src/main.js Normal file
View File

@@ -0,0 +1,94 @@
require('dotenv').config()
const web3 = require("@solana/web3.js");
const drift = require("@drift-labs/sdk");
const express = require('express');
const { createMetrics } = require('./metrics');
const LAMPORTS_PER_SOL = 1000000000;
const USDC_INT = 1000000;
const USDC_MARKET = 0;
const SOL_MARKET = 1;
const SOL_MINT_ADDRESS = 'So11111111111111111111111111111111111111112';
const USDC_MINT_ADDRESS = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
const USDC_MINT_PUBLIC_KEY = new web3.PublicKey(USDC_MINT_ADDRESS);
const keyPairFile = process.env.PRIVATE_KEY || process.env.PRIVATE_KEY_FILE;
const wallet = new drift.Wallet(drift.loadKeypair(keyPairFile));
const connection = new web3.Connection(process.env.RPC_ENDPOINT);
const [registry, metrics] = createMetrics();
const app = express();
const driftClient = new drift.DriftClient({
connection,
wallet,
env: 'mainnet-beta',
activeSubAccountId: 0,
subAccountIds: [0],
});
const log = (msg) => {
console.log(`[${new Date().toISOString()}] ${msg}`)
};
const trimWalletAddress = (walletAddress) => {
return `${walletAddress.slice(0,4)}...${walletAddress.slice(walletAddress.length-4, walletAddress.length)}`;
}
const quoteNumber = (val) => {
return drift.convertToNumber(val, drift.QUOTE_PRECISION);
}
const baseNumber = (val) => {
return drift.convertToNumber(val, drift.BASE_PRECISION);
}
const getWalletBalance = async (connection, publicKey) => {
const lamportsBalance = await connection.getBalance(publicKey);
return lamportsBalance / LAMPORTS_PER_SOL;
};
const getUsdcBalance = async (connection, publicKey) => {
const balance = await connection.getParsedTokenAccountsByOwner(
publicKey, { mint: USDC_MINT_PUBLIC_KEY }
);
return balance.value[0]?.account.data.parsed.info.tokenAmount.uiAmount;
};
const init = async() => {
await driftClient.subscribe();
log('DriftClient initialized');
ready = true;
};
let ready = false;
app.get('/metrics', async (req, res) => {
res.setHeader('Content-Type', registry.contentType);
registry.resetMetrics();
if(ready){
let user = driftClient.getUser();
let label = { wallet: wallet.publicKey.toString(), walletShort: trimWalletAddress(wallet.publicKey.toString()) };
metrics.totalCollateral.set(label, quoteNumber(user.getTotalCollateral()));
metrics.unrealizedPNL.set(label, quoteNumber(user.getUnrealizedPNL()));
metrics.solBalance.set(label, await getWalletBalance(connection, wallet.publicKey));
metrics.usdcBalance.set(label, await getUsdcBalance(connection, wallet.publicKey));
}
res.send(await registry.metrics());
});
app.listen(3000, () => {
log("Server is running on port 3000");
init();
});

View File

@@ -0,0 +1,42 @@
const client = require('prom-client');
const createMetrics = () => {
const registry = new client.Registry();
const solBalanceMetric = new client.Gauge({
name: "sol_balance",
help: "SOL Balance",
labelNames: ['wallet','walletShort']
});
const usdcBalanceMetric = new client.Gauge({
name: "usdc_balance",
help: "USDC Balance",
labelNames: ['wallet','walletShort']
});
const totalCollateralMetric = new client.Gauge({
name: "total_collateral",
help: "Total Collateral",
labelNames: ['wallet','walletShort']
});
const unrealizedPNLMetric = new client.Gauge({
name: "unrealized_pnl",
help: "Unrealized PNL",
labelNames: ['wallet','walletShort']
});
registry.registerMetric(usdcBalanceMetric);
registry.registerMetric(solBalanceMetric);
registry.registerMetric(totalCollateralMetric);
registry.registerMetric(unrealizedPNLMetric);
return [registry, {
usdcBalance: usdcBalanceMetric,
solBalance: solBalanceMetric,
totalCollateral: totalCollateralMetric,
unrealizedPNL: unrealizedPNLMetric
}];
};
module.exports = {
createMetrics
}

88
variables.tf Normal file
View File

@@ -0,0 +1,88 @@
variable "do_token" {
description = "DigitalOcean access token"
type = string
default = ""
}
variable "linode_token" {
description = "Linode access token"
type = string
default = ""
}
variable "bot" {
description = "Bot configuration"
type = object({
wallet_address = string
rpc_endpoint = string
ws_endpoint = string
keeper_private_key = string
jito_private_key = string
docker_image = string
docker_image_wallet_tracker = string
})
}
variable "monitoring" {
description = "Monitoring configuration"
default = {
grafana_user = "admin"
grafana_password = "grafana"
prometheus_password = "prompass"
}
type = object({
grafana_user = string
grafana_password = string
prometheus_password = string
})
}
variable "linode_instances" {
description = "List of server configurations for Linode"
default = [
{
label = "DK-LN-AMS"
group = "keeper"
image = "linode/ubuntu23.10"
region = "nl-ams"
type = "g6-nanode-1"
ntp_server = "ntp.amsterdam.jito.wtf"
jito_block_engine_url = "amsterdam.mainnet.block-engine.jito.wtf"
use_jito = true
},
{
label = "DK-LN-OSA"
group = "keeper"
image = "linode/ubuntu23.10"
region = "jp-osa"
type = "g6-nanode-1"
ntp_server = "ntp.tokyo.jito.wtf"
jito_block_engine_url = "tokyo.mainnet.block-engine.jito.wtf"
use_jito = true
},
]
}
variable "digitalocean_instances" {
description = "List of server configurations for DigitalOcean"
default = [
{
label = "DK-DO-FRA"
image = "ubuntu-23-10-x64"
region = "fra1"
type = "s-1vcpu-1gb"
ntp_server = "ntp.frankfurt.jito.wtf"
jito_block_engine_url = "frankfurt.mainnet.block-engine.jito.wtf"
use_jito = true
},
{
label = "DK-DO-NYC"
image = "ubuntu-23-10-x64"
region = "nyc1"
type = "s-1vcpu-1gb"
ntp_server = "ntp.ny.jito.wtf"
jito_block_engine_url = "ny.mainnet.block-engine.jito.wtf"
use_jito = true
}
]
}

View File

@@ -1,2 +1 @@
node_modules/

18
wallet-tracker/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Wallet-Tracker
## Endpoints
Metrics: /metrics/:wallet-address?
If a `wallet-address` is provided in the URL, the app will fetch the metrics states from the given address.
If only 1 wallet needs to be tracked, it is also possible to set the `WALLET_ADDRESS` environment variable and just call the `/metrics` path.
## Metrics
Jupiter:
- SOL Price
Solana:
- SOL Balance
- USDC Balance
- SOL Balance in USDC

View File

@@ -3,24 +3,35 @@ const { createMetrics } = require('./metrics');
const { loadWalletBalance, loadUSDCBalance, loadSolanaMarketData, extractWalletBalance, extractUSDCBalance, extractSOLPrice } = require('./solana');
const WALLET_ADDRESS = process.env.WALLET_ADDRESS;
const [registry, usdcBalanceMetric, solBalanceMetric, solUsdcBalanceMetric] = createMetrics();
const [registry, usdcBalanceMetric, solBalanceMetric, solUsdcBalanceMetric, solPriceMetric] = createMetrics();
const app = express();
app.get('/metrics', async (req, res) => {
const trimWalletAddress = (walletAddress) => {
return `${walletAddress.slice(0,4)}...${walletAddress.slice(walletAddress.length-4, walletAddress.length)}`;
}
app.get('/metrics/:addr?', async (req, res) => {
const walletAddress = req.params.addr || WALLET_ADDRESS;
console.log(`Gathering metrics for ${walletAddress}`);
res.setHeader('Content-Type', registry.contentType);
registry.resetMetrics();
let [solBalance, usdcBalance, marketData] = await Promise.all([
loadWalletBalance(WALLET_ADDRESS),
loadUSDCBalance(WALLET_ADDRESS),
loadWalletBalance(walletAddress),
loadUSDCBalance(walletAddress),
loadSolanaMarketData()]);
solBalanceMetric.set({ wallet: WALLET_ADDRESS}, extractWalletBalance(solBalance));
usdcBalanceMetric.set({ wallet: WALLET_ADDRESS}, extractUSDCBalance(usdcBalance));
solUsdcBalanceMetric.set({ wallet: WALLET_ADDRESS}, extractWalletBalance(solBalance) * extractSOLPrice(marketData));
let label = { wallet: walletAddress, walletShort: trimWalletAddress(walletAddress) };
solBalanceMetric.set(label, extractWalletBalance(solBalance));
usdcBalanceMetric.set(label, extractUSDCBalance(usdcBalance));
solUsdcBalanceMetric.set(label, extractWalletBalance(solBalance) * extractSOLPrice(marketData));
solPriceMetric.set(extractSOLPrice(marketData));
res.send(await registry.metrics());
});
app.listen(3000, () => {
console.log("Server is running on port 3000");
});
});

View File

@@ -6,26 +6,32 @@ const createMetrics = () => {
const solBalanceMetric = new client.Gauge({
name: "sol_balance",
help: "SOL Balance",
labelNames: ['wallet']
labelNames: ['wallet','walletShort']
});
const usdcBalanceMetric = new client.Gauge({
name: "usdc_balance",
help: "USDC Balance",
labelNames: ['wallet']
labelNames: ['wallet','walletShort']
});
const solUsdcBalanceMetric = new client.Gauge({
name: "sol_usdc_balance",
help: "SOL Balance in USDC",
labelNames: ['wallet']
labelNames: ['wallet','walletShort']
});
const solPriceMetric = new client.Gauge({
name: "sol_price",
help: "SOL Price in USDC"
});
registry.registerMetric(usdcBalanceMetric);
registry.registerMetric(solBalanceMetric);
registry.registerMetric(solUsdcBalanceMetric);
registry.registerMetric(solPriceMetric);
return [registry, usdcBalanceMetric, solBalanceMetric, solUsdcBalanceMetric];
return [registry, usdcBalanceMetric, solBalanceMetric, solUsdcBalanceMetric, solPriceMetric];
};
module.exports = {
createMetrics
}
}