/** * 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, ): Promise { 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, ): Promise { 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 { 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 { // 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 = { 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 { return { paperTrade: botConfig.paperTrade, useJito: botConfig.useJito, jitoTipLamports: botConfig.jitoTipLamports, slippageBps: botConfig.defaultSlippageBps, maxRetries: 3, basePriorityFeeMicroLamports: 100_000, priorityFeeEscalation: 1.5, }; } }