solana-sniper-bot/src/executor/trade-executor.ts
Jake Shore 4b0cffefba 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.
2026-02-15 13:16:35 -05:00

414 lines
14 KiB
TypeScript

/**
* 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,
};
}
}