- 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.
414 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|