Telegram-driven liquidity bot for Meteora DLMM on Solana. Built for fast, hands-on LP management from your phone: paste a pool link, get a position.
v2 is a full rewrite — layered TypeScript, websocket-driven rebalancing, and a security model that actually exists. See What changed in v2.
Warning
This bot signs transactions with a hot wallet, automatically. Extreme Mode trades continuously without asking. Bugs, network failures, or fast price movement can lose funds. Run it with money you can afford to lose.
| Feature | Description |
|---|---|
| Auto LP | Paste a pool link in chat → position opened with your active preset |
| Extreme Mode | 1-bin BidAsk position, auto-rebalanced as the active bin moves |
| Presets | Named strategies: SOL amount (fixed / 50% / max), range %, and shape |
| Multi-wallet | Import and switch wallets from Telegram; keys live only in .env |
| Sync | Adopts positions opened on the Meteora website, drops closed ones |
| RPC failover | Endpoints continuously scored; degraded ones are bypassed automatically |
| Stats | Live RPC/tx latency, cache hit ratios, cycle counts, memory |
Read this before running it.
- The bot only obeys
TELEGRAM_OWNER_IDS. It refuses to start without an allowlist. Anyone can message a Telegram bot whose @username they know — v1 had no authorization check at all, so a stranger who found it could spend the owner's wallet. - Private keys live only in
.env(chmod 600), never indata.json..envis rewritten atomically, and comments are preserved. - Messages containing a private key are deleted before anything else happens — including when the key is invalid, and including when an unauthorized user sends one.
- A pasted address is verified to be a real DLMM pool (owner check against the DLMM program) before any funds are sent to it.
data.jsonholds no secrets. A wallet name that looks like a base58 key is scrubbed before it can be written.
Both .env and data.json are gitignored.
- Node.js 20+
- A Telegram bot token (@BotFather)
- Your numeric chat ID (@userinfobot)
- A Solana RPC endpoint — Helius or similar strongly recommended over the public one
git clone https://github.com/dotnaonweh/meteora-bin-hunter.git
cd meteora-bin-hunter
npm install
npm run build
cp .env.example .env
chmod 600 .env
$EDITOR .env # set TELEGRAM_TOKEN and TELEGRAM_OWNER_IDSnpm start # production
npm run dev # watch mode, pretty logsWith PM2:
pm2 start dist/index.js --name meteora-bin-hunter
pm2 save && pm2 startupSend /start. Everything is buttons.
Presets — <name> <sol|max|50%> <range%> <spot|curve|bidask>, one per line:
SCALP max 7 bidask
SAFE 1 30 spot
HALF 50% 5 bidask
max uses your whole balance minus the reserve needed for rent and fees. 50% uses half of what's usable.
A classic DLMM position holds at most 70 bins. Bins are geometric — each is a (1 + binStep/10000) step — so at binStep 100 those 70 bins reach exactly -50.17%. That's the familiar "-49%" wall.
Anything wider automatically escalates to a resizable extended position (up to 1400 bins), the same path the official Meteora app uses. It can't fit in one transaction, so the deposit is landed across several:
| Range @ binStep 100 | Bins | Path |
|---|---|---|
| -30% | 36 | classic, 1 tx |
| -50% | 70 | classic, 1 tx |
| -51% | 72 | extended, multi-tx |
| -67% | 112 | extended, multi-tx |
| -70% | 121 | extended, multi-tx |
How wide you can go depends entirely on the pool's bin step. A fine-grained pool simply cannot reach a wide range — binStep 4 tops out around -42.9% even with all 1400 bins — so the bot rejects it up front with the actual reachable maximum instead of failing deep inside the SDK.
Important
v1's range math was wrong. It divided linearly (range% / binStep%) when bins compound geometrically, so it always undershot: a -49% preset opened only 49 bins (≈ -38% of real range), not the 68 actually needed. Every preset you have ever run opened a narrower position than you asked for and went out of range earlier than intended. This is now correct — which means your existing presets will behave differently (wider, holding range longer, with the same SOL spread over more bins).
Rent scales with bin count, so max and N% sizing now price the actual range being opened — position rent plus any bin arrays and token accounts that don't exist yet — rather than assuming a flat 0.08 SOL. A 1400-bin position can need well over 1 SOL of rent; a flat reserve would size the deposit to consume the balance and then fail.
Auto LP — paste a pool link, and the active preset is applied:
https://app.meteora.ag/dlmm/<pool_address>
Extreme Mode — opens a 1-bin BidAsk position at the active bin, then:
- Price moves below the bin → withdraw the token, re-add it to the same bin, wait.
- Price comes back → close, and reopen fresh at the new active bin.
- Price moves above the bin → close, and reopen fresh at the new active bin.
It repeats until you press Stop. Stop cancels in-flight work; it does not close the position, so remove it from Positions if you want out.
Business logic never talks to the network directly; infrastructure never knows what a preset is. Dependencies point inward, and there are no cycles.
src/
├── index.ts Composition root — the only place concretes are wired
│
├── config/ Env parsing, validation, defaults. No hardcoded values.
├── constants/ Mints, program IDs, enums
├── types/ Domain + Telegram types
│
├── core/ errors (severity classification), lifecycle (graceful shutdown)
│
├── net/ http (keep-alive pool, retry, dedupe), retry (jittered backoff),
│ circuit-breaker, rate-limiter
│
├── providers/rpc/ endpoint-pool (scoring + failover), subscriptions (websocket
│ account watching), lbpair-decoder (raw account → activeId)
│
├── adapters/ ── infrastructure ──
│ ├── dlmm/ pool-cache, tx (priority fees, idempotent confirm), client
│ ├── meteora/ pnl (cached PnL API)
│ └── telegram/ api, auth, poller, router, ui (pure renderers)
│
├── services/ ── business logic, no I/O primitives ──
│ ├── liquidity.ts add / remove LP
│ ├── positions.ts status, chain reconciliation
│ └── extreme/ machine.ts (PURE decision fn) · session.ts · manager.ts
│
├── state/ store (atomic writes), wallets, presets, positions
└── observability/ logger (structured), metrics (counters/gauges/histograms)
┌──────────────── Telegram long-poll ────────────────┐
│ poller → auth gate → router → service → adapter │
└───────────────────────────────────────────────────┘
│
startup ─→ config (validate, else exit) ─┤
↓ │
RPC pool (score all, pick fastest, keep scoring)
↓ │
state (load data.json, import WALLET_n from env)
│
Extreme ─→ websocket sub on LbPair ─→ decode activeId (43µs, 0 RPC)
↓ │
suppress if bin unchanged ────┤ ← most swaps don't move the bin
↓ │
pure decide(phase, bin) ─→ idle | rebalance | cycle | halt
↓
serialised execution, abortable at every await
The active bin arrives over a websocket, not a poll. v1 called DLMM.create() + getActiveBin() every 2.5 seconds to read one integer. v2 subscribes to the pool's LbPair account and decodes activeId straight out of the pushed buffer — 43µs of CPU and zero RPC calls, verified against mainnet to match the SDK exactly. The 2.5s timer survives only as a fallback for when the socket goes quiet.
The pool object is built once. v1 reconstructed the same DLMM pool 4–6 times per rebalance cycle. v2 caches it, refreshes only when a push marks it dirty or it ages out, and collapses concurrent construction of the same pool into a single request (50 concurrent cold reads → 2 RPC calls).
The strategy is a pure function. services/extreme/machine.ts is decide(state, currentBin) → action — no clock, no chain, no socket. Every branch of the original's rebalance logic is asserted in tests/machine.test.ts, so a future change cannot silently alter how the bot trades.
Failures are classified, not guessed at. Every error is retryable, recoverable, or fatal. Retries only ever fire on retryable — retrying a rejected input just burns rate-limit budget. Anything unrecognised defaults to recoverable rather than being hammered.
Cancellation is real. Every await in a session is threaded with an AbortSignal, and stop() waits for in-flight work to unwind. In v1, pressing Stop during a cycle could still open a brand-new position after you stopped.
Every value is an environment variable with a validated default — see .env.example. Config errors fail at startup with a plain message, not a stack trace.
The two that matter most:
| Variable | Why |
|---|---|
TELEGRAM_OWNER_IDS |
Required. Without it, anyone could spend your wallet. |
TX_PRIORITY_FEE_MICROLAMPORTS |
v1 sent zero priority fee, so its transactions lost the race for blockspace. Default 50000; raise it when the network is busy. |
Also worth knowing: EXTREME_MIN_EVAL_INTERVAL_MS defaults to 2500, which reproduces v1's cadence exactly. Set it to 0 to let the websocket drive rebalances at slot latency — faster, but it changes how often you trade.
Structured logs (pretty in dev, JSON lines in prod) with module names, durations, and RPC latency. Secrets are redacted at the logger, whatever a caller passes.
Metrics are on the 📈 Stats button and logged periodically: RPC/tx latency percentiles, pools cached, bin changes vs. suppressed pushes, websocket reconnects, cache hit ratios, cycles, errors, memory, and rejected unauthorized requests.
npm test # 58 tests, no network requiredCovers the rebalance decision machine exhaustively (including the deliberate > vs >= asymmetry carried over from v1), preset/pool-address parsing, sizing arithmetic, error classification, the circuit breaker, the rate limiter, and metrics.
The pool-address parser has explicit regression tests for v1's habit of taking the last base58-looking token in a message — which let trailing text redirect where funds went.
v1 was a single 969-line JavaScript file. Same features, rebuilt.
Security
- Added the authorization allowlist. v1 accepted commands from any Telegram user.
- Pasted addresses are verified to be DLMM pools before funds move.
- Pool-address extraction no longer takes the last match in a message.
- Key-bearing messages are deleted before any fallible work runs.
- One user can no longer stop another's Extreme session.
Correctness
- Stop now cancels in-flight work instead of letting it open an orphan position.
- Transactions carry priority fees and a slippage bound; v1 had neither.
- A confirmation timeout re-checks whether the tx actually landed, instead of assuming failure and double-opening.
data.jsonis written atomically; a crash mid-write no longer corrupts it.- Removing a
WALLET_nno longer silently erases the wallets numbered after it.
Performance
- Websocket-driven bins: ~1 RPC per bin change instead of ~2 every 2.5s per session, forever.
- Pool objects cached: 4–6 constructions per cycle → 0 on the hot path.
- Keep-alive connection pooling; v1 opened a fresh TLS connection per Telegram and PnL call.
- Keypairs decoded once, not on every
getActiveWallet()call. - State writes coalesced and off the synchronous deep-clone path.
Removed
discord.jsand@solana/spl-token— both were dependencies with zero imports.closeAndReopenPosition()(65 lines, never called), the unreadchatIdsset, the unreachable empty-wallet branch, and the dead'oor'state.- The remove-liquidity block that appeared 4×, the position lookup that appeared 3×, and the preset label that was reimplemented 4×.
MIT