From c9daaddb7722a7ae06f8bc31cae14c4818d13e6d Mon Sep 17 00:00:00 2001 From: Louis-Sinan Date: Sun, 21 Dec 2025 12:22:16 +0100 Subject: [PATCH] first commit --- .dockerignore | 7 + .env.example | 8 + .eslintrc.json | 22 ++ .gitignore | 10 + Dockerfile | 16 ++ Makefile | 32 +++ README.md | 75 +++++++ coverage/clover.xml | 6 + coverage/coverage-final.json | 1 + coverage/lcov-report/base.css | 224 ++++++++++++++++++++ coverage/lcov-report/block-navigation.js | 87 ++++++++ coverage/lcov-report/favicon.png | Bin 0 -> 445 bytes coverage/lcov-report/index.html | 101 +++++++++ coverage/lcov-report/prettify.css | 1 + coverage/lcov-report/prettify.js | 2 + coverage/lcov-report/sort-arrow-sprite.png | Bin 0 -> 138 bytes coverage/lcov-report/sorter.js | 210 ++++++++++++++++++ coverage/lcov.info | 0 docs/resources.md | 5 + jest.config.js | 8 + package.json | 47 +++++ report.md | 0 src/bot.ts | 191 +++++++++++++++++ src/index.ts | 45 ++++ src/meteora.ts | 234 +++++++++++++++++++++ src/swap.ts | 53 +++++ src/utils/config.ts | 43 ++++ src/utils/logger.ts | 15 ++ src/withdraw.ts | 24 +++ tests/integration/meteora_mock.test.ts | 25 +++ tests/unit/bot.test.ts | 81 +++++++ tests/unit/meteora.test.ts | 132 ++++++++++++ tests/unit/sanity.test.ts | 13 ++ tsconfig.json | 17 ++ 34 files changed, 1735 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 coverage/clover.xml create mode 100644 coverage/coverage-final.json create mode 100644 coverage/lcov-report/base.css create mode 100644 coverage/lcov-report/block-navigation.js create mode 100644 coverage/lcov-report/favicon.png create mode 100644 coverage/lcov-report/index.html create mode 100644 coverage/lcov-report/prettify.css create mode 100644 coverage/lcov-report/prettify.js create mode 100644 coverage/lcov-report/sort-arrow-sprite.png create mode 100644 coverage/lcov-report/sorter.js create mode 100644 coverage/lcov.info create mode 100644 docs/resources.md create mode 100644 jest.config.js create mode 100644 package.json create mode 100644 report.md create mode 100644 src/bot.ts create mode 100644 src/index.ts create mode 100644 src/meteora.ts create mode 100644 src/swap.ts create mode 100644 src/utils/config.ts create mode 100644 src/utils/logger.ts create mode 100644 src/withdraw.ts create mode 100644 tests/integration/meteora_mock.test.ts create mode 100644 tests/unit/bot.test.ts create mode 100644 tests/unit/meteora.test.ts create mode 100644 tests/unit/sanity.test.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e5a44ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.env +node_modules +dist +coverage +Dockerfile +Makefile +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d82d787 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..e95371f --- /dev/null +++ b/.eslintrc.json @@ -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": "^_" + } + ] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f09369 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.env +.DS_Store +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cb3e5a5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e558dad --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..918a56a --- /dev/null +++ b/README.md @@ -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 + 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. + diff --git a/coverage/clover.xml b/coverage/clover.xml new file mode 100644 index 0000000..dd8eb0b --- /dev/null +++ b/coverage/clover.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/coverage/coverage-final.json b/coverage/coverage-final.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/coverage/coverage-final.json @@ -0,0 +1 @@ +{} diff --git a/coverage/lcov-report/base.css b/coverage/lcov-report/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/coverage/lcov-report/base.css @@ -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; +} diff --git a/coverage/lcov-report/block-navigation.js b/coverage/lcov-report/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/coverage/lcov-report/block-navigation.js @@ -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); diff --git a/coverage/lcov-report/favicon.png b/coverage/lcov-report/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c1525b811a167671e9de1fa78aab9f5c0b61cef7 GIT binary patch literal 445 zcmV;u0Yd(XP))rP{nL}Ln%S7`m{0DjX9TLF* zFCb$4Oi7vyLOydb!7n&^ItCzb-%BoB`=x@N2jll2Nj`kauio%aw_@fe&*}LqlFT43 z8doAAe))z_%=P%v^@JHp3Hjhj^6*Kr_h|g_Gr?ZAa&y>wxHE99Gk>A)2MplWz2xdG zy8VD2J|Uf#EAw*bo5O*PO_}X2Tob{%bUoO2G~T`@%S6qPyc}VkhV}UifBuRk>%5v( z)x7B{I~z*k<7dv#5tC+m{km(D087J4O%+<<;K|qwefb6@GSX45wCK}Sn*> + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ Unknown% + Statements + 0/0 +
+ + +
+ Unknown% + Branches + 0/0 +
+ + +
+ Unknown% + Functions + 0/0 +
+ + +
+ Unknown% + Lines + 0/0 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/coverage/lcov-report/prettify.css b/coverage/lcov-report/prettify.css new file mode 100644 index 0000000..b317a7c --- /dev/null +++ b/coverage/lcov-report/prettify.css @@ -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} diff --git a/coverage/lcov-report/prettify.js b/coverage/lcov-report/prettify.js new file mode 100644 index 0000000..b322523 --- /dev/null +++ b/coverage/lcov-report/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/coverage/lcov-report/sort-arrow-sprite.png b/coverage/lcov-report/sort-arrow-sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed68316eb3f65dec9063332d2f69bf3093bbfab GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^>_9Bd!3HEZxJ@+%Qh}Z>jv*C{$p!i!8j}?a+@3A= zIAGwzjijN=FBi!|L1t?LM;Q;gkwn>2cAy-KV{dn nf0J1DIvEHQu*n~6U}x}qyky7vi4|9XhBJ7&`njxgN@xNA8m%nc literal 0 HcmV?d00001 diff --git a/coverage/lcov-report/sorter.js b/coverage/lcov-report/sorter.js new file mode 100644 index 0000000..4ed70ae --- /dev/null +++ b/coverage/lcov-report/sorter.js @@ -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 + ''; + } + } + 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); diff --git a/coverage/lcov.info b/coverage/lcov.info new file mode 100644 index 0000000..e69de29 diff --git a/docs/resources.md b/docs/resources.md new file mode 100644 index 0000000..5dbae60 --- /dev/null +++ b/docs/resources.md @@ -0,0 +1,5 @@ + + +https://fikunmi.substack.com/p/making-680-apr-with-meteoras-dlmms +https://www.youtube.com/watch?v=HV-Enxuet60 + diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..d1f666a --- /dev/null +++ b/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/tests/**/*.test.ts'], + collectCoverage: true, + coverageDirectory: 'coverage', + coveragePathIgnorePatterns: ['/node_modules/'], +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..872963c --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/report.md b/report.md new file mode 100644 index 0000000..e69de29 diff --git a/src/bot.ts b/src/bot.ts new file mode 100644 index 0000000..7454c54 --- /dev/null +++ b/src/bot.ts @@ -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 { + await this.meteora.init(); + } + + async rebalance(): Promise { + 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 { + 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 { + logger.info("Starting compounding..."); + await this.meteora.claimFees(); + logger.info("Compounding finished."); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4fd7275 --- /dev/null +++ b/src/index.ts @@ -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); +}); diff --git a/src/meteora.ts b/src/meteora.ts new file mode 100644 index 0000000..c82a098 --- /dev/null +++ b/src/meteora.ts @@ -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 { + 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 { + if (!this.dlmmPool) throw new Error("Pool not initialized"); + const activeBin = await this.dlmmPool.getActiveBin(); + return parseFloat(activeBin.price); + } +} diff --git a/src/swap.ts b/src/swap.ts new file mode 100644 index 0000000..403541e --- /dev/null +++ b/src/swap.ts @@ -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 { + 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; +} diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..38742f4 --- /dev/null +++ b/src/utils/config.ts @@ -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); diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..5d62f76 --- /dev/null +++ b/src/utils/logger.ts @@ -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; diff --git a/src/withdraw.ts b/src/withdraw.ts new file mode 100644 index 0000000..a7dfda9 --- /dev/null +++ b/src/withdraw.ts @@ -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(); diff --git a/tests/integration/meteora_mock.test.ts b/tests/integration/meteora_mock.test.ts new file mode 100644 index 0000000..ff9e365 --- /dev/null +++ b/tests/integration/meteora_mock.test.ts @@ -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); + }); +}); diff --git a/tests/unit/bot.test.ts b/tests/unit/bot.test.ts new file mode 100644 index 0000000..2851f02 --- /dev/null +++ b/tests/unit/bot.test.ts @@ -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; + + 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 + ); + }); +}); diff --git a/tests/unit/meteora.test.ts b/tests/unit/meteora.test.ts new file mode 100644 index 0000000..a4edf4c --- /dev/null +++ b/tests/unit/meteora.test.ts @@ -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") + ); + }); +}); diff --git a/tests/unit/sanity.test.ts b/tests/unit/sanity.test.ts new file mode 100644 index 0000000..697ab79 --- /dev/null +++ b/tests/unit/sanity.test.ts @@ -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); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..92159c2 --- /dev/null +++ b/tsconfig.json @@ -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"] +}