Initial commit: Solana meme coin sniper bot
- Scanner: pump.fun WebSocket, Raydium pool detection, copy trade wallet tracking - Analyzer: token scoring (0-100), rug check, liquidity verification, rugcheck.xyz API - Executor: Jupiter V6 swaps, Jito bundle MEV protection, paper trading mode - Portfolio: tiered take-profit (2x/3x/5x), stop loss, trailing stops, time exits - Control: Discord webhook alerts, kill switch, risk manager, daily loss limits - Utils: config loader, SQLite database, typed event bus, colorful logger 5,600+ lines of TypeScript. Starts in paper trade mode by default.
This commit is contained in:
commit
4b0cffefba
43
.env.example
Normal file
43
.env.example
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# ============================================
|
||||||
|
# Solana Sniper Bot — Configuration
|
||||||
|
# ============================================
|
||||||
|
# Copy this to .env and fill in your values
|
||||||
|
|
||||||
|
# === RPC Provider (REQUIRED for live trading) ===
|
||||||
|
# Get a Helius key at https://helius.dev (recommended)
|
||||||
|
# Or QuickNode at https://quicknode.com
|
||||||
|
HELIUS_API_KEY=your_helius_api_key_here
|
||||||
|
|
||||||
|
# Or set custom RPC URLs directly:
|
||||||
|
# RPC_URL=https://mainnet.helius-rpc.com/?api-key=YOUR_KEY
|
||||||
|
# RPC_WS_URL=wss://mainnet.helius-rpc.com/?api-key=YOUR_KEY
|
||||||
|
|
||||||
|
# === Wallet (REQUIRED for live trading) ===
|
||||||
|
# Base58-encoded private key of your bot wallet
|
||||||
|
# Generate a NEW wallet for the bot — never use your main wallet!
|
||||||
|
WALLET_PRIVATE_KEY=
|
||||||
|
|
||||||
|
# === Trading Mode ===
|
||||||
|
# true = paper trading (no real transactions), false = live trading
|
||||||
|
PAPER_TRADE=true
|
||||||
|
|
||||||
|
# === Risk Limits ===
|
||||||
|
MAX_POSITION_SIZE_SOL=0.5
|
||||||
|
MAX_DAILY_LOSS_SOL=2
|
||||||
|
# MAX_CONCURRENT_POSITIONS=5
|
||||||
|
|
||||||
|
# === Analyzer ===
|
||||||
|
# Minimum token score to buy (0-100, higher = safer)
|
||||||
|
MIN_TOKEN_SCORE=70
|
||||||
|
|
||||||
|
# === Jito (MEV protection + faster TX) ===
|
||||||
|
USE_JITO=true
|
||||||
|
JITO_TIP_LAMPORTS=10000
|
||||||
|
|
||||||
|
# === Discord Alerts (optional) ===
|
||||||
|
# Create a webhook in your Discord server settings
|
||||||
|
DISCORD_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# === Slippage ===
|
||||||
|
# In basis points (100 = 1%). Meme coins need high slippage.
|
||||||
|
# DEFAULT_SLIPPAGE_BPS=1500
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
.DS_Store
|
||||||
25
LICENSE
Normal file
25
LICENSE
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Jake Shore
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
DISCLAIMER: This software is for educational purposes only. Cryptocurrency
|
||||||
|
trading involves substantial risk of loss. The authors are not responsible
|
||||||
|
for any financial losses incurred through the use of this software.
|
||||||
255
README.md
Normal file
255
README.md
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
# ⚡ Solana Sniper Bot
|
||||||
|
|
||||||
|
Automated Solana meme coin trading bot — detects new tokens on pump.fun & Raydium, analyzes for rugs, executes via Jupiter + Jito, and manages your portfolio with tiered exits.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🔍 Token Scanner
|
||||||
|
- **pump.fun Monitor** — Real-time WebSocket listener for new token launches
|
||||||
|
- **Raydium Pool Scanner** — Detects new liquidity pools (V4 + CPMM)
|
||||||
|
- **Copy Trading** — Track profitable wallets and mirror their trades
|
||||||
|
- **Deduplication** — Unified scanner manager prevents double-detection
|
||||||
|
|
||||||
|
### 🛡️ Anti-Rug Analyzer
|
||||||
|
- **Mint Authority Check** — Is it revoked? (must be)
|
||||||
|
- **Freeze Authority Check** — Is it revoked? (must be)
|
||||||
|
- **LP Lock/Burn Verification** — Are liquidity tokens safe?
|
||||||
|
- **Top Holder Concentration** — Whale distribution analysis
|
||||||
|
- **Developer Wallet History** — Serial rug deployer detection
|
||||||
|
- **Social Verification** — Twitter, Telegram, Website check
|
||||||
|
- **Rugcheck.xyz Integration** — External risk scoring API
|
||||||
|
- **Token Scoring (0-100)** — Only trades tokens above your threshold
|
||||||
|
|
||||||
|
### ⚡ Trade Execution
|
||||||
|
- **Jupiter V6 SDK** — Optimal swap routing across all Solana DEXs
|
||||||
|
- **Jito Bundles** — MEV protection + faster transaction inclusion
|
||||||
|
- **Priority Fees** — Configurable fee strategy
|
||||||
|
- **Auto-Retry** — Smart retry with escalating fees on failure
|
||||||
|
- **Paper Trading** — Full simulation mode (no real transactions)
|
||||||
|
|
||||||
|
### 📊 Portfolio Management
|
||||||
|
- **Tiered Take Profits** — Sell 25% at 2x, 3x, 5x automatically
|
||||||
|
- **Stop Loss** — Configurable (default -30%)
|
||||||
|
- **Trailing Stop** — Activates at 2x+, trails at -20%
|
||||||
|
- **Time-Based Exit** — Dumps stale positions
|
||||||
|
- **Rug Detection Exit** — Emergency sell if liquidity vanishes
|
||||||
|
- **Real-Time Price Monitoring** — 5-second polling via Jupiter
|
||||||
|
|
||||||
|
### 🎮 Control Layer
|
||||||
|
- **Discord Alerts** — Rich embeds for every trade event
|
||||||
|
- **Kill Switch** — One command to close everything
|
||||||
|
- **Risk Manager** — Max position size, concurrent limits, daily loss cap
|
||||||
|
- **Paper Trade Mode** — Test everything risk-free
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ CONTROL LAYER │
|
||||||
|
│ Risk Manager · Kill Switch · Discord Alerts │
|
||||||
|
├──────────┬──────────┬──────────┬────────────────┤
|
||||||
|
│ SCANNER │ ANALYZER │ EXECUTOR │ PORTFOLIO │
|
||||||
|
│ │ │ │ MANAGER │
|
||||||
|
│ pump.fun │ Rug Check│ Jupiter │ │
|
||||||
|
│ Raydium │ Scoring │ Swap │ Take Profit │
|
||||||
|
│ Copy │ Socials │ Jito TX │ Stop Loss │
|
||||||
|
│ Trade │ LP Check │ Retry │ Trailing Stop │
|
||||||
|
└──────────┴──────────┴──────────┴────────────────┘
|
||||||
|
↑ ↓
|
||||||
|
Event Bus (typed events, async handlers)
|
||||||
|
↑ ↓
|
||||||
|
SQLite DB (trades, positions, analytics)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Clone & Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/jakeshore/solana-sniper-bot.git
|
||||||
|
cd solana-sniper-bot
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
```
|
||||||
|
|
||||||
|
**Minimum required:**
|
||||||
|
- `HELIUS_API_KEY` — Get one at [helius.dev](https://helius.dev) ($0 for 50K credits/day)
|
||||||
|
- That's it for paper trading!
|
||||||
|
|
||||||
|
**For live trading also add:**
|
||||||
|
- `WALLET_PRIVATE_KEY` — Your bot wallet's base58 private key
|
||||||
|
- `PAPER_TRADE=false`
|
||||||
|
|
||||||
|
### 3. Generate a Wallet (optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsx src/index.ts generate-wallet
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates a fresh keypair. Fund it with SOL via Phantom/Solflare.
|
||||||
|
|
||||||
|
### 4. Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Paper trading (safe, no real money)
|
||||||
|
npm run paper
|
||||||
|
|
||||||
|
# Development mode
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production
|
||||||
|
npm run build && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables (.env)
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `HELIUS_API_KEY` | — | Helius RPC key (recommended) |
|
||||||
|
| `WALLET_PRIVATE_KEY` | — | Bot wallet private key (base58) |
|
||||||
|
| `PAPER_TRADE` | `true` | Paper trade mode |
|
||||||
|
| `MAX_POSITION_SIZE_SOL` | `0.5` | Max SOL per trade |
|
||||||
|
| `MAX_DAILY_LOSS_SOL` | `2` | Daily loss limit |
|
||||||
|
| `MIN_TOKEN_SCORE` | `70` | Minimum safety score (0-100) |
|
||||||
|
| `USE_JITO` | `true` | Use Jito for MEV protection |
|
||||||
|
| `DISCORD_WEBHOOK_URL` | — | Discord webhook for alerts |
|
||||||
|
|
||||||
|
### Trading Config (config.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"takeProfitTiers": [
|
||||||
|
{ "multiplier": 2, "sellPercent": 25 },
|
||||||
|
{ "multiplier": 3, "sellPercent": 25 },
|
||||||
|
{ "multiplier": 5, "sellPercent": 25 }
|
||||||
|
],
|
||||||
|
"stopLossPercent": -30,
|
||||||
|
"trailingStopPercent": -20,
|
||||||
|
"maxHoldTimeMinutes": 60,
|
||||||
|
"scanPumpFun": true,
|
||||||
|
"scanRaydium": true,
|
||||||
|
"copyTradeEnabled": false,
|
||||||
|
"trackedWallets": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Copy Trading
|
||||||
|
|
||||||
|
Add wallets to track in `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"copyTradeEnabled": true,
|
||||||
|
"trackedWallets": [
|
||||||
|
{
|
||||||
|
"address": "WALLET_ADDRESS_HERE",
|
||||||
|
"label": "Smart Whale #1",
|
||||||
|
"tier": "S",
|
||||||
|
"enabled": true,
|
||||||
|
"maxCopyAmountSol": 1.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Token Scoring
|
||||||
|
|
||||||
|
Every detected token is scored 0-100:
|
||||||
|
|
||||||
|
| Check | Points | Condition |
|
||||||
|
|-------|--------|-----------|
|
||||||
|
| Mint Authority | +20 / -30 | Revoked / Active |
|
||||||
|
| Freeze Authority | +15 / -20 | Revoked / Active |
|
||||||
|
| LP Locked/Burned | +10 | Verified safe |
|
||||||
|
| Top 10 Holders <30% | +15 | Good distribution |
|
||||||
|
| Has Socials | +5 each | Twitter, TG, Website |
|
||||||
|
| Dev History Clean | +10 / -10 | No prior rugs |
|
||||||
|
| Rugcheck.xyz | variable | External score |
|
||||||
|
|
||||||
|
**Default minimum: 70/100** — configurable via `MIN_TOKEN_SCORE`.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
⚠️ **This bot trades meme coins, which are extremely high risk.** Most tokens go to zero.
|
||||||
|
|
||||||
|
Built-in safety rails:
|
||||||
|
- ✅ Paper trade mode by default
|
||||||
|
- ✅ Max position size limit
|
||||||
|
- ✅ Daily loss limit with auto-shutdown
|
||||||
|
- ✅ Kill switch for emergencies
|
||||||
|
- ✅ Wallet isolation (use a dedicated bot wallet)
|
||||||
|
- ✅ No leverage, spot only
|
||||||
|
- ✅ Anti-rug scoring before every trade
|
||||||
|
|
||||||
|
**Start with paper trading. Then small amounts. Never trade more than you can afford to lose.**
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Component | Choice |
|
||||||
|
|-----------|--------|
|
||||||
|
| Language | TypeScript (ESM) |
|
||||||
|
| Runtime | Node.js |
|
||||||
|
| RPC | Helius / QuickNode |
|
||||||
|
| DEX | Jupiter V6 API |
|
||||||
|
| Speed | Jito Bundles |
|
||||||
|
| Database | SQLite (better-sqlite3) |
|
||||||
|
| Alerts | Discord Webhooks |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.ts # Main entry point & CLI
|
||||||
|
├── types/
|
||||||
|
│ └── index.ts # All TypeScript types
|
||||||
|
├── scanner/
|
||||||
|
│ ├── pump-fun-scanner.ts # pump.fun WebSocket listener
|
||||||
|
│ ├── raydium-scanner.ts # Raydium new pool detector
|
||||||
|
│ ├── copy-trade-scanner.ts # Wallet tracking & mirroring
|
||||||
|
│ └── index.ts # Scanner manager
|
||||||
|
├── analyzer/
|
||||||
|
│ ├── token-analyzer.ts # Token safety scoring
|
||||||
|
│ ├── rugcheck-api.ts # Rugcheck.xyz integration
|
||||||
|
│ ├── liquidity-checker.ts # LP lock/burn verification
|
||||||
|
│ └── index.ts # Analyzer pipeline
|
||||||
|
├── executor/
|
||||||
|
│ ├── jupiter-swap.ts # Jupiter V6 swap execution
|
||||||
|
│ ├── jito-bundler.ts # Jito bundle submission
|
||||||
|
│ ├── trade-executor.ts # High-level trade manager
|
||||||
|
│ └── index.ts # Executor exports
|
||||||
|
├── portfolio/
|
||||||
|
│ ├── position-manager.ts # Position tracking & exits
|
||||||
|
│ ├── price-monitor.ts # Real-time price polling
|
||||||
|
│ └── index.ts # Portfolio exports
|
||||||
|
├── control/
|
||||||
|
│ ├── discord-alerts.ts # Discord webhook alerts
|
||||||
|
│ ├── kill-switch.ts # Emergency stop
|
||||||
|
│ ├── risk-manager.ts # Risk limit enforcement
|
||||||
|
│ └── index.ts # Control exports
|
||||||
|
└── utils/
|
||||||
|
├── config.ts # Configuration loader
|
||||||
|
├── database.ts # SQLite database
|
||||||
|
├── event-bus.ts # Inter-module events
|
||||||
|
├── logger.ts # Colorful logging
|
||||||
|
└── wallet.ts # Wallet utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — Use at your own risk. The authors are not responsible for any financial losses.
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This software is for educational purposes. Cryptocurrency trading involves substantial risk of loss. Past performance does not guarantee future results. Always do your own research and never invest more than you can afford to lose.
|
||||||
30
config.json
Normal file
30
config.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./config-schema.json",
|
||||||
|
"paperTrade": true,
|
||||||
|
"maxPositionSizeSol": 0.5,
|
||||||
|
"maxConcurrentPositions": 5,
|
||||||
|
"maxDailyLossSol": 2,
|
||||||
|
"defaultSlippageBps": 1500,
|
||||||
|
|
||||||
|
"minTokenScore": 70,
|
||||||
|
"requireMintRevoked": true,
|
||||||
|
"requireFreezeRevoked": true,
|
||||||
|
"maxTopHolderPercent": 50,
|
||||||
|
|
||||||
|
"takeProfitTiers": [
|
||||||
|
{ "multiplier": 2, "sellPercent": 25 },
|
||||||
|
{ "multiplier": 3, "sellPercent": 25 },
|
||||||
|
{ "multiplier": 5, "sellPercent": 25 }
|
||||||
|
],
|
||||||
|
"stopLossPercent": -30,
|
||||||
|
"trailingStopPercent": -20,
|
||||||
|
"maxHoldTimeMinutes": 60,
|
||||||
|
|
||||||
|
"scanPumpFun": true,
|
||||||
|
"scanRaydium": true,
|
||||||
|
"copyTradeEnabled": false,
|
||||||
|
"trackedWallets": [],
|
||||||
|
|
||||||
|
"useJito": true,
|
||||||
|
"jitoTipLamports": 10000
|
||||||
|
}
|
||||||
2196
package-lock.json
generated
Normal file
2196
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "solana-sniper-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Automated Solana meme coin trading bot — sniper, copy trader, rug checker, portfolio manager",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"paper": "PAPER_TRADE=true tsx src/index.ts",
|
||||||
|
"scanner": "tsx src/scanner/test-scanner.ts",
|
||||||
|
"analyzer": "tsx src/analyzer/test-analyzer.ts"
|
||||||
|
},
|
||||||
|
"keywords": ["solana", "meme-coin", "trading-bot", "sniper", "pump-fun", "jupiter", "raydium"],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@solana/web3.js": "^1.98.0",
|
||||||
|
"@solana/spl-token": "^0.4.9",
|
||||||
|
"bs58": "^6.0.0",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"better-sqlite3": "^11.7.0",
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"chalk": "^5.3.0",
|
||||||
|
"ora": "^8.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/ws": "^8.5.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/analyzer/index.ts
Normal file
145
src/analyzer/index.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Analyzer Module — unified exports and pipeline
|
||||||
|
*
|
||||||
|
* Runs all analysis checks in parallel for maximum speed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Connection } from '@solana/web3.js';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
import type { TokenAnalysis, PoolInfo } from '../types/index.js';
|
||||||
|
|
||||||
|
export { TokenAnalyzer } from './token-analyzer.js';
|
||||||
|
export type { AnalyzerDeps } from './token-analyzer.js';
|
||||||
|
|
||||||
|
export { getRugcheckReport } from './rugcheck-api.js';
|
||||||
|
export type { RugcheckReport, RugcheckFlag, RugcheckHolder } from './rugcheck-api.js';
|
||||||
|
|
||||||
|
export { LiquidityChecker } from './liquidity-checker.js';
|
||||||
|
export type { LPCheckResult } from './liquidity-checker.js';
|
||||||
|
|
||||||
|
// ──────────────────────── Unified Pipeline ────────────────────────
|
||||||
|
|
||||||
|
import { TokenAnalyzer } from './token-analyzer.js';
|
||||||
|
import { getRugcheckReport } from './rugcheck-api.js';
|
||||||
|
import { LiquidityChecker } from './liquidity-checker.js';
|
||||||
|
import type { RugcheckReport } from './rugcheck-api.js';
|
||||||
|
|
||||||
|
export interface PipelineResult {
|
||||||
|
/** Combined on-chain analysis */
|
||||||
|
analysis: TokenAnalysis;
|
||||||
|
/** Rugcheck.xyz external report */
|
||||||
|
rugcheck: RugcheckReport;
|
||||||
|
/** How long the full pipeline took (ms) */
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineOptions {
|
||||||
|
/** Pool info if already known (skips discovery) */
|
||||||
|
pool?: PoolInfo;
|
||||||
|
/** SOL price in USD for liquidity estimation */
|
||||||
|
solPriceUsd?: number;
|
||||||
|
/** Skip rugcheck API call (e.g. for speed) */
|
||||||
|
skipRugcheck?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnalyzerPipeline — orchestrates all analysis checks in parallel.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const pipeline = new AnalyzerPipeline(connection);
|
||||||
|
* const result = await pipeline.run(mintAddress, { pool });
|
||||||
|
*/
|
||||||
|
export class AnalyzerPipeline {
|
||||||
|
private readonly tokenAnalyzer: TokenAnalyzer;
|
||||||
|
private readonly liquidityChecker: LiquidityChecker;
|
||||||
|
private readonly log = new Logger('Analyzer');
|
||||||
|
|
||||||
|
constructor(connection: Connection) {
|
||||||
|
this.tokenAnalyzer = new TokenAnalyzer(connection);
|
||||||
|
this.liquidityChecker = new LiquidityChecker(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full analysis pipeline for a token mint.
|
||||||
|
*
|
||||||
|
* Executes on-chain analysis and rugcheck API call in parallel,
|
||||||
|
* then merges the rugcheck score into the final TokenAnalysis.
|
||||||
|
*/
|
||||||
|
async run(mint: string, options?: PipelineOptions): Promise<PipelineResult> {
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
this.log.info(`Pipeline started for ${mint.slice(0, 8)}…`);
|
||||||
|
|
||||||
|
// Run on-chain analysis + rugcheck in parallel
|
||||||
|
const [analysis, rugcheck] = await Promise.all([
|
||||||
|
this.tokenAnalyzer.analyze(mint, {
|
||||||
|
pool: options?.pool,
|
||||||
|
solPriceUsd: options?.solPriceUsd,
|
||||||
|
}),
|
||||||
|
options?.skipRugcheck
|
||||||
|
? this.emptyRugcheck(mint)
|
||||||
|
: getRugcheckReport(mint),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Merge rugcheck score into analysis ──
|
||||||
|
if (rugcheck.success) {
|
||||||
|
analysis.rugcheckScore = rugcheck.riskScore;
|
||||||
|
|
||||||
|
// Add rugcheck flags to analysis flags
|
||||||
|
for (const flag of rugcheck.flags) {
|
||||||
|
if (flag.severity === 'danger') {
|
||||||
|
analysis.flags.push(`RC:${flag.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust score based on rugcheck
|
||||||
|
// Good rugcheck (low risk) gives a small bonus, bad one penalizes
|
||||||
|
if (rugcheck.riskLevel === 'good') {
|
||||||
|
analysis.score = Math.min(100, analysis.score + 10);
|
||||||
|
} else if (rugcheck.riskLevel === 'danger') {
|
||||||
|
analysis.score = Math.max(0, analysis.score - 15);
|
||||||
|
analysis.flags.push('RUGCHECK_DANGER');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override LP status if rugcheck has better data
|
||||||
|
if (rugcheck.lpLocked && !analysis.lpLocked) {
|
||||||
|
analysis.lpLocked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Math.round(performance.now() - start);
|
||||||
|
|
||||||
|
this.log.info(
|
||||||
|
`Pipeline complete for ${mint.slice(0, 8)}… — ` +
|
||||||
|
`score=${analysis.score}/100, rugcheck=${rugcheck.riskLevel}, ` +
|
||||||
|
`flags=[${analysis.flags.join(', ')}], took ${durationMs}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { analysis, rugcheck, durationMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick analysis — on-chain only, no rugcheck API call.
|
||||||
|
* Faster but less comprehensive.
|
||||||
|
*/
|
||||||
|
async runQuick(mint: string, pool?: PoolInfo): Promise<TokenAnalysis> {
|
||||||
|
return this.tokenAnalyzer.analyze(mint, { pool });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──
|
||||||
|
|
||||||
|
private emptyRugcheck(mint: string): RugcheckReport {
|
||||||
|
return {
|
||||||
|
mint,
|
||||||
|
riskScore: -1,
|
||||||
|
riskLevel: 'unknown',
|
||||||
|
flags: [],
|
||||||
|
verified: false,
|
||||||
|
lpLocked: false,
|
||||||
|
lpLockedPercent: 0,
|
||||||
|
fetchedAt: new Date(),
|
||||||
|
success: false,
|
||||||
|
error: 'skipped',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src/analyzer/liquidity-checker.ts
Normal file
235
src/analyzer/liquidity-checker.ts
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Liquidity Checker — determines if LP tokens are burned or locked
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* 1. Get the LP mint's total supply
|
||||||
|
* 2. Get the largest holders of the LP mint
|
||||||
|
* 3. Check if the largest holder is a known burn address or locker program
|
||||||
|
* 4. Calculate what percentage of LP is burned/locked
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Connection, PublicKey } from '@solana/web3.js';
|
||||||
|
import { getMint } from '@solana/spl-token';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
import type { PoolInfo } from '../types/index.js';
|
||||||
|
|
||||||
|
/** Known burn / dead addresses where tokens go to die */
|
||||||
|
const BURN_ADDRESSES = new Set([
|
||||||
|
'1nc1nerator11111111111111111111111111111111',
|
||||||
|
'11111111111111111111111111111111',
|
||||||
|
'burnHbDxkqh6qx16Swp9GYeAdvp2mSAxhvT61sLe9H6',
|
||||||
|
'1111111111111111111111111111111111111111111', // common null
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Known LP locker programs on Solana */
|
||||||
|
const LOCKER_PROGRAMS = new Set([
|
||||||
|
// Raydium LP locker
|
||||||
|
'Lock7kBijGCQLEFAmXcengzXKA88iDNQPriQ7TbgJHsZ',
|
||||||
|
// Unicrypt Solana locker
|
||||||
|
'GJHLhPDuHsRW9CMN8buQQ4K59Bw4A2iCcSH1V4dr5EhN',
|
||||||
|
// StreamFlow vesting / locks
|
||||||
|
'strmRqUCoQUgGUan5YhzUZa6KqdzwX5L6FpUxfmKg5m',
|
||||||
|
// Team.finance locker
|
||||||
|
'LocpQgucEQHbqNABEYvBMrzRm6KsgjHCPBp19XLr4ci',
|
||||||
|
// DaoMaker locker
|
||||||
|
'DaojXnH6h6TcJ4CJhe5fFbo14bZPPMUj2UNAGPGtpKao',
|
||||||
|
// Uncx (Solana version)
|
||||||
|
'UncxpBy8V4FKojAGQHX3MFGBVWQU9aYiJqJEsZwULwg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface LPCheckResult {
|
||||||
|
/** Whether a majority of LP is sent to a burn address */
|
||||||
|
lpBurned: boolean;
|
||||||
|
/** Whether a majority of LP is held by a known locker */
|
||||||
|
lpLocked: boolean;
|
||||||
|
/** Percentage of LP that is burned (0-100) */
|
||||||
|
burnedPercent: number;
|
||||||
|
/** Percentage of LP that is locked (0-100) */
|
||||||
|
lockedPercent: number;
|
||||||
|
/** Total LP supply */
|
||||||
|
totalSupply: bigint;
|
||||||
|
/** Top LP holder address */
|
||||||
|
topHolderAddress?: string;
|
||||||
|
/** Top LP holder percent */
|
||||||
|
topHolderPercent: number;
|
||||||
|
/** Classification of top holder */
|
||||||
|
topHolderType: 'burn' | 'locker' | 'wallet' | 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LiquidityChecker {
|
||||||
|
private readonly connection: Connection;
|
||||||
|
private readonly log = new Logger('Analyzer');
|
||||||
|
|
||||||
|
constructor(connection: Connection) {
|
||||||
|
this.connection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if LP tokens for a pool are burned or locked.
|
||||||
|
*/
|
||||||
|
async checkLP(pool: PoolInfo): Promise<LPCheckResult> {
|
||||||
|
const defaultResult: LPCheckResult = {
|
||||||
|
lpBurned: false,
|
||||||
|
lpLocked: false,
|
||||||
|
burnedPercent: 0,
|
||||||
|
lockedPercent: 0,
|
||||||
|
totalSupply: 0n,
|
||||||
|
topHolderPercent: 0,
|
||||||
|
topHolderType: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lpMint = new PublicKey(pool.lpMint);
|
||||||
|
|
||||||
|
// Fetch LP mint info and largest accounts in parallel
|
||||||
|
const [mintInfo, largestAccounts] = await Promise.all([
|
||||||
|
getMint(this.connection, lpMint),
|
||||||
|
this.connection.getTokenLargestAccounts(lpMint),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalSupply = mintInfo.supply;
|
||||||
|
if (totalSupply === 0n) {
|
||||||
|
this.log.warn(`LP mint ${pool.lpMint.slice(0, 8)}… has zero supply`);
|
||||||
|
return defaultResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultResult.totalSupply = totalSupply;
|
||||||
|
|
||||||
|
const accounts = largestAccounts.value;
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return defaultResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze each holder
|
||||||
|
let burnedAmount = 0n;
|
||||||
|
let lockedAmount = 0n;
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const holderAddress = account.address.toBase58();
|
||||||
|
const amount = BigInt(account.amount);
|
||||||
|
|
||||||
|
const holderType = await this.classifyHolder(
|
||||||
|
account.address,
|
||||||
|
holderAddress,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (holderType === 'burn') {
|
||||||
|
burnedAmount += amount;
|
||||||
|
} else if (holderType === 'locker') {
|
||||||
|
lockedAmount += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const burnedPercent =
|
||||||
|
Number((burnedAmount * 10000n) / totalSupply) / 100;
|
||||||
|
const lockedPercent =
|
||||||
|
Number((lockedAmount * 10000n) / totalSupply) / 100;
|
||||||
|
|
||||||
|
// Classify the top holder specifically
|
||||||
|
const topHolder = accounts[0];
|
||||||
|
const topHolderAddress = topHolder.address.toBase58();
|
||||||
|
const topHolderPercent =
|
||||||
|
Number((BigInt(topHolder.amount) * 10000n) / totalSupply) / 100;
|
||||||
|
const topHolderType = await this.classifyHolder(
|
||||||
|
topHolder.address,
|
||||||
|
topHolderAddress,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: LPCheckResult = {
|
||||||
|
lpBurned: burnedPercent > 50,
|
||||||
|
lpLocked: lockedPercent > 50,
|
||||||
|
burnedPercent,
|
||||||
|
lockedPercent,
|
||||||
|
totalSupply,
|
||||||
|
topHolderAddress,
|
||||||
|
topHolderPercent,
|
||||||
|
topHolderType,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.log.info(
|
||||||
|
`LP check for ${pool.lpMint.slice(0, 8)}…: ` +
|
||||||
|
`burned=${burnedPercent.toFixed(1)}% locked=${lockedPercent.toFixed(1)}% ` +
|
||||||
|
`top holder=${topHolderType} (${topHolderPercent.toFixed(1)}%)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (err: any) {
|
||||||
|
this.log.warn(`LP check failed for ${pool.lpMint.slice(0, 8)}…: ${err.message}`);
|
||||||
|
return defaultResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a single LP mint directly (without needing full PoolInfo).
|
||||||
|
*/
|
||||||
|
async checkLPByMint(lpMint: string): Promise<LPCheckResult> {
|
||||||
|
const fakePool: PoolInfo = {
|
||||||
|
poolAddress: '',
|
||||||
|
baseMint: '',
|
||||||
|
quoteMint: '',
|
||||||
|
baseReserve: 0n,
|
||||||
|
quoteReserve: 0n,
|
||||||
|
lpMint,
|
||||||
|
dex: 'raydium',
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
return this.checkLP(fakePool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────── private helpers ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a token account holder as burn, locker, or wallet.
|
||||||
|
*
|
||||||
|
* We check:
|
||||||
|
* 1. Is the account address itself a known burn address?
|
||||||
|
* 2. Is the owner of the account a known burn address?
|
||||||
|
* 3. Is the owner of the account a known locker program?
|
||||||
|
*/
|
||||||
|
private async classifyHolder(
|
||||||
|
accountPubkey: PublicKey,
|
||||||
|
accountAddress: string,
|
||||||
|
): Promise<LPCheckResult['topHolderType']> {
|
||||||
|
// Direct address match for burn
|
||||||
|
if (BURN_ADDRESSES.has(accountAddress)) {
|
||||||
|
return 'burn';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the owner of this token account
|
||||||
|
const accountInfo = await this.connection.getAccountInfo(accountPubkey);
|
||||||
|
if (!accountInfo) return 'unknown';
|
||||||
|
|
||||||
|
const owner = accountInfo.owner.toBase58();
|
||||||
|
|
||||||
|
// Check owner against burn addresses
|
||||||
|
if (BURN_ADDRESSES.has(owner)) {
|
||||||
|
return 'burn';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check owner against locker programs
|
||||||
|
if (LOCKER_PROGRAMS.has(owner)) {
|
||||||
|
return 'locker';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For token accounts, the actual "owner" (wallet) is stored in the
|
||||||
|
// token account data at offset 32 (bytes 32-64)
|
||||||
|
if (accountInfo.data.length >= 64) {
|
||||||
|
const walletOwner = new PublicKey(
|
||||||
|
accountInfo.data.subarray(32, 64),
|
||||||
|
).toBase58();
|
||||||
|
|
||||||
|
if (BURN_ADDRESSES.has(walletOwner)) {
|
||||||
|
return 'burn';
|
||||||
|
}
|
||||||
|
if (LOCKER_PROGRAMS.has(walletOwner)) {
|
||||||
|
return 'locker';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'wallet';
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
213
src/analyzer/rugcheck-api.ts
Normal file
213
src/analyzer/rugcheck-api.ts
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Rugcheck.xyz API integration
|
||||||
|
*
|
||||||
|
* Fetches token safety reports from https://api.rugcheck.xyz/v1/tokens/{mint}/report
|
||||||
|
* Handles rate limits (429), retries, and timeouts gracefully.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
const RUGCHECK_BASE = 'https://api.rugcheck.xyz/v1';
|
||||||
|
const REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const BASE_RETRY_DELAY_MS = 1_500;
|
||||||
|
|
||||||
|
export interface RugcheckReport {
|
||||||
|
/** Mint address that was checked */
|
||||||
|
mint: string;
|
||||||
|
/** Overall risk score (0 = safest, higher = riskier) */
|
||||||
|
riskScore: number;
|
||||||
|
/** Human-readable risk level */
|
||||||
|
riskLevel: 'good' | 'warn' | 'danger' | 'unknown';
|
||||||
|
/** Individual risk flags / reasons */
|
||||||
|
flags: RugcheckFlag[];
|
||||||
|
/** Whether the token is verified on rugcheck */
|
||||||
|
verified: boolean;
|
||||||
|
/** Raw top holders info if available */
|
||||||
|
topHolders?: RugcheckHolder[];
|
||||||
|
/** LP info if available */
|
||||||
|
lpLocked: boolean;
|
||||||
|
lpLockedPercent: number;
|
||||||
|
/** Timestamp of when we fetched this */
|
||||||
|
fetchedAt: Date;
|
||||||
|
/** Whether the fetch was successful */
|
||||||
|
success: boolean;
|
||||||
|
/** Error message if fetch failed */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RugcheckFlag {
|
||||||
|
/** e.g. "MINT_AUTHORITY_NOT_REVOKED", "HIGH_HOLDER_CONCENTRATION" */
|
||||||
|
code: string;
|
||||||
|
/** Human-readable description */
|
||||||
|
description: string;
|
||||||
|
/** Severity: how dangerous this flag is */
|
||||||
|
severity: 'info' | 'warn' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RugcheckHolder {
|
||||||
|
address: string;
|
||||||
|
/** Percentage of supply held (0-100) */
|
||||||
|
pct: number;
|
||||||
|
/** Whether this is an insider */
|
||||||
|
insider: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = new Logger('Analyzer');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a rugcheck report for a given mint address.
|
||||||
|
*
|
||||||
|
* Returns a RugcheckReport with success=false on failure (never throws).
|
||||||
|
*/
|
||||||
|
export async function getRugcheckReport(
|
||||||
|
mint: string,
|
||||||
|
): Promise<RugcheckReport> {
|
||||||
|
const url = `${RUGCHECK_BASE}/tokens/${mint}/report`;
|
||||||
|
|
||||||
|
const emptyReport: RugcheckReport = {
|
||||||
|
mint,
|
||||||
|
riskScore: -1,
|
||||||
|
riskLevel: 'unknown',
|
||||||
|
flags: [],
|
||||||
|
verified: false,
|
||||||
|
lpLocked: false,
|
||||||
|
lpLockedPercent: 0,
|
||||||
|
fetchedAt: new Date(),
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
REQUEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': 'SolanaSniperBot/1.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
// ── Rate limited ──
|
||||||
|
if (resp.status === 429) {
|
||||||
|
const retryAfter = resp.headers.get('Retry-After');
|
||||||
|
const delayMs = retryAfter
|
||||||
|
? parseInt(retryAfter, 10) * 1_000
|
||||||
|
: BASE_RETRY_DELAY_MS * attempt;
|
||||||
|
|
||||||
|
log.warn(
|
||||||
|
`Rugcheck rate-limited (attempt ${attempt}/${MAX_RETRIES}), retrying in ${delayMs}ms`,
|
||||||
|
);
|
||||||
|
await sleep(delayMs);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Non-success status ──
|
||||||
|
if (!resp.ok) {
|
||||||
|
log.warn(
|
||||||
|
`Rugcheck returned ${resp.status} for ${mint.slice(0, 8)}… (attempt ${attempt})`,
|
||||||
|
);
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
await sleep(BASE_RETRY_DELAY_MS * attempt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return { ...emptyReport, error: `HTTP ${resp.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parse response ──
|
||||||
|
const json = await resp.json() as any;
|
||||||
|
|
||||||
|
return parseRugcheckResponse(mint, json);
|
||||||
|
} catch (err: any) {
|
||||||
|
const isAbort =
|
||||||
|
err.name === 'AbortError' || err.code === 'ABORT_ERR';
|
||||||
|
const label = isAbort ? 'timeout' : err.message;
|
||||||
|
|
||||||
|
log.warn(
|
||||||
|
`Rugcheck fetch error for ${mint.slice(0, 8)}… (attempt ${attempt}): ${label}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
await sleep(BASE_RETRY_DELAY_MS * attempt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...emptyReport, error: label };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyReport; // shouldn't reach here, but just in case
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────── helpers ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the raw rugcheck API JSON into our normalized type.
|
||||||
|
* The API shape can vary; we defensively extract what we need.
|
||||||
|
*/
|
||||||
|
function parseRugcheckResponse(mint: string, json: any): RugcheckReport {
|
||||||
|
// Rugcheck API v1 response shape (observed):
|
||||||
|
// { score: number, risks: [{ name, value, description, level }], ...}
|
||||||
|
const rawScore: number = json.score ?? json.riskScore ?? json.risk_score ?? -1;
|
||||||
|
|
||||||
|
// Normalize risk level
|
||||||
|
let riskLevel: RugcheckReport['riskLevel'] = 'unknown';
|
||||||
|
if (rawScore >= 0 && rawScore < 300) riskLevel = 'good';
|
||||||
|
else if (rawScore >= 300 && rawScore < 700) riskLevel = 'warn';
|
||||||
|
else if (rawScore >= 700) riskLevel = 'danger';
|
||||||
|
|
||||||
|
// Extract flags
|
||||||
|
const rawRisks: any[] =
|
||||||
|
json.risks ?? json.flags ?? json.risk_flags ?? [];
|
||||||
|
const flags: RugcheckFlag[] = rawRisks.map((r: any) => ({
|
||||||
|
code: r.name ?? r.code ?? 'UNKNOWN',
|
||||||
|
description: r.description ?? r.message ?? '',
|
||||||
|
severity: mapSeverity(r.level ?? r.severity ?? 'info'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Top holders
|
||||||
|
const rawHolders: any[] =
|
||||||
|
json.topHolders ?? json.top_holders ?? [];
|
||||||
|
const topHolders: RugcheckHolder[] = rawHolders.map((h: any) => ({
|
||||||
|
address: h.address ?? h.owner ?? '',
|
||||||
|
pct: h.pct ?? h.percentage ?? 0,
|
||||||
|
insider: h.insider ?? false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// LP info
|
||||||
|
const lpInfo = json.markets?.[0] ?? json.lp ?? {};
|
||||||
|
const lpLockedPercent: number =
|
||||||
|
lpInfo.lp_locked_pct ?? lpInfo.lpLockedPercent ?? 0;
|
||||||
|
const lpLocked = lpLockedPercent > 50;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mint,
|
||||||
|
riskScore: rawScore,
|
||||||
|
riskLevel,
|
||||||
|
flags,
|
||||||
|
verified: json.verified ?? json.is_verified ?? false,
|
||||||
|
topHolders: topHolders.length > 0 ? topHolders : undefined,
|
||||||
|
lpLocked,
|
||||||
|
lpLockedPercent,
|
||||||
|
fetchedAt: new Date(),
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSeverity(level: string): RugcheckFlag['severity'] {
|
||||||
|
const l = String(level).toLowerCase();
|
||||||
|
if (l === 'danger' || l === 'error' || l === 'critical' || l === 'high')
|
||||||
|
return 'danger';
|
||||||
|
if (l === 'warn' || l === 'warning' || l === 'medium') return 'warn';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
419
src/analyzer/token-analyzer.ts
Normal file
419
src/analyzer/token-analyzer.ts
Normal file
@ -0,0 +1,419 @@
|
|||||||
|
/**
|
||||||
|
* Token Analyzer — scores tokens 0-100 based on on-chain safety checks
|
||||||
|
*
|
||||||
|
* Scoring breakdown:
|
||||||
|
* Mint authority revoked: +20 (or -30)
|
||||||
|
* Freeze authority revoked: +15 (or -20)
|
||||||
|
* Top holder concentration: +15 / +5 / -15
|
||||||
|
* Liquidity > $1K: +10
|
||||||
|
* Metadata name: +5
|
||||||
|
* Metadata symbol: +5
|
||||||
|
* Metadata URI: +5
|
||||||
|
* Twitter found: +5
|
||||||
|
* Telegram found: +5
|
||||||
|
* Website found: +5
|
||||||
|
* Dev wallet clean: +10 (or -10 serial deployer)
|
||||||
|
* ────────────────────────────────
|
||||||
|
* Max theoretical: ~100 Min theoretical: negative (clamped to 0)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
PublicKey,
|
||||||
|
type GetProgramAccountsFilter,
|
||||||
|
} from '@solana/web3.js';
|
||||||
|
import { getMint, TOKEN_PROGRAM_ID } from '@solana/spl-token';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
import type { TokenAnalysis, PoolInfo } from '../types/index.js';
|
||||||
|
import { LiquidityChecker } from './liquidity-checker.js';
|
||||||
|
|
||||||
|
const METADATA_PROGRAM_ID = new PublicKey(
|
||||||
|
'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s',
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Known burn / dead addresses */
|
||||||
|
const BURN_ADDRESSES = new Set([
|
||||||
|
'1nc1nerator11111111111111111111111111111111',
|
||||||
|
'11111111111111111111111111111111',
|
||||||
|
'burnHbDxkqh6qx16Swp9GYeAdvp2mSAxhvT61sLe9H6',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface AnalyzerDeps {
|
||||||
|
connection: Connection;
|
||||||
|
/** Optional pool info to skip discovery */
|
||||||
|
pool?: PoolInfo;
|
||||||
|
/** SOL price in USD — used for liquidity USD estimation */
|
||||||
|
solPriceUsd?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TokenAnalyzer {
|
||||||
|
private readonly connection: Connection;
|
||||||
|
private readonly log = new Logger('Analyzer');
|
||||||
|
private readonly liquidityChecker: LiquidityChecker;
|
||||||
|
|
||||||
|
constructor(connection: Connection) {
|
||||||
|
this.connection = connection;
|
||||||
|
this.liquidityChecker = new LiquidityChecker(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────── public entry ────────────────────────
|
||||||
|
|
||||||
|
async analyze(
|
||||||
|
mint: string,
|
||||||
|
deps?: Partial<AnalyzerDeps>,
|
||||||
|
): Promise<TokenAnalysis> {
|
||||||
|
const mintPubkey = new PublicKey(mint);
|
||||||
|
this.log.info(`Analyzing token ${mint.slice(0, 8)}…`);
|
||||||
|
|
||||||
|
const flags: string[] = [];
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Run independent checks in parallel
|
||||||
|
const [
|
||||||
|
mintResult,
|
||||||
|
holderResult,
|
||||||
|
metadataResult,
|
||||||
|
devResult,
|
||||||
|
lpResult,
|
||||||
|
] = await Promise.allSettled([
|
||||||
|
this.checkAuthorities(mintPubkey),
|
||||||
|
this.checkTopHolders(mintPubkey),
|
||||||
|
this.checkMetadataAndSocials(mintPubkey),
|
||||||
|
this.checkDevWallet(mintPubkey),
|
||||||
|
deps?.pool
|
||||||
|
? this.liquidityChecker.checkLP(deps.pool)
|
||||||
|
: Promise.resolve({ lpBurned: false, lpLocked: false }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Mint & Freeze authority ──
|
||||||
|
let mintAuthorityRevoked = false;
|
||||||
|
let freezeAuthorityRevoked = false;
|
||||||
|
|
||||||
|
if (mintResult.status === 'fulfilled') {
|
||||||
|
const { mintAuth, freezeAuth } = mintResult.value;
|
||||||
|
mintAuthorityRevoked = mintAuth;
|
||||||
|
freezeAuthorityRevoked = freezeAuth;
|
||||||
|
|
||||||
|
if (mintAuthorityRevoked) {
|
||||||
|
score += 20;
|
||||||
|
} else {
|
||||||
|
score -= 30;
|
||||||
|
flags.push('MINT_AUTHORITY_ENABLED');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freezeAuthorityRevoked) {
|
||||||
|
score += 15;
|
||||||
|
} else {
|
||||||
|
score -= 20;
|
||||||
|
flags.push('FREEZE_AUTHORITY_ENABLED');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flags.push('AUTHORITY_CHECK_FAILED');
|
||||||
|
this.log.warn('Authority check failed', mintResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Top holder concentration ──
|
||||||
|
let topHolderConcentration = 100; // assume worst
|
||||||
|
|
||||||
|
if (holderResult.status === 'fulfilled') {
|
||||||
|
topHolderConcentration = holderResult.value;
|
||||||
|
if (topHolderConcentration < 30) {
|
||||||
|
score += 15;
|
||||||
|
} else if (topHolderConcentration <= 50) {
|
||||||
|
score += 5;
|
||||||
|
} else {
|
||||||
|
score -= 15;
|
||||||
|
flags.push('HIGH_HOLDER_CONCENTRATION');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flags.push('HOLDER_CHECK_FAILED');
|
||||||
|
this.log.warn('Holder check failed', holderResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Metadata + Socials ──
|
||||||
|
let hasSocials = false;
|
||||||
|
|
||||||
|
if (metadataResult.status === 'fulfilled') {
|
||||||
|
const md = metadataResult.value;
|
||||||
|
if (md.name) score += 5;
|
||||||
|
else flags.push('NO_NAME');
|
||||||
|
if (md.symbol) score += 5;
|
||||||
|
else flags.push('NO_SYMBOL');
|
||||||
|
if (md.uri) score += 5;
|
||||||
|
else flags.push('NO_URI');
|
||||||
|
|
||||||
|
if (md.twitter) { score += 5; hasSocials = true; }
|
||||||
|
if (md.telegram) { score += 5; hasSocials = true; }
|
||||||
|
if (md.website) { score += 5; hasSocials = true; }
|
||||||
|
} else {
|
||||||
|
flags.push('METADATA_CHECK_FAILED');
|
||||||
|
this.log.warn('Metadata check failed', metadataResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dev wallet history ──
|
||||||
|
let devWalletRisk: TokenAnalysis['devWalletRisk'] = 'unknown';
|
||||||
|
|
||||||
|
if (devResult.status === 'fulfilled') {
|
||||||
|
const isSerial = devResult.value;
|
||||||
|
if (isSerial) {
|
||||||
|
score -= 10;
|
||||||
|
devWalletRisk = 'high';
|
||||||
|
flags.push('SERIAL_DEPLOYER');
|
||||||
|
} else {
|
||||||
|
score += 10;
|
||||||
|
devWalletRisk = 'low';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flags.push('DEV_CHECK_FAILED');
|
||||||
|
this.log.warn('Dev wallet check failed', devResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LP burned / locked ──
|
||||||
|
let lpBurned = false;
|
||||||
|
let lpLocked = false;
|
||||||
|
|
||||||
|
if (lpResult.status === 'fulfilled') {
|
||||||
|
lpBurned = lpResult.value.lpBurned;
|
||||||
|
lpLocked = lpResult.value.lpLocked;
|
||||||
|
|
||||||
|
// Add liquidity score if pool has meaningful liquidity
|
||||||
|
if (deps?.pool) {
|
||||||
|
const liqUsd = deps.pool.liquidityUsd ?? 0;
|
||||||
|
if (liqUsd > 1_000) {
|
||||||
|
score += 10;
|
||||||
|
} else {
|
||||||
|
flags.push('LOW_LIQUIDITY');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flags.push('LP_CHECK_FAILED');
|
||||||
|
this.log.warn('LP check failed', lpResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp score
|
||||||
|
score = Math.max(0, Math.min(100, score));
|
||||||
|
|
||||||
|
const analysis: TokenAnalysis = {
|
||||||
|
mint,
|
||||||
|
score,
|
||||||
|
mintAuthorityRevoked,
|
||||||
|
freezeAuthorityRevoked,
|
||||||
|
lpLocked,
|
||||||
|
lpBurned,
|
||||||
|
topHolderConcentration,
|
||||||
|
devWalletRisk,
|
||||||
|
hasSocials,
|
||||||
|
flags,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.log.info(
|
||||||
|
`Score for ${mint.slice(0, 8)}…: ${score}/100 | flags: [${flags.join(', ')}]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────── private checks ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check mint & freeze authorities via getMint
|
||||||
|
*/
|
||||||
|
private async checkAuthorities(
|
||||||
|
mintPubkey: PublicKey,
|
||||||
|
): Promise<{ mintAuth: boolean; freezeAuth: boolean }> {
|
||||||
|
const mintInfo = await getMint(this.connection, mintPubkey);
|
||||||
|
return {
|
||||||
|
mintAuth: mintInfo.mintAuthority === null,
|
||||||
|
freezeAuth: mintInfo.freezeAuthority === null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-10 holder concentration as a percentage of total supply.
|
||||||
|
*/
|
||||||
|
private async checkTopHolders(mintPubkey: PublicKey): Promise<number> {
|
||||||
|
const [largestAccounts, mintInfo] = await Promise.all([
|
||||||
|
this.connection.getTokenLargestAccounts(mintPubkey),
|
||||||
|
getMint(this.connection, mintPubkey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalSupply = Number(mintInfo.supply);
|
||||||
|
if (totalSupply === 0) return 100;
|
||||||
|
|
||||||
|
// Filter out known burn addresses and LP pools
|
||||||
|
const top10 = largestAccounts.value
|
||||||
|
.slice(0, 10)
|
||||||
|
.filter((a) => !BURN_ADDRESSES.has(a.address.toBase58()));
|
||||||
|
|
||||||
|
const top10Total = top10.reduce(
|
||||||
|
(sum, acct) => sum + Number(acct.amount),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (top10Total / totalSupply) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch on-chain metadata, parse URI JSON for socials
|
||||||
|
*/
|
||||||
|
private async checkMetadataAndSocials(
|
||||||
|
mintPubkey: PublicKey,
|
||||||
|
): Promise<{
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
uri: string;
|
||||||
|
twitter?: string;
|
||||||
|
telegram?: string;
|
||||||
|
website?: string;
|
||||||
|
}> {
|
||||||
|
// Derive metadata PDA
|
||||||
|
const [metadataPda] = PublicKey.findProgramAddressSync(
|
||||||
|
[
|
||||||
|
Buffer.from('metadata'),
|
||||||
|
METADATA_PROGRAM_ID.toBuffer(),
|
||||||
|
mintPubkey.toBuffer(),
|
||||||
|
],
|
||||||
|
METADATA_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountInfo = await this.connection.getAccountInfo(metadataPda);
|
||||||
|
if (!accountInfo?.data) {
|
||||||
|
return { name: '', symbol: '', uri: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal Metaplex metadata deserialization (name starts at byte 65)
|
||||||
|
const data = accountInfo.data;
|
||||||
|
const nameLen = data.readUInt32LE(65);
|
||||||
|
const name = data
|
||||||
|
.subarray(69, 69 + nameLen)
|
||||||
|
.toString('utf-8')
|
||||||
|
.replace(/\0/g, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const symbolOffset = 69 + nameLen;
|
||||||
|
const symbolLen = data.readUInt32LE(symbolOffset);
|
||||||
|
const symbol = data
|
||||||
|
.subarray(symbolOffset + 4, symbolOffset + 4 + symbolLen)
|
||||||
|
.toString('utf-8')
|
||||||
|
.replace(/\0/g, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const uriOffset = symbolOffset + 4 + symbolLen;
|
||||||
|
const uriLen = data.readUInt32LE(uriOffset);
|
||||||
|
const uri = data
|
||||||
|
.subarray(uriOffset + 4, uriOffset + 4 + uriLen)
|
||||||
|
.toString('utf-8')
|
||||||
|
.replace(/\0/g, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Fetch URI JSON for socials
|
||||||
|
let twitter: string | undefined;
|
||||||
|
let telegram: string | undefined;
|
||||||
|
let website: string | undefined;
|
||||||
|
|
||||||
|
if (uri) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5_000);
|
||||||
|
const resp = await fetch(uri, { signal: controller.signal });
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const json = await resp.json() as any;
|
||||||
|
const raw = JSON.stringify(json).toLowerCase();
|
||||||
|
|
||||||
|
// Extract socials — check both top-level fields and nested
|
||||||
|
twitter =
|
||||||
|
json.twitter ||
|
||||||
|
json.extensions?.twitter ||
|
||||||
|
json.socials?.twitter ||
|
||||||
|
(raw.includes('twitter.com') || raw.includes('x.com')
|
||||||
|
? 'found'
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
telegram =
|
||||||
|
json.telegram ||
|
||||||
|
json.extensions?.telegram ||
|
||||||
|
json.socials?.telegram ||
|
||||||
|
(raw.includes('t.me') ? 'found' : undefined);
|
||||||
|
|
||||||
|
website =
|
||||||
|
json.website ||
|
||||||
|
json.extensions?.website ||
|
||||||
|
json.socials?.website ||
|
||||||
|
json.external_url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// URI fetch failed — not critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, symbol, uri, twitter, telegram, website };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplified dev wallet check — look for >3 token mints
|
||||||
|
* created by the same authority via getProgramAccounts.
|
||||||
|
*/
|
||||||
|
private async checkDevWallet(mintPubkey: PublicKey): Promise<boolean> {
|
||||||
|
// First fetch the mint to get the creator (original mint authority)
|
||||||
|
const mintInfo = await getMint(this.connection, mintPubkey);
|
||||||
|
|
||||||
|
// The mint authority is either current or we check the first authority
|
||||||
|
// For revoked mints, we look at the metadata update authority instead
|
||||||
|
let creator: PublicKey | null = mintInfo.mintAuthority;
|
||||||
|
|
||||||
|
if (!creator) {
|
||||||
|
// Try metadata update authority
|
||||||
|
const [metadataPda] = PublicKey.findProgramAddressSync(
|
||||||
|
[
|
||||||
|
Buffer.from('metadata'),
|
||||||
|
METADATA_PROGRAM_ID.toBuffer(),
|
||||||
|
mintPubkey.toBuffer(),
|
||||||
|
],
|
||||||
|
METADATA_PROGRAM_ID,
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountInfo = await this.connection.getAccountInfo(metadataPda);
|
||||||
|
if (accountInfo?.data) {
|
||||||
|
// Update authority is at bytes 1-33 in metadata account
|
||||||
|
creator = new PublicKey(accountInfo.data.subarray(1, 33));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!creator) return false; // can't determine — assume not serial
|
||||||
|
|
||||||
|
// Search for other token mints created by this authority
|
||||||
|
const filters: GetProgramAccountsFilter[] = [
|
||||||
|
{ dataSize: 82 }, // Mint account size
|
||||||
|
{
|
||||||
|
memcmp: {
|
||||||
|
offset: 4, // mintAuthority field offset (after 4-byte option prefix)
|
||||||
|
bytes: creator.toBase58(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accounts = await this.connection.getProgramAccounts(
|
||||||
|
TOKEN_PROGRAM_ID,
|
||||||
|
{
|
||||||
|
filters,
|
||||||
|
dataSlice: { offset: 0, length: 0 }, // we only need the count
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSerial = accounts.length > 3;
|
||||||
|
if (isSerial) {
|
||||||
|
this.log.warn(
|
||||||
|
`Serial deployer detected: ${creator.toBase58().slice(0, 8)}… has ${accounts.length} tokens`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return isSerial;
|
||||||
|
} catch {
|
||||||
|
// getProgramAccounts can be heavy — RPC might reject
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
331
src/control/discord-alerts.ts
Normal file
331
src/control/discord-alerts.ts
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* Discord Alerts — Send rich embed alerts to Discord via webhook
|
||||||
|
* Rate-limited to 5 messages per 5 seconds (Discord webhook limit)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BotEvent, Position, TradeResult, TradeSignal, TokenAnalysis, TokenInfo, PoolInfo, WalletActivity } from '../types/index.js';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
const SOLSCAN_TX = 'https://solscan.io/tx/';
|
||||||
|
const SOLSCAN_TOKEN = 'https://solscan.io/token/';
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
buy: 0x00ff88, // green
|
||||||
|
sell: 0xff4444, // red
|
||||||
|
rug: 0xff8800, // orange
|
||||||
|
error: 0x880000, // dark red
|
||||||
|
info: 0x5865f2, // blurple
|
||||||
|
warning: 0xffcc00, // yellow
|
||||||
|
success: 0x00ff88, // green
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Rate limiter: max 5 messages per 5000ms
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 5_000;
|
||||||
|
const RATE_LIMIT_MAX = 5;
|
||||||
|
|
||||||
|
interface DiscordEmbed {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
color: number;
|
||||||
|
fields?: { name: string; value: string; inline?: boolean }[];
|
||||||
|
footer?: { text: string };
|
||||||
|
timestamp?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DiscordAlerts {
|
||||||
|
private log = new Logger('Control');
|
||||||
|
private webhookUrl: string | null;
|
||||||
|
private messageTimestamps: number[] = [];
|
||||||
|
private queue: DiscordEmbed[] = [];
|
||||||
|
private processing = false;
|
||||||
|
|
||||||
|
constructor(webhookUrl?: string) {
|
||||||
|
this.webhookUrl = webhookUrl ?? null;
|
||||||
|
if (!this.webhookUrl) {
|
||||||
|
this.log.warn('Discord webhook URL not configured — alerts disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send alert for any BotEvent ───────────────────────────────────────
|
||||||
|
|
||||||
|
async sendAlert(event: BotEvent): Promise<void> {
|
||||||
|
if (!this.webhookUrl) return;
|
||||||
|
|
||||||
|
const embed = this.formatEvent(event);
|
||||||
|
if (!embed) return;
|
||||||
|
|
||||||
|
this.queue.push(embed);
|
||||||
|
await this.processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Format BotEvent into a Discord embed ──────────────────────────────
|
||||||
|
|
||||||
|
private formatEvent(event: BotEvent): DiscordEmbed | null {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'position_opened':
|
||||||
|
return this.formatPositionOpened(event.data);
|
||||||
|
|
||||||
|
case 'position_closed':
|
||||||
|
return this.formatPositionClosed(event.data);
|
||||||
|
|
||||||
|
case 'position_updated':
|
||||||
|
return null; // Too noisy — skip price update embeds
|
||||||
|
|
||||||
|
case 'trade_executed':
|
||||||
|
return this.formatTradeExecuted(event.data);
|
||||||
|
|
||||||
|
case 'trade_signal':
|
||||||
|
return this.formatTradeSignal(event.data);
|
||||||
|
|
||||||
|
case 'new_token_detected':
|
||||||
|
return this.formatNewToken(event.data);
|
||||||
|
|
||||||
|
case 'token_analyzed':
|
||||||
|
return this.formatTokenAnalyzed(event.data);
|
||||||
|
|
||||||
|
case 'copy_trade_detected':
|
||||||
|
return this.formatCopyTrade(event.data);
|
||||||
|
|
||||||
|
case 'rug_detected':
|
||||||
|
return this.formatRugDetected(event.data);
|
||||||
|
|
||||||
|
case 'kill_switch':
|
||||||
|
return {
|
||||||
|
title: '🛑 KILL SWITCH ACTIVATED',
|
||||||
|
description: event.data.reason,
|
||||||
|
color: COLORS.error,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'daily_limit_hit':
|
||||||
|
return {
|
||||||
|
title: '⚠️ Daily Loss Limit Hit',
|
||||||
|
description: `Total loss: **${this.fmtSol(event.data.totalLoss)} SOL** (limit: ${this.fmtSol(event.data.limit)} SOL)`,
|
||||||
|
color: COLORS.warning,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
title: `❌ Error: ${event.data.module}`,
|
||||||
|
description: `\`\`\`${event.data.error}\`\`\``,
|
||||||
|
color: COLORS.error,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Embed formatters ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private formatPositionOpened(position: Position): DiscordEmbed {
|
||||||
|
const txSig = position.trades[0]?.txSignature;
|
||||||
|
return {
|
||||||
|
title: `🟢 BUY — ${position.symbol}`,
|
||||||
|
color: COLORS.buy,
|
||||||
|
fields: [
|
||||||
|
{ name: 'Token', value: `[${position.mint.slice(0, 8)}…](${SOLSCAN_TOKEN}${position.mint})`, inline: true },
|
||||||
|
{ name: 'Amount', value: `${this.fmtSol(position.entryAmountSol)} SOL`, inline: true },
|
||||||
|
{ name: 'Price', value: `${position.entryPriceSol.toFixed(10)} SOL`, inline: true },
|
||||||
|
{ name: 'Tokens', value: position.tokensHeld.toString(), inline: true },
|
||||||
|
{ name: 'Signal', value: position.signal.type, inline: true },
|
||||||
|
{ name: 'Confidence', value: `${position.signal.confidence}%`, inline: true },
|
||||||
|
],
|
||||||
|
url: txSig ? `${SOLSCAN_TX}${txSig}` : undefined,
|
||||||
|
footer: { text: `Stop Loss: ${this.fmtSol(position.stopLossPrice)} SOL/token` },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatPositionClosed(position: Position & { reason: string }): DiscordEmbed {
|
||||||
|
const lastTrade = position.trades[position.trades.length - 1];
|
||||||
|
const txSig = lastTrade?.txSignature;
|
||||||
|
const isProfit = position.pnlSol >= 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${isProfit ? '🟢' : '🔴'} SELL — ${position.symbol} (${position.reason})`,
|
||||||
|
color: isProfit ? COLORS.buy : COLORS.sell,
|
||||||
|
fields: [
|
||||||
|
{ name: 'Token', value: `[${position.mint.slice(0, 8)}…](${SOLSCAN_TOKEN}${position.mint})`, inline: true },
|
||||||
|
{ name: 'PnL', value: `${isProfit ? '+' : ''}${this.fmtSol(position.pnlSol)} SOL (${this.fmtPct(position.pnlPercent)})`, inline: true },
|
||||||
|
{ name: 'Entry', value: `${this.fmtSol(position.entryAmountSol)} SOL`, inline: true },
|
||||||
|
{ name: 'Hold Time', value: this.fmtDuration(Date.now() - position.openedAt.getTime()), inline: true },
|
||||||
|
{ name: 'Highest', value: `${position.highestPriceSol.toFixed(10)} SOL`, inline: true },
|
||||||
|
{ name: 'Exit Reason', value: position.reason, inline: true },
|
||||||
|
],
|
||||||
|
url: txSig ? `${SOLSCAN_TX}${txSig}` : undefined,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatTradeExecuted(trade: TradeResult): DiscordEmbed {
|
||||||
|
return {
|
||||||
|
title: trade.success ? '✅ Trade Executed' : '❌ Trade Failed',
|
||||||
|
color: trade.success ? COLORS.success : COLORS.error,
|
||||||
|
fields: [
|
||||||
|
{ name: 'In', value: `${this.fmtSol(trade.inputAmount)}`, inline: true },
|
||||||
|
{ name: 'Out', value: `${trade.outputAmount.toFixed(4)}`, inline: true },
|
||||||
|
{ name: 'Fees', value: `${this.fmtSol(trade.fees)} SOL`, inline: true },
|
||||||
|
...(trade.txSignature ? [{ name: 'Tx', value: `[View](${SOLSCAN_TX}${trade.txSignature})`, inline: true }] : []),
|
||||||
|
...(trade.error ? [{ name: 'Error', value: trade.error, inline: false }] : []),
|
||||||
|
],
|
||||||
|
timestamp: trade.executedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatTradeSignal(signal: TradeSignal): DiscordEmbed {
|
||||||
|
return {
|
||||||
|
title: `📡 Signal: ${signal.type}`,
|
||||||
|
color: COLORS.info,
|
||||||
|
fields: [
|
||||||
|
{ name: 'Token', value: `[${signal.mint.slice(0, 8)}…](${SOLSCAN_TOKEN}${signal.mint})`, inline: true },
|
||||||
|
{ name: 'Confidence', value: `${signal.confidence}%`, inline: true },
|
||||||
|
{ name: 'Size', value: `${this.fmtSol(signal.suggestedAmountSol)} SOL`, inline: true },
|
||||||
|
{ name: 'Reason', value: signal.reason, inline: false },
|
||||||
|
],
|
||||||
|
timestamp: signal.timestamp.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatNewToken(data: TokenInfo & { pool: PoolInfo }): DiscordEmbed {
|
||||||
|
return {
|
||||||
|
title: `🆕 New Token: ${data.symbol ?? data.name}`,
|
||||||
|
color: COLORS.info,
|
||||||
|
fields: [
|
||||||
|
{ name: 'Mint', value: `[${data.mint.slice(0, 8)}…](${SOLSCAN_TOKEN}${data.mint})`, inline: true },
|
||||||
|
{ name: 'DEX', value: data.pool.dex, inline: true },
|
||||||
|
{ name: 'Creator', value: data.creator.slice(0, 8) + '…', inline: true },
|
||||||
|
...(data.pool.liquidityUsd ? [{ name: 'Liquidity', value: `$${data.pool.liquidityUsd?.toLocaleString() ?? 'N/A'}`, inline: true }] : []),
|
||||||
|
],
|
||||||
|
timestamp: data.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatTokenAnalyzed(analysis: TokenAnalysis): DiscordEmbed {
|
||||||
|
return {
|
||||||
|
title: `🔍 Analysis: Score ${analysis.score}/100`,
|
||||||
|
color: analysis.score >= 70 ? COLORS.success : analysis.score >= 40 ? COLORS.warning : COLORS.error,
|
||||||
|
fields: [
|
||||||
|
{ name: 'Mint Auth Revoked', value: analysis.mintAuthorityRevoked ? '✅' : '❌', inline: true },
|
||||||
|
{ name: 'Freeze Auth Revoked', value: analysis.freezeAuthorityRevoked ? '✅' : '❌', inline: true },
|
||||||
|
{ name: 'LP Locked/Burned', value: `${analysis.lpLocked ? '🔒' : '❌'} / ${analysis.lpBurned ? '🔥' : '❌'}`, inline: true },
|
||||||
|
{ name: 'Top 10 Holders', value: `${analysis.topHolderConcentration.toFixed(1)}%`, inline: true },
|
||||||
|
{ name: 'Dev Risk', value: analysis.devWalletRisk, inline: true },
|
||||||
|
{ name: 'Socials', value: analysis.hasSocials ? '✅' : '❌', inline: true },
|
||||||
|
...(analysis.flags.length > 0 ? [{ name: 'Flags', value: analysis.flags.join(', '), inline: false }] : []),
|
||||||
|
],
|
||||||
|
timestamp: analysis.timestamp.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatCopyTrade(activity: WalletActivity): DiscordEmbed {
|
||||||
|
return {
|
||||||
|
title: `👀 Copy Trade: ${activity.type.toUpperCase()}`,
|
||||||
|
color: activity.type === 'buy' ? COLORS.buy : COLORS.sell,
|
||||||
|
fields: [
|
||||||
|
{ name: 'Wallet', value: activity.wallet.slice(0, 8) + '…', inline: true },
|
||||||
|
{ name: 'Token', value: `[${activity.mint.slice(0, 8)}…](${SOLSCAN_TOKEN}${activity.mint})`, inline: true },
|
||||||
|
{ name: 'Amount', value: `${this.fmtSol(activity.amountSol)} SOL`, inline: true },
|
||||||
|
{ name: 'Tx', value: `[View](${SOLSCAN_TX}${activity.txSignature})`, inline: true },
|
||||||
|
],
|
||||||
|
timestamp: activity.timestamp.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatRugDetected(data: { mint: string; reason: string }): DiscordEmbed {
|
||||||
|
return {
|
||||||
|
title: '🚨 RUG DETECTED',
|
||||||
|
description: data.reason,
|
||||||
|
color: COLORS.rug,
|
||||||
|
fields: [
|
||||||
|
{ name: 'Token', value: `[${data.mint.slice(0, 8)}…](${SOLSCAN_TOKEN}${data.mint})`, inline: true },
|
||||||
|
],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rate-limited queue processor ──────────────────────────────────────
|
||||||
|
|
||||||
|
private async processQueue(): Promise<void> {
|
||||||
|
if (this.processing || !this.webhookUrl) return;
|
||||||
|
this.processing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (this.queue.length > 0) {
|
||||||
|
// Clean old timestamps outside the rate window
|
||||||
|
const now = Date.now();
|
||||||
|
this.messageTimestamps = this.messageTimestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
|
||||||
|
|
||||||
|
// Check rate limit
|
||||||
|
if (this.messageTimestamps.length >= RATE_LIMIT_MAX) {
|
||||||
|
const oldestInWindow = this.messageTimestamps[0];
|
||||||
|
const waitMs = RATE_LIMIT_WINDOW_MS - (now - oldestInWindow) + 100; // +100ms buffer
|
||||||
|
this.log.debug(`Rate limited — waiting ${waitMs}ms`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, waitMs));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const embed = this.queue.shift()!;
|
||||||
|
await this.postEmbed(embed);
|
||||||
|
this.messageTimestamps.push(Date.now());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST embed to Discord webhook ─────────────────────────────────────
|
||||||
|
|
||||||
|
private async postEmbed(embed: DiscordEmbed): Promise<void> {
|
||||||
|
if (!this.webhookUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
embeds: [embed],
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
// Discord rate limit response
|
||||||
|
const body = await response.json().catch(() => ({})) as { retry_after?: number };
|
||||||
|
const retryAfterMs = ((body as any).retry_after ?? 5) * 1000;
|
||||||
|
this.log.warn(`Discord 429 — retrying after ${retryAfterMs}ms`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryAfterMs));
|
||||||
|
// Re-queue
|
||||||
|
this.queue.unshift(embed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.log.error(`Discord webhook error: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.log.error(`Discord webhook failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formatting helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fmtSol(amount: number): string {
|
||||||
|
return amount.toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fmtPct(percent: number): string {
|
||||||
|
const sign = percent >= 0 ? '+' : '';
|
||||||
|
return `${sign}${percent.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fmtDuration(ms: number): string {
|
||||||
|
const minutes = Math.floor(ms / 60_000);
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainMin = minutes % 60;
|
||||||
|
return `${hours}h ${remainMin}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/control/index.ts
Normal file
9
src/control/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Control module exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { DiscordAlerts } from './discord-alerts.js';
|
||||||
|
export { KillSwitch } from './kill-switch.js';
|
||||||
|
export type { KillSwitchCallback } from './kill-switch.js';
|
||||||
|
export { RiskManager } from './risk-manager.js';
|
||||||
|
export type { DailyStats } from './risk-manager.js';
|
||||||
103
src/control/kill-switch.ts
Normal file
103
src/control/kill-switch.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Kill Switch — Emergency stop mechanism
|
||||||
|
* Halts all new trades and can trigger liquidation of open positions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BotConfig, BotEvent } from '../types/index.js';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export type KillSwitchCallback = (event: BotEvent) => void | Promise<void>;
|
||||||
|
|
||||||
|
export class KillSwitch {
|
||||||
|
private log = new Logger('Control');
|
||||||
|
private active = false;
|
||||||
|
private activatedAt: Date | null = null;
|
||||||
|
private reason: string | null = null;
|
||||||
|
private onActivate: KillSwitchCallback | null = null;
|
||||||
|
private config: BotConfig;
|
||||||
|
|
||||||
|
constructor(config: BotConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a callback that fires when the kill switch is activated.
|
||||||
|
* Typically used to trigger sell-all and send alerts.
|
||||||
|
*/
|
||||||
|
onActivated(callback: KillSwitchCallback): void {
|
||||||
|
this.onActivate = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activate kill switch ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async activate(reason: string = 'Manual activation'): Promise<void> {
|
||||||
|
if (this.active) {
|
||||||
|
this.log.warn('Kill switch already active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.active = true;
|
||||||
|
this.activatedAt = new Date();
|
||||||
|
this.reason = reason;
|
||||||
|
|
||||||
|
this.log.error(`🛑 KILL SWITCH ACTIVATED: ${reason}`);
|
||||||
|
|
||||||
|
// Fire callback to trigger sell-all and alerts
|
||||||
|
if (this.onActivate) {
|
||||||
|
const event: BotEvent = {
|
||||||
|
type: 'kill_switch',
|
||||||
|
data: { reason },
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.onActivate(event);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.log.error(`Kill switch callback error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Deactivate kill switch ────────────────────────────────────────────
|
||||||
|
|
||||||
|
deactivate(): void {
|
||||||
|
if (!this.active) {
|
||||||
|
this.log.warn('Kill switch is not active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.active = false;
|
||||||
|
this.log.success('Kill switch deactivated — trading resumed');
|
||||||
|
this.reason = null;
|
||||||
|
this.activatedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Check if active ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
isActive(): boolean {
|
||||||
|
return this.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get status info ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getStatus(): { active: boolean; reason: string | null; activatedAt: Date | null } {
|
||||||
|
return {
|
||||||
|
active: this.active,
|
||||||
|
reason: this.reason,
|
||||||
|
activatedAt: this.activatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-activate when daily loss limit is hit ────────────────────────
|
||||||
|
|
||||||
|
async checkDailyLoss(currentDailyLossSol: number): Promise<void> {
|
||||||
|
if (this.active) return;
|
||||||
|
|
||||||
|
const limit = this.config.maxDailyLossSol;
|
||||||
|
|
||||||
|
// currentDailyLossSol is negative when losing
|
||||||
|
if (currentDailyLossSol <= -limit) {
|
||||||
|
this.log.error(`Daily loss limit hit: ${currentDailyLossSol.toFixed(4)} SOL (limit: -${limit.toFixed(4)} SOL)`);
|
||||||
|
await this.activate(`Daily loss limit exceeded: ${currentDailyLossSol.toFixed(4)} SOL lost (max: -${limit.toFixed(4)} SOL)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/control/risk-manager.ts
Normal file
166
src/control/risk-manager.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Risk Manager — Enforce risk limits on trading
|
||||||
|
* Tracks daily P&L, position counts, and enforces max limits
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BotConfig } from '../types/index.js';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export interface DailyStats {
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
totalTrades: number;
|
||||||
|
winCount: number;
|
||||||
|
lossCount: number;
|
||||||
|
totalPnlSol: number;
|
||||||
|
grossProfitSol: number;
|
||||||
|
grossLossSol: number;
|
||||||
|
largestWinSol: number;
|
||||||
|
largestLossSol: number;
|
||||||
|
openPositions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RiskManager {
|
||||||
|
private log = new Logger('Control');
|
||||||
|
private config: BotConfig;
|
||||||
|
|
||||||
|
// Daily tracking
|
||||||
|
private currentDate: string;
|
||||||
|
private dailyPnlSol = 0;
|
||||||
|
private totalTrades = 0;
|
||||||
|
private winCount = 0;
|
||||||
|
private lossCount = 0;
|
||||||
|
private grossProfitSol = 0;
|
||||||
|
private grossLossSol = 0;
|
||||||
|
private largestWinSol = 0;
|
||||||
|
private largestLossSol = 0;
|
||||||
|
|
||||||
|
// External state provider
|
||||||
|
private getOpenPositionCount: (() => number) | null = null;
|
||||||
|
|
||||||
|
constructor(config: BotConfig) {
|
||||||
|
this.config = config;
|
||||||
|
this.currentDate = this.todayString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a function that returns current open position count.
|
||||||
|
* Typically: () => positionManager.getOpenCount()
|
||||||
|
*/
|
||||||
|
setOpenPositionCountProvider(fn: () => number): void {
|
||||||
|
this.getOpenPositionCount = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Can we open a new position? ───────────────────────────────────────
|
||||||
|
|
||||||
|
canOpenPosition(amountSol: number): { allowed: boolean; reason?: string } {
|
||||||
|
this.rolloverIfNewDay();
|
||||||
|
|
||||||
|
// Check kill switch isn't triggered by daily loss
|
||||||
|
if (this.dailyPnlSol <= -this.config.maxDailyLossSol) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Daily loss limit reached: ${this.dailyPnlSol.toFixed(4)} SOL (max: -${this.config.maxDailyLossSol.toFixed(4)} SOL)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check position size
|
||||||
|
if (amountSol > this.config.maxPositionSizeSol) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Position size ${amountSol.toFixed(4)} SOL exceeds max ${this.config.maxPositionSizeSol.toFixed(4)} SOL`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check concurrent positions
|
||||||
|
const openCount = this.getOpenPositionCount?.() ?? 0;
|
||||||
|
if (openCount >= this.config.maxConcurrentPositions) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Max concurrent positions reached: ${openCount}/${this.config.maxConcurrentPositions}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if position would push us past daily loss limit
|
||||||
|
// Worst case: total loss of this position
|
||||||
|
if (this.dailyPnlSol - amountSol <= -this.config.maxDailyLossSol) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Position of ${amountSol.toFixed(4)} SOL would risk exceeding daily loss limit`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Record realized P&L from a trade ──────────────────────────────────
|
||||||
|
|
||||||
|
recordPnl(pnlSol: number): void {
|
||||||
|
this.rolloverIfNewDay();
|
||||||
|
|
||||||
|
this.dailyPnlSol += pnlSol;
|
||||||
|
this.totalTrades += 1;
|
||||||
|
|
||||||
|
if (pnlSol >= 0) {
|
||||||
|
this.winCount += 1;
|
||||||
|
this.grossProfitSol += pnlSol;
|
||||||
|
if (pnlSol > this.largestWinSol) this.largestWinSol = pnlSol;
|
||||||
|
} else {
|
||||||
|
this.lossCount += 1;
|
||||||
|
this.grossLossSol += Math.abs(pnlSol);
|
||||||
|
if (Math.abs(pnlSol) > this.largestLossSol) this.largestLossSol = Math.abs(pnlSol);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = pnlSol >= 0 ? '+' : '';
|
||||||
|
this.log.info(`P&L recorded: ${sign}${pnlSol.toFixed(4)} SOL | Daily: ${this.dailyPnlSol >= 0 ? '+' : ''}${this.dailyPnlSol.toFixed(4)} SOL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get today's stats ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getDailyStats(): DailyStats {
|
||||||
|
this.rolloverIfNewDay();
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: this.currentDate,
|
||||||
|
totalTrades: this.totalTrades,
|
||||||
|
winCount: this.winCount,
|
||||||
|
lossCount: this.lossCount,
|
||||||
|
totalPnlSol: this.dailyPnlSol,
|
||||||
|
grossProfitSol: this.grossProfitSol,
|
||||||
|
grossLossSol: this.grossLossSol,
|
||||||
|
largestWinSol: this.largestWinSol,
|
||||||
|
largestLossSol: this.largestLossSol,
|
||||||
|
openPositions: this.getOpenPositionCount?.() ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get current daily P&L (for kill switch checks) ────────────────────
|
||||||
|
|
||||||
|
getDailyPnl(): number {
|
||||||
|
this.rolloverIfNewDay();
|
||||||
|
return this.dailyPnlSol;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rollover on new day ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private rolloverIfNewDay(): void {
|
||||||
|
const today = this.todayString();
|
||||||
|
if (today !== this.currentDate) {
|
||||||
|
this.log.info(`New trading day: ${today} — resetting daily stats`);
|
||||||
|
this.log.info(`Yesterday summary: ${this.totalTrades} trades, PnL: ${this.dailyPnlSol >= 0 ? '+' : ''}${this.dailyPnlSol.toFixed(4)} SOL, W/L: ${this.winCount}/${this.lossCount}`);
|
||||||
|
|
||||||
|
this.currentDate = today;
|
||||||
|
this.dailyPnlSol = 0;
|
||||||
|
this.totalTrades = 0;
|
||||||
|
this.winCount = 0;
|
||||||
|
this.lossCount = 0;
|
||||||
|
this.grossProfitSol = 0;
|
||||||
|
this.grossLossSol = 0;
|
||||||
|
this.largestWinSol = 0;
|
||||||
|
this.largestLossSol = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private todayString(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/executor/index.ts
Normal file
15
src/executor/index.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Executor Module
|
||||||
|
*
|
||||||
|
* Trade execution layer for the Solana sniper bot.
|
||||||
|
* Handles Jupiter swaps, Jito bundling, and high-level trade orchestration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { JupiterSwap, JupiterSwapError } from './jupiter-swap.js';
|
||||||
|
export type { JupiterQuoteResponse, JupiterSwapResponse, SwapConfig } from './jupiter-swap.js';
|
||||||
|
|
||||||
|
export { JitoBundler } from './jito-bundler.js';
|
||||||
|
export type { BundleStatus, BundleStatusResponse, BundleResult } from './jito-bundler.js';
|
||||||
|
|
||||||
|
export { TradeExecutor } from './trade-executor.js';
|
||||||
|
export type { ExecuteTradeConfig } from './trade-executor.js';
|
||||||
333
src/executor/jito-bundler.ts
Normal file
333
src/executor/jito-bundler.ts
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* Jito Bundle Client
|
||||||
|
*
|
||||||
|
* Sends transactions via the Jito block engine for faster inclusion
|
||||||
|
* and MEV protection. Bundles transactions with a tip to Jito validators.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
Keypair,
|
||||||
|
PublicKey,
|
||||||
|
SystemProgram,
|
||||||
|
TransactionMessage,
|
||||||
|
VersionedTransaction,
|
||||||
|
TransactionInstruction,
|
||||||
|
} from '@solana/web3.js';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import bs58 from 'bs58';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
import { JITO_BLOCK_ENGINE, LAMPORTS_PER_SOL } from '../types/index.js';
|
||||||
|
|
||||||
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Official Jito tip accounts — pick one at random per bundle
|
||||||
|
* to distribute tips across validators.
|
||||||
|
*/
|
||||||
|
const JITO_TIP_ACCOUNTS: string[] = [
|
||||||
|
'96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5',
|
||||||
|
'HFqU5x63VTqvQss8hp11i4bVqkfRtQ7NmXwkiNPLNiU7',
|
||||||
|
'Cw8CFyM9FkoMi7K7Crf6HNQqf4uEMzpKw6QNghXLvLkY',
|
||||||
|
'ADaUMid9yfUytqMBgopwjb2DTLSLqth5Qz2GW573a6Yq',
|
||||||
|
'DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh',
|
||||||
|
'ADuUkR4vqLUMWXxW9gh6D6L8pMSawimctcNZ5pGwDcEt',
|
||||||
|
'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL',
|
||||||
|
'3AVi9Tg9Uo68tJfuvoKvqKNWKkC5wPdSSdeBnizKZ6jT',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Default tip: 0.0001 SOL */
|
||||||
|
const DEFAULT_TIP_LAMPORTS = 100_000;
|
||||||
|
|
||||||
|
/** Bundle polling interval and max attempts */
|
||||||
|
const POLL_INTERVAL_MS = 2_000;
|
||||||
|
const MAX_POLL_ATTEMPTS = 30; // 60 seconds total
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type BundleStatus =
|
||||||
|
| 'Invalid'
|
||||||
|
| 'Pending'
|
||||||
|
| 'Failed'
|
||||||
|
| 'Landed'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export interface BundleStatusResponse {
|
||||||
|
bundleId: string;
|
||||||
|
status: BundleStatus;
|
||||||
|
slot?: number;
|
||||||
|
transactions?: string[]; // signatures
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleResult {
|
||||||
|
bundleId: string;
|
||||||
|
status: BundleStatus;
|
||||||
|
signatures: string[];
|
||||||
|
landed: boolean;
|
||||||
|
slot?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Class ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class JitoBundler {
|
||||||
|
private connection: Connection;
|
||||||
|
private blockEngineUrl: string;
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
connection: Connection,
|
||||||
|
options?: {
|
||||||
|
blockEngineUrl?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.connection = connection;
|
||||||
|
this.blockEngineUrl = options?.blockEngineUrl ?? JITO_BLOCK_ENGINE;
|
||||||
|
this.logger = new Logger('Executor');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send Bundle ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a signed transaction as a Jito bundle with a validator tip.
|
||||||
|
*
|
||||||
|
* The tip transaction is created and appended to the bundle automatically.
|
||||||
|
* Both the main TX and the tip TX are sent together as an atomic bundle.
|
||||||
|
*
|
||||||
|
* @param signedTx - Already-signed VersionedTransaction
|
||||||
|
* @param walletKeypair - Wallet to pay the tip from
|
||||||
|
* @param tipLamports - Tip amount (default 100,000 = 0.0001 SOL)
|
||||||
|
* @returns bundleId string for status tracking
|
||||||
|
*/
|
||||||
|
async sendBundle(
|
||||||
|
signedTx: VersionedTransaction,
|
||||||
|
walletKeypair: Keypair,
|
||||||
|
tipLamports: number = DEFAULT_TIP_LAMPORTS,
|
||||||
|
): Promise<string> {
|
||||||
|
// 1. Create tip transaction
|
||||||
|
const tipTx = await this.buildTipTransaction(walletKeypair, tipLamports);
|
||||||
|
tipTx.sign([walletKeypair]);
|
||||||
|
|
||||||
|
// 2. Encode both transactions as base58
|
||||||
|
const mainTxEncoded = bs58.encode(signedTx.serialize());
|
||||||
|
const tipTxEncoded = bs58.encode(tipTx.serialize());
|
||||||
|
|
||||||
|
// 3. Send bundle via JSON-RPC
|
||||||
|
const bundleId = await this.submitBundle([mainTxEncoded, tipTxEncoded]);
|
||||||
|
|
||||||
|
this.logger.info(`Jito bundle sent: ${bundleId} (tip: ${tipLamports / LAMPORTS_PER_SOL} SOL)`);
|
||||||
|
return bundleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a single pre-serialized transaction (already includes tip)
|
||||||
|
* as a Jito bundle. Use this when the tip is embedded in the swap TX itself.
|
||||||
|
*/
|
||||||
|
async sendBundleRaw(
|
||||||
|
serializedTxs: Uint8Array[],
|
||||||
|
): Promise<string> {
|
||||||
|
const encoded = serializedTxs.map((tx) => bs58.encode(tx));
|
||||||
|
const bundleId = await this.submitBundle(encoded);
|
||||||
|
this.logger.info(`Jito raw bundle sent: ${bundleId}`);
|
||||||
|
return bundleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bundle Status ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the status of a submitted bundle.
|
||||||
|
*/
|
||||||
|
async getBundleStatus(bundleId: string): Promise<BundleStatusResponse> {
|
||||||
|
const body = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'getBundleStatuses',
|
||||||
|
params: [[bundleId]],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${this.blockEngineUrl}/api/v1/bundles`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Jito getBundleStatus HTTP ${res.status}: ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
|
||||||
|
if (json.error) {
|
||||||
|
throw new Error(`Jito RPC error: ${JSON.stringify(json.error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses = json.result?.value ?? [];
|
||||||
|
if (statuses.length === 0) {
|
||||||
|
return { bundleId, status: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = statuses[0];
|
||||||
|
return {
|
||||||
|
bundleId: s.bundle_id ?? bundleId,
|
||||||
|
status: this.normalizeStatus(s.confirmation_status ?? s.err),
|
||||||
|
slot: s.slot,
|
||||||
|
transactions: s.transactions,
|
||||||
|
error: s.err ? JSON.stringify(s.err) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wait for Landing ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll until the bundle lands or we timeout.
|
||||||
|
*
|
||||||
|
* Returns a BundleResult with the final status.
|
||||||
|
*/
|
||||||
|
async waitForBundle(
|
||||||
|
bundleId: string,
|
||||||
|
timeoutMs: number = POLL_INTERVAL_MS * MAX_POLL_ATTEMPTS,
|
||||||
|
): Promise<BundleResult> {
|
||||||
|
const maxAttempts = Math.ceil(timeoutMs / POLL_INTERVAL_MS);
|
||||||
|
let lastStatus: BundleStatusResponse = { bundleId, status: 'Pending' };
|
||||||
|
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
await this.sleep(POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
lastStatus = await this.getBundleStatus(bundleId);
|
||||||
|
this.logger.debug(`Bundle ${bundleId.slice(0, 12)}... status: ${lastStatus.status}`);
|
||||||
|
|
||||||
|
if (lastStatus.status === 'Landed') {
|
||||||
|
this.logger.success(`Bundle landed in slot ${lastStatus.slot}`);
|
||||||
|
return {
|
||||||
|
bundleId,
|
||||||
|
status: 'Landed',
|
||||||
|
signatures: lastStatus.transactions ?? [],
|
||||||
|
landed: true,
|
||||||
|
slot: lastStatus.slot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastStatus.status === 'Failed' || lastStatus.status === 'Invalid') {
|
||||||
|
this.logger.warn(`Bundle failed: ${lastStatus.error}`);
|
||||||
|
return {
|
||||||
|
bundleId,
|
||||||
|
status: lastStatus.status,
|
||||||
|
signatures: lastStatus.transactions ?? [],
|
||||||
|
landed: false,
|
||||||
|
error: lastStatus.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.debug(`Bundle status poll error: ${err.message}`);
|
||||||
|
// Continue polling — transient errors are expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(`Bundle ${bundleId.slice(0, 12)}... timed out after ${timeoutMs}ms`);
|
||||||
|
return {
|
||||||
|
bundleId,
|
||||||
|
status: lastStatus.status,
|
||||||
|
signatures: lastStatus.transactions ?? [],
|
||||||
|
landed: false,
|
||||||
|
error: 'Polling timed out',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internals ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit encoded transactions as a Jito bundle via JSON-RPC.
|
||||||
|
*/
|
||||||
|
private async submitBundle(encodedTxs: string[]): Promise<string> {
|
||||||
|
const body = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'sendBundle',
|
||||||
|
params: [encodedTxs],
|
||||||
|
};
|
||||||
|
|
||||||
|
let res: any;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${this.blockEngineUrl}/api/v1/bundles`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(`Jito bundle network error: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Jito sendBundle HTTP ${res.status}: ${errText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await res.json()) as any;
|
||||||
|
|
||||||
|
if (json.error) {
|
||||||
|
throw new Error(`Jito sendBundle RPC error: ${JSON.stringify(json.error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundleId = json.result;
|
||||||
|
if (!bundleId || typeof bundleId !== 'string') {
|
||||||
|
throw new Error(`Jito sendBundle: unexpected result: ${JSON.stringify(json)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a tip transaction to a random Jito validator tip account.
|
||||||
|
*/
|
||||||
|
private async buildTipTransaction(
|
||||||
|
payer: Keypair,
|
||||||
|
tipLamports: number,
|
||||||
|
): Promise<VersionedTransaction> {
|
||||||
|
const tipAccount = this.pickRandomTipAccount();
|
||||||
|
this.logger.debug(`Tipping ${tipLamports} lamports to ${tipAccount.slice(0, 12)}...`);
|
||||||
|
|
||||||
|
const tipIx = SystemProgram.transfer({
|
||||||
|
fromPubkey: payer.publicKey,
|
||||||
|
toPubkey: new PublicKey(tipAccount),
|
||||||
|
lamports: tipLamports,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { blockhash } = await this.connection.getLatestBlockhash('confirmed');
|
||||||
|
|
||||||
|
const messageV0 = new TransactionMessage({
|
||||||
|
payerKey: payer.publicKey,
|
||||||
|
recentBlockhash: blockhash,
|
||||||
|
instructions: [tipIx],
|
||||||
|
}).compileToV0Message();
|
||||||
|
|
||||||
|
return new VersionedTransaction(messageV0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a random tip account from the Jito tip account list.
|
||||||
|
*/
|
||||||
|
private pickRandomTipAccount(): string {
|
||||||
|
const idx = Math.floor(Math.random() * JITO_TIP_ACCOUNTS.length);
|
||||||
|
return JITO_TIP_ACCOUNTS[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize various Jito status strings to our BundleStatus type.
|
||||||
|
*/
|
||||||
|
private normalizeStatus(raw: string | undefined): BundleStatus {
|
||||||
|
if (!raw) return 'unknown';
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
if (lower === 'landed' || lower === 'confirmed' || lower === 'finalized') return 'Landed';
|
||||||
|
if (lower === 'failed') return 'Failed';
|
||||||
|
if (lower === 'invalid') return 'Invalid';
|
||||||
|
if (lower === 'pending' || lower === 'processed') return 'Pending';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
385
src/executor/jupiter-swap.ts
Normal file
385
src/executor/jupiter-swap.ts
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* Jupiter V6 Swap Client
|
||||||
|
*
|
||||||
|
* Handles quote fetching and swap execution via the Jupiter aggregator.
|
||||||
|
* Supports buy (SOL→Token) and sell (Token→SOL) with priority fee
|
||||||
|
* configuration and comprehensive error handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
Keypair,
|
||||||
|
VersionedTransaction,
|
||||||
|
TransactionMessage,
|
||||||
|
AddressLookupTableAccount,
|
||||||
|
SendOptions,
|
||||||
|
} from '@solana/web3.js';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
import { JUPITER_V6_API, LAMPORTS_PER_SOL, SOL_MINT } from '../types/index.js';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface JupiterQuoteResponse {
|
||||||
|
inputMint: string;
|
||||||
|
outputMint: string;
|
||||||
|
inAmount: string;
|
||||||
|
outAmount: string;
|
||||||
|
otherAmountThreshold: string;
|
||||||
|
swapMode: string;
|
||||||
|
slippageBps: number;
|
||||||
|
priceImpactPct: string;
|
||||||
|
routePlan: RoutePlan[];
|
||||||
|
contextSlot?: number;
|
||||||
|
timeTaken?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoutePlan {
|
||||||
|
swapInfo: {
|
||||||
|
ammKey: string;
|
||||||
|
label: string;
|
||||||
|
inputMint: string;
|
||||||
|
outputMint: string;
|
||||||
|
inAmount: string;
|
||||||
|
outAmount: string;
|
||||||
|
feeAmount: string;
|
||||||
|
feeMint: string;
|
||||||
|
};
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JupiterSwapResponse {
|
||||||
|
swapTransaction: string; // base64 serialized versioned tx
|
||||||
|
lastValidBlockHeight: number;
|
||||||
|
prioritizationFeeLamports?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwapConfig {
|
||||||
|
/** Priority fee in micro-lamports per compute unit */
|
||||||
|
priorityFeeMicroLamports?: number;
|
||||||
|
/** Priority fee as a fixed amount in lamports (overrides compute unit price) */
|
||||||
|
priorityFeeLamports?: number | 'auto';
|
||||||
|
/** Max compute units for the transaction */
|
||||||
|
computeUnitLimit?: number;
|
||||||
|
/** Use dynamic slippage from Jupiter */
|
||||||
|
dynamicSlippage?: boolean;
|
||||||
|
/** Wrap/unwrap SOL automatically */
|
||||||
|
wrapUnwrapSol?: boolean;
|
||||||
|
/** Use shared accounts to reduce tx size */
|
||||||
|
useSharedAccounts?: boolean;
|
||||||
|
/** Skip user account creation (for speed) */
|
||||||
|
asLegacyTransaction?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JupiterSwapError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly code:
|
||||||
|
| 'SLIPPAGE_EXCEEDED'
|
||||||
|
| 'INSUFFICIENT_BALANCE'
|
||||||
|
| 'ROUTE_NOT_FOUND'
|
||||||
|
| 'QUOTE_FAILED'
|
||||||
|
| 'SWAP_FAILED'
|
||||||
|
| 'TX_FAILED'
|
||||||
|
| 'TIMEOUT'
|
||||||
|
| 'UNKNOWN',
|
||||||
|
public readonly details?: any,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'JupiterSwapError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Class ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class JupiterSwap {
|
||||||
|
private connection: Connection;
|
||||||
|
private apiBaseUrl: string;
|
||||||
|
private logger: Logger;
|
||||||
|
private defaultSwapConfig: SwapConfig;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
connection: Connection,
|
||||||
|
options?: {
|
||||||
|
apiBaseUrl?: string;
|
||||||
|
defaultSwapConfig?: SwapConfig;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.connection = connection;
|
||||||
|
this.apiBaseUrl = options?.apiBaseUrl ?? JUPITER_V6_API;
|
||||||
|
this.logger = new Logger('Executor');
|
||||||
|
this.defaultSwapConfig = {
|
||||||
|
priorityFeeMicroLamports: 100_000, // 0.1 lamport/CU — reasonable default
|
||||||
|
computeUnitLimit: 400_000,
|
||||||
|
wrapUnwrapSol: true,
|
||||||
|
useSharedAccounts: true,
|
||||||
|
dynamicSlippage: false,
|
||||||
|
asLegacyTransaction: false,
|
||||||
|
...options?.defaultSwapConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quote ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a swap quote from Jupiter V6.
|
||||||
|
*
|
||||||
|
* @param inputMint - Mint address of the token you're selling
|
||||||
|
* @param outputMint - Mint address of the token you're buying
|
||||||
|
* @param amountRaw - Raw amount in smallest unit (lamports for SOL, raw tokens for SPL)
|
||||||
|
* @param slippageBps - Max slippage in basis points (e.g. 100 = 1%)
|
||||||
|
*/
|
||||||
|
async getQuote(
|
||||||
|
inputMint: string,
|
||||||
|
outputMint: string,
|
||||||
|
amountRaw: number | bigint,
|
||||||
|
slippageBps: number,
|
||||||
|
): Promise<JupiterQuoteResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
inputMint,
|
||||||
|
outputMint,
|
||||||
|
amount: amountRaw.toString(),
|
||||||
|
slippageBps: slippageBps.toString(),
|
||||||
|
swapMode: 'ExactIn',
|
||||||
|
onlyDirectRoutes: 'false',
|
||||||
|
maxAccounts: '64',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `${this.apiBaseUrl}/quote?${params.toString()}`;
|
||||||
|
this.logger.debug(`Fetching quote: ${inputMint.slice(0, 8)}→${outputMint.slice(0, 8)} amount=${amountRaw}`);
|
||||||
|
|
||||||
|
let res: any;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new JupiterSwapError(
|
||||||
|
`Network error fetching Jupiter quote: ${err.message}`,
|
||||||
|
'QUOTE_FAILED',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
if (res.status === 400 && body.includes('Could not find any route')) {
|
||||||
|
throw new JupiterSwapError(
|
||||||
|
`No route found for ${inputMint.slice(0, 8)}→${outputMint.slice(0, 8)}`,
|
||||||
|
'ROUTE_NOT_FOUND',
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new JupiterSwapError(
|
||||||
|
`Jupiter quote HTTP ${res.status}: ${body}`,
|
||||||
|
'QUOTE_FAILED',
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const quote = (await res.json()) as JupiterQuoteResponse;
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Quote received: in=${quote.inAmount} out=${quote.outAmount} impact=${quote.priceImpactPct}%`,
|
||||||
|
);
|
||||||
|
return quote;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Swap ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build, sign, and send a swap transaction from a Jupiter quote.
|
||||||
|
*
|
||||||
|
* Returns the transaction signature on success.
|
||||||
|
*/
|
||||||
|
async executeSwap(
|
||||||
|
quoteResponse: JupiterQuoteResponse,
|
||||||
|
walletKeypair: Keypair,
|
||||||
|
config?: SwapConfig,
|
||||||
|
): Promise<string> {
|
||||||
|
const cfg = { ...this.defaultSwapConfig, ...config };
|
||||||
|
|
||||||
|
// 1. Get serialized transaction from Jupiter /swap
|
||||||
|
const swapBody: Record<string, any> = {
|
||||||
|
quoteResponse,
|
||||||
|
userPublicKey: walletKeypair.publicKey.toBase58(),
|
||||||
|
wrapAndUnwrapSol: cfg.wrapUnwrapSol ?? true,
|
||||||
|
useSharedAccounts: cfg.useSharedAccounts ?? true,
|
||||||
|
asLegacyTransaction: cfg.asLegacyTransaction ?? false,
|
||||||
|
dynamicComputeUnitLimit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Priority fee configuration
|
||||||
|
if (cfg.priorityFeeLamports !== undefined) {
|
||||||
|
swapBody.prioritizationFeeLamports = cfg.priorityFeeLamports;
|
||||||
|
} else if (cfg.priorityFeeMicroLamports !== undefined) {
|
||||||
|
swapBody.computeUnitPriceMicroLamports = cfg.priorityFeeMicroLamports;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.dynamicSlippage) {
|
||||||
|
swapBody.dynamicSlippage = { maxBps: quoteResponse.slippageBps };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug('Requesting swap transaction from Jupiter...');
|
||||||
|
|
||||||
|
let swapRes: any;
|
||||||
|
try {
|
||||||
|
swapRes = await fetch(`${this.apiBaseUrl}/swap`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify(swapBody),
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new JupiterSwapError(
|
||||||
|
`Network error building swap: ${err.message}`,
|
||||||
|
'SWAP_FAILED',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!swapRes.ok) {
|
||||||
|
const body = await swapRes.text().catch(() => '');
|
||||||
|
this.classifyAndThrow(swapRes.status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const swapData = (await swapRes.json()) as JupiterSwapResponse;
|
||||||
|
|
||||||
|
// 2. Deserialize and sign the versioned transaction
|
||||||
|
const txBuf = Buffer.from(swapData.swapTransaction, 'base64');
|
||||||
|
const versionedTx = VersionedTransaction.deserialize(txBuf);
|
||||||
|
versionedTx.sign([walletKeypair]);
|
||||||
|
|
||||||
|
this.logger.debug('Transaction signed, sending to network...');
|
||||||
|
|
||||||
|
// 3. Send the signed transaction
|
||||||
|
const signature = await this.sendTransaction(versionedTx, swapData.lastValidBlockHeight);
|
||||||
|
|
||||||
|
this.logger.success(`Swap confirmed: ${signature}`);
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convenience: Buy ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buy a token with SOL.
|
||||||
|
*
|
||||||
|
* @param tokenMint - Token to buy
|
||||||
|
* @param amountSol - Amount of SOL to spend
|
||||||
|
* @param slippageBps - Slippage tolerance
|
||||||
|
* @param config - Optional swap config overrides
|
||||||
|
*/
|
||||||
|
async buyToken(
|
||||||
|
tokenMint: string,
|
||||||
|
amountSol: number,
|
||||||
|
slippageBps: number,
|
||||||
|
walletKeypair: Keypair,
|
||||||
|
config?: SwapConfig,
|
||||||
|
): Promise<{ quote: JupiterQuoteResponse; txSignature: string }> {
|
||||||
|
const amountLamports = Math.round(amountSol * LAMPORTS_PER_SOL);
|
||||||
|
const quote = await this.getQuote(SOL_MINT, tokenMint, amountLamports, slippageBps);
|
||||||
|
const txSignature = await this.executeSwap(quote, walletKeypair, config);
|
||||||
|
return { quote, txSignature };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convenience: Sell ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sell a token for SOL.
|
||||||
|
*
|
||||||
|
* @param tokenMint - Token to sell
|
||||||
|
* @param amountTokens - Raw amount of tokens (smallest unit)
|
||||||
|
* @param slippageBps - Slippage tolerance
|
||||||
|
* @param config - Optional swap config overrides
|
||||||
|
*/
|
||||||
|
async sellToken(
|
||||||
|
tokenMint: string,
|
||||||
|
amountTokens: number | bigint,
|
||||||
|
slippageBps: number,
|
||||||
|
walletKeypair: Keypair,
|
||||||
|
config?: SwapConfig,
|
||||||
|
): Promise<{ quote: JupiterQuoteResponse; txSignature: string }> {
|
||||||
|
const quote = await this.getQuote(tokenMint, SOL_MINT, amountTokens, slippageBps);
|
||||||
|
const txSignature = await this.executeSwap(quote, walletKeypair, config);
|
||||||
|
return { quote, txSignature };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a signed versioned transaction with confirmation.
|
||||||
|
*/
|
||||||
|
private async sendTransaction(
|
||||||
|
tx: VersionedTransaction,
|
||||||
|
lastValidBlockHeight: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const rawTx = tx.serialize();
|
||||||
|
|
||||||
|
const sendOptions: SendOptions = {
|
||||||
|
skipPreflight: true, // skip for speed — we trust Jupiter's simulation
|
||||||
|
maxRetries: 2,
|
||||||
|
preflightCommitment: 'confirmed',
|
||||||
|
};
|
||||||
|
|
||||||
|
let signature: string;
|
||||||
|
try {
|
||||||
|
signature = await this.connection.sendRawTransaction(rawTx, sendOptions);
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.message ?? '';
|
||||||
|
if (msg.includes('insufficient funds') || msg.includes('Insufficient')) {
|
||||||
|
throw new JupiterSwapError('Insufficient SOL balance for transaction', 'INSUFFICIENT_BALANCE', err);
|
||||||
|
}
|
||||||
|
throw new JupiterSwapError(`Failed to send transaction: ${msg}`, 'TX_FAILED', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`TX sent: ${signature}, awaiting confirmation...`);
|
||||||
|
|
||||||
|
// Wait for confirmation with a timeout based on lastValidBlockHeight
|
||||||
|
try {
|
||||||
|
const latestBlockhash = await this.connection.getLatestBlockhash('confirmed');
|
||||||
|
const confirmation = await this.connection.confirmTransaction(
|
||||||
|
{
|
||||||
|
signature,
|
||||||
|
blockhash: latestBlockhash.blockhash,
|
||||||
|
lastValidBlockHeight: Math.max(lastValidBlockHeight, latestBlockhash.lastValidBlockHeight),
|
||||||
|
},
|
||||||
|
'confirmed',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmation.value.err) {
|
||||||
|
const errStr = JSON.stringify(confirmation.value.err);
|
||||||
|
if (errStr.includes('SlippageToleranceExceeded') || errStr.includes('6001')) {
|
||||||
|
throw new JupiterSwapError('Slippage tolerance exceeded on-chain', 'SLIPPAGE_EXCEEDED', confirmation.value.err);
|
||||||
|
}
|
||||||
|
throw new JupiterSwapError(`Transaction failed on-chain: ${errStr}`, 'TX_FAILED', confirmation.value.err);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof JupiterSwapError) throw err;
|
||||||
|
// Timeout — transaction may still land but we couldn't confirm in time
|
||||||
|
throw new JupiterSwapError(
|
||||||
|
`Transaction confirmation timed out: ${signature}`,
|
||||||
|
'TIMEOUT',
|
||||||
|
{ signature, error: err.message },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify HTTP error responses from Jupiter into typed errors.
|
||||||
|
*/
|
||||||
|
private classifyAndThrow(status: number, body: string): never {
|
||||||
|
const lower = body.toLowerCase();
|
||||||
|
if (lower.includes('slippage') || lower.includes('tolerance')) {
|
||||||
|
throw new JupiterSwapError(`Slippage exceeded: ${body}`, 'SLIPPAGE_EXCEEDED', body);
|
||||||
|
}
|
||||||
|
if (lower.includes('insufficient') || lower.includes('balance')) {
|
||||||
|
throw new JupiterSwapError(`Insufficient balance: ${body}`, 'INSUFFICIENT_BALANCE', body);
|
||||||
|
}
|
||||||
|
if (lower.includes('route') || lower.includes('no swap')) {
|
||||||
|
throw new JupiterSwapError(`No route found: ${body}`, 'ROUTE_NOT_FOUND', body);
|
||||||
|
}
|
||||||
|
throw new JupiterSwapError(`Jupiter swap error (HTTP ${status}): ${body}`, 'SWAP_FAILED', body);
|
||||||
|
}
|
||||||
|
}
|
||||||
413
src/executor/trade-executor.ts
Normal file
413
src/executor/trade-executor.ts
Normal file
@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* Trade Executor — High-level trade execution manager
|
||||||
|
*
|
||||||
|
* Orchestrates the full lifecycle of a trade:
|
||||||
|
* 1. Get Jupiter quote
|
||||||
|
* 2. Sign transaction
|
||||||
|
* 3. Send via Jito or normal RPC
|
||||||
|
* 4. Confirm and return result
|
||||||
|
*
|
||||||
|
* Supports paper trading, retry logic with escalating priority fees,
|
||||||
|
* and both buy (SOL→Token) and sell (Token→SOL) flows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
Keypair,
|
||||||
|
VersionedTransaction,
|
||||||
|
LAMPORTS_PER_SOL as SOLANA_LAMPORTS,
|
||||||
|
} from '@solana/web3.js';
|
||||||
|
import bs58 from 'bs58';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
import {
|
||||||
|
TradeOrder,
|
||||||
|
TradeResult,
|
||||||
|
BotConfig,
|
||||||
|
SOL_MINT,
|
||||||
|
LAMPORTS_PER_SOL,
|
||||||
|
} from '../types/index.js';
|
||||||
|
import { JupiterSwap, JupiterQuoteResponse, JupiterSwapError, SwapConfig } from './jupiter-swap.js';
|
||||||
|
import { JitoBundler } from './jito-bundler.js';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ExecuteTradeConfig {
|
||||||
|
/** If true, simulate only — no real transactions */
|
||||||
|
paperTrade: boolean;
|
||||||
|
/** Use Jito block engine for MEV protection */
|
||||||
|
useJito: boolean;
|
||||||
|
/** Jito tip in lamports */
|
||||||
|
jitoTipLamports: number;
|
||||||
|
/** Slippage tolerance in basis points */
|
||||||
|
slippageBps: number;
|
||||||
|
/** Max retry attempts */
|
||||||
|
maxRetries: number;
|
||||||
|
/** Base priority fee in micro-lamports per CU */
|
||||||
|
basePriorityFeeMicroLamports: number;
|
||||||
|
/** Priority fee escalation multiplier per retry */
|
||||||
|
priorityFeeEscalation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EXECUTE_CONFIG: ExecuteTradeConfig = {
|
||||||
|
paperTrade: false,
|
||||||
|
useJito: true,
|
||||||
|
jitoTipLamports: 100_000,
|
||||||
|
slippageBps: 300,
|
||||||
|
maxRetries: 3,
|
||||||
|
basePriorityFeeMicroLamports: 100_000,
|
||||||
|
priorityFeeEscalation: 1.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Class ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class TradeExecutor {
|
||||||
|
private connection: Connection;
|
||||||
|
private walletKeypair: Keypair;
|
||||||
|
private jupiter: JupiterSwap;
|
||||||
|
private jito: JitoBundler;
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
connection: Connection,
|
||||||
|
walletKeypair: Keypair,
|
||||||
|
options?: {
|
||||||
|
jupiterApiBaseUrl?: string;
|
||||||
|
jitoBlockEngineUrl?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.connection = connection;
|
||||||
|
this.walletKeypair = walletKeypair;
|
||||||
|
this.jupiter = new JupiterSwap(connection, {
|
||||||
|
apiBaseUrl: options?.jupiterApiBaseUrl,
|
||||||
|
});
|
||||||
|
this.jito = new JitoBundler(connection, {
|
||||||
|
blockEngineUrl: options?.jitoBlockEngineUrl,
|
||||||
|
});
|
||||||
|
this.logger = new Logger('Executor');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Buy ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a buy order: SOL → Token.
|
||||||
|
*
|
||||||
|
* @param mint - Token mint address to buy
|
||||||
|
* @param amountSol - Amount of SOL to spend
|
||||||
|
* @param config - Partial config (merged with defaults)
|
||||||
|
*/
|
||||||
|
async executeBuy(
|
||||||
|
mint: string,
|
||||||
|
amountSol: number,
|
||||||
|
config?: Partial<ExecuteTradeConfig>,
|
||||||
|
): Promise<TradeResult> {
|
||||||
|
const cfg = { ...DEFAULT_EXECUTE_CONFIG, ...config };
|
||||||
|
const orderId = this.generateOrderId();
|
||||||
|
const amountLamports = Math.round(amountSol * LAMPORTS_PER_SOL);
|
||||||
|
|
||||||
|
this.logger.trade('BUY', mint.slice(0, 8), amountSol, cfg.paperTrade ? '📝 PAPER' : undefined);
|
||||||
|
|
||||||
|
// Get quote first (needed for both paper and live)
|
||||||
|
let quote: JupiterQuoteResponse;
|
||||||
|
try {
|
||||||
|
quote = await this.jupiter.getQuote(SOL_MINT, mint, amountLamports, cfg.slippageBps);
|
||||||
|
} catch (err: any) {
|
||||||
|
return this.failedResult(orderId, amountSol, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimatedTokens = Number(quote.outAmount);
|
||||||
|
const pricePerToken = estimatedTokens > 0 ? amountSol / estimatedTokens : 0;
|
||||||
|
|
||||||
|
// ── Paper Trade ──
|
||||||
|
if (cfg.paperTrade) {
|
||||||
|
return this.paperTradeResult(orderId, 'buy', amountSol, estimatedTokens, pricePerToken, quote);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live Trade with Retries ──
|
||||||
|
return this.executeWithRetries(
|
||||||
|
orderId,
|
||||||
|
'buy',
|
||||||
|
amountSol,
|
||||||
|
estimatedTokens,
|
||||||
|
pricePerToken,
|
||||||
|
quote,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sell ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a sell order: Token → SOL.
|
||||||
|
*
|
||||||
|
* @param mint - Token mint address to sell
|
||||||
|
* @param amountTokens - Raw token amount (smallest unit)
|
||||||
|
* @param config - Partial config (merged with defaults)
|
||||||
|
*/
|
||||||
|
async executeSell(
|
||||||
|
mint: string,
|
||||||
|
amountTokens: bigint | number,
|
||||||
|
config?: Partial<ExecuteTradeConfig>,
|
||||||
|
): Promise<TradeResult> {
|
||||||
|
const cfg = { ...DEFAULT_EXECUTE_CONFIG, ...config };
|
||||||
|
const orderId = this.generateOrderId();
|
||||||
|
const rawAmount = typeof amountTokens === 'bigint' ? amountTokens : BigInt(Math.round(amountTokens));
|
||||||
|
|
||||||
|
this.logger.trade('SELL', mint.slice(0, 8), Number(rawAmount), cfg.paperTrade ? '📝 PAPER' : undefined);
|
||||||
|
|
||||||
|
// Get quote
|
||||||
|
let quote: JupiterQuoteResponse;
|
||||||
|
try {
|
||||||
|
quote = await this.jupiter.getQuote(mint, SOL_MINT, rawAmount, cfg.slippageBps);
|
||||||
|
} catch (err: any) {
|
||||||
|
return this.failedResult(orderId, Number(rawAmount), err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimatedSolOut = Number(quote.outAmount) / LAMPORTS_PER_SOL;
|
||||||
|
const pricePerToken = Number(rawAmount) > 0 ? estimatedSolOut / Number(rawAmount) : 0;
|
||||||
|
|
||||||
|
// ── Paper Trade ──
|
||||||
|
if (cfg.paperTrade) {
|
||||||
|
return this.paperTradeResult(orderId, 'sell', Number(rawAmount), estimatedSolOut, pricePerToken, quote);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live Trade with Retries ──
|
||||||
|
return this.executeWithRetries(
|
||||||
|
orderId,
|
||||||
|
'sell',
|
||||||
|
Number(rawAmount),
|
||||||
|
estimatedSolOut,
|
||||||
|
pricePerToken,
|
||||||
|
quote,
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core Execution with Retries ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private async executeWithRetries(
|
||||||
|
orderId: string,
|
||||||
|
side: 'buy' | 'sell',
|
||||||
|
inputAmount: number,
|
||||||
|
estimatedOutput: number,
|
||||||
|
pricePerToken: number,
|
||||||
|
quote: JupiterQuoteResponse,
|
||||||
|
cfg: ExecuteTradeConfig,
|
||||||
|
): Promise<TradeResult> {
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= cfg.maxRetries; attempt++) {
|
||||||
|
// Escalate priority fee on retries
|
||||||
|
const priorityMultiplier = Math.pow(cfg.priorityFeeEscalation, attempt);
|
||||||
|
const priorityFee = Math.round(cfg.basePriorityFeeMicroLamports * priorityMultiplier);
|
||||||
|
|
||||||
|
if (attempt > 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Retry ${attempt}/${cfg.maxRetries} — priority fee: ${priorityFee} µ-lamports/CU`,
|
||||||
|
);
|
||||||
|
// Re-fetch quote on retries (price may have changed)
|
||||||
|
try {
|
||||||
|
quote = await this.jupiter.getQuote(
|
||||||
|
quote.inputMint,
|
||||||
|
quote.outputMint,
|
||||||
|
BigInt(quote.inAmount),
|
||||||
|
cfg.slippageBps,
|
||||||
|
);
|
||||||
|
estimatedOutput = side === 'buy'
|
||||||
|
? Number(quote.outAmount)
|
||||||
|
: Number(quote.outAmount) / LAMPORTS_PER_SOL;
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Failed to refresh quote on retry: ${err.message}`);
|
||||||
|
// Use stale quote — better than failing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const swapConfig: SwapConfig = {
|
||||||
|
priorityFeeMicroLamports: priorityFee,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let txSignature: string;
|
||||||
|
|
||||||
|
if (cfg.useJito) {
|
||||||
|
txSignature = await this.executeViaJito(quote, swapConfig, cfg.jitoTipLamports);
|
||||||
|
} else {
|
||||||
|
txSignature = await this.jupiter.executeSwap(quote, this.walletKeypair, swapConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate fees
|
||||||
|
const baseFee = 5000 / LAMPORTS_PER_SOL; // ~0.000005 SOL
|
||||||
|
const priorityFeeSol = (priorityFee * 400_000) / 1e6 / LAMPORTS_PER_SOL; // rough estimate
|
||||||
|
const jitoTipSol = cfg.useJito ? cfg.jitoTipLamports / LAMPORTS_PER_SOL : 0;
|
||||||
|
const totalFees = baseFee + priorityFeeSol + jitoTipSol;
|
||||||
|
|
||||||
|
this.logger.success(
|
||||||
|
`${side.toUpperCase()} executed: ${txSignature}` +
|
||||||
|
(attempt > 0 ? ` (attempt ${attempt + 1})` : ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderId,
|
||||||
|
success: true,
|
||||||
|
txSignature,
|
||||||
|
inputAmount,
|
||||||
|
outputAmount: estimatedOutput,
|
||||||
|
pricePerToken,
|
||||||
|
fees: totalFees,
|
||||||
|
jitoTip: jitoTipSol || undefined,
|
||||||
|
executedAt: new Date(),
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
lastError = err;
|
||||||
|
this.logger.error(
|
||||||
|
`${side.toUpperCase()} attempt ${attempt + 1} failed: ${err.message}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Don't retry on certain errors
|
||||||
|
if (err instanceof JupiterSwapError) {
|
||||||
|
if (
|
||||||
|
err.code === 'INSUFFICIENT_BALANCE' ||
|
||||||
|
err.code === 'ROUTE_NOT_FOUND'
|
||||||
|
) {
|
||||||
|
this.logger.error(`Non-retryable error: ${err.code}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries exhausted
|
||||||
|
return this.failedResult(orderId, inputAmount, lastError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Jito Execution Path ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute swap via Jito block engine:
|
||||||
|
* 1. Get serialized swap TX from Jupiter
|
||||||
|
* 2. Sign it
|
||||||
|
* 3. Send as Jito bundle with tip
|
||||||
|
* 4. Wait for bundle to land
|
||||||
|
*/
|
||||||
|
private async executeViaJito(
|
||||||
|
quote: JupiterQuoteResponse,
|
||||||
|
swapConfig: SwapConfig,
|
||||||
|
tipLamports: number,
|
||||||
|
): Promise<string> {
|
||||||
|
// Get the serialized transaction from Jupiter's /swap endpoint
|
||||||
|
// We'll use a lower-level approach: fetch the swap tx, sign, then bundle via Jito
|
||||||
|
const swapBody: Record<string, any> = {
|
||||||
|
quoteResponse: quote,
|
||||||
|
userPublicKey: this.walletKeypair.publicKey.toBase58(),
|
||||||
|
wrapAndUnwrapSol: true,
|
||||||
|
useSharedAccounts: true,
|
||||||
|
dynamicComputeUnitLimit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (swapConfig.priorityFeeMicroLamports) {
|
||||||
|
swapBody.computeUnitPriceMicroLamports = swapConfig.priorityFeeMicroLamports;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('https://quote-api.jup.ag/v6/swap', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(swapBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
throw new JupiterSwapError(`Jupiter swap API error: ${body}`, 'SWAP_FAILED', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const swapData = (await res.json()) as { swapTransaction: string; lastValidBlockHeight: number };
|
||||||
|
const txBuf = Buffer.from(swapData.swapTransaction, 'base64');
|
||||||
|
const versionedTx = VersionedTransaction.deserialize(txBuf);
|
||||||
|
versionedTx.sign([this.walletKeypair]);
|
||||||
|
|
||||||
|
// Send as Jito bundle
|
||||||
|
const bundleId = await this.jito.sendBundle(versionedTx, this.walletKeypair, tipLamports);
|
||||||
|
this.logger.info(`Waiting for Jito bundle: ${bundleId}`);
|
||||||
|
|
||||||
|
const result = await this.jito.waitForBundle(bundleId, 60_000);
|
||||||
|
|
||||||
|
if (!result.landed) {
|
||||||
|
throw new Error(`Jito bundle failed: ${result.error ?? result.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the main transaction signature (first in bundle)
|
||||||
|
const mainSig = result.signatures[0];
|
||||||
|
if (!mainSig) {
|
||||||
|
throw new Error('Jito bundle landed but no transaction signature returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mainSig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Paper Trading ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private paperTradeResult(
|
||||||
|
orderId: string,
|
||||||
|
side: 'buy' | 'sell',
|
||||||
|
inputAmount: number,
|
||||||
|
estimatedOutput: number,
|
||||||
|
pricePerToken: number,
|
||||||
|
quote: JupiterQuoteResponse,
|
||||||
|
): TradeResult {
|
||||||
|
this.logger.info('📝 PAPER TRADE — no real transaction sent');
|
||||||
|
this.logger.info(
|
||||||
|
` Side: ${side.toUpperCase()} | Input: ${inputAmount} | Est. output: ${estimatedOutput}`,
|
||||||
|
);
|
||||||
|
this.logger.info(
|
||||||
|
` Price impact: ${quote.priceImpactPct}% | Routes: ${quote.routePlan.length}`,
|
||||||
|
);
|
||||||
|
this.logger.info(
|
||||||
|
` Route: ${quote.routePlan.map((r) => r.swapInfo.label).join(' → ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderId,
|
||||||
|
success: true,
|
||||||
|
txSignature: `paper_${orderId}`,
|
||||||
|
inputAmount,
|
||||||
|
outputAmount: estimatedOutput,
|
||||||
|
pricePerToken,
|
||||||
|
fees: 0,
|
||||||
|
executedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private failedResult(orderId: string, inputAmount: number, error?: Error | any): TradeResult {
|
||||||
|
return {
|
||||||
|
orderId,
|
||||||
|
success: false,
|
||||||
|
inputAmount,
|
||||||
|
outputAmount: 0,
|
||||||
|
fees: 0,
|
||||||
|
error: error?.message ?? 'Unknown error',
|
||||||
|
executedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateOrderId(): string {
|
||||||
|
const ts = Date.now().toString(36);
|
||||||
|
const rand = Math.random().toString(36).slice(2, 8);
|
||||||
|
return `tx_${ts}_${rand}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an ExecuteTradeConfig from a BotConfig.
|
||||||
|
* Convenience method for wiring up with the global bot config.
|
||||||
|
*/
|
||||||
|
static configFromBotConfig(botConfig: BotConfig): Partial<ExecuteTradeConfig> {
|
||||||
|
return {
|
||||||
|
paperTrade: botConfig.paperTrade,
|
||||||
|
useJito: botConfig.useJito,
|
||||||
|
jitoTipLamports: botConfig.jitoTipLamports,
|
||||||
|
slippageBps: botConfig.defaultSlippageBps,
|
||||||
|
maxRetries: 3,
|
||||||
|
basePriorityFeeMicroLamports: 100_000,
|
||||||
|
priorityFeeEscalation: 1.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
424
src/index.ts
Normal file
424
src/index.ts
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Solana Sniper Bot — Main Entry Point
|
||||||
|
*
|
||||||
|
* Detect → Analyze → Execute → Manage
|
||||||
|
*
|
||||||
|
* Starts in PAPER TRADE mode by default.
|
||||||
|
* Set PAPER_TRADE=false in .env for live trading.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Connection, Keypair } from '@solana/web3.js';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { loadConfig, validateConfig } from './utils/config.js';
|
||||||
|
import { Logger } from './utils/logger.js';
|
||||||
|
import { EventBus } from './utils/event-bus.js';
|
||||||
|
import { BotDatabase } from './utils/database.js';
|
||||||
|
import { loadWallet, getBalance, generateWallet } from './utils/wallet.js';
|
||||||
|
import { ScannerManager, type ScannerEvent } from './scanner/index.js';
|
||||||
|
import { AnalyzerPipeline } from './analyzer/index.js';
|
||||||
|
import { TradeExecutor } from './executor/index.js';
|
||||||
|
import { PositionManager, PriceMonitor } from './portfolio/index.js';
|
||||||
|
import { DiscordAlerts, KillSwitch, RiskManager } from './control/index.js';
|
||||||
|
import type { BotConfig, BotEvent, TradeSignal, TokenAnalysis, Position } from './types/index.js';
|
||||||
|
|
||||||
|
const log = new Logger('Bot');
|
||||||
|
|
||||||
|
// ============ Banner ============
|
||||||
|
|
||||||
|
function printBanner(config: BotConfig) {
|
||||||
|
console.log('');
|
||||||
|
console.log(chalk.magenta(' ╔═══════════════════════════════════════════╗'));
|
||||||
|
console.log(chalk.magenta(' ║') + chalk.white.bold(' ⚡ SOLANA SNIPER BOT ⚡ ') + chalk.magenta('║'));
|
||||||
|
console.log(chalk.magenta(' ║') + chalk.gray(' Detect. Analyze. Execute. Profit. ') + chalk.magenta('║'));
|
||||||
|
console.log(chalk.magenta(' ╚═══════════════════════════════════════════╝'));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (config.paperTrade) {
|
||||||
|
console.log(chalk.yellow.bold(' 📝 MODE: PAPER TRADING (no real transactions)'));
|
||||||
|
} else {
|
||||||
|
console.log(chalk.green.bold(' 💰 MODE: LIVE TRADING'));
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Main Bot Class ============
|
||||||
|
|
||||||
|
class SniperBot {
|
||||||
|
private config: BotConfig;
|
||||||
|
private connection: Connection;
|
||||||
|
private wallet: Keypair | null = null;
|
||||||
|
private eventBus: EventBus;
|
||||||
|
private db: BotDatabase;
|
||||||
|
|
||||||
|
// Modules
|
||||||
|
private scanner: ScannerManager;
|
||||||
|
private analyzer: AnalyzerPipeline;
|
||||||
|
private executor: TradeExecutor;
|
||||||
|
private positionManager: PositionManager;
|
||||||
|
private priceMonitor: PriceMonitor;
|
||||||
|
private discordAlerts: DiscordAlerts;
|
||||||
|
private killSwitch: KillSwitch;
|
||||||
|
private riskManager: RiskManager;
|
||||||
|
|
||||||
|
private isRunning = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Load config
|
||||||
|
this.config = loadConfig();
|
||||||
|
printBanner(this.config);
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
const warnings = validateConfig(this.config);
|
||||||
|
for (const w of warnings) {
|
||||||
|
log.warn(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core infrastructure
|
||||||
|
this.connection = new Connection(this.config.rpcUrl, {
|
||||||
|
commitment: 'confirmed',
|
||||||
|
wsEndpoint: this.config.rpcWsUrl,
|
||||||
|
});
|
||||||
|
this.eventBus = new EventBus();
|
||||||
|
this.db = new BotDatabase();
|
||||||
|
|
||||||
|
// Load or generate wallet
|
||||||
|
if (this.config.walletPrivateKey) {
|
||||||
|
this.wallet = loadWallet(this.config.walletPrivateKey);
|
||||||
|
} else if (this.config.paperTrade) {
|
||||||
|
log.info('No wallet configured — generating ephemeral wallet for paper trading');
|
||||||
|
const { publicKey, privateKey } = generateWallet();
|
||||||
|
this.wallet = loadWallet(privateKey);
|
||||||
|
log.info(`Paper wallet: ${publicKey}`);
|
||||||
|
} else {
|
||||||
|
log.error('WALLET_PRIVATE_KEY required for live trading!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize modules
|
||||||
|
this.scanner = new ScannerManager(this.config);
|
||||||
|
this.analyzer = new AnalyzerPipeline(this.connection);
|
||||||
|
this.executor = new TradeExecutor(this.connection, this.wallet);
|
||||||
|
this.positionManager = new PositionManager(this.config);
|
||||||
|
this.priceMonitor = new PriceMonitor();
|
||||||
|
this.discordAlerts = new DiscordAlerts(this.config.discordWebhookUrl);
|
||||||
|
this.killSwitch = new KillSwitch(this.config);
|
||||||
|
this.riskManager = new RiskManager(this.config);
|
||||||
|
|
||||||
|
// Bridge scanner events to our event bus
|
||||||
|
this.scanner.onEvent(async (scanEvent: ScannerEvent) => {
|
||||||
|
if (scanEvent.type === 'new_token' || scanEvent.type === 'graduation' || scanEvent.type === 'new_pool') {
|
||||||
|
const mint = scanEvent.tokenInfo?.mint || scanEvent.pool?.baseMint || '';
|
||||||
|
if (mint) {
|
||||||
|
await this.eventBus.emit({
|
||||||
|
type: 'new_token_detected',
|
||||||
|
data: {
|
||||||
|
mint,
|
||||||
|
name: scanEvent.tokenInfo?.name || '',
|
||||||
|
symbol: scanEvent.tokenInfo?.symbol || '',
|
||||||
|
pool: scanEvent.pool,
|
||||||
|
...scanEvent.tokenInfo,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (scanEvent.type === 'copy_trade' && scanEvent.walletActivity) {
|
||||||
|
await this.eventBus.emit({
|
||||||
|
type: 'copy_trade_detected',
|
||||||
|
data: scanEvent.walletActivity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire up event handlers
|
||||||
|
this.setupEventHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventHandlers() {
|
||||||
|
// Scanner → Analyzer: when new token detected, analyze it
|
||||||
|
this.eventBus.on('new_token_detected', async (event) => {
|
||||||
|
if (event.type !== 'new_token_detected') return;
|
||||||
|
const { mint, name, symbol } = event.data as any;
|
||||||
|
log.info(`New token detected: ${symbol || 'unknown'} (${mint})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.analyzer.run(mint);
|
||||||
|
await this.eventBus.emit({ type: 'token_analyzed', data: result.analysis });
|
||||||
|
} catch (e: any) {
|
||||||
|
log.error(`Analysis failed for ${mint}: ${e.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Analyzer → Trade Decision: if score passes, create signal
|
||||||
|
this.eventBus.on('token_analyzed', async (event) => {
|
||||||
|
if (event.type !== 'token_analyzed') return;
|
||||||
|
const analysis = event.data as TokenAnalysis;
|
||||||
|
|
||||||
|
if (analysis.score >= this.config.minTokenScore) {
|
||||||
|
log.success(`Token ${analysis.mint} scored ${analysis.score}/100 — PASS`);
|
||||||
|
|
||||||
|
const signal: TradeSignal = {
|
||||||
|
type: 'new_token',
|
||||||
|
mint: analysis.mint,
|
||||||
|
analysis,
|
||||||
|
confidence: analysis.score,
|
||||||
|
suggestedAmountSol: this.config.maxPositionSizeSol,
|
||||||
|
reason: `Score ${analysis.score}/100`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
await this.eventBus.emit({ type: 'trade_signal', data: signal });
|
||||||
|
} else {
|
||||||
|
log.info(`Token ${analysis.mint} scored ${analysis.score}/100 — SKIP (need ${this.config.minTokenScore}+)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy trade detection → analyze then signal
|
||||||
|
this.eventBus.on('copy_trade_detected', async (event) => {
|
||||||
|
if (event.type !== 'copy_trade_detected') return;
|
||||||
|
const activity = event.data as any;
|
||||||
|
|
||||||
|
if (activity.type === 'buy') {
|
||||||
|
log.info(`Copy trade detected: wallet ${activity.wallet} bought ${activity.mint}`);
|
||||||
|
try {
|
||||||
|
const { analysis } = await this.analyzer.run(activity.mint);
|
||||||
|
if (analysis.score >= Math.max(this.config.minTokenScore - 10, 50)) {
|
||||||
|
const signal: TradeSignal = {
|
||||||
|
type: 'copy_trade',
|
||||||
|
mint: activity.mint,
|
||||||
|
analysis,
|
||||||
|
sourceWallet: activity.wallet,
|
||||||
|
confidence: analysis.score,
|
||||||
|
suggestedAmountSol: Math.min(
|
||||||
|
this.config.maxPositionSizeSol,
|
||||||
|
activity.amountSol * 0.5 // copy at 50% of their size
|
||||||
|
),
|
||||||
|
reason: `Copy trade from ${activity.wallet.slice(0, 8)}... (score ${analysis.score})`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
await this.eventBus.emit({ type: 'trade_signal', data: signal });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
log.error(`Copy trade analysis failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trade Signal → Execute
|
||||||
|
this.eventBus.on('trade_signal', async (event) => {
|
||||||
|
if (event.type !== 'trade_signal') return;
|
||||||
|
const signal = event.data as TradeSignal;
|
||||||
|
|
||||||
|
// Safety checks
|
||||||
|
if (this.killSwitch.isActive()) {
|
||||||
|
log.warn('Kill switch active — skipping trade');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.riskManager.canOpenPosition(signal.suggestedAmountSol)) {
|
||||||
|
log.warn('Risk limit reached — skipping trade');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Executing ${signal.type} trade: ${signal.mint} for ${signal.suggestedAmountSol} SOL`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.executor.executeBuy(signal.mint, signal.suggestedAmountSol, {
|
||||||
|
paperTrade: this.config.paperTrade,
|
||||||
|
useJito: this.config.useJito,
|
||||||
|
jitoTipLamports: this.config.jitoTipLamports,
|
||||||
|
slippageBps: this.config.defaultSlippageBps,
|
||||||
|
});
|
||||||
|
await this.eventBus.emit({ type: 'trade_executed', data: result });
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const position = this.positionManager.openPosition(result, signal);
|
||||||
|
await this.eventBus.emit({ type: 'position_opened', data: position });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
log.error(`Trade execution failed: ${e.message}`);
|
||||||
|
await this.eventBus.emit({ type: 'error', data: { module: 'executor', error: e.message } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Position Updates → Check exits
|
||||||
|
this.eventBus.on('position_updated', async (event) => {
|
||||||
|
if (event.type !== 'position_updated') return;
|
||||||
|
const position = event.data as any;
|
||||||
|
|
||||||
|
const exits = this.positionManager.checkExits();
|
||||||
|
for (const exit of exits) {
|
||||||
|
if (this.killSwitch.isActive()) continue;
|
||||||
|
|
||||||
|
const pos = this.positionManager.getPositionByMint(exit.mint);
|
||||||
|
if (!pos) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info(`Exit signal for ${pos.symbol}: ${exit.reason}`);
|
||||||
|
const sellAmount = BigInt(Math.floor(
|
||||||
|
Number(pos.tokensHeld) * (exit.sellPercent / 100)
|
||||||
|
));
|
||||||
|
const result = await this.executor.executeSell(pos.mint, sellAmount, {
|
||||||
|
paperTrade: this.config.paperTrade,
|
||||||
|
useJito: this.config.useJito,
|
||||||
|
jitoTipLamports: this.config.jitoTipLamports,
|
||||||
|
slippageBps: this.config.defaultSlippageBps,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.positionManager.recordSell(pos.id, result, exit.sellPercent);
|
||||||
|
this.riskManager.recordPnl(result.outputAmount - pos.entryAmountSol * (exit.sellPercent / 100));
|
||||||
|
|
||||||
|
await this.eventBus.emit({
|
||||||
|
type: exit.sellPercent === 100 ? 'position_closed' : 'position_updated',
|
||||||
|
data: { ...pos, reason: exit.reason } as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
log.error(`Exit execution failed for ${pos.symbol}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kill switch on daily limit
|
||||||
|
this.eventBus.on('daily_limit_hit', async () => {
|
||||||
|
log.error('Daily loss limit hit — activating kill switch!');
|
||||||
|
this.killSwitch.activate('Daily loss limit reached');
|
||||||
|
// Sell all positions
|
||||||
|
await this.emergencySellAll('Daily loss limit');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Discord alerts for everything
|
||||||
|
this.eventBus.onAll(async (event) => {
|
||||||
|
if (this.config.discordWebhookUrl) {
|
||||||
|
await this.discordAlerts.sendAlert(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async emergencySellAll(reason: string) {
|
||||||
|
const positions = this.positionManager.getOpenPositions();
|
||||||
|
for (const pos of positions) {
|
||||||
|
try {
|
||||||
|
const result = await this.executor.executeSell(pos.mint, pos.tokensHeld, {
|
||||||
|
paperTrade: this.config.paperTrade,
|
||||||
|
useJito: this.config.useJito,
|
||||||
|
slippageBps: 2500, // Higher slippage for emergency exits
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
this.positionManager.recordSell(pos.id, result, 100);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
log.error(`Emergency sell failed for ${pos.symbol}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
log.info('Starting bot...');
|
||||||
|
this.isRunning = true;
|
||||||
|
|
||||||
|
// Show wallet balance
|
||||||
|
if (this.wallet) {
|
||||||
|
try {
|
||||||
|
const balance = await getBalance(this.connection, this.wallet.publicKey);
|
||||||
|
log.info(`Wallet balance: ${balance.toFixed(4)} SOL`);
|
||||||
|
|
||||||
|
if (balance < 0.01 && !this.config.paperTrade) {
|
||||||
|
log.error('Wallet balance too low for live trading!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
log.warn(`Could not fetch balance: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start modules
|
||||||
|
await this.scanner.start();
|
||||||
|
this.priceMonitor.init(
|
||||||
|
() => this.positionManager.getOpenPositions().map((p: Position) => p.mint),
|
||||||
|
(mint: string, price: number) => {
|
||||||
|
const updated = this.positionManager.updatePrice(mint, price);
|
||||||
|
if (updated) {
|
||||||
|
this.eventBus.emit({ type: 'position_updated', data: updated });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.priceMonitor.start();
|
||||||
|
|
||||||
|
log.success('Bot is running! Scanning for opportunities...');
|
||||||
|
log.info(`Mode: ${this.config.paperTrade ? 'PAPER TRADE' : 'LIVE'}`);
|
||||||
|
log.info(`Max position: ${this.config.maxPositionSizeSol} SOL`);
|
||||||
|
log.info(`Min score: ${this.config.minTokenScore}/100`);
|
||||||
|
log.info(`Scanners: pump.fun=${this.config.scanPumpFun}, raydium=${this.config.scanRaydium}, copy=${this.config.copyTradeEnabled}`);
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const shutdown = async () => {
|
||||||
|
log.info('Shutting down...');
|
||||||
|
this.isRunning = false;
|
||||||
|
this.scanner.stop();
|
||||||
|
this.priceMonitor.stop();
|
||||||
|
this.db.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
|
||||||
|
// Keep alive
|
||||||
|
await new Promise(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ CLI Commands ============
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0];
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'generate-wallet': {
|
||||||
|
const { publicKey, privateKey } = generateWallet();
|
||||||
|
console.log(chalk.green('\n New Solana Wallet Generated:'));
|
||||||
|
console.log(chalk.white(` Public Key: ${publicKey}`));
|
||||||
|
console.log(chalk.white(` Private Key: ${privateKey}`));
|
||||||
|
console.log(chalk.yellow('\n ⚠️ Save the private key! Add it to .env as WALLET_PRIVATE_KEY'));
|
||||||
|
console.log(chalk.yellow(' ⚠️ Fund this wallet with SOL before live trading\n'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'balance': {
|
||||||
|
const config = loadConfig();
|
||||||
|
if (!config.walletPrivateKey) {
|
||||||
|
console.log(chalk.red('No wallet configured. Set WALLET_PRIVATE_KEY in .env'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const conn = new Connection(config.rpcUrl);
|
||||||
|
const wallet = loadWallet(config.walletPrivateKey);
|
||||||
|
const balance = await getBalance(conn, wallet.publicKey);
|
||||||
|
console.log(chalk.white(`\n Wallet: ${wallet.publicKey.toBase58()}`));
|
||||||
|
console.log(chalk.green(` Balance: ${balance.toFixed(4)} SOL\n`));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'help': {
|
||||||
|
console.log(chalk.white(`
|
||||||
|
Solana Sniper Bot — Commands:
|
||||||
|
|
||||||
|
npm run dev Start the bot (dev mode)
|
||||||
|
npm run paper Start in paper trading mode
|
||||||
|
npm start Start the bot (production)
|
||||||
|
|
||||||
|
node dist/index.js generate-wallet Generate a new wallet
|
||||||
|
node dist/index.js balance Check wallet balance
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
.env Environment variables (API keys, wallet)
|
||||||
|
config.json Trading parameters (risk, exits, scanners)
|
||||||
|
`));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Start the bot
|
||||||
|
const bot = new SniperBot();
|
||||||
|
await bot.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/portfolio/index.ts
Normal file
8
src/portfolio/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Portfolio module exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { PositionManager } from './position-manager.js';
|
||||||
|
export type { ExitSignal } from './position-manager.js';
|
||||||
|
export { PriceMonitor } from './price-monitor.js';
|
||||||
|
export type { PriceUpdateCallback } from './price-monitor.js';
|
||||||
279
src/portfolio/position-manager.ts
Normal file
279
src/portfolio/position-manager.ts
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* Position Manager — Track and manage all open positions
|
||||||
|
* Handles open/close, PnL calculation, and exit condition checks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import type {
|
||||||
|
Position,
|
||||||
|
BotConfig,
|
||||||
|
TakeProfitTier,
|
||||||
|
TradeResult,
|
||||||
|
TradeSignal,
|
||||||
|
} from '../types/index.js';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export interface ExitSignal {
|
||||||
|
positionId: string;
|
||||||
|
mint: string;
|
||||||
|
reason: 'stop_loss' | 'take_profit' | 'trailing_stop' | 'time_exit' | 'rug_exit';
|
||||||
|
sellPercent: number; // 0-100, percentage of remaining tokens to sell
|
||||||
|
tier?: number; // which TP tier triggered
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PositionManager {
|
||||||
|
private positions: Map<string, Position> = new Map();
|
||||||
|
private log = new Logger('Portfolio');
|
||||||
|
private config: BotConfig;
|
||||||
|
|
||||||
|
constructor(config: BotConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open a new position from a buy TradeResult ────────────────────────
|
||||||
|
|
||||||
|
openPosition(tradeResult: TradeResult, signal: TradeSignal): Position {
|
||||||
|
const entryPrice = tradeResult.pricePerToken ?? (tradeResult.inputAmount / tradeResult.outputAmount);
|
||||||
|
const stopLossPrice = entryPrice * (1 + this.config.stopLossPercent / 100); // stopLossPercent is negative, e.g. -30
|
||||||
|
|
||||||
|
const position: Position = {
|
||||||
|
id: randomUUID(),
|
||||||
|
mint: signal.mint,
|
||||||
|
symbol: signal.tokenInfo?.symbol ?? signal.mint.slice(0, 6),
|
||||||
|
entryPriceSol: entryPrice,
|
||||||
|
entryAmountSol: tradeResult.inputAmount,
|
||||||
|
tokensHeld: BigInt(Math.floor(tradeResult.outputAmount)),
|
||||||
|
currentPriceSol: entryPrice,
|
||||||
|
currentValueSol: tradeResult.inputAmount,
|
||||||
|
pnlSol: 0,
|
||||||
|
pnlPercent: 0,
|
||||||
|
highestPriceSol: entryPrice,
|
||||||
|
takeProfitTier: 0,
|
||||||
|
stopLossPrice,
|
||||||
|
trailingStopActive: false,
|
||||||
|
trailingStopPrice: 0,
|
||||||
|
signal,
|
||||||
|
trades: [tradeResult],
|
||||||
|
openedAt: new Date(),
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.positions.set(position.id, position);
|
||||||
|
this.log.success(`Opened position ${position.symbol} | ${tradeResult.inputAmount.toFixed(4)} SOL @ ${entryPrice.toFixed(10)} SOL/token`);
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update price for a token, recalculate PnL ────────────────────────
|
||||||
|
|
||||||
|
updatePrice(mint: string, newPriceSol: number): Position | null {
|
||||||
|
const position = this.getPositionByMint(mint);
|
||||||
|
if (!position || position.status === 'closed') return null;
|
||||||
|
|
||||||
|
position.currentPriceSol = newPriceSol;
|
||||||
|
position.currentValueSol = Number(position.tokensHeld) * newPriceSol;
|
||||||
|
position.pnlSol = position.currentValueSol - position.entryAmountSol;
|
||||||
|
position.pnlPercent = (position.pnlSol / position.entryAmountSol) * 100;
|
||||||
|
|
||||||
|
// Track highest price for trailing stop
|
||||||
|
if (newPriceSol > position.highestPriceSol) {
|
||||||
|
position.highestPriceSol = newPriceSol;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate trailing stop once we cross 2x
|
||||||
|
if (newPriceSol >= position.entryPriceSol * 2 && !position.trailingStopActive) {
|
||||||
|
position.trailingStopActive = true;
|
||||||
|
position.trailingStopPrice = newPriceSol * (1 + this.config.trailingStopPercent / 100);
|
||||||
|
this.log.info(`Trailing stop activated for ${position.symbol} at ${position.trailingStopPrice.toFixed(10)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update trailing stop price as price rises
|
||||||
|
if (position.trailingStopActive) {
|
||||||
|
const newTrailingStop = position.highestPriceSol * (1 + this.config.trailingStopPercent / 100);
|
||||||
|
if (newTrailingStop > position.trailingStopPrice) {
|
||||||
|
position.trailingStopPrice = newTrailingStop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
position.lastUpdated = new Date();
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update liquidity data for a position ──────────────────────────────
|
||||||
|
|
||||||
|
updateLiquidity(mint: string, currentLiquidityUsd: number): void {
|
||||||
|
const position = this.getPositionByMint(mint);
|
||||||
|
if (!position || position.status === 'closed') return;
|
||||||
|
|
||||||
|
// Store liquidity on the signal's pool if available
|
||||||
|
if (position.signal.pool) {
|
||||||
|
position.signal.pool.liquidityUsd = currentLiquidityUsd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Check all positions for exit conditions ───────────────────────────
|
||||||
|
|
||||||
|
checkExits(): ExitSignal[] {
|
||||||
|
const exits: ExitSignal[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [, position] of this.positions) {
|
||||||
|
if (position.status === 'closed') continue;
|
||||||
|
|
||||||
|
// (e) Rug exit: liquidity dropped >50% from entry
|
||||||
|
const entryLiquidity = position.signal.pool?.liquidityUsd;
|
||||||
|
const currentLiquidity = position.signal.pool?.liquidityUsd;
|
||||||
|
if (entryLiquidity && currentLiquidity && currentLiquidity < entryLiquidity * 0.5) {
|
||||||
|
exits.push({
|
||||||
|
positionId: position.id,
|
||||||
|
mint: position.mint,
|
||||||
|
reason: 'rug_exit',
|
||||||
|
sellPercent: 100,
|
||||||
|
});
|
||||||
|
this.log.warn(`🚨 RUG EXIT: ${position.symbol} — liquidity dropped >50%`);
|
||||||
|
continue; // Rug overrides everything
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a) Stop loss
|
||||||
|
if (position.currentPriceSol <= position.stopLossPrice) {
|
||||||
|
exits.push({
|
||||||
|
positionId: position.id,
|
||||||
|
mint: position.mint,
|
||||||
|
reason: 'stop_loss',
|
||||||
|
sellPercent: 100,
|
||||||
|
});
|
||||||
|
this.log.warn(`🛑 STOP LOSS: ${position.symbol} @ ${position.pnlPercent.toFixed(1)}%`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) Take profit tiers
|
||||||
|
const tpExit = this.checkTakeProfitTiers(position);
|
||||||
|
if (tpExit) {
|
||||||
|
exits.push(tpExit);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (c) Trailing stop
|
||||||
|
if (position.trailingStopActive && position.currentPriceSol <= position.trailingStopPrice) {
|
||||||
|
exits.push({
|
||||||
|
positionId: position.id,
|
||||||
|
mint: position.mint,
|
||||||
|
reason: 'trailing_stop',
|
||||||
|
sellPercent: 100, // Sell remaining
|
||||||
|
});
|
||||||
|
this.log.warn(`📉 TRAILING STOP: ${position.symbol} — dropped from high of ${position.highestPriceSol.toFixed(10)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (d) Time exit: open > maxHoldTimeMinutes with < 10% gain
|
||||||
|
const holdTimeMs = now - position.openedAt.getTime();
|
||||||
|
const maxHoldMs = this.config.maxHoldTimeMinutes * 60 * 1000;
|
||||||
|
if (holdTimeMs > maxHoldMs && position.pnlPercent < 10) {
|
||||||
|
exits.push({
|
||||||
|
positionId: position.id,
|
||||||
|
mint: position.mint,
|
||||||
|
reason: 'time_exit',
|
||||||
|
sellPercent: 100,
|
||||||
|
});
|
||||||
|
this.log.warn(`⏰ TIME EXIT: ${position.symbol} — held ${Math.round(holdTimeMs / 60000)}min with only ${position.pnlPercent.toFixed(1)}% gain`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return exits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Check take profit tiers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private checkTakeProfitTiers(position: Position): ExitSignal | null {
|
||||||
|
const tiers = this.config.takeProfitTiers;
|
||||||
|
const priceMultiplier = position.currentPriceSol / position.entryPriceSol;
|
||||||
|
|
||||||
|
// Iterate tiers in order — find the highest one we've hit that we haven't taken yet
|
||||||
|
for (let i = tiers.length - 1; i >= 0; i--) {
|
||||||
|
const tier = tiers[i];
|
||||||
|
if (priceMultiplier >= tier.multiplier && position.takeProfitTier <= i) {
|
||||||
|
position.takeProfitTier = i + 1;
|
||||||
|
this.log.success(`🎯 TAKE PROFIT T${i + 1}: ${position.symbol} @ ${priceMultiplier.toFixed(1)}x (selling ${tier.sellPercent}%)`);
|
||||||
|
return {
|
||||||
|
positionId: position.id,
|
||||||
|
mint: position.mint,
|
||||||
|
reason: 'take_profit',
|
||||||
|
sellPercent: tier.sellPercent,
|
||||||
|
tier: i + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Record a sell (partial or full) ───────────────────────────────────
|
||||||
|
|
||||||
|
recordSell(positionId: string, tradeResult: TradeResult, sellPercent: number): Position | null {
|
||||||
|
const position = this.positions.get(positionId);
|
||||||
|
if (!position) {
|
||||||
|
this.log.error(`Position not found: ${positionId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
position.trades.push(tradeResult);
|
||||||
|
|
||||||
|
// Calculate tokens sold
|
||||||
|
const tokensSold = BigInt(Math.floor(Number(position.tokensHeld) * (sellPercent / 100)));
|
||||||
|
position.tokensHeld -= tokensSold;
|
||||||
|
|
||||||
|
if (position.tokensHeld <= 0n || sellPercent >= 100) {
|
||||||
|
position.tokensHeld = 0n;
|
||||||
|
position.status = 'closed';
|
||||||
|
position.currentValueSol = 0;
|
||||||
|
this.log.trade('SELL', position.symbol, tradeResult.outputAmount, `CLOSED — PnL: ${position.pnlPercent.toFixed(1)}%`);
|
||||||
|
} else {
|
||||||
|
position.status = 'partial_exit';
|
||||||
|
position.currentValueSol = Number(position.tokensHeld) * position.currentPriceSol;
|
||||||
|
this.log.trade('SELL', position.symbol, tradeResult.outputAmount, `${sellPercent}% sold — ${position.tokensHeld.toString()} tokens remaining`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate PnL based on total SOL in vs total SOL out
|
||||||
|
const totalSolIn = position.trades
|
||||||
|
.filter(t => t.outputAmount > 0 && t.inputAmount > 0 && t.orderId !== tradeResult.orderId)
|
||||||
|
.reduce((sum, t) => sum + t.inputAmount, position.entryAmountSol);
|
||||||
|
|
||||||
|
position.pnlSol = position.currentValueSol + tradeResult.outputAmount - totalSolIn;
|
||||||
|
position.pnlPercent = (position.pnlSol / position.entryAmountSol) * 100;
|
||||||
|
position.lastUpdated = new Date();
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Getters ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getPosition(id: string): Position | undefined {
|
||||||
|
return this.positions.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPositionByMint(mint: string): Position | undefined {
|
||||||
|
for (const [, position] of this.positions) {
|
||||||
|
if (position.mint === mint && position.status !== 'closed') {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpenPositions(): Position[] {
|
||||||
|
return Array.from(this.positions.values()).filter(p => p.status !== 'closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllPositions(): Position[] {
|
||||||
|
return Array.from(this.positions.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpenCount(): number {
|
||||||
|
return this.getOpenPositions().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpenMints(): string[] {
|
||||||
|
return this.getOpenPositions().map(p => p.mint);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/portfolio/price-monitor.ts
Normal file
170
src/portfolio/price-monitor.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Price Monitor — Periodically fetch prices for all open positions
|
||||||
|
* Uses Jupiter Quote API to get current token-to-SOL prices
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { JUPITER_V6_API, SOL_MINT, LAMPORTS_PER_SOL } from '../types/index.js';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5_000; // 5 seconds
|
||||||
|
const QUOTE_AMOUNT_LAMPORTS = 1_000_000; // 0.001 SOL worth of tokens for quote
|
||||||
|
|
||||||
|
export type PriceUpdateCallback = (mint: string, priceSol: number) => void;
|
||||||
|
|
||||||
|
export class PriceMonitor {
|
||||||
|
private log = new Logger('Portfolio');
|
||||||
|
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private running = false;
|
||||||
|
private onPriceUpdate: PriceUpdateCallback | null = null;
|
||||||
|
private getMints: (() => string[]) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param getMints — function that returns list of token mints to price
|
||||||
|
* @param onUpdate — callback fired when a price is fetched
|
||||||
|
*/
|
||||||
|
init(getMints: () => string[], onUpdate: PriceUpdateCallback): void {
|
||||||
|
this.getMints = getMints;
|
||||||
|
this.onPriceUpdate = onUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start polling ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.running) {
|
||||||
|
this.log.warn('Price monitor already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.getMints || !this.onPriceUpdate) {
|
||||||
|
this.log.error('Price monitor not initialized — call init() first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
this.log.info('Price monitor started (polling every 5s)');
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
this.pollPrices();
|
||||||
|
|
||||||
|
// Schedule recurring polls
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.pollPrices();
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stop polling ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!this.running) return;
|
||||||
|
this.running = false;
|
||||||
|
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.info('Price monitor stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this.running;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Poll all active positions ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private async pollPrices(): Promise<void> {
|
||||||
|
if (!this.getMints || !this.onPriceUpdate) return;
|
||||||
|
|
||||||
|
const mints = this.getMints();
|
||||||
|
if (mints.length === 0) return;
|
||||||
|
|
||||||
|
// Fetch prices concurrently, but stagger slightly to avoid rate limits
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
mints.map((mint, i) =>
|
||||||
|
this.delayedFetch(mint, i * 200) // 200ms stagger between requests
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const result = results[i];
|
||||||
|
if (result.status === 'fulfilled' && result.value !== null) {
|
||||||
|
try {
|
||||||
|
this.onPriceUpdate(mints[i], result.value);
|
||||||
|
} catch (err) {
|
||||||
|
this.log.error(`Price update callback error for ${mints[i]}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch price with delay ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async delayedFetch(mint: string, delayMs: number): Promise<number | null> {
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
return this.fetchPrice(mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch price from Jupiter Quote API ────────────────────────────────
|
||||||
|
//
|
||||||
|
// We ask: "How much SOL do I get for QUOTE_AMOUNT of this token?"
|
||||||
|
// Then derive price = outAmount(SOL) / inAmount(tokens)
|
||||||
|
//
|
||||||
|
// Alternative approach: ask how many tokens for X SOL, derive price inversely
|
||||||
|
|
||||||
|
private async fetchPrice(mint: string): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
// Get quote: how much SOL for 1 unit of the token (in smallest denomination)
|
||||||
|
// We use the reverse: how many tokens for a small SOL amount, then compute price
|
||||||
|
const url = new URL(`${JUPITER_V6_API}/quote`);
|
||||||
|
url.searchParams.set('inputMint', SOL_MINT);
|
||||||
|
url.searchParams.set('outputMint', mint);
|
||||||
|
url.searchParams.set('amount', QUOTE_AMOUNT_LAMPORTS.toString());
|
||||||
|
url.searchParams.set('slippageBps', '300'); // 3% slippage for quotes
|
||||||
|
url.searchParams.set('onlyDirectRoutes', 'false');
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
this.log.warn(`Jupiter rate limited for ${mint.slice(0, 8)}…`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.log.debug(`Jupiter quote failed for ${mint.slice(0, 8)}… — HTTP ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as {
|
||||||
|
inAmount: string;
|
||||||
|
outAmount: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const solIn = Number(data.inAmount) / LAMPORTS_PER_SOL;
|
||||||
|
const tokensOut = Number(data.outAmount);
|
||||||
|
|
||||||
|
if (tokensOut === 0) return null;
|
||||||
|
|
||||||
|
// Price per token in SOL = SOL spent / tokens received
|
||||||
|
const priceSol = solIn / tokensOut;
|
||||||
|
|
||||||
|
return priceSol;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||||
|
this.log.debug(`Jupiter quote timeout for ${mint.slice(0, 8)}…`);
|
||||||
|
} else {
|
||||||
|
this.log.debug(`Price fetch error for ${mint.slice(0, 8)}…: ${err.message}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch a single price on-demand (public) ───────────────────────────
|
||||||
|
|
||||||
|
async getPrice(mint: string): Promise<number | null> {
|
||||||
|
return this.fetchPrice(mint);
|
||||||
|
}
|
||||||
|
}
|
||||||
419
src/scanner/copy-trade-scanner.ts
Normal file
419
src/scanner/copy-trade-scanner.ts
Normal file
@ -0,0 +1,419 @@
|
|||||||
|
/**
|
||||||
|
* Copy Trade Scanner — Tracks wallet addresses and detects swap activity
|
||||||
|
*
|
||||||
|
* Subscribes to transaction logs for each tracked wallet via WebSocket.
|
||||||
|
* When a tracked wallet executes a swap on any DEX (Jupiter, Raydium, Orca, etc.),
|
||||||
|
* detects the token, direction (buy/sell), and amount.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
PublicKey,
|
||||||
|
type Logs,
|
||||||
|
type Context,
|
||||||
|
} from '@solana/web3.js';
|
||||||
|
import type { WalletActivity, TrackedWallet } from '../types/index.js';
|
||||||
|
import {
|
||||||
|
SOL_MINT,
|
||||||
|
WSOL_MINT,
|
||||||
|
RAYDIUM_V4_PROGRAM,
|
||||||
|
RAYDIUM_CPMM_PROGRAM,
|
||||||
|
PUMP_FUN_PROGRAM,
|
||||||
|
} from '../types/index.js';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// Known DEX program IDs to detect swaps
|
||||||
|
const KNOWN_DEX_PROGRAMS: Record<string, string> = {
|
||||||
|
'675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8': 'Raydium V4',
|
||||||
|
'CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C': 'Raydium CPMM',
|
||||||
|
'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4': 'Jupiter V6',
|
||||||
|
'JUP4Fb2cqiRUcaTHdrPC8h2gNsA2ETXiPDD33WcPX73': 'Jupiter V4',
|
||||||
|
'whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc': 'Orca Whirlpool',
|
||||||
|
'9W959DqEETiGZocYWCQPaJ6sBmUzgfxXfqGeTEdp3aQP': 'Orca V2',
|
||||||
|
'LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo': 'Meteora DLMM',
|
||||||
|
'Eo7WjKq67rjJQSZxS6z3YkapzY3eMj6Xy8X5EQVn5UaB': 'Meteora Pools',
|
||||||
|
'6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P': 'Pump.fun',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SWAP_KEYWORDS = [
|
||||||
|
'Instruction: Swap',
|
||||||
|
'Instruction: Route',
|
||||||
|
'Instruction: SharedAccountsRoute',
|
||||||
|
'Instruction: ExactOutRoute',
|
||||||
|
'Instruction: Buy',
|
||||||
|
'Instruction: Sell',
|
||||||
|
'ray_log',
|
||||||
|
'Program log: Swap',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface WalletSubscription {
|
||||||
|
wallet: TrackedWallet;
|
||||||
|
subscriptionId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CopyTradeCallback = (activity: WalletActivity) => void | Promise<void>;
|
||||||
|
|
||||||
|
export class CopyTradeScanner {
|
||||||
|
private connection: Connection;
|
||||||
|
private wsConnection: Connection;
|
||||||
|
private logger: Logger;
|
||||||
|
private running = false;
|
||||||
|
private subscriptions = new Map<string, WalletSubscription>();
|
||||||
|
private onActivityCallback: CopyTradeCallback | null = null;
|
||||||
|
private processedSignatures = new Map<string, number>(); // sig -> timestamp
|
||||||
|
private maxProcessedCache = 20_000;
|
||||||
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor(rpcUrl: string, wsUrl: string) {
|
||||||
|
this.connection = new Connection(rpcUrl, 'confirmed');
|
||||||
|
this.wsConnection = new Connection(wsUrl, {
|
||||||
|
commitment: 'confirmed',
|
||||||
|
wsEndpoint: wsUrl,
|
||||||
|
});
|
||||||
|
this.logger = new Logger('Scanner');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register callback for wallet activity events
|
||||||
|
*/
|
||||||
|
onActivity(callback: CopyTradeCallback): void {
|
||||||
|
this.onActivityCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a wallet to track. If scanner is running, subscribes immediately.
|
||||||
|
*/
|
||||||
|
async addWallet(wallet: TrackedWallet): Promise<void> {
|
||||||
|
if (this.subscriptions.has(wallet.address)) {
|
||||||
|
this.logger.warn(`Wallet ${wallet.label} (${wallet.address.slice(0, 8)}...) already tracked`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.running) {
|
||||||
|
await this.subscribeToWallet(wallet);
|
||||||
|
} else {
|
||||||
|
// Store for later; will subscribe on start()
|
||||||
|
this.subscriptions.set(wallet.address, {
|
||||||
|
wallet,
|
||||||
|
subscriptionId: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
`📋 Tracking wallet: ${wallet.label} [${wallet.tier}] (${wallet.address.slice(0, 8)}...)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a wallet from tracking. Unsubscribes from WebSocket.
|
||||||
|
*/
|
||||||
|
async removeWallet(address: string): Promise<void> {
|
||||||
|
const sub = this.subscriptions.get(address);
|
||||||
|
if (!sub) return;
|
||||||
|
|
||||||
|
if (sub.subscriptionId >= 0) {
|
||||||
|
try {
|
||||||
|
await this.wsConnection.removeOnLogsListener(sub.subscriptionId);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.subscriptions.delete(address);
|
||||||
|
this.logger.info(`Stopped tracking wallet: ${sub.wallet.label} (${address.slice(0, 8)}...)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start monitoring all added wallets
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.running) {
|
||||||
|
this.logger.warn('CopyTradeScanner already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
this.logger.info('🚀 Starting copy trade scanner...');
|
||||||
|
|
||||||
|
// Subscribe to all pre-added wallets
|
||||||
|
const wallets = [...this.subscriptions.values()].filter(
|
||||||
|
(s) => s.subscriptionId === -1,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const sub of wallets) {
|
||||||
|
try {
|
||||||
|
await this.subscribeToWallet(sub.wallet);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to subscribe to ${sub.wallet.label} (${sub.wallet.address.slice(0, 8)}...)`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.success(
|
||||||
|
`Copy trade scanner active — tracking ${this.subscriptions.size} wallet(s)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start periodic cache cleanup
|
||||||
|
this.startCacheCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all wallet subscriptions
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.running) return;
|
||||||
|
|
||||||
|
this.running = false;
|
||||||
|
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
this.cleanupInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanups = [...this.subscriptions.values()]
|
||||||
|
.filter((s) => s.subscriptionId >= 0)
|
||||||
|
.map((s) =>
|
||||||
|
this.wsConnection
|
||||||
|
.removeOnLogsListener(s.subscriptionId)
|
||||||
|
.catch(() => {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.allSettled(cleanups);
|
||||||
|
|
||||||
|
// Reset subscription IDs but keep wallet list
|
||||||
|
for (const [address, sub] of this.subscriptions) {
|
||||||
|
this.subscriptions.set(address, { ...sub, subscriptionId: -1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processedSignatures.clear();
|
||||||
|
this.logger.info('Copy trade scanner stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of currently tracked wallets
|
||||||
|
*/
|
||||||
|
getTrackedWallets(): TrackedWallet[] {
|
||||||
|
return [...this.subscriptions.values()].map((s) => s.wallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Internal subscription management ============
|
||||||
|
|
||||||
|
private async subscribeToWallet(wallet: TrackedWallet): Promise<void> {
|
||||||
|
if (!wallet.enabled) {
|
||||||
|
this.logger.debug(`Wallet ${wallet.label} is disabled — skipping subscription`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pubkey = new PublicKey(wallet.address);
|
||||||
|
|
||||||
|
const subscriptionId = this.wsConnection.onLogs(
|
||||||
|
pubkey,
|
||||||
|
async (logs: Logs, _ctx: Context) => {
|
||||||
|
try {
|
||||||
|
await this.handleWalletLogs(wallet, logs);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Error processing logs for ${wallet.label}`, err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'confirmed',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.subscriptions.set(wallet.address, { wallet, subscriptionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Log processing ============
|
||||||
|
|
||||||
|
private async handleWalletLogs(
|
||||||
|
wallet: TrackedWallet,
|
||||||
|
logs: Logs,
|
||||||
|
): Promise<void> {
|
||||||
|
const { signature, err, logs: logMessages } = logs;
|
||||||
|
|
||||||
|
// Skip failed transactions
|
||||||
|
if (err) return;
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
if (this.processedSignatures.has(signature)) return;
|
||||||
|
this.processedSignatures.set(signature, Date.now());
|
||||||
|
|
||||||
|
// Check if this is a swap transaction by looking for DEX programs in logs
|
||||||
|
const detectedDex = this.detectDex(logMessages);
|
||||||
|
if (!detectedDex) return; // Not a swap
|
||||||
|
|
||||||
|
// Check for swap-related instructions
|
||||||
|
const isSwap = logMessages.some((log) =>
|
||||||
|
SWAP_KEYWORDS.some((keyword) => log.includes(keyword)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isSwap) return;
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
`👀 Swap detected from ${wallet.label} [${wallet.tier}] on ${detectedDex} — tx: ${signature.slice(0, 16)}...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activity = await this.parseSwapTransaction(wallet, signature);
|
||||||
|
if (activity) {
|
||||||
|
this.logger.success(
|
||||||
|
`📋 ${wallet.label} ${activity.type.toUpperCase()} ${activity.mint.slice(0, 12)}... — ${activity.amountSol.toFixed(4)} SOL`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.onActivityCallback) {
|
||||||
|
await this.onActivityCallback(activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Error parsing swap tx for ${wallet.label}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect which DEX is involved by checking for program invocations in logs
|
||||||
|
*/
|
||||||
|
private detectDex(logMessages: string[]): string | null {
|
||||||
|
for (const log of logMessages) {
|
||||||
|
for (const [programId, dexName] of Object.entries(KNOWN_DEX_PROGRAMS)) {
|
||||||
|
if (log.includes(programId)) {
|
||||||
|
return dexName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and parse a swap transaction to determine direction, token, and amounts
|
||||||
|
*/
|
||||||
|
private async parseSwapTransaction(
|
||||||
|
wallet: TrackedWallet,
|
||||||
|
signature: string,
|
||||||
|
): Promise<WalletActivity | null> {
|
||||||
|
const tx = await this.connection.getParsedTransaction(signature, {
|
||||||
|
maxSupportedTransactionVersion: 0,
|
||||||
|
commitment: 'confirmed',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tx?.meta) return null;
|
||||||
|
|
||||||
|
// Analyze token balance changes to determine buy/sell direction and amounts
|
||||||
|
const preBalances = tx.meta.preTokenBalances || [];
|
||||||
|
const postBalances = tx.meta.postTokenBalances || [];
|
||||||
|
|
||||||
|
// Find the wallet's account index
|
||||||
|
const accountKeys: string[] = tx.transaction.message.accountKeys.map((k: any) =>
|
||||||
|
typeof k === 'string' ? k : k.pubkey.toBase58(),
|
||||||
|
);
|
||||||
|
const walletIndex = accountKeys.indexOf(wallet.address);
|
||||||
|
|
||||||
|
// Track SOL changes (pre/post lamport balance)
|
||||||
|
let solChange = BigInt(0);
|
||||||
|
if (walletIndex >= 0) {
|
||||||
|
const preSol = BigInt(tx.meta.preBalances[walletIndex] || 0);
|
||||||
|
const postSol = BigInt(tx.meta.postBalances[walletIndex] || 0);
|
||||||
|
solChange = postSol - preSol;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track token balance changes for the wallet
|
||||||
|
const tokenChanges = new Map<
|
||||||
|
string,
|
||||||
|
{ pre: bigint; post: bigint; change: bigint }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const balance of preBalances) {
|
||||||
|
if (balance.owner === wallet.address && balance.mint !== SOL_MINT && balance.mint !== WSOL_MINT) {
|
||||||
|
tokenChanges.set(balance.mint, {
|
||||||
|
pre: BigInt(balance.uiTokenAmount?.amount || '0'),
|
||||||
|
post: BigInt(0),
|
||||||
|
change: BigInt(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const balance of postBalances) {
|
||||||
|
if (balance.owner === wallet.address && balance.mint !== SOL_MINT && balance.mint !== WSOL_MINT) {
|
||||||
|
const existing = tokenChanges.get(balance.mint) || {
|
||||||
|
pre: BigInt(0),
|
||||||
|
post: BigInt(0),
|
||||||
|
change: BigInt(0),
|
||||||
|
};
|
||||||
|
existing.post = BigInt(balance.uiTokenAmount?.amount || '0');
|
||||||
|
existing.change = existing.post - existing.pre;
|
||||||
|
tokenChanges.set(balance.mint, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the primary token and direction
|
||||||
|
let primaryMint: string | null = null;
|
||||||
|
let primaryChange = BigInt(0);
|
||||||
|
|
||||||
|
for (const [mint, changes] of tokenChanges) {
|
||||||
|
if (mint === SOL_MINT || mint === WSOL_MINT) continue;
|
||||||
|
const absChange = changes.change < BigInt(0) ? -changes.change : changes.change;
|
||||||
|
const absPrimary = primaryChange < BigInt(0) ? -primaryChange : primaryChange;
|
||||||
|
if (absChange > absPrimary) {
|
||||||
|
primaryMint = mint;
|
||||||
|
primaryChange = changes.change;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!primaryMint) {
|
||||||
|
this.logger.debug('No token balance change detected for wallet');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction: token balance increased → BUY, decreased → SELL
|
||||||
|
const type: 'buy' | 'sell' = primaryChange > BigInt(0) ? 'buy' : 'sell';
|
||||||
|
|
||||||
|
// Calculate SOL amount (absolute value)
|
||||||
|
const absSolChange = solChange < BigInt(0) ? -solChange : solChange;
|
||||||
|
const amountSol = Number(absSolChange) / 1_000_000_000;
|
||||||
|
|
||||||
|
// Token amount (absolute value)
|
||||||
|
const absTokenChange =
|
||||||
|
primaryChange < BigInt(0) ? -primaryChange : primaryChange;
|
||||||
|
|
||||||
|
return {
|
||||||
|
wallet: wallet.address,
|
||||||
|
type,
|
||||||
|
mint: primaryMint,
|
||||||
|
amountSol,
|
||||||
|
amountTokens: absTokenChange,
|
||||||
|
txSignature: signature,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Cache management ============
|
||||||
|
|
||||||
|
private startCacheCleanup(): void {
|
||||||
|
this.cleanupInterval = setInterval(() => {
|
||||||
|
if (!this.running) return;
|
||||||
|
this.pruneCache();
|
||||||
|
}, 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneCache(): void {
|
||||||
|
if (this.processedSignatures.size > this.maxProcessedCache) {
|
||||||
|
const entries = [...this.processedSignatures.entries()].sort(
|
||||||
|
(a, b) => a[1] - b[1],
|
||||||
|
);
|
||||||
|
const toRemove = entries.slice(
|
||||||
|
0,
|
||||||
|
entries.length - this.maxProcessedCache / 2,
|
||||||
|
);
|
||||||
|
for (const [sig] of toRemove) {
|
||||||
|
this.processedSignatures.delete(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRunning(): boolean {
|
||||||
|
return this.running;
|
||||||
|
}
|
||||||
|
|
||||||
|
get trackedCount(): number {
|
||||||
|
return this.subscriptions.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
323
src/scanner/index.ts
Normal file
323
src/scanner/index.ts
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Scanner Module — Unified entry point for all scanners
|
||||||
|
*
|
||||||
|
* Exports:
|
||||||
|
* - PumpFunScanner — pump.fun token creation & graduation detection
|
||||||
|
* - RaydiumScanner — Raydium V4 + CPMM new pool detection
|
||||||
|
* - CopyTradeScanner — Tracked wallet swap detection
|
||||||
|
* - ScannerManager — Orchestrates all scanners with deduplication
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { PumpFunScanner } from './pump-fun-scanner.js';
|
||||||
|
export type { PumpFunTokenEvent, PumpFunTokenCallback } from './pump-fun-scanner.js';
|
||||||
|
|
||||||
|
export { RaydiumScanner } from './raydium-scanner.js';
|
||||||
|
export type { RaydiumPoolCallback } from './raydium-scanner.js';
|
||||||
|
|
||||||
|
export { CopyTradeScanner } from './copy-trade-scanner.js';
|
||||||
|
export type { CopyTradeCallback } from './copy-trade-scanner.js';
|
||||||
|
|
||||||
|
// ============ ScannerManager ============
|
||||||
|
|
||||||
|
import type { TokenInfo, PoolInfo, WalletActivity, TrackedWallet, BotConfig } from '../types/index.js';
|
||||||
|
import { PumpFunScanner } from './pump-fun-scanner.js';
|
||||||
|
import type { PumpFunTokenEvent } from './pump-fun-scanner.js';
|
||||||
|
import { RaydiumScanner } from './raydium-scanner.js';
|
||||||
|
import { CopyTradeScanner } from './copy-trade-scanner.js';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// ============ Scanner event types ============
|
||||||
|
|
||||||
|
export interface ScannerEvent {
|
||||||
|
type: 'new_token' | 'graduation' | 'new_pool' | 'copy_trade';
|
||||||
|
tokenInfo?: TokenInfo;
|
||||||
|
pool?: PoolInfo;
|
||||||
|
walletActivity?: WalletActivity;
|
||||||
|
source: 'pumpfun' | 'raydium' | 'raydium-cpmm' | 'copy-trade';
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScannerEventCallback = (event: ScannerEvent) => void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScannerManager — Orchestrates all three scanners, deduplicates events,
|
||||||
|
* and provides a unified callback interface.
|
||||||
|
*/
|
||||||
|
export class ScannerManager {
|
||||||
|
private pumpFunScanner: PumpFunScanner;
|
||||||
|
private raydiumScanner: RaydiumScanner;
|
||||||
|
private copyTradeScanner: CopyTradeScanner;
|
||||||
|
private logger: Logger;
|
||||||
|
private running = false;
|
||||||
|
private eventCallback: ScannerEventCallback | null = null;
|
||||||
|
|
||||||
|
// Deduplication: track recently seen mints to avoid duplicate events
|
||||||
|
// when the same token shows up on both pump.fun graduation AND Raydium new pool
|
||||||
|
private recentMints = new Map<string, number>(); // mint -> timestamp
|
||||||
|
private dedupeWindowMs = 30_000; // 30 second window for deduplication
|
||||||
|
private maxDedupeCache = 5_000;
|
||||||
|
private dedupeInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// Config flags
|
||||||
|
private scanPumpFun: boolean;
|
||||||
|
private scanRaydium: boolean;
|
||||||
|
private copyTradeEnabled: boolean;
|
||||||
|
|
||||||
|
constructor(config: BotConfig) {
|
||||||
|
const rpcUrl = config.rpcUrl;
|
||||||
|
const wsUrl = config.rpcWsUrl;
|
||||||
|
|
||||||
|
this.pumpFunScanner = new PumpFunScanner(rpcUrl, wsUrl);
|
||||||
|
this.raydiumScanner = new RaydiumScanner(rpcUrl, wsUrl);
|
||||||
|
this.copyTradeScanner = new CopyTradeScanner(rpcUrl, wsUrl);
|
||||||
|
this.logger = new Logger('Scanner');
|
||||||
|
|
||||||
|
this.scanPumpFun = config.scanPumpFun;
|
||||||
|
this.scanRaydium = config.scanRaydium;
|
||||||
|
this.copyTradeEnabled = config.copyTradeEnabled;
|
||||||
|
|
||||||
|
this.setupCallbacks();
|
||||||
|
|
||||||
|
// Pre-load tracked wallets
|
||||||
|
if (config.trackedWallets.length > 0) {
|
||||||
|
for (const wallet of config.trackedWallets) {
|
||||||
|
this.copyTradeScanner.addWallet(wallet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a unified callback for all scanner events
|
||||||
|
*/
|
||||||
|
onEvent(callback: ScannerEventCallback): void {
|
||||||
|
this.eventCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start all enabled scanners
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.running) {
|
||||||
|
this.logger.warn('ScannerManager already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
this.logger.info('═══════════════════════════════════════');
|
||||||
|
this.logger.info(' Starting Scanner Module');
|
||||||
|
this.logger.info('═══════════════════════════════════════');
|
||||||
|
|
||||||
|
const startPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
if (this.scanPumpFun) {
|
||||||
|
startPromises.push(
|
||||||
|
this.pumpFunScanner.start().catch((err) => {
|
||||||
|
this.logger.error('Failed to start pump.fun scanner', err);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.info(' pump.fun scanner: DISABLED');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.scanRaydium) {
|
||||||
|
startPromises.push(
|
||||||
|
this.raydiumScanner.start().catch((err) => {
|
||||||
|
this.logger.error('Failed to start Raydium scanner', err);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.info(' Raydium scanner: DISABLED');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.copyTradeEnabled) {
|
||||||
|
startPromises.push(
|
||||||
|
this.copyTradeScanner.start().catch((err) => {
|
||||||
|
this.logger.error('Failed to start copy trade scanner', err);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.info(' Copy trade scanner: DISABLED');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(startPromises);
|
||||||
|
|
||||||
|
this.logger.info('═══════════════════════════════════════');
|
||||||
|
this.logger.success('Scanner Module active');
|
||||||
|
|
||||||
|
// Start deduplication cleanup interval
|
||||||
|
this.startDedupeCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all scanners
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.running) return;
|
||||||
|
|
||||||
|
this.running = false;
|
||||||
|
this.logger.info('Stopping all scanners...');
|
||||||
|
|
||||||
|
if (this.dedupeInterval) {
|
||||||
|
clearInterval(this.dedupeInterval);
|
||||||
|
this.dedupeInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled([
|
||||||
|
this.pumpFunScanner.stop(),
|
||||||
|
this.raydiumScanner.stop(),
|
||||||
|
this.copyTradeScanner.stop(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.recentMints.clear();
|
||||||
|
this.logger.info('All scanners stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a wallet for copy trading (can be called while running)
|
||||||
|
*/
|
||||||
|
async addTrackedWallet(wallet: TrackedWallet): Promise<void> {
|
||||||
|
await this.copyTradeScanner.addWallet(wallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a tracked wallet
|
||||||
|
*/
|
||||||
|
async removeTrackedWallet(address: string): Promise<void> {
|
||||||
|
await this.copyTradeScanner.removeWallet(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all tracked wallets
|
||||||
|
*/
|
||||||
|
getTrackedWallets(): TrackedWallet[] {
|
||||||
|
return this.copyTradeScanner.getTrackedWallets();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access individual scanners for advanced use
|
||||||
|
*/
|
||||||
|
get pumpFun(): PumpFunScanner {
|
||||||
|
return this.pumpFunScanner;
|
||||||
|
}
|
||||||
|
|
||||||
|
get raydium(): RaydiumScanner {
|
||||||
|
return this.raydiumScanner;
|
||||||
|
}
|
||||||
|
|
||||||
|
get copyTrade(): CopyTradeScanner {
|
||||||
|
return this.copyTradeScanner;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRunning(): boolean {
|
||||||
|
return this.running;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Internal callback wiring ============
|
||||||
|
|
||||||
|
private setupCallbacks(): void {
|
||||||
|
// pump.fun events → unified events
|
||||||
|
this.pumpFunScanner.onToken(async (event: PumpFunTokenEvent) => {
|
||||||
|
if (event.type === 'creation') {
|
||||||
|
await this.emitDeduped(event.tokenInfo.mint, {
|
||||||
|
type: 'new_token',
|
||||||
|
tokenInfo: event.tokenInfo,
|
||||||
|
pool: event.pool,
|
||||||
|
source: 'pumpfun',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
} else if (event.type === 'graduation') {
|
||||||
|
await this.emitDeduped(event.tokenInfo.mint, {
|
||||||
|
type: 'graduation',
|
||||||
|
tokenInfo: event.tokenInfo,
|
||||||
|
pool: event.pool,
|
||||||
|
source: 'pumpfun',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Raydium pool events → unified events
|
||||||
|
this.raydiumScanner.onPool(async (pool: PoolInfo) => {
|
||||||
|
await this.emitDeduped(pool.baseMint, {
|
||||||
|
type: 'new_pool',
|
||||||
|
pool,
|
||||||
|
source: pool.dex === 'raydium-cpmm' ? 'raydium-cpmm' : 'raydium',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy trade events → unified events (no dedup needed)
|
||||||
|
this.copyTradeScanner.onActivity(async (activity: WalletActivity) => {
|
||||||
|
await this.emit({
|
||||||
|
type: 'copy_trade',
|
||||||
|
walletActivity: activity,
|
||||||
|
source: 'copy-trade',
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Deduplication ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit with mint-based deduplication.
|
||||||
|
* If the same mint was seen within the deduplication window,
|
||||||
|
* the event is suppressed (e.g. pump.fun graduation + Raydium pool creation).
|
||||||
|
*/
|
||||||
|
private async emitDeduped(
|
||||||
|
mint: string,
|
||||||
|
event: ScannerEvent,
|
||||||
|
): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const lastSeen = this.recentMints.get(mint);
|
||||||
|
|
||||||
|
if (lastSeen && now - lastSeen < this.dedupeWindowMs) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Deduped event for ${mint.slice(0, 12)}... (${event.source}, ${event.type}) — seen ${now - lastSeen}ms ago`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recentMints.set(mint, now);
|
||||||
|
await this.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async emit(event: ScannerEvent): Promise<void> {
|
||||||
|
if (this.eventCallback) {
|
||||||
|
try {
|
||||||
|
await this.eventCallback(event);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Error in scanner event callback', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodically clean up the deduplication cache
|
||||||
|
*/
|
||||||
|
private startDedupeCleanup(): void {
|
||||||
|
this.dedupeInterval = setInterval(() => {
|
||||||
|
if (!this.running) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const cutoff = now - this.dedupeWindowMs * 2;
|
||||||
|
|
||||||
|
for (const [mint, timestamp] of this.recentMints) {
|
||||||
|
if (timestamp < cutoff) {
|
||||||
|
this.recentMints.delete(mint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard cap
|
||||||
|
if (this.recentMints.size > this.maxDedupeCache) {
|
||||||
|
const entries = [...this.recentMints.entries()].sort(
|
||||||
|
(a, b) => a[1] - b[1],
|
||||||
|
);
|
||||||
|
const toRemove = entries.slice(0, entries.length - this.maxDedupeCache / 2);
|
||||||
|
for (const [m] of toRemove) {
|
||||||
|
this.recentMints.delete(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
645
src/scanner/pump-fun-scanner.ts
Normal file
645
src/scanner/pump-fun-scanner.ts
Normal file
@ -0,0 +1,645 @@
|
|||||||
|
/**
|
||||||
|
* Pump.fun Scanner — WebSocket listener for new token creations and bonding curve graduations
|
||||||
|
*
|
||||||
|
* Monitors the pump.fun program (6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P)
|
||||||
|
* for two key events:
|
||||||
|
* 1. New token creation (mint, creator, name, symbol, URI)
|
||||||
|
* 2. Bonding curve graduation / migration to Raydium
|
||||||
|
*
|
||||||
|
* Uses Solana WebSocket `onLogs` subscription for real-time detection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
PublicKey,
|
||||||
|
Logs,
|
||||||
|
Context,
|
||||||
|
} from '@solana/web3.js';
|
||||||
|
import bs58 from 'bs58';
|
||||||
|
import type { TokenInfo, PoolInfo } from '../types/index.js';
|
||||||
|
import {
|
||||||
|
PUMP_FUN_PROGRAM,
|
||||||
|
PUMP_FUN_MIGRATION_AUTHORITY,
|
||||||
|
SOL_MINT,
|
||||||
|
} from '../types/index.js';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// ============ Pump.fun layout constants ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pump.fun instruction discriminators (first 8 bytes of SHA256 of instruction name)
|
||||||
|
* These are the known discriminators for the pump.fun program.
|
||||||
|
*/
|
||||||
|
const CREATE_DISCRIMINATOR = Buffer.from([0x18, 0x1e, 0xc8, 0x28, 0x05, 0x1c, 0x07, 0x77]); // "create" instruction
|
||||||
|
const MIGRATION_LOG_PREFIX = 'Program log: Instruction: Migrate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bonding curve account layout offsets (pump.fun specific)
|
||||||
|
* The bonding curve PDA stores token state on-chain.
|
||||||
|
*/
|
||||||
|
const BONDING_CURVE_LAYOUT = {
|
||||||
|
discriminator: 0, // 8 bytes
|
||||||
|
virtualTokenReserves: 8, // u64
|
||||||
|
virtualSolReserves: 16, // u64
|
||||||
|
realTokenReserves: 24, // u64
|
||||||
|
realSolReserves: 32, // u64
|
||||||
|
tokenTotalSupply: 40, // u64
|
||||||
|
complete: 48, // bool (1 byte) — true when graduated
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface PumpFunTokenEvent {
|
||||||
|
type: 'creation' | 'graduation';
|
||||||
|
tokenInfo: TokenInfo;
|
||||||
|
pool?: PoolInfo;
|
||||||
|
bondingCurveAddress?: string;
|
||||||
|
txSignature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PumpFunTokenCallback = (event: PumpFunTokenEvent) => void | Promise<void>;
|
||||||
|
|
||||||
|
export class PumpFunScanner {
|
||||||
|
private connection: Connection;
|
||||||
|
private wsConnection: Connection;
|
||||||
|
private logger: Logger;
|
||||||
|
private running = false;
|
||||||
|
private subscriptionId: number | null = null;
|
||||||
|
private onTokenCallback: PumpFunTokenCallback | null = null;
|
||||||
|
private processedSignatures = new Set<string>();
|
||||||
|
private maxProcessedCache = 10_000;
|
||||||
|
|
||||||
|
private readonly programId = new PublicKey(PUMP_FUN_PROGRAM);
|
||||||
|
private readonly migrationAuthority = new PublicKey(PUMP_FUN_MIGRATION_AUTHORITY);
|
||||||
|
|
||||||
|
constructor(rpcUrl: string, wsUrl: string) {
|
||||||
|
this.connection = new Connection(rpcUrl, 'confirmed');
|
||||||
|
this.wsConnection = new Connection(wsUrl, {
|
||||||
|
commitment: 'confirmed',
|
||||||
|
wsEndpoint: wsUrl,
|
||||||
|
});
|
||||||
|
this.logger = new Logger('Scanner');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register callback for token events (creation / graduation)
|
||||||
|
*/
|
||||||
|
onToken(callback: PumpFunTokenCallback): void {
|
||||||
|
this.onTokenCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start listening for pump.fun events via WebSocket
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.running) {
|
||||||
|
this.logger.warn('PumpFunScanner already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
this.logger.info('🚀 Starting pump.fun scanner...');
|
||||||
|
this.logger.info(` Program: ${PUMP_FUN_PROGRAM}`);
|
||||||
|
this.logger.info(` Migration Authority: ${PUMP_FUN_MIGRATION_AUTHORITY}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Subscribe to all logs mentioning the pump.fun program
|
||||||
|
this.subscriptionId = this.wsConnection.onLogs(
|
||||||
|
this.programId,
|
||||||
|
async (logs: Logs, ctx: Context) => {
|
||||||
|
try {
|
||||||
|
await this.handleLogs(logs, ctx);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Error processing pump.fun logs', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'confirmed',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.success('pump.fun scanner active — listening for new tokens & graduations');
|
||||||
|
} catch (err) {
|
||||||
|
this.running = false;
|
||||||
|
this.logger.error('Failed to start pump.fun scanner', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the scanner and clean up subscriptions
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.running) return;
|
||||||
|
|
||||||
|
this.running = false;
|
||||||
|
|
||||||
|
if (this.subscriptionId !== null) {
|
||||||
|
try {
|
||||||
|
await this.wsConnection.removeOnLogsListener(this.subscriptionId);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
this.subscriptionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processedSignatures.clear();
|
||||||
|
this.logger.info('pump.fun scanner stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Internal event processing ============
|
||||||
|
|
||||||
|
private async handleLogs(logs: Logs, _ctx: Context): Promise<void> {
|
||||||
|
const { signature, err, logs: logMessages } = logs;
|
||||||
|
|
||||||
|
// Skip failed transactions
|
||||||
|
if (err) return;
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
if (this.processedSignatures.has(signature)) return;
|
||||||
|
this.processedSignatures.add(signature);
|
||||||
|
this.pruneCache();
|
||||||
|
|
||||||
|
// Check log messages for event type
|
||||||
|
const isCreation = logMessages.some(
|
||||||
|
(log) =>
|
||||||
|
log.includes('Program log: Instruction: Create') &&
|
||||||
|
!log.includes('CreateIdempotent'),
|
||||||
|
);
|
||||||
|
const isMigration = logMessages.some(
|
||||||
|
(log) =>
|
||||||
|
log.includes(MIGRATION_LOG_PREFIX) ||
|
||||||
|
log.includes('Program log: Instruction: Withdraw'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isCreation) {
|
||||||
|
await this.handleCreation(signature, logMessages);
|
||||||
|
} else if (isMigration) {
|
||||||
|
await this.handleGraduation(signature, logMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle new token creation event
|
||||||
|
* Fetches the transaction to parse the full instruction data
|
||||||
|
*/
|
||||||
|
private async handleCreation(
|
||||||
|
signature: string,
|
||||||
|
logMessages: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.info(`🆕 New pump.fun token detected — tx: ${signature.slice(0, 16)}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch full transaction to get instruction data and accounts
|
||||||
|
const tx = await this.connection.getParsedTransaction(signature, {
|
||||||
|
maxSupportedTransactionVersion: 0,
|
||||||
|
commitment: 'confirmed',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tx?.meta || !tx.transaction) {
|
||||||
|
this.logger.warn(`Could not fetch tx ${signature}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the create instruction
|
||||||
|
const tokenInfo = await this.parseCreateTransaction(tx, signature, logMessages);
|
||||||
|
if (!tokenInfo) return;
|
||||||
|
|
||||||
|
// Derive bonding curve PDA
|
||||||
|
const [bondingCurvePda] = PublicKey.findProgramAddressSync(
|
||||||
|
[
|
||||||
|
Buffer.from('bonding-curve'),
|
||||||
|
new PublicKey(tokenInfo.mint).toBuffer(),
|
||||||
|
],
|
||||||
|
this.programId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const event: PumpFunTokenEvent = {
|
||||||
|
type: 'creation',
|
||||||
|
tokenInfo,
|
||||||
|
bondingCurveAddress: bondingCurvePda.toBase58(),
|
||||||
|
txSignature: signature,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.success(
|
||||||
|
`Token Created: ${tokenInfo.symbol} (${tokenInfo.name}) — Mint: ${tokenInfo.mint.slice(0, 12)}...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.onTokenCallback) {
|
||||||
|
await this.onTokenCallback(event);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Error processing creation tx ${signature}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle bonding curve graduation / migration to Raydium
|
||||||
|
*/
|
||||||
|
private async handleGraduation(
|
||||||
|
signature: string,
|
||||||
|
logMessages: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.info(`🎓 Pump.fun graduation detected — tx: ${signature.slice(0, 16)}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tx = await this.connection.getParsedTransaction(signature, {
|
||||||
|
maxSupportedTransactionVersion: 0,
|
||||||
|
commitment: 'confirmed',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tx?.meta || !tx.transaction) {
|
||||||
|
this.logger.warn(`Could not fetch graduation tx ${signature}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the mint from the transaction accounts
|
||||||
|
const accountKeys = tx.transaction.message.accountKeys.map((k) =>
|
||||||
|
typeof k === 'string' ? k : k.pubkey.toBase58(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The migration authority should be in the signers or account list
|
||||||
|
const hasMigrationAuth = accountKeys.includes(
|
||||||
|
this.migrationAuthority.toBase58(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasMigrationAuth) {
|
||||||
|
// Double-check inner instructions for the migration authority
|
||||||
|
const innerInstructions = tx.meta.innerInstructions || [];
|
||||||
|
let foundMigrationAuth = false;
|
||||||
|
for (const inner of innerInstructions) {
|
||||||
|
for (const ix of inner.instructions) {
|
||||||
|
if ('parsed' in ix) continue;
|
||||||
|
const ixAccounts = (ix as any).accounts || [];
|
||||||
|
if (
|
||||||
|
ixAccounts.some(
|
||||||
|
(a: any) =>
|
||||||
|
a.toBase58?.() === this.migrationAuthority.toBase58() ||
|
||||||
|
a === PUMP_FUN_MIGRATION_AUTHORITY,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
foundMigrationAuth = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundMigrationAuth) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundMigrationAuth) {
|
||||||
|
this.logger.debug('Graduation tx lacks migration authority — skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract mint address from token balance changes
|
||||||
|
const tokenMint = this.extractMintFromBalanceChanges(tx);
|
||||||
|
if (!tokenMint) {
|
||||||
|
this.logger.warn('Could not determine mint from graduation tx');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build minimal token info (we may not have name/symbol from the graduation tx)
|
||||||
|
const tokenInfo = await this.fetchTokenMetadata(tokenMint);
|
||||||
|
|
||||||
|
// Try to find the Raydium pool address from inner instructions
|
||||||
|
const poolAddress = this.extractRaydiumPoolFromLogs(logMessages);
|
||||||
|
|
||||||
|
const pool: PoolInfo | undefined = poolAddress
|
||||||
|
? {
|
||||||
|
poolAddress,
|
||||||
|
baseMint: tokenMint,
|
||||||
|
quoteMint: SOL_MINT,
|
||||||
|
baseReserve: BigInt(0), // Will be filled by Raydium scanner
|
||||||
|
quoteReserve: BigInt(0),
|
||||||
|
lpMint: '',
|
||||||
|
dex: 'raydium',
|
||||||
|
createdAt: new Date(),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const event: PumpFunTokenEvent = {
|
||||||
|
type: 'graduation',
|
||||||
|
tokenInfo,
|
||||||
|
pool,
|
||||||
|
txSignature: signature,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.success(
|
||||||
|
`🎓 GRADUATED: ${tokenInfo.symbol} (${tokenInfo.mint.slice(0, 12)}...) → Raydium`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.onTokenCallback) {
|
||||||
|
await this.onTokenCallback(event);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Error processing graduation tx ${signature}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Transaction parsing helpers ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a pump.fun Create instruction from a parsed transaction
|
||||||
|
* Extracts: mint, creator, name, symbol, URI from instruction data
|
||||||
|
*/
|
||||||
|
private async parseCreateTransaction(
|
||||||
|
tx: any,
|
||||||
|
signature: string,
|
||||||
|
logMessages: string[],
|
||||||
|
): Promise<TokenInfo | null> {
|
||||||
|
try {
|
||||||
|
const accountKeys = tx.transaction.message.accountKeys.map((k: any) =>
|
||||||
|
typeof k === 'string' ? k : k.pubkey.toBase58(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// In a pump.fun create instruction, the accounts layout is typically:
|
||||||
|
// [0] mint, [1] mintAuthority, [2] bondingCurve, [3] bondingCurveTokenAccount,
|
||||||
|
// [4] globalState, [5] metadata, [6] creator (fee payer), ...
|
||||||
|
// Find the outer pump.fun instruction
|
||||||
|
let mint: string | null = null;
|
||||||
|
let creator: string | null = null;
|
||||||
|
|
||||||
|
for (const ix of tx.transaction.message.instructions) {
|
||||||
|
const programId =
|
||||||
|
typeof ix.programId === 'string'
|
||||||
|
? ix.programId
|
||||||
|
: ix.programId?.toBase58?.();
|
||||||
|
|
||||||
|
if (programId === PUMP_FUN_PROGRAM && 'data' in ix && !('parsed' in ix)) {
|
||||||
|
// This is the raw pump.fun instruction
|
||||||
|
const ixAccounts: string[] = ((ix as any).accounts || []).map(
|
||||||
|
(a: any) => (typeof a === 'string' ? a : a.toBase58()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Account layout for pump.fun create:
|
||||||
|
// 0: mint, 1: mintAuthority (PDA), 2: bondingCurve (PDA),
|
||||||
|
// 3: associatedBondingCurve, 4: global, 5: mplTokenMetadata,
|
||||||
|
// 6: metadata, 7: user (creator)
|
||||||
|
if (ixAccounts.length >= 8) {
|
||||||
|
mint = ixAccounts[0];
|
||||||
|
creator = ixAccounts[7];
|
||||||
|
} else if (ixAccounts.length >= 2) {
|
||||||
|
mint = ixAccounts[0];
|
||||||
|
creator = accountKeys[0]; // fee payer fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to decode instruction data for name/symbol/uri
|
||||||
|
const dataStr = typeof ix.data === 'string' ? ix.data : '';
|
||||||
|
if (dataStr) {
|
||||||
|
const parsed = this.decodeCreateInstructionData(dataStr);
|
||||||
|
if (parsed && mint) {
|
||||||
|
return {
|
||||||
|
mint,
|
||||||
|
name: parsed.name,
|
||||||
|
symbol: parsed.symbol,
|
||||||
|
decimals: 6, // pump.fun tokens are always 6 decimals
|
||||||
|
supply: BigInt(1_000_000_000) * BigInt(10 ** 6), // 1B supply standard
|
||||||
|
creator: creator || accountKeys[0],
|
||||||
|
createdAt: new Date(),
|
||||||
|
uri: parsed.uri,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: parse from log messages
|
||||||
|
const nameFromLogs = this.parseFieldFromLogs(logMessages, 'name');
|
||||||
|
const symbolFromLogs = this.parseFieldFromLogs(logMessages, 'symbol');
|
||||||
|
const uriFromLogs = this.parseFieldFromLogs(logMessages, 'uri');
|
||||||
|
|
||||||
|
// If we still don't have a mint, try token balance changes
|
||||||
|
if (!mint) {
|
||||||
|
mint = this.extractMintFromBalanceChanges(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mint) {
|
||||||
|
this.logger.warn('Could not extract mint from create tx');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mint,
|
||||||
|
name: nameFromLogs || 'Unknown',
|
||||||
|
symbol: symbolFromLogs || 'UNKNOWN',
|
||||||
|
decimals: 6,
|
||||||
|
supply: BigInt(1_000_000_000) * BigInt(10 ** 6),
|
||||||
|
creator: creator || accountKeys[0],
|
||||||
|
createdAt: new Date(),
|
||||||
|
uri: uriFromLogs ?? undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Failed to parse create transaction', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode pump.fun Create instruction data
|
||||||
|
* Layout: [8 bytes discriminator][4 bytes name_len][name bytes][4 bytes symbol_len][symbol bytes][4 bytes uri_len][uri bytes]
|
||||||
|
*/
|
||||||
|
private decodeCreateInstructionData(
|
||||||
|
dataBase58: string,
|
||||||
|
): { name: string; symbol: string; uri: string } | null {
|
||||||
|
try {
|
||||||
|
const data = Buffer.from(bs58.decode(dataBase58));
|
||||||
|
|
||||||
|
// Skip 8-byte discriminator
|
||||||
|
let offset = 8;
|
||||||
|
|
||||||
|
// Name: [u32 length][utf8 bytes]
|
||||||
|
if (offset + 4 > data.length) return null;
|
||||||
|
const nameLen = data.readUInt32LE(offset);
|
||||||
|
offset += 4;
|
||||||
|
if (offset + nameLen > data.length || nameLen > 200) return null;
|
||||||
|
const name = data.subarray(offset, offset + nameLen).toString('utf8');
|
||||||
|
offset += nameLen;
|
||||||
|
|
||||||
|
// Symbol: [u32 length][utf8 bytes]
|
||||||
|
if (offset + 4 > data.length) return null;
|
||||||
|
const symbolLen = data.readUInt32LE(offset);
|
||||||
|
offset += 4;
|
||||||
|
if (offset + symbolLen > data.length || symbolLen > 50) return null;
|
||||||
|
const symbol = data.subarray(offset, offset + symbolLen).toString('utf8');
|
||||||
|
offset += symbolLen;
|
||||||
|
|
||||||
|
// URI: [u32 length][utf8 bytes]
|
||||||
|
if (offset + 4 > data.length) return null;
|
||||||
|
const uriLen = data.readUInt32LE(offset);
|
||||||
|
offset += 4;
|
||||||
|
if (offset + uriLen > data.length || uriLen > 500) return null;
|
||||||
|
const uri = data.subarray(offset, offset + uriLen).toString('utf8');
|
||||||
|
|
||||||
|
return { name, symbol, uri };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract mint address from post-token-balance changes in a transaction
|
||||||
|
*/
|
||||||
|
private extractMintFromBalanceChanges(tx: any): string | null {
|
||||||
|
const postTokenBalances = tx.meta?.postTokenBalances || [];
|
||||||
|
for (const balance of postTokenBalances) {
|
||||||
|
const mint = balance.mint;
|
||||||
|
if (mint && mint !== SOL_MINT) {
|
||||||
|
return mint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to extract a Raydium pool address from log messages
|
||||||
|
*/
|
||||||
|
private extractRaydiumPoolFromLogs(logs: string[]): string | null {
|
||||||
|
for (const log of logs) {
|
||||||
|
// Look for Raydium program invocation with pool address
|
||||||
|
const raydiumMatch = log.match(
|
||||||
|
/675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8.*?([1-9A-HJ-NP-Za-km-z]{32,44})/,
|
||||||
|
);
|
||||||
|
if (raydiumMatch) return raydiumMatch[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse structured fields from program logs (some pump.fun versions emit these)
|
||||||
|
*/
|
||||||
|
private parseFieldFromLogs(
|
||||||
|
logs: string[],
|
||||||
|
field: string,
|
||||||
|
): string | null {
|
||||||
|
for (const log of logs) {
|
||||||
|
// Try JSON-style parsing: "Program data: ..."
|
||||||
|
// Or simple key=value in logs
|
||||||
|
const regex = new RegExp(`"${field}"\\s*:\\s*"([^"]+)"`, 'i');
|
||||||
|
const match = log.match(regex);
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch token metadata from on-chain (fallback when not available from tx data)
|
||||||
|
*/
|
||||||
|
private async fetchTokenMetadata(mint: string): Promise<TokenInfo> {
|
||||||
|
try {
|
||||||
|
// Try to get token metadata from the Metaplex metadata PDA
|
||||||
|
const METADATA_PROGRAM = new PublicKey(
|
||||||
|
'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s',
|
||||||
|
);
|
||||||
|
const [metadataPda] = PublicKey.findProgramAddressSync(
|
||||||
|
[
|
||||||
|
Buffer.from('metadata'),
|
||||||
|
METADATA_PROGRAM.toBuffer(),
|
||||||
|
new PublicKey(mint).toBuffer(),
|
||||||
|
],
|
||||||
|
METADATA_PROGRAM,
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadataAccount = await this.connection.getAccountInfo(metadataPda);
|
||||||
|
if (metadataAccount?.data) {
|
||||||
|
const parsed = this.parseMetaplexMetadata(metadataAccount.data);
|
||||||
|
if (parsed) {
|
||||||
|
return {
|
||||||
|
mint,
|
||||||
|
name: parsed.name,
|
||||||
|
symbol: parsed.symbol,
|
||||||
|
decimals: 6,
|
||||||
|
supply: BigInt(1_000_000_000) * BigInt(10 ** 6),
|
||||||
|
creator: parsed.updateAuthority || 'unknown',
|
||||||
|
createdAt: new Date(),
|
||||||
|
uri: parsed.uri,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mint,
|
||||||
|
name: 'Unknown',
|
||||||
|
symbol: 'UNKNOWN',
|
||||||
|
decimals: 6,
|
||||||
|
supply: BigInt(0),
|
||||||
|
creator: 'unknown',
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Metaplex metadata parser — extracts name, symbol, URI
|
||||||
|
* Layout: [1 key][32 updateAuth][32 mint][4+name][4+symbol][4+uri]...
|
||||||
|
*/
|
||||||
|
private parseMetaplexMetadata(
|
||||||
|
data: Buffer,
|
||||||
|
): { name: string; symbol: string; uri: string; updateAuthority: string } | null {
|
||||||
|
try {
|
||||||
|
let offset = 1; // skip key byte
|
||||||
|
|
||||||
|
// Update authority (32 bytes)
|
||||||
|
const updateAuthority = new PublicKey(
|
||||||
|
data.subarray(offset, offset + 32),
|
||||||
|
).toBase58();
|
||||||
|
offset += 32;
|
||||||
|
|
||||||
|
// Mint (32 bytes) — skip
|
||||||
|
offset += 32;
|
||||||
|
|
||||||
|
// Name: [u32 len][bytes]
|
||||||
|
const nameLen = data.readUInt32LE(offset);
|
||||||
|
offset += 4;
|
||||||
|
if (nameLen > 200) return null;
|
||||||
|
const name = data
|
||||||
|
.subarray(offset, offset + nameLen)
|
||||||
|
.toString('utf8')
|
||||||
|
.replace(/\0+$/, '')
|
||||||
|
.trim();
|
||||||
|
offset += nameLen;
|
||||||
|
|
||||||
|
// Symbol: [u16 len? / u32 len][bytes]
|
||||||
|
// Metaplex uses u32LE for all string lengths in the metadata account
|
||||||
|
const symbolLen = data.readUInt32LE(offset);
|
||||||
|
offset += 4;
|
||||||
|
if (symbolLen > 50) return null;
|
||||||
|
const symbol = data
|
||||||
|
.subarray(offset, offset + symbolLen)
|
||||||
|
.toString('utf8')
|
||||||
|
.replace(/\0+$/, '')
|
||||||
|
.trim();
|
||||||
|
offset += symbolLen;
|
||||||
|
|
||||||
|
// URI: [u32 len][bytes]
|
||||||
|
const uriLen = data.readUInt32LE(offset);
|
||||||
|
offset += 4;
|
||||||
|
if (uriLen > 500) return null;
|
||||||
|
const uri = data
|
||||||
|
.subarray(offset, offset + uriLen)
|
||||||
|
.toString('utf8')
|
||||||
|
.replace(/\0+$/, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return { name, symbol, uri, updateAuthority };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune the processed signatures cache to prevent memory leaks
|
||||||
|
*/
|
||||||
|
private pruneCache(): void {
|
||||||
|
if (this.processedSignatures.size > this.maxProcessedCache) {
|
||||||
|
const entries = [...this.processedSignatures];
|
||||||
|
const toRemove = entries.slice(0, entries.length - this.maxProcessedCache / 2);
|
||||||
|
for (const sig of toRemove) {
|
||||||
|
this.processedSignatures.delete(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRunning(): boolean {
|
||||||
|
return this.running;
|
||||||
|
}
|
||||||
|
}
|
||||||
573
src/scanner/raydium-scanner.ts
Normal file
573
src/scanner/raydium-scanner.ts
Normal file
@ -0,0 +1,573 @@
|
|||||||
|
/**
|
||||||
|
* Raydium Scanner — Monitors Raydium V4 and CPMM programs for new pool creations
|
||||||
|
*
|
||||||
|
* Raydium V4: 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8
|
||||||
|
* Raydium CPMM: CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C
|
||||||
|
*
|
||||||
|
* Subscribes via Solana WebSocket onLogs, then fetches full tx to parse pool state.
|
||||||
|
* Only emits pools where SOL (So11111111111111111111111111111111) is the quote token.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Connection,
|
||||||
|
PublicKey,
|
||||||
|
type Logs,
|
||||||
|
type Context,
|
||||||
|
} from '@solana/web3.js';
|
||||||
|
import type { PoolInfo } from '../types/index.js';
|
||||||
|
import {
|
||||||
|
RAYDIUM_V4_PROGRAM,
|
||||||
|
RAYDIUM_CPMM_PROGRAM,
|
||||||
|
SOL_MINT,
|
||||||
|
WSOL_MINT,
|
||||||
|
} from '../types/index.js';
|
||||||
|
import { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// ============ Raydium V4 AMM layout constants ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raydium V4 AMM account layout — key offsets for pool state data.
|
||||||
|
* Full layout is ~752 bytes. We only need a few fields.
|
||||||
|
*/
|
||||||
|
const RAYDIUM_V4_POOL_LAYOUT = {
|
||||||
|
status: 0, // u64 (8 bytes) — pool status
|
||||||
|
nonce: 8, // u64
|
||||||
|
maxOrder: 16, // u64
|
||||||
|
depth: 24, // u64
|
||||||
|
baseDecimal: 32, // u64
|
||||||
|
quoteDecimal: 40, // u64
|
||||||
|
state: 48, // u64
|
||||||
|
resetFlag: 56, // u64
|
||||||
|
minSize: 64, // u64
|
||||||
|
volMaxCutRatio: 72, // u64
|
||||||
|
amountWaveRatio: 80, // u64
|
||||||
|
baseLotSize: 88, // u64
|
||||||
|
quoteLotSize: 96, // u64
|
||||||
|
minPriceMultiplier: 104, // u64
|
||||||
|
maxPriceMultiplier: 112, // u64
|
||||||
|
systemDecimalValue: 120, // u64
|
||||||
|
baseMint: 264, // Pubkey (32 bytes)
|
||||||
|
quoteMint: 296, // Pubkey (32 bytes)
|
||||||
|
baseVault: 328, // Pubkey (32 bytes)
|
||||||
|
baseDepositTotal: 360, // u64
|
||||||
|
quoteVault: 368, // Pubkey (32 bytes)
|
||||||
|
quoteDepositTotal: 400, // u64
|
||||||
|
lpMint: 408, // Pubkey (32 bytes)
|
||||||
|
openOrders: 440, // Pubkey
|
||||||
|
marketId: 472, // Pubkey
|
||||||
|
marketProgramId: 504, // Pubkey
|
||||||
|
targetOrders: 536, // Pubkey
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raydium CPMM pool layout — key offsets.
|
||||||
|
*/
|
||||||
|
const RAYDIUM_CPMM_POOL_LAYOUT = {
|
||||||
|
discriminator: 0, // 8 bytes (anchor discriminator)
|
||||||
|
ammConfig: 8, // Pubkey (32)
|
||||||
|
poolCreator: 40, // Pubkey (32)
|
||||||
|
token0Vault: 72, // Pubkey (32)
|
||||||
|
token1Vault: 104, // Pubkey (32)
|
||||||
|
lpMint: 136, // Pubkey (32)
|
||||||
|
token0Mint: 168, // Pubkey (32)
|
||||||
|
token1Mint: 200, // Pubkey (32)
|
||||||
|
token0Program: 232, // Pubkey (32)
|
||||||
|
token1Program: 264, // Pubkey (32)
|
||||||
|
observationKey: 296, // Pubkey (32)
|
||||||
|
authBump: 328, // u8
|
||||||
|
status: 329, // u8
|
||||||
|
lpDecimals: 330, // u8
|
||||||
|
mint0Decimals: 331, // u8
|
||||||
|
mint1Decimals: 332, // u8
|
||||||
|
lpSupply: 336, // u64 (pad to 8 alignment)
|
||||||
|
protocolFeesToken0: 344, // u64
|
||||||
|
protocolFeesToken1: 352, // u64
|
||||||
|
fundFeesToken0: 360, // u64
|
||||||
|
fundFeesToken1: 368, // u64
|
||||||
|
openTime: 376, // u64
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const MIN_POOL_DATA_LEN_V4 = 540;
|
||||||
|
const MIN_POOL_DATA_LEN_CPMM = 384;
|
||||||
|
|
||||||
|
export type RaydiumPoolCallback = (pool: PoolInfo) => void | Promise<void>;
|
||||||
|
|
||||||
|
export class RaydiumScanner {
|
||||||
|
private connection: Connection;
|
||||||
|
private wsConnection: Connection;
|
||||||
|
private logger: Logger;
|
||||||
|
private running = false;
|
||||||
|
private v4SubscriptionId: number | null = null;
|
||||||
|
private cpmmSubscriptionId: number | null = null;
|
||||||
|
private onPoolCallback: RaydiumPoolCallback | null = null;
|
||||||
|
private processedSignatures = new Set<string>();
|
||||||
|
private maxProcessedCache = 10_000;
|
||||||
|
|
||||||
|
private readonly v4ProgramId = new PublicKey(RAYDIUM_V4_PROGRAM);
|
||||||
|
private readonly cpmmProgramId = new PublicKey(RAYDIUM_CPMM_PROGRAM);
|
||||||
|
|
||||||
|
constructor(rpcUrl: string, wsUrl: string) {
|
||||||
|
this.connection = new Connection(rpcUrl, 'confirmed');
|
||||||
|
this.wsConnection = new Connection(wsUrl, {
|
||||||
|
commitment: 'confirmed',
|
||||||
|
wsEndpoint: wsUrl,
|
||||||
|
});
|
||||||
|
this.logger = new Logger('Scanner');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register callback for new pool events
|
||||||
|
*/
|
||||||
|
onPool(callback: RaydiumPoolCallback): void {
|
||||||
|
this.onPoolCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start monitoring Raydium V4 and CPMM for new pool creations
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.running) {
|
||||||
|
this.logger.warn('RaydiumScanner already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
this.logger.info('🚀 Starting Raydium scanner...');
|
||||||
|
this.logger.info(` V4 Program: ${RAYDIUM_V4_PROGRAM}`);
|
||||||
|
this.logger.info(` CPMM Program: ${RAYDIUM_CPMM_PROGRAM}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Subscribe to Raydium V4 logs
|
||||||
|
this.v4SubscriptionId = this.wsConnection.onLogs(
|
||||||
|
this.v4ProgramId,
|
||||||
|
async (logs: Logs, ctx: Context) => {
|
||||||
|
try {
|
||||||
|
await this.handleV4Logs(logs, ctx);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Error processing Raydium V4 logs', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'confirmed',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe to Raydium CPMM logs
|
||||||
|
this.cpmmSubscriptionId = this.wsConnection.onLogs(
|
||||||
|
this.cpmmProgramId,
|
||||||
|
async (logs: Logs, ctx: Context) => {
|
||||||
|
try {
|
||||||
|
await this.handleCpmmLogs(logs, ctx);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Error processing Raydium CPMM logs', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'confirmed',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.success('Raydium scanner active — monitoring V4 + CPMM pools');
|
||||||
|
} catch (err) {
|
||||||
|
this.running = false;
|
||||||
|
this.logger.error('Failed to start Raydium scanner', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the scanner and clean up all subscriptions
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (!this.running) return;
|
||||||
|
|
||||||
|
this.running = false;
|
||||||
|
|
||||||
|
const cleanups: Promise<void>[] = [];
|
||||||
|
|
||||||
|
if (this.v4SubscriptionId !== null) {
|
||||||
|
cleanups.push(
|
||||||
|
this.wsConnection
|
||||||
|
.removeOnLogsListener(this.v4SubscriptionId)
|
||||||
|
.catch(() => {}),
|
||||||
|
);
|
||||||
|
this.v4SubscriptionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cpmmSubscriptionId !== null) {
|
||||||
|
cleanups.push(
|
||||||
|
this.wsConnection
|
||||||
|
.removeOnLogsListener(this.cpmmSubscriptionId)
|
||||||
|
.catch(() => {}),
|
||||||
|
);
|
||||||
|
this.cpmmSubscriptionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(cleanups);
|
||||||
|
this.processedSignatures.clear();
|
||||||
|
this.logger.info('Raydium scanner stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Raydium V4 processing ============
|
||||||
|
|
||||||
|
private async handleV4Logs(logs: Logs, _ctx: Context): Promise<void> {
|
||||||
|
const { signature, err, logs: logMessages } = logs;
|
||||||
|
|
||||||
|
if (err) return;
|
||||||
|
if (this.processedSignatures.has(signature)) return;
|
||||||
|
this.processedSignatures.add(signature);
|
||||||
|
this.pruneCache();
|
||||||
|
|
||||||
|
// Raydium V4 "initialize2" or "initialize" is the pool creation instruction
|
||||||
|
const isPoolCreation = logMessages.some(
|
||||||
|
(log) =>
|
||||||
|
log.includes('Program log: initialize2:') ||
|
||||||
|
log.includes('Program log: ray_log') ||
|
||||||
|
(log.includes('Program log: Instruction: ') && log.includes('nitialize')),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isPoolCreation) return;
|
||||||
|
|
||||||
|
this.logger.info(`🏊 Raydium V4 pool creation — tx: ${signature.slice(0, 16)}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tx = await this.connection.getParsedTransaction(signature, {
|
||||||
|
maxSupportedTransactionVersion: 0,
|
||||||
|
commitment: 'confirmed',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tx?.meta || !tx.transaction) return;
|
||||||
|
|
||||||
|
const pool = await this.parseV4PoolFromTx(tx, signature);
|
||||||
|
if (pool) {
|
||||||
|
await this.emitPool(pool);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Error fetching V4 pool tx ${signature}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Raydium V4 pool information from a transaction.
|
||||||
|
* Tries to read the AMM account on-chain for accurate pool data.
|
||||||
|
*/
|
||||||
|
private async parseV4PoolFromTx(tx: any, signature: string): Promise<PoolInfo | null> {
|
||||||
|
try {
|
||||||
|
const accountKeys: string[] = tx.transaction.message.accountKeys.map((k: any) =>
|
||||||
|
typeof k === 'string' ? k : k.pubkey.toBase58(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the instruction targeting the Raydium V4 program
|
||||||
|
let ammAddress: string | null = null;
|
||||||
|
|
||||||
|
for (const ix of tx.transaction.message.instructions) {
|
||||||
|
const programId =
|
||||||
|
typeof ix.programId === 'string'
|
||||||
|
? ix.programId
|
||||||
|
: ix.programId?.toBase58?.();
|
||||||
|
|
||||||
|
if (programId === RAYDIUM_V4_PROGRAM) {
|
||||||
|
const ixAccounts: string[] = ((ix as any).accounts || []).map(
|
||||||
|
(a: any) => (typeof a === 'string' ? a : a.toBase58()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// In Raydium V4 initialize2, the AMM account is typically index 4
|
||||||
|
if (ixAccounts.length >= 11) {
|
||||||
|
ammAddress = ixAccounts[4] || ixAccounts[3];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ammAddress) {
|
||||||
|
this.logger.debug('Could not find AMM address from V4 init tx');
|
||||||
|
return this.parsePoolFromTokenBalances(tx, signature, 'raydium');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the AMM account data
|
||||||
|
const ammAccountInfo = await this.connection.getAccountInfo(
|
||||||
|
new PublicKey(ammAddress),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!ammAccountInfo?.data || ammAccountInfo.data.length < MIN_POOL_DATA_LEN_V4) {
|
||||||
|
this.logger.debug(`AMM account data too small: ${ammAccountInfo?.data?.length}`);
|
||||||
|
return this.parsePoolFromTokenBalances(tx, signature, 'raydium');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = ammAccountInfo.data;
|
||||||
|
|
||||||
|
// Parse key fields from the V4 layout
|
||||||
|
const baseMint = new PublicKey(
|
||||||
|
data.subarray(RAYDIUM_V4_POOL_LAYOUT.baseMint, RAYDIUM_V4_POOL_LAYOUT.baseMint + 32),
|
||||||
|
).toBase58();
|
||||||
|
const quoteMint = new PublicKey(
|
||||||
|
data.subarray(RAYDIUM_V4_POOL_LAYOUT.quoteMint, RAYDIUM_V4_POOL_LAYOUT.quoteMint + 32),
|
||||||
|
).toBase58();
|
||||||
|
const lpMint = new PublicKey(
|
||||||
|
data.subarray(RAYDIUM_V4_POOL_LAYOUT.lpMint, RAYDIUM_V4_POOL_LAYOUT.lpMint + 32),
|
||||||
|
).toBase58();
|
||||||
|
|
||||||
|
// Read reserves
|
||||||
|
const baseDepositTotal = data.readBigUInt64LE(RAYDIUM_V4_POOL_LAYOUT.baseDepositTotal);
|
||||||
|
const quoteDepositTotal = data.readBigUInt64LE(RAYDIUM_V4_POOL_LAYOUT.quoteDepositTotal);
|
||||||
|
|
||||||
|
// Only emit pools with SOL as quote
|
||||||
|
if (quoteMint !== SOL_MINT && quoteMint !== WSOL_MINT && baseMint !== SOL_MINT && baseMint !== WSOL_MINT) {
|
||||||
|
this.logger.debug(`Skipping non-SOL pool: ${baseMint}/${quoteMint}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize: base = token, quote = SOL
|
||||||
|
const isSolBase = baseMint === SOL_MINT || baseMint === WSOL_MINT;
|
||||||
|
const pool: PoolInfo = {
|
||||||
|
poolAddress: ammAddress,
|
||||||
|
baseMint: isSolBase ? quoteMint : baseMint,
|
||||||
|
quoteMint: SOL_MINT,
|
||||||
|
baseReserve: isSolBase ? quoteDepositTotal : baseDepositTotal,
|
||||||
|
quoteReserve: isSolBase ? baseDepositTotal : quoteDepositTotal,
|
||||||
|
lpMint,
|
||||||
|
dex: 'raydium',
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Error parsing V4 pool from tx', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Raydium CPMM processing ============
|
||||||
|
|
||||||
|
private async handleCpmmLogs(logs: Logs, _ctx: Context): Promise<void> {
|
||||||
|
const { signature, err, logs: logMessages } = logs;
|
||||||
|
|
||||||
|
if (err) return;
|
||||||
|
if (this.processedSignatures.has(signature)) return;
|
||||||
|
this.processedSignatures.add(signature);
|
||||||
|
this.pruneCache();
|
||||||
|
|
||||||
|
// CPMM pool creation instruction
|
||||||
|
const isPoolCreation = logMessages.some(
|
||||||
|
(log) =>
|
||||||
|
log.includes('Program log: Instruction: Initialize') ||
|
||||||
|
log.includes('Program log: instruction: Initialize'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isPoolCreation) return;
|
||||||
|
|
||||||
|
this.logger.info(`🏊 Raydium CPMM pool creation — tx: ${signature.slice(0, 16)}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tx = await this.connection.getParsedTransaction(signature, {
|
||||||
|
maxSupportedTransactionVersion: 0,
|
||||||
|
commitment: 'confirmed',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tx?.meta || !tx.transaction) return;
|
||||||
|
|
||||||
|
const pool = await this.parseCpmmPoolFromTx(tx, signature);
|
||||||
|
if (pool) {
|
||||||
|
await this.emitPool(pool);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Error fetching CPMM pool tx ${signature}`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Raydium CPMM pool information
|
||||||
|
*/
|
||||||
|
private async parseCpmmPoolFromTx(tx: any, signature: string): Promise<PoolInfo | null> {
|
||||||
|
try {
|
||||||
|
let poolAddress: string | null = null;
|
||||||
|
|
||||||
|
for (const ix of tx.transaction.message.instructions) {
|
||||||
|
const programId =
|
||||||
|
typeof ix.programId === 'string'
|
||||||
|
? ix.programId
|
||||||
|
: ix.programId?.toBase58?.();
|
||||||
|
|
||||||
|
if (programId === RAYDIUM_CPMM_PROGRAM) {
|
||||||
|
const ixAccounts: string[] = ((ix as any).accounts || []).map(
|
||||||
|
(a: any) => (typeof a === 'string' ? a : a.toBase58()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// CPMM Initialize accounts layout:
|
||||||
|
// [0] creator, [1] ammConfig, [2] authority, [3] poolState,
|
||||||
|
// [4] token0Mint, [5] token1Mint, [6] lpMint, [7] creatorToken0,
|
||||||
|
// [8] creatorToken1, [9] creatorLpToken, [10] token0Vault,
|
||||||
|
// [11] token1Vault, [12] createPoolFee, ...
|
||||||
|
if (ixAccounts.length >= 12) {
|
||||||
|
poolAddress = ixAccounts[3]; // poolState account
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!poolAddress) {
|
||||||
|
this.logger.debug('Could not find CPMM pool address');
|
||||||
|
return this.parsePoolFromTokenBalances(tx, signature, 'raydium-cpmm');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the pool state account
|
||||||
|
const poolAccountInfo = await this.connection.getAccountInfo(
|
||||||
|
new PublicKey(poolAddress),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!poolAccountInfo?.data ||
|
||||||
|
poolAccountInfo.data.length < MIN_POOL_DATA_LEN_CPMM
|
||||||
|
) {
|
||||||
|
return this.parsePoolFromTokenBalances(tx, signature, 'raydium-cpmm');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = poolAccountInfo.data;
|
||||||
|
|
||||||
|
const token0Mint = new PublicKey(
|
||||||
|
data.subarray(
|
||||||
|
RAYDIUM_CPMM_POOL_LAYOUT.token0Mint,
|
||||||
|
RAYDIUM_CPMM_POOL_LAYOUT.token0Mint + 32,
|
||||||
|
),
|
||||||
|
).toBase58();
|
||||||
|
const token1Mint = new PublicKey(
|
||||||
|
data.subarray(
|
||||||
|
RAYDIUM_CPMM_POOL_LAYOUT.token1Mint,
|
||||||
|
RAYDIUM_CPMM_POOL_LAYOUT.token1Mint + 32,
|
||||||
|
),
|
||||||
|
).toBase58();
|
||||||
|
const lpMint = new PublicKey(
|
||||||
|
data.subarray(
|
||||||
|
RAYDIUM_CPMM_POOL_LAYOUT.lpMint,
|
||||||
|
RAYDIUM_CPMM_POOL_LAYOUT.lpMint + 32,
|
||||||
|
),
|
||||||
|
).toBase58();
|
||||||
|
|
||||||
|
// Only SOL pairs
|
||||||
|
const isSol0 = token0Mint === SOL_MINT || token0Mint === WSOL_MINT;
|
||||||
|
const isSol1 = token1Mint === SOL_MINT || token1Mint === WSOL_MINT;
|
||||||
|
if (!isSol0 && !isSol1) {
|
||||||
|
this.logger.debug(`Skipping non-SOL CPMM pool: ${token0Mint}/${token1Mint}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read vault balances for reserves
|
||||||
|
const token0Vault = new PublicKey(
|
||||||
|
data.subarray(
|
||||||
|
RAYDIUM_CPMM_POOL_LAYOUT.token0Vault,
|
||||||
|
RAYDIUM_CPMM_POOL_LAYOUT.token0Vault + 32,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const token1Vault = new PublicKey(
|
||||||
|
data.subarray(
|
||||||
|
RAYDIUM_CPMM_POOL_LAYOUT.token1Vault,
|
||||||
|
RAYDIUM_CPMM_POOL_LAYOUT.token1Vault + 32,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch vault token balances for reserves
|
||||||
|
let reserve0 = BigInt(0);
|
||||||
|
let reserve1 = BigInt(0);
|
||||||
|
try {
|
||||||
|
const [vault0Info, vault1Info] = await Promise.all([
|
||||||
|
this.connection.getTokenAccountBalance(token0Vault),
|
||||||
|
this.connection.getTokenAccountBalance(token1Vault),
|
||||||
|
]);
|
||||||
|
reserve0 = BigInt(vault0Info.value.amount);
|
||||||
|
reserve1 = BigInt(vault1Info.value.amount);
|
||||||
|
} catch {
|
||||||
|
// Vaults might not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool: PoolInfo = {
|
||||||
|
poolAddress,
|
||||||
|
baseMint: isSol0 ? token1Mint : token0Mint,
|
||||||
|
quoteMint: SOL_MINT,
|
||||||
|
baseReserve: isSol0 ? reserve1 : reserve0,
|
||||||
|
quoteReserve: isSol0 ? reserve0 : reserve1,
|
||||||
|
lpMint,
|
||||||
|
dex: 'raydium-cpmm',
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Error parsing CPMM pool from tx', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Fallback parser ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback: Parse pool info from transaction token balance changes
|
||||||
|
* When we can't read the on-chain account, we still extract mints from tx.
|
||||||
|
*/
|
||||||
|
private parsePoolFromTokenBalances(
|
||||||
|
tx: any,
|
||||||
|
signature: string,
|
||||||
|
dex: 'raydium' | 'raydium-cpmm',
|
||||||
|
): PoolInfo | null {
|
||||||
|
try {
|
||||||
|
const postTokenBalances = tx.meta?.postTokenBalances || [];
|
||||||
|
const mints = new Set<string>();
|
||||||
|
|
||||||
|
for (const balance of postTokenBalances) {
|
||||||
|
if (balance.mint) mints.add(balance.mint as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the non-SOL mint
|
||||||
|
const tokenMints = [...mints].filter(
|
||||||
|
(m) => m !== SOL_MINT && m !== WSOL_MINT,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tokenMints.length === 0) return null;
|
||||||
|
|
||||||
|
const baseMint = tokenMints[0];
|
||||||
|
|
||||||
|
const accountKeys: string[] = tx.transaction.message.accountKeys.map((k: any) =>
|
||||||
|
typeof k === 'string' ? k : k.pubkey.toBase58(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
poolAddress: accountKeys[3] || signature,
|
||||||
|
baseMint,
|
||||||
|
quoteMint: SOL_MINT,
|
||||||
|
baseReserve: BigInt(0),
|
||||||
|
quoteReserve: BigInt(0),
|
||||||
|
lpMint: '',
|
||||||
|
dex,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Emit helper ============
|
||||||
|
|
||||||
|
private async emitPool(pool: PoolInfo): Promise<void> {
|
||||||
|
this.logger.success(
|
||||||
|
`New ${pool.dex} pool: ${pool.baseMint.slice(0, 12)}... / SOL — Pool: ${pool.poolAddress.slice(0, 12)}...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pool.quoteReserve > BigInt(0)) {
|
||||||
|
const solLiquidity = Number(pool.quoteReserve) / 1_000_000_000;
|
||||||
|
this.logger.info(` Liquidity: ${solLiquidity.toFixed(2)} SOL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.onPoolCallback) {
|
||||||
|
await this.onPoolCallback(pool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneCache(): void {
|
||||||
|
if (this.processedSignatures.size > this.maxProcessedCache) {
|
||||||
|
const entries = [...this.processedSignatures];
|
||||||
|
const toRemove = entries.slice(0, entries.length - this.maxProcessedCache / 2);
|
||||||
|
for (const sig of toRemove) {
|
||||||
|
this.processedSignatures.delete(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRunning(): boolean {
|
||||||
|
return this.running;
|
||||||
|
}
|
||||||
|
}
|
||||||
248
src/types/index.ts
Normal file
248
src/types/index.ts
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Core types for Solana Sniper Bot
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============ Token & Market Types ============
|
||||||
|
|
||||||
|
export interface TokenInfo {
|
||||||
|
mint: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
decimals: number;
|
||||||
|
supply: bigint;
|
||||||
|
creator: string;
|
||||||
|
createdAt: Date;
|
||||||
|
uri?: string; // Metadata URI
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
twitter?: string;
|
||||||
|
telegram?: string;
|
||||||
|
website?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenAnalysis {
|
||||||
|
mint: string;
|
||||||
|
score: number; // 0-100, higher = safer
|
||||||
|
mintAuthorityRevoked: boolean;
|
||||||
|
freezeAuthorityRevoked: boolean;
|
||||||
|
lpLocked: boolean;
|
||||||
|
lpBurned: boolean;
|
||||||
|
topHolderConcentration: number; // % held by top 10
|
||||||
|
devWalletRisk: 'low' | 'medium' | 'high' | 'unknown';
|
||||||
|
hasSocials: boolean;
|
||||||
|
rugcheckScore?: number; // external API score
|
||||||
|
flags: string[]; // warning flags
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoolInfo {
|
||||||
|
poolAddress: string;
|
||||||
|
baseMint: string; // token mint
|
||||||
|
quoteMint: string; // usually SOL (So11111111111111111111111111111111)
|
||||||
|
baseReserve: bigint;
|
||||||
|
quoteReserve: bigint;
|
||||||
|
lpMint: string;
|
||||||
|
dex: 'raydium' | 'raydium-cpmm' | 'meteora' | 'orca' | 'pumpfun';
|
||||||
|
createdAt: Date;
|
||||||
|
liquidityUsd?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceData {
|
||||||
|
mint: string;
|
||||||
|
priceUsd: number;
|
||||||
|
priceSol: number;
|
||||||
|
volume24h?: number;
|
||||||
|
marketCapUsd?: number;
|
||||||
|
liquidityUsd?: number;
|
||||||
|
priceChange5m?: number;
|
||||||
|
priceChange1h?: number;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Trading Types ============
|
||||||
|
|
||||||
|
export interface TradeSignal {
|
||||||
|
type: 'new_token' | 'copy_trade' | 'volume_spike' | 'arbitrage';
|
||||||
|
mint: string;
|
||||||
|
tokenInfo?: TokenInfo;
|
||||||
|
analysis?: TokenAnalysis;
|
||||||
|
pool?: PoolInfo;
|
||||||
|
sourceWallet?: string; // for copy trades
|
||||||
|
confidence: number; // 0-100
|
||||||
|
suggestedAmountSol: number;
|
||||||
|
reason: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradeOrder {
|
||||||
|
id: string;
|
||||||
|
mint: string;
|
||||||
|
side: 'buy' | 'sell';
|
||||||
|
amountSol?: number; // for buys
|
||||||
|
amountTokens?: bigint; // for sells
|
||||||
|
slippageBps: number; // basis points (100 = 1%)
|
||||||
|
maxRetries: number;
|
||||||
|
useJito: boolean;
|
||||||
|
jitoTipLamports?: number;
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradeResult {
|
||||||
|
orderId: string;
|
||||||
|
success: boolean;
|
||||||
|
txSignature?: string;
|
||||||
|
inputAmount: number; // SOL or tokens
|
||||||
|
outputAmount: number; // tokens or SOL
|
||||||
|
pricePerToken?: number;
|
||||||
|
fees: number; // in SOL
|
||||||
|
jitoTip?: number; // in SOL
|
||||||
|
error?: string;
|
||||||
|
executedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
id: string;
|
||||||
|
mint: string;
|
||||||
|
symbol: string;
|
||||||
|
entryPriceSol: number;
|
||||||
|
entryAmountSol: number;
|
||||||
|
tokensHeld: bigint;
|
||||||
|
currentPriceSol: number;
|
||||||
|
currentValueSol: number;
|
||||||
|
pnlSol: number;
|
||||||
|
pnlPercent: number;
|
||||||
|
highestPriceSol: number;
|
||||||
|
takeProfitTier: number; // 0-4 (which tier we've hit)
|
||||||
|
stopLossPrice: number;
|
||||||
|
trailingStopActive: boolean;
|
||||||
|
trailingStopPrice: number;
|
||||||
|
signal: TradeSignal;
|
||||||
|
trades: TradeResult[];
|
||||||
|
openedAt: Date;
|
||||||
|
lastUpdated: Date;
|
||||||
|
status: 'open' | 'partial_exit' | 'closed';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Copy Trade Types ============
|
||||||
|
|
||||||
|
export interface TrackedWallet {
|
||||||
|
address: string;
|
||||||
|
label: string;
|
||||||
|
tier: 'S' | 'A' | 'B' | 'C';
|
||||||
|
winRate: number;
|
||||||
|
avgReturn: number;
|
||||||
|
totalTrades: number;
|
||||||
|
enabled: boolean;
|
||||||
|
maxCopyAmountSol: number;
|
||||||
|
addedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletActivity {
|
||||||
|
wallet: string;
|
||||||
|
type: 'buy' | 'sell';
|
||||||
|
mint: string;
|
||||||
|
amountSol: number;
|
||||||
|
amountTokens: bigint;
|
||||||
|
txSignature: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Config Types ============
|
||||||
|
|
||||||
|
export interface BotConfig {
|
||||||
|
// RPC & Network
|
||||||
|
rpcUrl: string;
|
||||||
|
rpcWsUrl: string;
|
||||||
|
heliusApiKey?: string;
|
||||||
|
jitoBlockEngineUrl: string;
|
||||||
|
|
||||||
|
// Wallet
|
||||||
|
walletPrivateKey: string; // base58
|
||||||
|
|
||||||
|
// Trading params
|
||||||
|
paperTrade: boolean;
|
||||||
|
maxPositionSizeSol: number;
|
||||||
|
maxConcurrentPositions: number;
|
||||||
|
maxDailyLossSol: number;
|
||||||
|
defaultSlippageBps: number;
|
||||||
|
|
||||||
|
// Analyzer thresholds
|
||||||
|
minTokenScore: number; // minimum score to buy (0-100)
|
||||||
|
requireMintRevoked: boolean;
|
||||||
|
requireFreezeRevoked: boolean;
|
||||||
|
maxTopHolderPercent: number;
|
||||||
|
|
||||||
|
// Exit strategy
|
||||||
|
takeProfitTiers: TakeProfitTier[];
|
||||||
|
stopLossPercent: number; // e.g., -30
|
||||||
|
trailingStopPercent: number; // e.g., -20 (activates after first TP)
|
||||||
|
maxHoldTimeMinutes: number; // exit if no movement
|
||||||
|
|
||||||
|
// Scanner
|
||||||
|
scanPumpFun: boolean;
|
||||||
|
scanRaydium: boolean;
|
||||||
|
copyTradeEnabled: boolean;
|
||||||
|
trackedWallets: TrackedWallet[];
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
discordWebhookUrl?: string;
|
||||||
|
|
||||||
|
// Jito
|
||||||
|
useJito: boolean;
|
||||||
|
jitoTipLamports: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TakeProfitTier {
|
||||||
|
multiplier: number; // e.g., 2 = 2x
|
||||||
|
sellPercent: number; // e.g., 25 = sell 25% of remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Events ============
|
||||||
|
|
||||||
|
export type BotEvent =
|
||||||
|
| { type: 'new_token_detected'; data: TokenInfo & { pool: PoolInfo } }
|
||||||
|
| { type: 'token_analyzed'; data: TokenAnalysis }
|
||||||
|
| { type: 'trade_signal'; data: TradeSignal }
|
||||||
|
| { type: 'trade_executed'; data: TradeResult }
|
||||||
|
| { type: 'position_opened'; data: Position }
|
||||||
|
| { type: 'position_updated'; data: Position }
|
||||||
|
| { type: 'position_closed'; data: Position & { reason: string } }
|
||||||
|
| { type: 'copy_trade_detected'; data: WalletActivity }
|
||||||
|
| { type: 'rug_detected'; data: { mint: string; reason: string } }
|
||||||
|
| { type: 'kill_switch'; data: { reason: string } }
|
||||||
|
| { type: 'daily_limit_hit'; data: { totalLoss: number; limit: number } }
|
||||||
|
| { type: 'error'; data: { module: string; error: string } };
|
||||||
|
|
||||||
|
export type EventHandler = (event: BotEvent) => void | Promise<void>;
|
||||||
|
|
||||||
|
// ============ Database Types ============
|
||||||
|
|
||||||
|
export interface TradeRecord {
|
||||||
|
id: string;
|
||||||
|
mint: string;
|
||||||
|
symbol: string;
|
||||||
|
side: 'buy' | 'sell';
|
||||||
|
amountSol: number;
|
||||||
|
amountTokens: string; // stored as string for bigint
|
||||||
|
pricePerToken: number;
|
||||||
|
txSignature: string;
|
||||||
|
signal_type: string;
|
||||||
|
pnlSol?: number;
|
||||||
|
pnlPercent?: number;
|
||||||
|
fees: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Constants ============
|
||||||
|
|
||||||
|
export const SOL_MINT = 'So11111111111111111111111111111111';
|
||||||
|
export const WSOL_MINT = 'So11111111111111111111111111111111';
|
||||||
|
export const PUMP_FUN_PROGRAM = '6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P';
|
||||||
|
export const PUMP_FUN_MIGRATION_AUTHORITY = '39azUYFWPz3VHgKCf3VChUwbpURdCHRxjWVowf5jUJjg';
|
||||||
|
export const RAYDIUM_V4_PROGRAM = '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8';
|
||||||
|
export const RAYDIUM_CPMM_PROGRAM = 'CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C';
|
||||||
|
export const METEORA_DLMM_PROGRAM = 'LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo';
|
||||||
|
export const JUPITER_V6_API = 'https://quote-api.jup.ag/v6';
|
||||||
|
export const JITO_BLOCK_ENGINE = 'https://mainnet.block-engine.jito.wtf';
|
||||||
|
export const LAMPORTS_PER_SOL = 1_000_000_000;
|
||||||
152
src/utils/config.ts
Normal file
152
src/utils/config.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Bot configuration loader
|
||||||
|
* Loads from .env + config.json with sane defaults
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import type { BotConfig, TakeProfitTier, TrackedWallet } from '../types/index.js';
|
||||||
|
|
||||||
|
const DEFAULT_TAKE_PROFIT_TIERS: TakeProfitTier[] = [
|
||||||
|
{ multiplier: 2, sellPercent: 25 },
|
||||||
|
{ multiplier: 3, sellPercent: 25 },
|
||||||
|
{ multiplier: 5, sellPercent: 25 },
|
||||||
|
// remaining 25% rides with trailing stop
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: BotConfig = {
|
||||||
|
// RPC
|
||||||
|
rpcUrl: 'https://api.mainnet-beta.solana.com',
|
||||||
|
rpcWsUrl: 'wss://api.mainnet-beta.solana.com',
|
||||||
|
heliusApiKey: '',
|
||||||
|
jitoBlockEngineUrl: 'https://mainnet.block-engine.jito.wtf',
|
||||||
|
|
||||||
|
// Wallet
|
||||||
|
walletPrivateKey: '',
|
||||||
|
|
||||||
|
// Trading
|
||||||
|
paperTrade: true, // SAFE DEFAULT: paper trade
|
||||||
|
maxPositionSizeSol: 0.5,
|
||||||
|
maxConcurrentPositions: 5,
|
||||||
|
maxDailyLossSol: 2,
|
||||||
|
defaultSlippageBps: 1500, // 15% for meme coins
|
||||||
|
|
||||||
|
// Analyzer
|
||||||
|
minTokenScore: 70,
|
||||||
|
requireMintRevoked: true,
|
||||||
|
requireFreezeRevoked: true,
|
||||||
|
maxTopHolderPercent: 50,
|
||||||
|
|
||||||
|
// Exit strategy
|
||||||
|
takeProfitTiers: DEFAULT_TAKE_PROFIT_TIERS,
|
||||||
|
stopLossPercent: -30,
|
||||||
|
trailingStopPercent: -20,
|
||||||
|
maxHoldTimeMinutes: 60,
|
||||||
|
|
||||||
|
// Scanner
|
||||||
|
scanPumpFun: true,
|
||||||
|
scanRaydium: true,
|
||||||
|
copyTradeEnabled: false,
|
||||||
|
trackedWallets: [],
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
discordWebhookUrl: '',
|
||||||
|
|
||||||
|
// Jito
|
||||||
|
useJito: true,
|
||||||
|
jitoTipLamports: 10_000, // 0.00001 SOL
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadConfig(configPath?: string): BotConfig {
|
||||||
|
// Start with defaults
|
||||||
|
let config = { ...DEFAULT_CONFIG };
|
||||||
|
|
||||||
|
// Load .env if present
|
||||||
|
const envPath = resolve(process.cwd(), '.env');
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
const envContent = readFileSync(envPath, 'utf8');
|
||||||
|
for (const line of envContent.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eqIdx = trimmed.indexOf('=');
|
||||||
|
if (eqIdx === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eqIdx).trim();
|
||||||
|
const value = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config.json if present
|
||||||
|
const jsonPath = configPath || resolve(process.cwd(), 'config.json');
|
||||||
|
if (existsSync(jsonPath)) {
|
||||||
|
try {
|
||||||
|
const jsonContent = JSON.parse(readFileSync(jsonPath, 'utf8'));
|
||||||
|
config = deepMerge(config, jsonContent);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Warning: Failed to parse ${jsonPath}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override with env vars
|
||||||
|
if (process.env.RPC_URL) config.rpcUrl = process.env.RPC_URL;
|
||||||
|
if (process.env.RPC_WS_URL) config.rpcWsUrl = process.env.RPC_WS_URL;
|
||||||
|
if (process.env.HELIUS_API_KEY) {
|
||||||
|
config.heliusApiKey = process.env.HELIUS_API_KEY;
|
||||||
|
// Auto-configure Helius URLs if using Helius key
|
||||||
|
if (!process.env.RPC_URL) {
|
||||||
|
config.rpcUrl = `https://mainnet.helius-rpc.com/?api-key=${config.heliusApiKey}`;
|
||||||
|
}
|
||||||
|
if (!process.env.RPC_WS_URL) {
|
||||||
|
config.rpcWsUrl = `wss://mainnet.helius-rpc.com/?api-key=${config.heliusApiKey}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (process.env.WALLET_PRIVATE_KEY) config.walletPrivateKey = process.env.WALLET_PRIVATE_KEY;
|
||||||
|
if (process.env.PAPER_TRADE !== undefined) config.paperTrade = process.env.PAPER_TRADE !== 'false';
|
||||||
|
if (process.env.DISCORD_WEBHOOK_URL) config.discordWebhookUrl = process.env.DISCORD_WEBHOOK_URL;
|
||||||
|
if (process.env.MAX_POSITION_SIZE_SOL) config.maxPositionSizeSol = parseFloat(process.env.MAX_POSITION_SIZE_SOL);
|
||||||
|
if (process.env.MAX_DAILY_LOSS_SOL) config.maxDailyLossSol = parseFloat(process.env.MAX_DAILY_LOSS_SOL);
|
||||||
|
if (process.env.MIN_TOKEN_SCORE) config.minTokenScore = parseInt(process.env.MIN_TOKEN_SCORE, 10);
|
||||||
|
if (process.env.USE_JITO !== undefined) config.useJito = process.env.USE_JITO !== 'false';
|
||||||
|
if (process.env.JITO_TIP_LAMPORTS) config.jitoTipLamports = parseInt(process.env.JITO_TIP_LAMPORTS, 10);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>): T {
|
||||||
|
const result = { ...target };
|
||||||
|
for (const key of Object.keys(source) as (keyof T)[]) {
|
||||||
|
const val = source[key];
|
||||||
|
if (val && typeof val === 'object' && !Array.isArray(val) && typeof result[key] === 'object') {
|
||||||
|
result[key] = deepMerge(result[key] as any, val as any);
|
||||||
|
} else if (val !== undefined) {
|
||||||
|
result[key] = val as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateConfig(config: BotConfig): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!config.paperTrade && !config.walletPrivateKey) {
|
||||||
|
errors.push('WALLET_PRIVATE_KEY is required for live trading');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.rpcUrl || config.rpcUrl === 'https://api.mainnet-beta.solana.com') {
|
||||||
|
errors.push('WARNING: Using public RPC. Get a Helius/QuickNode key for reliable trading.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.maxPositionSizeSol > 5) {
|
||||||
|
errors.push(`Max position size is ${config.maxPositionSizeSol} SOL - that's aggressive. Are you sure?`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.defaultSlippageBps > 3000) {
|
||||||
|
errors.push(`Slippage is ${config.defaultSlippageBps / 100}% - that's very high`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.minTokenScore < 50) {
|
||||||
|
errors.push(`Min token score is ${config.minTokenScore} - very risky. Recommend 70+`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
207
src/utils/database.ts
Normal file
207
src/utils/database.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* SQLite database for trade history, positions, and analytics
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { Logger } from './logger.js';
|
||||||
|
import type { TradeRecord, Position } from '../types/index.js';
|
||||||
|
|
||||||
|
const log = new Logger('DB');
|
||||||
|
|
||||||
|
export class BotDatabase {
|
||||||
|
private db: Database.Database;
|
||||||
|
|
||||||
|
constructor(dbPath?: string) {
|
||||||
|
const path = dbPath || resolve(process.cwd(), 'data', 'bot.db');
|
||||||
|
this.db = new Database(path);
|
||||||
|
this.db.pragma('journal_mode = WAL');
|
||||||
|
this.init();
|
||||||
|
log.info(`Database initialized at ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS trades (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
mint TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
side TEXT NOT NULL CHECK(side IN ('buy', 'sell')),
|
||||||
|
amount_sol REAL NOT NULL,
|
||||||
|
amount_tokens TEXT NOT NULL,
|
||||||
|
price_per_token REAL NOT NULL,
|
||||||
|
tx_signature TEXT,
|
||||||
|
signal_type TEXT NOT NULL,
|
||||||
|
pnl_sol REAL,
|
||||||
|
pnl_percent REAL,
|
||||||
|
fees REAL DEFAULT 0,
|
||||||
|
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS positions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
mint TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
entry_price_sol REAL NOT NULL,
|
||||||
|
entry_amount_sol REAL NOT NULL,
|
||||||
|
tokens_held TEXT NOT NULL,
|
||||||
|
current_price_sol REAL,
|
||||||
|
highest_price_sol REAL,
|
||||||
|
take_profit_tier INTEGER DEFAULT 0,
|
||||||
|
stop_loss_price REAL,
|
||||||
|
trailing_stop_active INTEGER DEFAULT 0,
|
||||||
|
trailing_stop_price REAL,
|
||||||
|
signal_data TEXT,
|
||||||
|
status TEXT DEFAULT 'open' CHECK(status IN ('open', 'partial_exit', 'closed')),
|
||||||
|
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
closed_at TEXT,
|
||||||
|
close_reason TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wallet_tracking (
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
mint TEXT NOT NULL,
|
||||||
|
side TEXT NOT NULL,
|
||||||
|
amount_sol REAL NOT NULL,
|
||||||
|
amount_tokens TEXT NOT NULL,
|
||||||
|
tx_signature TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (tx_signature)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_stats (
|
||||||
|
date TEXT PRIMARY KEY,
|
||||||
|
total_trades INTEGER DEFAULT 0,
|
||||||
|
wins INTEGER DEFAULT 0,
|
||||||
|
losses INTEGER DEFAULT 0,
|
||||||
|
total_pnl_sol REAL DEFAULT 0,
|
||||||
|
total_volume_sol REAL DEFAULT 0,
|
||||||
|
best_trade_pnl REAL DEFAULT 0,
|
||||||
|
worst_trade_pnl REAL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS token_cache (
|
||||||
|
mint TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
symbol TEXT,
|
||||||
|
score INTEGER,
|
||||||
|
analysis_data TEXT,
|
||||||
|
last_checked TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trades_mint ON trades(mint);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trades_timestamp ON trades(timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_positions_status ON positions(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_positions_mint ON positions(mint);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallet_tracking_address ON wallet_tracking(address);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Trades ===
|
||||||
|
|
||||||
|
insertTrade(trade: TradeRecord): void {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
INSERT INTO trades (id, mint, symbol, side, amount_sol, amount_tokens, price_per_token, tx_signature, signal_type, pnl_sol, pnl_percent, fees, timestamp)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
stmt.run(
|
||||||
|
trade.id, trade.mint, trade.symbol, trade.side, trade.amountSol,
|
||||||
|
trade.amountTokens, trade.pricePerToken, trade.txSignature,
|
||||||
|
trade.signal_type, trade.pnlSol || null, trade.pnlPercent || null,
|
||||||
|
trade.fees, trade.timestamp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecentTrades(limit = 50): TradeRecord[] {
|
||||||
|
return this.db.prepare(
|
||||||
|
'SELECT * FROM trades ORDER BY timestamp DESC LIMIT ?'
|
||||||
|
).all(limit) as TradeRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Positions ===
|
||||||
|
|
||||||
|
insertPosition(pos: Partial<Position> & { id: string; mint: string; symbol: string; entryPriceSol: number; entryAmountSol: number; tokensHeld: bigint }): void {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
INSERT INTO positions (id, mint, symbol, entry_price_sol, entry_amount_sol, tokens_held, current_price_sol, highest_price_sol, stop_loss_price, signal_data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
stmt.run(
|
||||||
|
pos.id, pos.mint, pos.symbol, pos.entryPriceSol, pos.entryAmountSol,
|
||||||
|
pos.tokensHeld.toString(), pos.entryPriceSol, pos.entryPriceSol,
|
||||||
|
pos.stopLossPrice || pos.entryPriceSol * 0.7,
|
||||||
|
pos.signal ? JSON.stringify(pos.signal) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpenPositions(): any[] {
|
||||||
|
return this.db.prepare(
|
||||||
|
"SELECT * FROM positions WHERE status IN ('open', 'partial_exit') ORDER BY opened_at DESC"
|
||||||
|
).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePosition(id: string, updates: Record<string, any>): void {
|
||||||
|
const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(', ');
|
||||||
|
const stmt = this.db.prepare(`UPDATE positions SET ${setClauses} WHERE id = ?`);
|
||||||
|
stmt.run(...Object.values(updates), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
closePosition(id: string, reason: string): void {
|
||||||
|
this.db.prepare(`
|
||||||
|
UPDATE positions SET status = 'closed', closed_at = datetime('now'), close_reason = ? WHERE id = ?
|
||||||
|
`).run(reason, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Daily Stats ===
|
||||||
|
|
||||||
|
getDailyPnl(date?: string): { totalPnl: number; trades: number; wins: number; losses: number } {
|
||||||
|
const d = date || new Date().toISOString().slice(0, 10);
|
||||||
|
const row = this.db.prepare(
|
||||||
|
'SELECT total_pnl_sol, total_trades, wins, losses FROM daily_stats WHERE date = ?'
|
||||||
|
).get(d) as any;
|
||||||
|
|
||||||
|
return row
|
||||||
|
? { totalPnl: row.total_pnl_sol, trades: row.total_trades, wins: row.wins, losses: row.losses }
|
||||||
|
: { totalPnl: 0, trades: 0, wins: 0, losses: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDailyStats(pnlSol: number, isWin: boolean): void {
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO daily_stats (date, total_trades, wins, losses, total_pnl_sol, total_volume_sol, best_trade_pnl, worst_trade_pnl)
|
||||||
|
VALUES (?, 1, ?, ?, ?, 0, MAX(?, 0), MIN(?, 0))
|
||||||
|
ON CONFLICT(date) DO UPDATE SET
|
||||||
|
total_trades = total_trades + 1,
|
||||||
|
wins = wins + ?,
|
||||||
|
losses = losses + ?,
|
||||||
|
total_pnl_sol = total_pnl_sol + ?,
|
||||||
|
best_trade_pnl = MAX(best_trade_pnl, ?),
|
||||||
|
worst_trade_pnl = MIN(worst_trade_pnl, ?)
|
||||||
|
`).run(
|
||||||
|
date, isWin ? 1 : 0, isWin ? 0 : 1, pnlSol, pnlSol, pnlSol,
|
||||||
|
isWin ? 1 : 0, isWin ? 0 : 1, pnlSol, pnlSol, pnlSol
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Token Cache ===
|
||||||
|
|
||||||
|
getCachedAnalysis(mint: string, maxAgeMinutes = 5): any | null {
|
||||||
|
const row = this.db.prepare(`
|
||||||
|
SELECT analysis_data, last_checked FROM token_cache WHERE mint = ?
|
||||||
|
AND datetime(last_checked, '+' || ? || ' minutes') > datetime('now')
|
||||||
|
`).get(mint, maxAgeMinutes) as any;
|
||||||
|
|
||||||
|
return row ? JSON.parse(row.analysis_data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheAnalysis(mint: string, name: string, symbol: string, score: number, analysis: any): void {
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO token_cache (mint, name, symbol, score, analysis_data, last_checked)
|
||||||
|
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
`).run(mint, name, symbol, score, JSON.stringify(analysis));
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.db.close();
|
||||||
|
log.info('Database closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/utils/event-bus.ts
Normal file
43
src/utils/event-bus.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Simple typed event bus for inter-module communication
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BotEvent, EventHandler } from '../types/index.js';
|
||||||
|
|
||||||
|
export class EventBus {
|
||||||
|
private handlers: Map<string, Set<EventHandler>> = new Map();
|
||||||
|
private globalHandlers: Set<EventHandler> = new Set();
|
||||||
|
|
||||||
|
on(eventType: BotEvent['type'], handler: EventHandler): () => void {
|
||||||
|
if (!this.handlers.has(eventType)) {
|
||||||
|
this.handlers.set(eventType, new Set());
|
||||||
|
}
|
||||||
|
this.handlers.get(eventType)!.add(handler);
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
this.handlers.get(eventType)?.delete(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onAll(handler: EventHandler): () => void {
|
||||||
|
this.globalHandlers.add(handler);
|
||||||
|
return () => { this.globalHandlers.delete(handler); };
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit(event: BotEvent): Promise<void> {
|
||||||
|
const typeHandlers = this.handlers.get(event.type) || new Set();
|
||||||
|
const allHandlers = [...typeHandlers, ...this.globalHandlers];
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
allHandlers.map(handler => {
|
||||||
|
try {
|
||||||
|
return Promise.resolve(handler(event));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Event handler error for ${event.type}:`, e);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/utils/logger.ts
Normal file
67
src/utils/logger.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Colorful console logger with module prefixes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import chalk from 'chalk';
|
||||||
|
|
||||||
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'success';
|
||||||
|
|
||||||
|
const LEVEL_COLORS: Record<LogLevel, (s: string) => string> = {
|
||||||
|
debug: chalk.gray,
|
||||||
|
info: chalk.cyan,
|
||||||
|
warn: chalk.yellow,
|
||||||
|
error: chalk.red,
|
||||||
|
success: chalk.green,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODULE_COLORS: Record<string, (s: string) => string> = {
|
||||||
|
scanner: chalk.magenta,
|
||||||
|
analyzer: chalk.yellow,
|
||||||
|
executor: chalk.green,
|
||||||
|
portfolio: chalk.cyan,
|
||||||
|
control: chalk.blue,
|
||||||
|
bot: chalk.white,
|
||||||
|
db: chalk.gray,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Logger {
|
||||||
|
private module: string;
|
||||||
|
private colorFn: (s: string) => string;
|
||||||
|
|
||||||
|
constructor(module: string) {
|
||||||
|
this.module = module;
|
||||||
|
this.colorFn = MODULE_COLORS[module.toLowerCase()] || chalk.white;
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(level: LogLevel, message: string, data?: any) {
|
||||||
|
const timestamp = new Date().toISOString().slice(11, 23);
|
||||||
|
const levelStr = LEVEL_COLORS[level](`[${level.toUpperCase()}]`.padEnd(9));
|
||||||
|
const moduleStr = this.colorFn(`[${this.module}]`);
|
||||||
|
const msg = `${chalk.gray(timestamp)} ${levelStr} ${moduleStr} ${message}`;
|
||||||
|
|
||||||
|
if (level === 'error') {
|
||||||
|
console.error(msg, data ? data : '');
|
||||||
|
} else {
|
||||||
|
console.log(msg, data ? data : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(msg: string, data?: any) { this.log('debug', msg, data); }
|
||||||
|
info(msg: string, data?: any) { this.log('info', msg, data); }
|
||||||
|
warn(msg: string, data?: any) { this.log('warn', msg, data); }
|
||||||
|
error(msg: string, data?: any) { this.log('error', msg, data); }
|
||||||
|
success(msg: string, data?: any) { this.log('success', msg, data); }
|
||||||
|
|
||||||
|
trade(side: 'BUY' | 'SELL', symbol: string, amountSol: number, extra?: string) {
|
||||||
|
const arrow = side === 'BUY' ? chalk.green('▲ BUY ') : chalk.red('▼ SELL');
|
||||||
|
const amt = chalk.white(`${amountSol.toFixed(4)} SOL`);
|
||||||
|
const sym = chalk.bold(symbol);
|
||||||
|
this.log('info', `${arrow} ${sym} for ${amt}${extra ? ` — ${extra}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
pnl(symbol: string, pnlPercent: number, pnlSol: number) {
|
||||||
|
const color = pnlPercent >= 0 ? chalk.green : chalk.red;
|
||||||
|
const sign = pnlPercent >= 0 ? '+' : '';
|
||||||
|
this.log('info', `P&L ${chalk.bold(symbol)}: ${color(`${sign}${pnlPercent.toFixed(1)}%`)} (${color(`${sign}${pnlSol.toFixed(4)} SOL`)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/utils/wallet.ts
Normal file
60
src/utils/wallet.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Wallet utilities — key management, balance checks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Keypair, Connection, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js';
|
||||||
|
import bs58 from 'bs58';
|
||||||
|
import { Logger } from './logger.js';
|
||||||
|
|
||||||
|
const log = new Logger('Wallet');
|
||||||
|
|
||||||
|
export function loadWallet(privateKeyBase58: string): Keypair {
|
||||||
|
try {
|
||||||
|
const secretKey = bs58.decode(privateKeyBase58);
|
||||||
|
const keypair = Keypair.fromSecretKey(secretKey);
|
||||||
|
log.info(`Wallet loaded: ${keypair.publicKey.toBase58()}`);
|
||||||
|
return keypair;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Invalid wallet private key. Must be base58-encoded.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateWallet(): { publicKey: string; privateKey: string } {
|
||||||
|
const keypair = Keypair.generate();
|
||||||
|
return {
|
||||||
|
publicKey: keypair.publicKey.toBase58(),
|
||||||
|
privateKey: bs58.encode(keypair.secretKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBalance(connection: Connection, publicKey: PublicKey): Promise<number> {
|
||||||
|
const lamports = await connection.getBalance(publicKey);
|
||||||
|
return lamports / LAMPORTS_PER_SOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSplTokenBalance(
|
||||||
|
connection: Connection,
|
||||||
|
walletPubkey: PublicKey,
|
||||||
|
mintPubkey: PublicKey
|
||||||
|
): Promise<{ amount: bigint; decimals: number; uiAmount: number }> {
|
||||||
|
const accounts = await connection.getTokenAccountsByOwner(walletPubkey, { mint: mintPubkey });
|
||||||
|
|
||||||
|
if (accounts.value.length === 0) {
|
||||||
|
return { amount: 0n, decimals: 0, uiAmount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse token account data
|
||||||
|
const data = accounts.value[0].account.data;
|
||||||
|
// Token account layout: mint(32) + owner(32) + amount(8) + ...
|
||||||
|
const amount = data.readBigUInt64LE(64);
|
||||||
|
// Get decimals from mint
|
||||||
|
const mintInfo = await connection.getParsedAccountInfo(mintPubkey);
|
||||||
|
const decimals = (mintInfo.value?.data as any)?.parsed?.info?.decimals || 0;
|
||||||
|
const uiAmount = Number(amount) / Math.pow(10, decimals);
|
||||||
|
|
||||||
|
return { amount, decimals, uiAmount };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortenAddress(address: string, chars = 4): string {
|
||||||
|
return `${address.slice(0, chars)}...${address.slice(-chars)}`;
|
||||||
|
}
|
||||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user