first commit

This commit is contained in:
Louis-Sinan
2025-12-21 12:22:16 +01:00
commit c9daaddb77
34 changed files with 1735 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.env
node_modules
dist
coverage
Dockerfile
Makefile
README.md

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
RPC_URL=https://api.mainnet-beta.solana.com
PRIVATE_KEY=[VOTRE_CLE_PRIVEE]
PAIR_ADDRESS=G7ixPyiyNeggVf1VanSetFMNbVuVCPtimJmd9axfQqng
TARGET_RATIO=0.5
CHECK_INTERVAL_CRON="*/5 * * * *"
COMPOUND_INTERVAL_CRON="0 0 * * *"
REBALANCE_THRESHOLD_PERCENT=0.15
LOG_LEVEL=info

22
.eslintrc.json Normal file
View File

@@ -0,0 +1,22 @@
{
"parser": "@typescript-eslint/parser",
"extends": [
"plugin:@typescript-eslint/recommended",
"prettier",
"plugin:prettier/recommended"
],
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module"
},
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
]
}
}

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules/
dist/
.env
.DS_Store
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.idea/
.vscode/

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:20-slim AS base
WORKDIR /app
COPY package*.json ./
FROM base AS dependencies
RUN npm install
FROM dependencies AS build
COPY . .
RUN npm run build
# Production stage
FROM base AS release
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]

32
Makefile Normal file
View File

@@ -0,0 +1,32 @@
# Variables
IMAGE_NAME=meteora-dlmm-bot
.PHONY: build test lint docker-build docker-test docker-lint docker-run clean install
install:
npm install
lint-fix:
npm run lint:fix
build:
docker build -t meteora-dlmm-bot .
test: build
docker run --rm meteora-dlmm-bot npm run test
lint: build
docker run --rm meteora-dlmm-bot npm run lint
run: build
docker run --rm --name meteora-dlmm-bot --env-file .env meteora-dlmm-bot
stop:
docker stop meteora-dlmm-bot || true
docker rm meteora-dlmm-bot || true
withdraw: build
docker run --rm --name meteora-dlmm-bot-withdraw --env-file .env meteora-dlmm-bot npm run withdraw:prod
clean:
rm -rf dist coverage

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# Meteora DLMM Automated Trading Bot
A high-performance TypeScript trading bot designed for Meteora's Dynamic Liquidity Market Maker (DLMM) on Solana. The bot optimizes yield through precision rebalancing, native SOL management, and resilient transaction handling.
## 🚀 Key Features
- **Precision Rebalancing (15% Rule)**: Instead of fixed intervals, the bot rebalances when the active price bin enters the outer 15% of your position's range. This ensures your liquidity stays centered and productive.
- **Micro-Frequency Monitoring**: Checks your portfolio every 5 minutes (`*/5 * * * *`) to react quickly to market volatility.
- **Native SOL Awareness**: Seamlessly handles SOL-only accounts. It detects SOL in your wallet, reserves a **0.1 SOL gas buffer**, and automatically performs initial deposits.
- **Balanced Entry (50/50 Swap)**: If you enter with only SOL, the bot uses Jupiter to automatically swap ~50% for the opposite asset, ensuring your position is productive in both directions from the start.
- **Dust Retention (ATA optimization)**: Leaves a tiny amount of tokens (0.0001) in your wallet to keep the Associated Token Accounts open, preventing repetitive SOL rent fees.
- **Value-Based Compounding**: Monitors your wallet and triggers a re-deposit whenever uninvested fees/balance exceed **$1.00 USD**.
- **Transaction Resilience**: Built-in retry logic (2 retries per operation) and exponential backoff to handle Solana network congestion.
- **Dockerized Environment**: Runs on Node 20 within a controlled Docker container to eliminate "it works on my machine" issues.
## 🛠 Prerequisites
- **Docker** (Highly Recommended)
- A Solana wallet with SOL (the bot will handle the rest).
- A reliable Solana RPC URL (Mainnet).
## 📦 Quick Start
1. **Clone & Configure**:
```bash
git clone <repository-url>
cd meteora-dlmm-bot
cp .env.example .env
```
Edit `.env` with your `RPC_URL`, `PRIVATE_KEY` (Base58), and `PAIR_ADDRESS`.
2. **Run with Docker**:
```bash
make docker-build
make docker-run
```
## 🧠 How It Works (Internal Logic)
### 1. The 5-Minute Cycle
Every 5 minutes, the bot wakes up and recalculates your entire portfolio state:
- Fetches the current `Active Bin` from the DLMM pool.
- Retrieves all your open positions for that specific pair.
- Queries wallet balances for both tokens (including Native SOL).
### 2. Rebalance Trigger
For each position, the bot calculates a "Healthy Range":
- If the `Active Bin` is within the middle 70% of your range, it does nothing.
- If the `Active Bin` hits the bottom 15% or top 15% "Danger Zones", a rebalance is triggered.
### 3. The Rebalance Process
When triggered, the bot:
1. **Withdraws** all liquidity from the current position.
2. **Claims** all accrued fees.
3. **Consolidates** the tokens in your wallet.
4. **Redeposits** a new position exactly centered around the new `Active Bin`, providing immediate "Spot" liquidity.
### 4. Resilience Features
- **Retries**: If a transaction fails (e.g., blockhash expired), it retries up to 2 times automatically.
- **Safety Buffer**: Always leaves 0.1 SOL in your wallet to ensure you can always pay for future rebalances and withdrawals.
- **Cycle-Level Safety**: If the RPC fails or the API is down, the bot logs the error but keeps running, attempting the check again in 5 minutes.
## 💡 Configuration Tips
- `DRY_RUN=true`: Use this to see what the bot *would* do without spending any SOL.
- `CHECK_INTERVAL_CRON`: Adjust frequency (e.g., `*/1 * * * *` for 1-minute checks).
- `REBALANCE_THRESHOLD_PERCENT`: Default is `0.15` (15%). Decrease for tighter ranges, increase for more "passive" management.
## 📜 Project Structure
- `src/bot.ts`: Core strategy logic and decision engine.
- `src/meteora.ts`: Low-level Meteora SDK interactions and retry logic.
- `src/utils/config.ts`: Environment validation and sanitization.
- `tests/`: Comprehensive test suite for rebalancing math and balance detection.

6
coverage/clover.xml Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<coverage generated="1766232661534" clover="3.2.0">
<project timestamp="1766232661534" name="All files">
<metrics statements="0" coveredstatements="0" conditionals="0" coveredconditionals="0" methods="0" coveredmethods="0" elements="0" coveredelements="0" complexity="0" loc="0" ncloc="0" packages="0" files="0" classes="0"/>
</project>
</coverage>

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,224 @@
body, html {
margin:0; padding: 0;
height: 100%;
}
body {
font-family: Helvetica Neue, Helvetica, Arial;
font-size: 14px;
color:#333;
}
.small { font-size: 12px; }
*, *:after, *:before {
-webkit-box-sizing:border-box;
-moz-box-sizing:border-box;
box-sizing:border-box;
}
h1 { font-size: 20px; margin: 0;}
h2 { font-size: 14px; }
pre {
font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
margin: 0;
padding: 0;
-moz-tab-size: 2;
-o-tab-size: 2;
tab-size: 2;
}
a { color:#0074D9; text-decoration:none; }
a:hover { text-decoration:underline; }
.strong { font-weight: bold; }
.space-top1 { padding: 10px 0 0 0; }
.pad2y { padding: 20px 0; }
.pad1y { padding: 10px 0; }
.pad2x { padding: 0 20px; }
.pad2 { padding: 20px; }
.pad1 { padding: 10px; }
.space-left2 { padding-left:55px; }
.space-right2 { padding-right:20px; }
.center { text-align:center; }
.clearfix { display:block; }
.clearfix:after {
content:'';
display:block;
height:0;
clear:both;
visibility:hidden;
}
.fl { float: left; }
@media only screen and (max-width:640px) {
.col3 { width:100%; max-width:100%; }
.hide-mobile { display:none!important; }
}
.quiet {
color: #7f7f7f;
color: rgba(0,0,0,0.5);
}
.quiet a { opacity: 0.7; }
.fraction {
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 10px;
color: #555;
background: #E8E8E8;
padding: 4px 5px;
border-radius: 3px;
vertical-align: middle;
}
div.path a:link, div.path a:visited { color: #333; }
table.coverage {
border-collapse: collapse;
margin: 10px 0 0 0;
padding: 0;
}
table.coverage td {
margin: 0;
padding: 0;
vertical-align: top;
}
table.coverage td.line-count {
text-align: right;
padding: 0 5px 0 20px;
}
table.coverage td.line-coverage {
text-align: right;
padding-right: 10px;
min-width:20px;
}
table.coverage td span.cline-any {
display: inline-block;
padding: 0 5px;
width: 100%;
}
.missing-if-branch {
display: inline-block;
margin-right: 5px;
border-radius: 3px;
position: relative;
padding: 0 4px;
background: #333;
color: yellow;
}
.skip-if-branch {
display: none;
margin-right: 10px;
position: relative;
padding: 0 4px;
background: #ccc;
color: white;
}
.missing-if-branch .typ, .skip-if-branch .typ {
color: inherit !important;
}
.coverage-summary {
border-collapse: collapse;
width: 100%;
}
.coverage-summary tr { border-bottom: 1px solid #bbb; }
.keyline-all { border: 1px solid #ddd; }
.coverage-summary td, .coverage-summary th { padding: 10px; }
.coverage-summary tbody { border: 1px solid #bbb; }
.coverage-summary td { border-right: 1px solid #bbb; }
.coverage-summary td:last-child { border-right: none; }
.coverage-summary th {
text-align: left;
font-weight: normal;
white-space: nowrap;
}
.coverage-summary th.file { border-right: none !important; }
.coverage-summary th.pct { }
.coverage-summary th.pic,
.coverage-summary th.abs,
.coverage-summary td.pct,
.coverage-summary td.abs { text-align: right; }
.coverage-summary td.file { white-space: nowrap; }
.coverage-summary td.pic { min-width: 120px !important; }
.coverage-summary tfoot td { }
.coverage-summary .sorter {
height: 10px;
width: 7px;
display: inline-block;
margin-left: 0.5em;
background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
}
.coverage-summary .sorted .sorter {
background-position: 0 -20px;
}
.coverage-summary .sorted-desc .sorter {
background-position: 0 -10px;
}
.status-line { height: 10px; }
/* yellow */
.cbranch-no { background: yellow !important; color: #111; }
/* dark red */
.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
.low .chart { border:1px solid #C21F39 }
.highlighted,
.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
background: #C21F39 !important;
}
/* medium red */
.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
/* light red */
.low, .cline-no { background:#FCE1E5 }
/* light green */
.high, .cline-yes { background:rgb(230,245,208) }
/* medium green */
.cstat-yes { background:rgb(161,215,106) }
/* dark green */
.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
.high .chart { border:1px solid rgb(77,146,33) }
/* dark yellow (gold) */
.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
.medium .chart { border:1px solid #f9cd0b; }
/* light yellow */
.medium { background: #fff4c2; }
.cstat-skip { background: #ddd; color: #111; }
.fstat-skip { background: #ddd; color: #111 !important; }
.cbranch-skip { background: #ddd !important; color: #111; }
span.cline-neutral { background: #eaeaea; }
.coverage-summary td.empty {
opacity: .5;
padding-top: 4px;
padding-bottom: 4px;
line-height: 1;
color: #888;
}
.cover-fill, .cover-empty {
display:inline-block;
height: 12px;
}
.chart {
line-height: 0;
}
.cover-empty {
background: white;
}
.cover-full {
border-right: none !important;
}
pre.prettyprint {
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
.com { color: #999 !important; }
.ignore-none { color: #999; font-weight: normal; }
.wrapper {
min-height: 100%;
height: auto !important;
height: 100%;
margin: 0 auto -48px;
}
.footer, .push {
height: 48px;
}

View File

@@ -0,0 +1,87 @@
/* eslint-disable */
var jumpToCode = (function init() {
// Classes of code we would like to highlight in the file view
var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
// Elements to highlight in the file listing view
var fileListingElements = ['td.pct.low'];
// We don't want to select elements that are direct descendants of another match
var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
// Selector that finds elements on the page to which we can jump
var selector =
fileListingElements.join(', ') +
', ' +
notSelector +
missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
// The NodeList of matching elements
var missingCoverageElements = document.querySelectorAll(selector);
var currentIndex;
function toggleClass(index) {
missingCoverageElements
.item(currentIndex)
.classList.remove('highlighted');
missingCoverageElements.item(index).classList.add('highlighted');
}
function makeCurrent(index) {
toggleClass(index);
currentIndex = index;
missingCoverageElements.item(index).scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
});
}
function goToPrevious() {
var nextIndex = 0;
if (typeof currentIndex !== 'number' || currentIndex === 0) {
nextIndex = missingCoverageElements.length - 1;
} else if (missingCoverageElements.length > 1) {
nextIndex = currentIndex - 1;
}
makeCurrent(nextIndex);
}
function goToNext() {
var nextIndex = 0;
if (
typeof currentIndex === 'number' &&
currentIndex < missingCoverageElements.length - 1
) {
nextIndex = currentIndex + 1;
}
makeCurrent(nextIndex);
}
return function jump(event) {
if (
document.getElementById('fileSearch') === document.activeElement &&
document.activeElement != null
) {
// if we're currently focused on the search input, we don't want to navigate
return;
}
switch (event.which) {
case 78: // n
case 74: // j
goToNext();
break;
case 66: // b
case 75: // k
case 80: // p
goToPrevious();
break;
}
};
})();
window.addEventListener('keydown', jumpToCode);

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

View File

@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for All files</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="prettify.css" />
<link rel="stylesheet" href="base.css" />
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1>All files</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">Unknown% </span>
<span class="quiet">Statements</span>
<span class='fraction'>0/0</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">Unknown% </span>
<span class="quiet">Branches</span>
<span class='fraction'>0/0</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">Unknown% </span>
<span class="quiet">Functions</span>
<span class='fraction'>0/0</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">Unknown% </span>
<span class="quiet">Lines</span>
<span class='fraction'>0/0</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line medium'></div>
<div class="pad1">
<table class="coverage-summary">
<thead>
<tr>
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2025-12-20T12:11:01.527Z
</div>
<script src="prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="sorter.js"></script>
<script src="block-navigation.js"></script>
</body>
</html>

View File

@@ -0,0 +1 @@
.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

View File

@@ -0,0 +1,210 @@
/* eslint-disable */
var addSorting = (function() {
'use strict';
var cols,
currentSort = {
index: 0,
desc: false
};
// returns the summary table element
function getTable() {
return document.querySelector('.coverage-summary');
}
// returns the thead element of the summary table
function getTableHeader() {
return getTable().querySelector('thead tr');
}
// returns the tbody element of the summary table
function getTableBody() {
return getTable().querySelector('tbody');
}
// returns the th element for nth column
function getNthColumn(n) {
return getTableHeader().querySelectorAll('th')[n];
}
function onFilterInput() {
const searchValue = document.getElementById('fileSearch').value;
const rows = document.getElementsByTagName('tbody')[0].children;
// Try to create a RegExp from the searchValue. If it fails (invalid regex),
// it will be treated as a plain text search
let searchRegex;
try {
searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
} catch (error) {
searchRegex = null;
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
let isMatch = false;
if (searchRegex) {
// If a valid regex was created, use it for matching
isMatch = searchRegex.test(row.textContent);
} else {
// Otherwise, fall back to the original plain text search
isMatch = row.textContent
.toLowerCase()
.includes(searchValue.toLowerCase());
}
row.style.display = isMatch ? '' : 'none';
}
}
// loads the search box
function addSearchBox() {
var template = document.getElementById('filterTemplate');
var templateClone = template.content.cloneNode(true);
templateClone.getElementById('fileSearch').oninput = onFilterInput;
template.parentElement.appendChild(templateClone);
}
// loads all columns
function loadColumns() {
var colNodes = getTableHeader().querySelectorAll('th'),
colNode,
cols = [],
col,
i;
for (i = 0; i < colNodes.length; i += 1) {
colNode = colNodes[i];
col = {
key: colNode.getAttribute('data-col'),
sortable: !colNode.getAttribute('data-nosort'),
type: colNode.getAttribute('data-type') || 'string'
};
cols.push(col);
if (col.sortable) {
col.defaultDescSort = col.type === 'number';
colNode.innerHTML =
colNode.innerHTML + '<span class="sorter"></span>';
}
}
return cols;
}
// attaches a data attribute to every tr element with an object
// of data values keyed by column name
function loadRowData(tableRow) {
var tableCols = tableRow.querySelectorAll('td'),
colNode,
col,
data = {},
i,
val;
for (i = 0; i < tableCols.length; i += 1) {
colNode = tableCols[i];
col = cols[i];
val = colNode.getAttribute('data-value');
if (col.type === 'number') {
val = Number(val);
}
data[col.key] = val;
}
return data;
}
// loads all row data
function loadData() {
var rows = getTableBody().querySelectorAll('tr'),
i;
for (i = 0; i < rows.length; i += 1) {
rows[i].data = loadRowData(rows[i]);
}
}
// sorts the table using the data for the ith column
function sortByIndex(index, desc) {
var key = cols[index].key,
sorter = function(a, b) {
a = a.data[key];
b = b.data[key];
return a < b ? -1 : a > b ? 1 : 0;
},
finalSorter = sorter,
tableBody = document.querySelector('.coverage-summary tbody'),
rowNodes = tableBody.querySelectorAll('tr'),
rows = [],
i;
if (desc) {
finalSorter = function(a, b) {
return -1 * sorter(a, b);
};
}
for (i = 0; i < rowNodes.length; i += 1) {
rows.push(rowNodes[i]);
tableBody.removeChild(rowNodes[i]);
}
rows.sort(finalSorter);
for (i = 0; i < rows.length; i += 1) {
tableBody.appendChild(rows[i]);
}
}
// removes sort indicators for current column being sorted
function removeSortIndicators() {
var col = getNthColumn(currentSort.index),
cls = col.className;
cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
col.className = cls;
}
// adds sort indicators for current column being sorted
function addSortIndicators() {
getNthColumn(currentSort.index).className += currentSort.desc
? ' sorted-desc'
: ' sorted';
}
// adds event listeners for all sorter widgets
function enableUI() {
var i,
el,
ithSorter = function ithSorter(i) {
var col = cols[i];
return function() {
var desc = col.defaultDescSort;
if (currentSort.index === i) {
desc = !currentSort.desc;
}
sortByIndex(i, desc);
removeSortIndicators();
currentSort.index = i;
currentSort.desc = desc;
addSortIndicators();
};
};
for (i = 0; i < cols.length; i += 1) {
if (cols[i].sortable) {
// add the click event handler on the th so users
// dont have to click on those tiny arrows
el = getNthColumn(i).querySelector('.sorter').parentElement;
if (el.addEventListener) {
el.addEventListener('click', ithSorter(i));
} else {
el.attachEvent('onclick', ithSorter(i));
}
}
}
}
// adds sorting functionality to the UI
return function() {
if (!getTable()) {
return;
}
cols = loadColumns();
loadData();
addSearchBox();
addSortIndicators();
enableUI();
};
})();
window.addEventListener('load', addSorting);

0
coverage/lcov.info Normal file
View File

5
docs/resources.md Normal file
View File

@@ -0,0 +1,5 @@
https://fikunmi.substack.com/p/making-680-apr-with-meteoras-dlmms
https://www.youtube.com/watch?v=HV-Enxuet60

8
jest.config.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.ts'],
collectCoverage: true,
coverageDirectory: 'coverage',
coveragePathIgnorePatterns: ['/node_modules/'],
};

47
package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "meteora-dlmm-bot",
"version": "1.0.0",
"description": "",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"start": "ts-node src/index.ts",
"build": "tsc",
"test": "jest",
"test:watch": "jest --watch",
"lint": "eslint 'src/**/*.ts' 'tests/**/*.ts'",
"lint:fix": "eslint 'src/**/*.ts' 'tests/**/*.ts' --fix",
"withdraw": "ts-node src/withdraw.ts",
"withdraw:prod": "node dist/withdraw.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@jup-ag/api": "^6.0.47",
"@meteora-ag/dlmm": "^1.9.0",
"@solana/web3.js": "^1.98.4",
"@types/node": "^25.0.3",
"bs58": "^5.0.0",
"decimal.js": "^10.6.0",
"dotenv": "^17.2.3",
"node-cron": "^4.2.1",
"pino": "^10.1.0",
"pino-pretty": "^13.1.3"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0",
"prettier": "^3.3.3",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
}
}

0
report.md Normal file
View File

191
src/bot.ts Normal file
View File

@@ -0,0 +1,191 @@
import { MeteoraWrapper } from "./meteora";
import logger from "./utils/logger";
import { REBALANCE_THRESHOLD_PERCENT, COMPOUND_THRESHOLD_USD, JLP_USD_ESTIMATE, SOL_USD_ESTIMATE } from "./utils/config";
import { getSwapQuote, executeSwap } from "./swap";
import { PublicKey } from "@solana/web3.js";
import { BN } from "@coral-xyz/anchor";
export class DLMMBot {
private meteora: MeteoraWrapper;
constructor() {
this.meteora = new MeteoraWrapper();
}
async init(): Promise<void> {
await this.meteora.init();
}
async rebalance(): Promise<void> {
const startTime = new Date().toISOString();
try {
logger.info(`--- Portfolio Status Report [${startTime}] ---`);
// Force refresh of pool data in each cycle
await this.meteora.init();
const activeBin = await this.meteora.getActiveBin();
const positions = await this.meteora.getPositions();
logger.info(`Active Bin ID: ${activeBin.binId}`);
logger.info(`Number of active positions: ${positions.length}`);
if (positions.length === 0) {
logger.info(
"No active positions found. Checking for tokens to deposit..."
);
await this.handleInitialDeposit(activeBin.binId);
return;
}
let priceRangeRebalance = false;
for (const position of positions) {
const lowerBinId = position.positionData.lowerBinId;
const upperBinId = position.positionData.upperBinId;
const range = upperBinId - lowerBinId;
const threshold = Math.floor(range * REBALANCE_THRESHOLD_PERCENT);
const lowerBoundary = lowerBinId + threshold;
const upperBoundary = upperBinId - threshold;
const distanceToLower = activeBin.binId - lowerBoundary;
const distanceToUpper = upperBoundary - activeBin.binId;
logger.info(`Position ${position.publicKey.toBase58()}: Range [${lowerBinId}, ${upperBinId}], Threshold Boundary [${lowerBoundary}, ${upperBoundary}]`);
logger.info(`Distance to boundaries: Lower=+${distanceToLower}, Upper=+${distanceToUpper}`);
if (activeBin.binId <= lowerBoundary || activeBin.binId >= upperBoundary) {
logger.info("Active bin reached boundary. Price range rebalance required.");
priceRangeRebalance = true;
break;
}
}
let rebalanceRequired = priceRangeRebalance;
let totalValueUSD = 0;
let hasSubstantialBalance = false;
if (!rebalanceRequired) {
// Value-based compound check
const { balanceX, balanceY } = await this.meteora.getBalances();
const tokenX = this.meteora.dlmmPool?.tokenX;
const tokenY = this.meteora.dlmmPool?.tokenY;
if (!tokenX || !tokenY) {
logger.warn("Pool tokens not available for value calculation.");
return;
}
const totalX = balanceX.toNumber() / 10 ** (tokenX.mint as any).decimals;
const totalY = balanceY.toNumber() / 10 ** (tokenY.mint as any).decimals;
const isXSol = tokenX.mint.address.toBase58() === "So11111111111111111111111111111111111111112";
const isYSol = tokenY.mint.address.toBase58() === "So11111111111111111111111111111111111111112";
let valueX = 0;
let valueY = 0;
if (isXSol) {
valueX = totalX * SOL_USD_ESTIMATE;
valueY = totalY * JLP_USD_ESTIMATE;
} else if (isYSol) {
valueY = totalY * SOL_USD_ESTIMATE;
valueX = totalX * JLP_USD_ESTIMATE;
} else {
valueX = totalX * JLP_USD_ESTIMATE;
valueY = totalY * JLP_USD_ESTIMATE;
}
totalValueUSD = valueX + valueY;
hasSubstantialBalance = totalValueUSD >= COMPOUND_THRESHOLD_USD;
if (hasSubstantialBalance) {
logger.info(`Substantial wallet balance detected ($${totalValueUSD.toFixed(2)}). Triggering value-based compounding...`);
rebalanceRequired = true;
} else {
logger.info(`Portfolio healthy. Uninvested balance ($${totalValueUSD.toFixed(2)}) is below threshold ($${COMPOUND_THRESHOLD_USD}).`);
logger.info(`--- Cycle Complete [${new Date().toISOString()}] ---`);
return;
}
}
await this.meteora.withdrawAll();
// Claim fees after withdrawal to consolidate balances
await this.meteora.claimFees();
const { balanceX, balanceY } = await this.meteora.getBalances();
if (balanceX.isZero() && balanceY.isZero()) {
logger.warn("No balances available to re-deposit after withdrawal.");
return;
}
// Financial Safety: Only skip if NOT a price-range rebalance and value is too low
const estimatedFeeUSD = 0.005 * SOL_USD_ESTIMATE;
if (!priceRangeRebalance && totalValueUSD < estimatedFeeUSD) {
logger.warn(`Skipping compounding: Total uninvested value ($${totalValueUSD.toFixed(2)}) is less than estimated transaction fees ($${estimatedFeeUSD.toFixed(2)}).`);
return;
}
await this.meteora.deposit(balanceX, balanceY, activeBin.binId);
logger.info("Rebalance/Compounding completed successfully.");
logger.info(`--- Cycle Complete [${new Date().toISOString()}] ---`);
} catch (error: any) {
logger.error(`CRITICAL: Error during rebalance cycle: ${error.message}`);
}
}
private async handleInitialDeposit(activeBinId: number): Promise<void> {
let { balanceX, balanceY } = await this.meteora.getBalances();
if (balanceX.isZero() && balanceY.isZero()) {
logger.info("No token balances found for initial deposit.");
return;
}
// Balanced investment logic: if one side is zero, swap 50%
const SOL_MINT = "So11111111111111111111111111111111111111112";
const pool = this.meteora.dlmmPool;
if (!pool) {
logger.error("Pool not initialized during handleInitialDeposit.");
return;
}
const isXSol = pool.tokenX.mint.address.toBase58() === SOL_MINT;
const isYSol = pool.tokenY.mint.address.toBase58() === SOL_MINT;
if (balanceX.isZero() || balanceY.isZero()) {
logger.info("Single asset detected. Performing 50/50 swap to balance the position...");
try {
if (isXSol && !balanceX.isZero()) {
const swapAmount = balanceX.div(new BN(2)).toNumber();
logger.info(`Swapping ${swapAmount / 1e9} SOL for TokenY...`);
const quote = await getSwapQuote(SOL_MINT, pool.tokenY.mint.address.toBase58(), swapAmount);
await executeSwap(quote);
} else if (isYSol && !balanceY.isZero()) {
const swapAmount = balanceY.div(new BN(2)).toNumber();
logger.info(`Swapping ${swapAmount / 1e9} SOL for TokenX...`);
const quote = await getSwapQuote(SOL_MINT, pool.tokenX.mint.address.toBase58(), swapAmount);
await executeSwap(quote);
} else {
logger.warn("Only non-SOL asset detected. Proceeding with single-sided deposit (no auto-swap for non-SOL yet).");
}
// Refetch balances after swap
({ balanceX, balanceY } = await this.meteora.getBalances());
} catch (error: any) {
logger.error(`Swap failed during initial deposit: ${error.message}. Proceeding with current balances.`);
}
}
logger.info(
`Initial deposit: X=${balanceX.toString()}, Y=${balanceY.toString()}`
);
await this.meteora.deposit(balanceX, balanceY, activeBinId);
}
async compound(): Promise<void> {
logger.info("Starting compounding...");
await this.meteora.claimFees();
logger.info("Compounding finished.");
}
}

45
src/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import cron from "node-cron";
import { DLMMBot } from "./bot";
import { CHECK_INTERVAL_CRON, COMPOUND_INTERVAL_CRON } from "./utils/config";
import logger from "./utils/logger";
async function main() {
logger.info("Meteora DLMM Bot starting...");
const bot = new DLMMBot();
await bot.init();
logger.info("Running initial rebalance check...");
try {
await bot.rebalance();
} catch (error: any) {
logger.error(`Initial rebalance check failed: ${error.message}`);
}
// Schedule rebalance (hourly by default)
cron.schedule(CHECK_INTERVAL_CRON, async () => {
try {
await bot.rebalance();
} catch (error: any) {
logger.error(`Error during rebalance: ${error.message}`);
}
});
// Schedule compound (daily by default)
cron.schedule(COMPOUND_INTERVAL_CRON, async () => {
try {
await bot.compound();
} catch (error: any) {
logger.error(`Error during compounding: ${error.message}`);
}
});
logger.info(`Bot scheduled with check interval: ${CHECK_INTERVAL_CRON}`);
logger.info(
`Bot scheduled with compound interval: ${COMPOUND_INTERVAL_CRON}`
);
}
main().catch((err) => {
logger.error(`Fatal error: ${err.message}`);
process.exit(1);
});

234
src/meteora.ts Normal file
View File

@@ -0,0 +1,234 @@
import DLMM, { StrategyType } from "@meteora-ag/dlmm";
import {
sendAndConfirmTransaction,
Keypair,
PublicKey,
} from "@solana/web3.js";
import { DRY_RUN, wallet, connection, poolAddress, MIN_SOL_FOR_GAS, SLIPPAGE_BPS, MAX_RETRIES } from "./utils/config";
import logger from "./utils/logger";
import { BN } from "@coral-xyz/anchor";
import { getAccount, getAssociatedTokenAddress } from "@solana/spl-token";
export class MeteoraWrapper {
public dlmmPool: DLMM | null = null;
async init() {
this.dlmmPool = await DLMM.create(connection, poolAddress);
logger.info(`DLMM Pool initialized: ${poolAddress.toBase58()}`);
}
async getActiveBin() {
if (!this.dlmmPool) throw new Error("Pool not initialized");
return await this.dlmmPool.getActiveBin();
}
async getPositions() {
if (!this.dlmmPool) throw new Error("Pool not initialized");
const { userPositions } = await this.dlmmPool.getPositionsByUserAndLbPair(
wallet.publicKey
);
return userPositions;
}
async withdrawAll() {
if (!this.dlmmPool) throw new Error("Pool not initialized");
const positions = await this.getPositions();
for (const position of positions) {
logger.info(
`Withdrawing from position: ${position.publicKey.toBase58()}`
);
const minBinId = position.positionData.lowerBinId;
const maxBinId = position.positionData.upperBinId;
try {
const transactions = await this.dlmmPool.removeLiquidity({
position: position.publicKey,
user: wallet.publicKey,
fromBinId: minBinId,
toBinId: maxBinId,
bps: new BN(10000), // 100%
shouldClaimAndClose: true,
});
for (const tx of transactions) {
if (DRY_RUN) {
logger.info("[DRY RUN] Withdrawal transaction part skipped.");
continue;
}
const txHash = await this.sendTransactionWithRetry(tx);
logger.info(`Withdrawal part successful: ${txHash}`);
}
} catch (error: any) {
logger.error(
`Failed to withdraw from position ${position.publicKey.toBase58()}: ${error?.message || "Unknown error"}`
);
throw error;
}
}
}
async deposit(amountX: BN, amountY: BN, activeBinId: number) {
if (!this.dlmmPool) throw new Error("Pool not initialized");
const range = 15;
const minBinId = activeBinId - range;
const maxBinId = activeBinId + range;
const tokenXSymbol = this.dlmmPool.tokenX.mint.address.equals(new PublicKey("So11111111111111111111111111111111111111112")) ? "SOL" : "TokenX";
const tokenYSymbol = this.dlmmPool.tokenY.mint.address.equals(new PublicKey("So11111111111111111111111111111111111111112")) ? "SOL" : "TokenY";
logger.info(
`Depositing liquidity: ${amountX.toString()} ${tokenXSymbol}, ${amountY.toString()} ${tokenYSymbol} in range [${minBinId}, ${maxBinId}] around bin ${activeBinId}`
);
try {
const positionKeypair = Keypair.generate();
const tx =
await this.dlmmPool.initializePositionAndAddLiquidityByStrategy({
positionPubKey: positionKeypair.publicKey,
user: wallet.publicKey,
totalXAmount: amountX,
totalYAmount: amountY,
strategy: {
maxBinId,
minBinId,
strategyType: StrategyType.Spot,
},
slippage: SLIPPAGE_BPS / 100, // Convert BPS to percentage (100 BPS = 1%)
});
if (DRY_RUN) {
logger.info("[DRY RUN] Deposit transaction created but not sent.");
return;
}
const txHash = await this.sendTransactionWithRetry(tx, [positionKeypair]);
logger.info(`Deposit successful! Transaction Hash: ${txHash}`);
} catch (error: any) {
logger.error(`Deposit failed: ${error?.message || "Unknown error"}`);
throw error;
}
}
private async sendTransactionWithRetry(tx: any, additionalSigners: Keypair[] = [], retries: number = MAX_RETRIES): Promise<string> {
let lastError: any;
const signers = [wallet, ...additionalSigners];
for (let i = 0; i < retries + 1; i++) {
try {
return await sendAndConfirmTransaction(connection, tx, signers);
} catch (error: any) {
lastError = error;
if (i < retries) {
const delay = Math.pow(2, i) * 2000;
logger.warn(`Transaction failed, retrying in ${delay / 1000}s (${i + 1}/${retries})... Error: ${error.message}`);
await new Promise(res => setTimeout(res, delay));
}
}
}
throw lastError;
}
async getBalances(): Promise<{ balanceX: BN; balanceY: BN }> {
if (!this.dlmmPool) throw new Error("Pool not initialized");
const tokenXMint = this.dlmmPool.tokenX.mint.address;
const tokenYMint = this.dlmmPool.tokenY.mint.address;
const SOL_MINT = new PublicKey("So11111111111111111111111111111111111111112");
let balanceX = new BN(0);
let balanceY = new BN(0);
const tokenXLabel = tokenXMint.equals(SOL_MINT) ? "SOL" : tokenXMint.toBase58().slice(0, 4) + "...";
const tokenYLabel = tokenYMint.equals(SOL_MINT) ? "SOL" : tokenYMint.toBase58().slice(0, 4) + "...";
// Fetch Token X balance
if (tokenXMint.equals(SOL_MINT)) {
const solBalance = await connection.getBalance(wallet.publicKey);
// Reserve MIN_SOL_FOR_GAS for fees and rent
const buffer = MIN_SOL_FOR_GAS * 10 ** 9;
balanceX = new BN(Math.max(0, solBalance - buffer));
logger.info(`Wallet Balance (Native SOL): ${(solBalance / 10 ** 9).toFixed(4)} SOL (${solBalance} lamports). Available for deposit: ${(balanceX.toNumber() / 10 ** 9).toFixed(4)} SOL`);
if (solBalance < 0.2 * 10 ** 9) {
logger.warn("CRITICAL: Wallet SOL balance is below 0.2 SOL. Transactions may fail soon due to rent!");
}
} else {
try {
const tokenXATA = await getAssociatedTokenAddress(tokenXMint, wallet.publicKey);
const accountX = await getAccount(connection, tokenXATA);
const rawAmount = new BN(accountX.amount.toString());
// Subtract dust (0.0001 tokens) to keep ATA alive if amount > 0.0001
const decimals = (this.dlmmPool.tokenX.mint as any).decimals || 0;
const dust = new BN(0.0001 * 10 ** decimals);
balanceX = rawAmount.gt(dust) ? rawAmount.sub(dust) : new BN(0);
logger.info(`Wallet Balance (${tokenXLabel}): ${(balanceX.toNumber() / 10 ** decimals).toFixed(4)} (Total: ${rawAmount.toString()} lamports)`);
} catch {
logger.debug(`Token X ATA not found: ${tokenXMint.toBase58()}`);
}
}
// Fetch Token Y balance
if (tokenYMint.equals(SOL_MINT)) {
const solBalance = await connection.getBalance(wallet.publicKey);
const buffer = MIN_SOL_FOR_GAS * 10 ** 9;
balanceY = new BN(Math.max(0, solBalance - buffer));
// Only log if not already logged (in case both are SOL, though unlikely in a pair)
if (!tokenXMint.equals(SOL_MINT)) {
logger.info(`Wallet Balance (Native SOL): ${(solBalance / 10 ** 9).toFixed(4)} SOL (${solBalance} lamports). Available for deposit: ${(balanceY.toNumber() / 10 ** 9).toFixed(4)} SOL`);
if (solBalance < 0.2 * 10 ** 9) {
logger.warn("CRITICAL: Wallet SOL balance is below 0.2 SOL. Transactions may fail soon due to rent!");
}
}
} else {
try {
const tokenYATA = await getAssociatedTokenAddress(tokenYMint, wallet.publicKey);
const accountY = await getAccount(connection, tokenYATA);
const rawAmount = new BN(accountY.amount.toString());
const decimals = (this.dlmmPool.tokenY.mint as any).decimals || 0;
const dust = new BN(0.0001 * 10 ** decimals);
balanceY = rawAmount.gt(dust) ? rawAmount.sub(dust) : new BN(0);
logger.info(`Wallet Balance (${tokenYLabel}): ${(balanceY.toNumber() / 10 ** decimals).toFixed(4)} (Total: ${rawAmount.toString()} lamports)`);
} catch {
logger.debug(`Token Y ATA not found: ${tokenYMint.toBase58()}`);
}
}
return { balanceX, balanceY };
}
async claimFees() {
if (!this.dlmmPool) throw new Error("Pool not initialized");
const positions = await this.getPositions();
for (const position of positions) {
logger.info(
`Claiming fees from position: ${position.publicKey.toBase58()}`
);
const transactions = await this.dlmmPool.claimAllRewardsByPosition({
owner: wallet.publicKey,
position,
});
for (const tx of transactions) {
if (DRY_RUN) {
logger.info("[DRY RUN] Fee claim transaction part skipped.");
continue;
}
const txHash = await this.sendTransactionWithRetry(tx);
logger.info(`Fees claimed part: ${txHash}`);
}
}
}
/**
* Returns the current price of TokenX in terms of TokenY.
* E.g., if X is SOL and Y is USDC, returns USDC price of 1 SOL.
*/
async getTokenPrice(): Promise<number> {
if (!this.dlmmPool) throw new Error("Pool not initialized");
const activeBin = await this.dlmmPool.getActiveBin();
return parseFloat(activeBin.price);
}
}

53
src/swap.ts Normal file
View File

@@ -0,0 +1,53 @@
import { createJupiterApiClient } from "@jup-ag/api";
import { wallet, connection } from "./utils/config";
import { VersionedTransaction } from "@solana/web3.js";
import logger from "./utils/logger";
const jupiterQuoteApi = createJupiterApiClient();
export async function getSwapQuote(
inputMint: string,
outputMint: string,
amount: number,
slippageBps = 50
) {
const quote = await jupiterQuoteApi.quoteGet({
inputMint,
outputMint,
amount,
slippageBps,
});
return quote;
}
export async function executeSwap(quoteResponse: any): Promise<string> {
const { swapTransaction } = await jupiterQuoteApi.swapPost({
swapRequest: {
quoteResponse,
userPublicKey: wallet.publicKey.toBase58(),
wrapAndUnwrapSol: true,
},
});
const swapTransactionBuf = Buffer.from(swapTransaction, "base64");
const transaction = VersionedTransaction.deserialize(swapTransactionBuf);
transaction.sign([wallet]);
const txid = await connection.sendTransaction(transaction, {
skipPreflight: true,
maxRetries: 2,
});
logger.info(`Swap executed: https://solscan.io/tx/${txid}`);
logger.info("Waiting for swap confirmation...");
const latestBlockhash = await connection.getLatestBlockhash();
await connection.confirmTransaction({
signature: txid,
blockhash: latestBlockhash.blockhash,
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight
});
logger.info("Swap confirmed.");
return txid;
}

43
src/utils/config.ts Normal file
View File

@@ -0,0 +1,43 @@
import dotenv from "dotenv";
import { Connection, Keypair, PublicKey } from "@solana/web3.js";
import bs58 from "bs58";
dotenv.config();
export const RPC_URL =
process.env.RPC_URL || "https://api.mainnet-beta.solana.com";
export const PRIVATE_KEY = process.env.PRIVATE_KEY || "";
export const PAIR_ADDRESS = process.env.PAIR_ADDRESS || "";
export const REBALANCE_THRESHOLD_PERCENT = parseFloat(
process.env.REBALANCE_THRESHOLD_PERCENT || "0.15"
);
export const COMPOUND_THRESHOLD_USD = parseFloat(
process.env.COMPOUND_THRESHOLD_USD || "1.0"
);
export const MIN_SOL_FOR_GAS = 0.1; // Reserved for gas and rent
export const TARGET_RATIO = parseFloat(process.env.TARGET_RATIO || "0.5");
export const CHECK_INTERVAL_CRON = (
process.env.CHECK_INTERVAL_CRON || "*/5 * * * *"
).replace(/^["']|["']$/g, "").trim();
export const COMPOUND_INTERVAL_CRON = (
process.env.COMPOUND_INTERVAL_CRON || "0 0 * * *"
).replace(/^["']|["']$/g, "").trim();
export const DRY_RUN = process.env.DRY_RUN === "true";
export const SLIPPAGE_BPS = parseInt(process.env.SLIPPAGE_BPS || "100"); // 1% default, but user can change
export const MAX_RETRIES = parseInt(process.env.MAX_RETRIES || "3");
// Fallback USD estimates for value calculation (JLP: $3, SOL: $100)
export const JLP_USD_ESTIMATE = 3.0;
export const SOL_USD_ESTIMATE = 100.0;
if (!PRIVATE_KEY) {
throw new Error("PRIVATE_KEY is not defined in .env");
}
if (!PAIR_ADDRESS) {
throw new Error("PAIR_ADDRESS is not defined in .env");
}
export const wallet = Keypair.fromSecretKey(bs58.decode(PRIVATE_KEY));
export const connection = new Connection(RPC_URL, "confirmed");
export const poolAddress = new PublicKey(PAIR_ADDRESS);

15
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,15 @@
import pino from "pino";
const logger = pino({
level: process.env.LOG_LEVEL || "info",
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss Z",
ignore: "pid,hostname",
},
},
});
export default logger;

24
src/withdraw.ts Normal file
View File

@@ -0,0 +1,24 @@
import { MeteoraWrapper } from "./meteora";
import logger from "./utils/logger";
async function withdraw() {
logger.info("Starting emergency withdrawal...");
const meteora = new MeteoraWrapper();
try {
await meteora.init();
logger.info("Withdrawing all liquidity positions...");
await meteora.withdrawAll();
logger.info("Claiming all fees...");
await meteora.claimFees();
logger.info("Emergency withdrawal completed successfully.");
} catch (error: any) {
logger.error(`Emergency withdrawal failed: ${error.message}`);
process.exit(1);
}
}
withdraw();

View File

@@ -0,0 +1,25 @@
import { Connection, PublicKey } from "@solana/web3.js";
import DLMM from "@meteora-ag/dlmm";
// Mocking the whole DLMM module
jest.mock("@meteora-ag/dlmm");
describe("Meteora SDK Mock Test", () => {
it("should mock DLMM.create", async () => {
const mockPool = {
getActiveBin: jest.fn().mockResolvedValue({ binId: 100, price: "1.23" }),
};
(DLMM.create as jest.Mock).mockResolvedValue(mockPool);
const connection = new Connection("https://api.mainnet-beta.solana.com");
const poolAddress = new PublicKey(
"LbVRzDTvBDEcrthxfZ4RL6yiq3uZw8bS6MwtdY6UhFQ"
);
const pool = await DLMM.create(connection, poolAddress);
const activeBin = await pool.getActiveBin();
expect(activeBin.binId).toBe(100);
expect(DLMM.create).toHaveBeenCalledWith(connection, poolAddress);
});
});

81
tests/unit/bot.test.ts Normal file
View File

@@ -0,0 +1,81 @@
import { DLMMBot } from "../../src/bot";
import { MeteoraWrapper } from "../../src/meteora";
import logger from "../../src/utils/logger";
import { BN } from "@coral-xyz/anchor";
jest.mock("../../src/utils/config", () => ({
connection: { getActiveBin: jest.fn() },
wallet: { publicKey: { toBase58: () => "wallet1" } },
REBALANCE_THRESHOLD_PERCENT: 0.15,
poolAddress: { toBase58: () => "pool1" },
DRY_RUN: false,
SLIPPAGE_BPS: 100,
MAX_RETRIES: 3,
JLP_USD_ESTIMATE: 3.0,
SOL_USD_ESTIMATE: 100.0,
COMPOUND_THRESHOLD_USD: 1.0,
}));
jest.mock("../../src/utils/logger", () => ({
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
}));
jest.mock("../../src/meteora");
describe("DLMMBot Rebalance Logic", () => {
let bot: DLMMBot;
let mockMeteora: jest.Mocked<MeteoraWrapper>;
beforeEach(() => {
jest.clearAllMocks();
bot = new DLMMBot();
mockMeteora = (bot as any).meteora;
mockMeteora.getBalances.mockResolvedValue({ balanceX: new BN(0), balanceY: new BN(0) });
mockMeteora.getTokenPrice.mockResolvedValue(1.0);
mockMeteora.dlmmPool = {
tokenX: { mint: { decimals: 9, address: { toBase58: () => "So11111111111111111111111111111111111111112" } } },
tokenY: { mint: { decimals: 6, address: { toBase58: () => "JLP" } } }
} as any;
});
it("should not rebalance if within range", async () => {
mockMeteora.getActiveBin.mockResolvedValue({ binId: 100 } as any);
mockMeteora.getPositions.mockResolvedValue([
{
publicKey: { toBase58: () => "pos1" },
positionData: { lowerBinId: 90, upperBinId: 110 },
},
] as any);
await bot.rebalance();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Portfolio healthy")
);
expect(mockMeteora.withdrawAll).not.toHaveBeenCalled();
});
it("should trigger rebalance if out of range", async () => {
mockMeteora.getActiveBin.mockResolvedValue({ binId: 120 } as any);
mockMeteora.getPositions.mockResolvedValue([
{
publicKey: { toBase58: () => "pos1" },
positionData: { lowerBinId: 90, upperBinId: 110 },
},
] as any);
mockMeteora.getBalances.mockResolvedValue({ balanceX: new BN(100), balanceY: new BN(100) });
mockMeteora.getTokenPrice.mockResolvedValue(1.0);
// dlmmPool already mocked in beforeEach
await bot.rebalance();
expect(mockMeteora.withdrawAll).toHaveBeenCalled();
expect(mockMeteora.getBalances).toHaveBeenCalled();
expect(mockMeteora.deposit).toHaveBeenCalledWith(
new BN(100),
new BN(100),
120
);
});
});

132
tests/unit/meteora.test.ts Normal file
View File

@@ -0,0 +1,132 @@
import { PublicKey } from "@solana/web3.js";
import { BN } from "@coral-xyz/anchor";
// Objects prefixed with 'mock' are hoisted and allowed in jest.mock factory
const mockInternalConfig = {
connection: {},
wallet: { publicKey: new PublicKey("LbVRzDTvBDEcrthxfZ4RL6yiq3uZw8bS6MwtdY6UhFQ") },
poolAddress: new PublicKey("LbVRzDTvBDEcrthxfZ4RL6yiq3uZw8bS6MwtdY6UhFQ"),
DRY_RUN: false,
SLIPPAGE_BPS: 100,
MAX_RETRIES: 3,
MIN_SOL_FOR_GAS: 0.1,
};
jest.mock("../../src/utils/config", () => mockInternalConfig);
jest.mock("@meteora-ag/dlmm");
jest.mock("@solana/web3.js", () => ({
...jest.requireActual("@solana/web3.js"),
sendAndConfirmTransaction: jest.fn(),
}));
jest.mock("../../src/utils/logger", () => ({
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
}));
import { MeteoraWrapper } from "../../src/meteora";
import DLMM from "@meteora-ag/dlmm";
import { sendAndConfirmTransaction } from "@solana/web3.js";
import logger from "../../src/utils/logger";
describe("MeteoraWrapper", () => {
let wrapper: MeteoraWrapper;
let mockDlmm: any;
beforeEach(async () => {
jest.clearAllMocks();
wrapper = new MeteoraWrapper();
mockDlmm = {
getActiveBin: jest.fn(),
getPositionsByUserAndLbPair: jest.fn(),
removeLiquidity: jest.fn(),
initializePositionAndAddLiquidityByStrategy: jest.fn(),
claimAllRewardsByPosition: jest.fn(),
tokenX: { mint: { address: new PublicKey("LbVRzDTvBDEcrthxfZ4RL6yiq3uZw8bS6MwtdY6UhFQ") } },
tokenY: { mint: { address: new PublicKey("LbVRzDTvBDEcrthxfZ4RL6yiq3uZw8bS6MwtdY6UhFQ") } },
};
(DLMM.create as jest.Mock).mockResolvedValue(mockDlmm);
await wrapper.init();
});
it("should get active bin", async () => {
mockDlmm.getActiveBin.mockResolvedValue({ binId: 100 });
const bin = await wrapper.getActiveBin();
expect(bin.binId).toBe(100);
});
it("should withdraw all liquidity from positions", async () => {
const mockPosition = {
publicKey: new PublicKey("LbVRzDTvBDEcrthxfZ4RL6yiq3uZw8bS6MwtdY6UhFQ"),
positionData: { lowerBinId: 90, upperBinId: 110 },
};
mockDlmm.getPositionsByUserAndLbPair.mockResolvedValue({
userPositions: [mockPosition],
});
mockDlmm.removeLiquidity.mockResolvedValue([{}]);
(sendAndConfirmTransaction as jest.Mock).mockResolvedValue("txHash");
await wrapper.withdrawAll();
expect(mockDlmm.removeLiquidity).toHaveBeenCalled();
expect(sendAndConfirmTransaction).toHaveBeenCalled();
});
it("should deposit liquidity", async () => {
mockDlmm.initializePositionAndAddLiquidityByStrategy.mockResolvedValue({});
(sendAndConfirmTransaction as jest.Mock).mockResolvedValue("txHash");
await wrapper.deposit(
new BN(100),
new BN(100),
100
);
expect(
mockDlmm.initializePositionAndAddLiquidityByStrategy
).toHaveBeenCalled();
expect(sendAndConfirmTransaction).toHaveBeenCalled();
});
it("should skip transactions in DRY_RUN mode", async () => {
mockInternalConfig.DRY_RUN = true;
mockDlmm.initializePositionAndAddLiquidityByStrategy.mockResolvedValue({});
await wrapper.deposit(
new BN(100),
new BN(100),
100
);
expect(sendAndConfirmTransaction).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("[DRY RUN]")
);
mockInternalConfig.DRY_RUN = false; // Reset
});
it("should get token balances", async () => {
mockDlmm.tokenX = { mint: { address: new PublicKey("LbVRzDTvBDEcrthxfZ4RL6yiq3uZw8bS6MwtdY6UhFQ") } };
mockDlmm.tokenY = { mint: { address: new PublicKey("LbVRzDTvBDEcrthxfZ4RL6yiq3uZw8bS6MwtdY6UhFQ") } };
const balances = await wrapper.getBalances();
expect(balances).toBeDefined();
});
it("should log deposit details", async () => {
mockDlmm.initializePositionAndAddLiquidityByStrategy.mockResolvedValue({});
mockDlmm.tokenX = { mint: { address: new PublicKey("LbVRzDTvBDEcrthxfZ4RL6yiq3uZw8bS6MwtdY6UhFQ") } };
mockDlmm.tokenY = { mint: { address: new PublicKey("LbVRzDTvBDEcrthxfZ4RL6yiq3uZw8bS6MwtdY6UhFQ") } };
(sendAndConfirmTransaction as jest.Mock).mockResolvedValue("txHash");
await wrapper.deposit(new BN(100), new BN(100), 100);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("Depositing liquidity")
);
});
});

13
tests/unit/sanity.test.ts Normal file
View File

@@ -0,0 +1,13 @@
import { BN } from "@coral-xyz/anchor";
describe("Basic Setup Test", () => {
it("should handle BN correctly", () => {
const amount = new BN(100);
expect(amount.toString()).toBe("100");
});
it("should be able to import from src (sanity check)", () => {
// We'll add more complex tests once we mock external dependencies
expect(true).toBe(true);
});
});

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"lib": ["ESNext"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"moduleResolution": "node",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}