|
|
@@ -0,0 +1,402 @@
|
|
|
+import { Connection, PublicKey, Keypair, VersionedTransaction, VersionedTransactionResponse } from '@solana/web3.js'
|
|
|
+import bs58 from 'bs58'
|
|
|
+
|
|
|
+const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
|
|
|
+const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA')
|
|
|
+const TOKEN_2022_PROGRAM_ID = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb')
|
|
|
+const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL')
|
|
|
+
|
|
|
+export interface SwapResult {
|
|
|
+ success: boolean
|
|
|
+ swaps: SwapInfo[]
|
|
|
+}
|
|
|
+
|
|
|
+export interface SwapInfo {
|
|
|
+ mint: string
|
|
|
+ symbol: string | null
|
|
|
+ amount: number
|
|
|
+ swappedUsd: number
|
|
|
+ txSignature: string
|
|
|
+}
|
|
|
+
|
|
|
+async function findAssociatedTokenAddress(
|
|
|
+ walletAddress: PublicKey,
|
|
|
+ tokenMintAddress: PublicKey,
|
|
|
+ programId: PublicKey = TOKEN_PROGRAM_ID
|
|
|
+): Promise<PublicKey> {
|
|
|
+ const [address] = await PublicKey.findProgramAddress(
|
|
|
+ [walletAddress.toBuffer(), programId.toBuffer(), tokenMintAddress.toBuffer()],
|
|
|
+ ASSOCIATED_TOKEN_PROGRAM_ID
|
|
|
+ )
|
|
|
+ return address
|
|
|
+}
|
|
|
+
|
|
|
+export class JupiterSwapper {
|
|
|
+ private connection: Connection
|
|
|
+ private keypair: Keypair
|
|
|
+ private walletAddress: string
|
|
|
+ private jupiterBaseUrl: string
|
|
|
+ private apiKey: string | undefined
|
|
|
+ private rpcUrl: string
|
|
|
+
|
|
|
+ constructor(rpcUrl: string, privateKey: string, apiKey?: string) {
|
|
|
+ this.rpcUrl = rpcUrl
|
|
|
+ this.connection = new Connection(rpcUrl, 'confirmed')
|
|
|
+ this.keypair = Keypair.fromSecretKey(bs58.decode(privateKey))
|
|
|
+ this.walletAddress = this.keypair.publicKey.toString()
|
|
|
+ this.jupiterBaseUrl = 'https://api.jup.ag/swap/v1'
|
|
|
+ this.apiKey = apiKey
|
|
|
+ }
|
|
|
+
|
|
|
+ getWalletAddress(): string {
|
|
|
+ return this.walletAddress
|
|
|
+ }
|
|
|
+
|
|
|
+ private getHeaders(): Record<string, string> {
|
|
|
+ const headers: Record<string, string> = {
|
|
|
+ Accept: 'application/json',
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ }
|
|
|
+ if (this.apiKey) {
|
|
|
+ headers['x-api-key'] = this.apiKey
|
|
|
+ }
|
|
|
+ return headers
|
|
|
+ }
|
|
|
+
|
|
|
+ private async getTokenProgramId(mintAddress: string): Promise<PublicKey> {
|
|
|
+ try {
|
|
|
+ const mintAccountInfo = await this.connection.getAccountInfo(new PublicKey(mintAddress))
|
|
|
+ if (mintAccountInfo) {
|
|
|
+ const owner = mintAccountInfo.owner.toString()
|
|
|
+ if (owner === TOKEN_2022_PROGRAM_ID.toString()) {
|
|
|
+ return TOKEN_2022_PROGRAM_ID
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return TOKEN_PROGRAM_ID
|
|
|
+ } catch {
|
|
|
+ return TOKEN_PROGRAM_ID
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async getTokenBalance(mintAddress: string): Promise<number> {
|
|
|
+ try {
|
|
|
+ if (mintAddress === 'So11111111111111111111111111111111111111112') {
|
|
|
+ const balance = await this.connection.getBalance(this.keypair.publicKey)
|
|
|
+ return balance / 1e9
|
|
|
+ }
|
|
|
+
|
|
|
+ const programId = await this.getTokenProgramId(mintAddress)
|
|
|
+ const tokenAccount = await findAssociatedTokenAddress(
|
|
|
+ this.keypair.publicKey,
|
|
|
+ new PublicKey(mintAddress),
|
|
|
+ programId
|
|
|
+ )
|
|
|
+
|
|
|
+ try {
|
|
|
+ const accountInfo = await this.connection.getTokenAccountBalance(tokenAccount)
|
|
|
+ return parseFloat(accountInfo.value.uiAmountString || '0')
|
|
|
+ } catch {
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error(`Error getting balance for ${mintAddress}:`, error)
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async getTokenPrices(tokenAddresses: string[]): Promise<Record<string, { price?: number; symbol?: string }>> {
|
|
|
+ try {
|
|
|
+ const ids = tokenAddresses.join(',')
|
|
|
+ const response = await fetch(`https://api.jup.ag/price/v2?ids=${ids}`, {
|
|
|
+ headers: this.getHeaders(),
|
|
|
+ })
|
|
|
+ const data = (await response.json()) as { data?: Record<string, { price?: number; symbol?: string }> }
|
|
|
+ return data.data || {}
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Error fetching token prices:', error)
|
|
|
+ return {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async sleep(ms: number): Promise<void> {
|
|
|
+ return new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
+ }
|
|
|
+
|
|
|
+ private async fetchQuote(
|
|
|
+ inputMint: string,
|
|
|
+ outputMint: string,
|
|
|
+ amount: string | number,
|
|
|
+ retries = 3,
|
|
|
+ swapMode = 'ExactIn',
|
|
|
+ restrictIntermediate = true
|
|
|
+ ): Promise<unknown> {
|
|
|
+ const rawAmount = typeof amount === 'string' ? amount : String(Math.floor(Number(amount)))
|
|
|
+ if (!rawAmount || Number(rawAmount) <= 0) {
|
|
|
+ throw new Error(`Invalid quote amount: ${amount}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ for (let attempt = 1; attempt <= retries; attempt++) {
|
|
|
+ try {
|
|
|
+ const params = new URLSearchParams({
|
|
|
+ inputMint,
|
|
|
+ outputMint,
|
|
|
+ amount: rawAmount,
|
|
|
+ swapMode,
|
|
|
+ slippageBps: '100',
|
|
|
+ onlyDirectRoutes: 'false',
|
|
|
+ restrictIntermediateTokens: String(restrictIntermediate),
|
|
|
+ })
|
|
|
+
|
|
|
+ const response = await fetch(`${this.jupiterBaseUrl}/quote?${params.toString()}`, {
|
|
|
+ headers: this.getHeaders(),
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ const data = (await response.json()) as { message?: string; error?: string }
|
|
|
+ throw new Error(data.message || data.error || `HTTP ${response.status}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ return await response.json()
|
|
|
+ } catch (error) {
|
|
|
+ const message = error instanceof Error ? error.message : String(error)
|
|
|
+ console.warn(`Quote attempt ${attempt} failed: ${message}`)
|
|
|
+
|
|
|
+ if (attempt < retries) {
|
|
|
+ await this.sleep(2000 * attempt)
|
|
|
+ } else {
|
|
|
+ throw new Error(`Failed to fetch quote: ${message}`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ throw new Error('All quote attempts failed')
|
|
|
+ }
|
|
|
+
|
|
|
+ private async executeSwap(quoteData: unknown, retries = 3): Promise<{ swapTransaction: string }> {
|
|
|
+ for (let attempt = 1; attempt <= retries; attempt++) {
|
|
|
+ try {
|
|
|
+ const response = await fetch(`${this.jupiterBaseUrl}/swap`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: this.getHeaders(),
|
|
|
+ body: JSON.stringify({
|
|
|
+ quoteResponse: quoteData,
|
|
|
+ userPublicKey: this.walletAddress,
|
|
|
+ wrapAndUnwrapSol: true,
|
|
|
+ prioritizationFeeLamports: 10000,
|
|
|
+ dynamicSlippage: false,
|
|
|
+ }),
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ const data = (await response.json()) as { message?: string }
|
|
|
+ throw new Error(data.message || `HTTP ${response.status}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = (await response.json()) as { swapTransaction?: string }
|
|
|
+ if (!data.swapTransaction) {
|
|
|
+ throw new Error('Failed to get swap transaction')
|
|
|
+ }
|
|
|
+
|
|
|
+ return data as { swapTransaction: string }
|
|
|
+ } catch (error) {
|
|
|
+ const message = error instanceof Error ? error.message : String(error)
|
|
|
+ console.warn(`Swap execution attempt ${attempt} failed: ${message}`)
|
|
|
+
|
|
|
+ if (attempt < retries) {
|
|
|
+ await this.sleep(2000 * attempt)
|
|
|
+ } else {
|
|
|
+ throw new Error(`Failed to execute swap: ${message}`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ throw new Error('All swap execution attempts failed')
|
|
|
+ }
|
|
|
+
|
|
|
+ private extractBalanceChanges(tx: VersionedTransactionResponse | null): Array<{ mint: string; amount: number; change: number }> {
|
|
|
+ const changedTokens: Array<{ mint: string; amount: number; change: number }> = []
|
|
|
+
|
|
|
+ if (!tx?.meta?.preTokenBalances || !tx?.meta?.postTokenBalances) {
|
|
|
+ return changedTokens
|
|
|
+ }
|
|
|
+
|
|
|
+ const preBalances: Record<string, number> = {}
|
|
|
+
|
|
|
+ tx.meta.preTokenBalances.forEach((balance) => {
|
|
|
+ if (balance.owner === this.walletAddress) {
|
|
|
+ const key = `${balance.accountIndex}-${balance.mint}`
|
|
|
+ preBalances[key] = parseFloat(balance.uiTokenAmount?.uiAmountString || '0')
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ tx.meta.postTokenBalances.forEach((balance) => {
|
|
|
+ if (balance.owner === this.walletAddress) {
|
|
|
+ const key = `${balance.accountIndex}-${balance.mint}`
|
|
|
+ const pre = preBalances[key]
|
|
|
+ const postAmount = parseFloat(balance.uiTokenAmount?.uiAmountString || '0')
|
|
|
+
|
|
|
+ if (pre !== undefined) {
|
|
|
+ const diff = postAmount - pre
|
|
|
+ if (Math.abs(diff) > 0.000001) {
|
|
|
+ changedTokens.push({
|
|
|
+ mint: balance.mint,
|
|
|
+ amount: postAmount,
|
|
|
+ change: diff,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } else if (postAmount > 0.000001) {
|
|
|
+ changedTokens.push({
|
|
|
+ mint: balance.mint,
|
|
|
+ amount: postAmount,
|
|
|
+ change: postAmount,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ return changedTokens
|
|
|
+ }
|
|
|
+
|
|
|
+ async analyzeCloseTxAndSwapRemains(
|
|
|
+ txSignature: string,
|
|
|
+ keepUsdValue: number,
|
|
|
+ whitelist: string[]
|
|
|
+ ): Promise<SwapResult> {
|
|
|
+ if (!txSignature) {
|
|
|
+ console.warn('[Jupiter] No tx signature provided, skipping swap analysis')
|
|
|
+ return { success: false, swaps: [] }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!this.apiKey) {
|
|
|
+ console.warn('[Jupiter] No Jupiter API key provided, skipping swap')
|
|
|
+ return { success: false, swaps: [] }
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(`\n[Jupiter] Analyzing close transaction: ${txSignature.slice(0, 20)}...`)
|
|
|
+
|
|
|
+ const swaps: SwapInfo[] = []
|
|
|
+
|
|
|
+ try {
|
|
|
+ const tx = await this.connection.getTransaction(txSignature, {
|
|
|
+ commitment: 'confirmed',
|
|
|
+ maxSupportedTransactionVersion: 0,
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!tx || !tx.meta) {
|
|
|
+ console.error('[Jupiter] Transaction not found or invalid')
|
|
|
+ return { success: false, swaps: [] }
|
|
|
+ }
|
|
|
+
|
|
|
+ const changedTokens = this.extractBalanceChanges(tx)
|
|
|
+ if (changedTokens.length === 0) {
|
|
|
+ console.log('[Jupiter] No token balance changes found')
|
|
|
+ return { success: true, swaps: [] }
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(`[Jupiter] Found ${changedTokens.length} tokens with balance changes`)
|
|
|
+
|
|
|
+ const mints = changedTokens.map((t) => t.mint)
|
|
|
+ const prices = await this.getTokenPrices(mints)
|
|
|
+
|
|
|
+ for (const token of changedTokens) {
|
|
|
+ const mint = token.mint
|
|
|
+ const symbol = prices[mint]?.symbol || null
|
|
|
+ const isWhitelisted = whitelist.includes(mint)
|
|
|
+
|
|
|
+ if (isWhitelisted) {
|
|
|
+ console.log(`[Jupiter] ${mint.slice(0, 8)}... is whitelisted, skipping swap`)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ const currentBalance = await this.getTokenBalance(mint)
|
|
|
+ if (currentBalance <= 0) {
|
|
|
+ console.log(`[Jupiter] ${mint.slice(0, 8)}... balance is 0, skipping`)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ const priceUsd = prices[mint]?.price || 0
|
|
|
+ if (priceUsd <= 0) {
|
|
|
+ console.warn(`[Jupiter] ${mint.slice(0, 8)}... price not available, skipping`)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ const totalValueUsd = currentBalance * priceUsd
|
|
|
+ console.log(
|
|
|
+ `[Jupiter] ${mint.slice(0, 8)}... : balance=${currentBalance.toFixed(4)}, price=$${priceUsd.toFixed(4)}, value=$${totalValueUsd.toFixed(2)}`
|
|
|
+ )
|
|
|
+
|
|
|
+ if (totalValueUsd <= keepUsdValue) {
|
|
|
+ console.log(`[Jupiter] Value $${totalValueUsd.toFixed(2)} <= $${keepUsdValue}, keeping all`)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ const swapValueUsd = totalValueUsd - keepUsdValue
|
|
|
+ console.log(`[Jupiter] Swapping $${swapValueUsd.toFixed(2)} worth to USDC (keeping $${keepUsdValue})`)
|
|
|
+
|
|
|
+ const swapAmountRaw = Math.floor((swapValueUsd / priceUsd) * Math.pow(10, 6))
|
|
|
+
|
|
|
+ try {
|
|
|
+ let quoteData: unknown
|
|
|
+ try {
|
|
|
+ quoteData = await this.fetchQuote(mint, USDC_MINT, swapAmountRaw, 2, 'ExactIn', true)
|
|
|
+ } catch (e) {
|
|
|
+ const message = e instanceof Error ? e.message : String(e)
|
|
|
+ if (message.includes('No routes found')) {
|
|
|
+ console.log('[Jupiter] Retrying with allow all intermediates...')
|
|
|
+ quoteData = await this.fetchQuote(mint, USDC_MINT, swapAmountRaw, 2, 'ExactIn', false)
|
|
|
+ } else {
|
|
|
+ throw e
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const quote = quoteData as {
|
|
|
+ routePlan?: Array<{ swapInfo?: { label?: string } }>
|
|
|
+ outAmount?: string
|
|
|
+ priceImpactPct?: string
|
|
|
+ }
|
|
|
+ if (quote.routePlan && quote.routePlan.length > 0) {
|
|
|
+ const routeLabels = quote.routePlan.map((r) => r.swapInfo?.label || 'Unknown').join(' -> ')
|
|
|
+ console.log(`[Jupiter] Route: ${routeLabels}`)
|
|
|
+ console.log(`[Jupiter] Expected output: ${quote.outAmount} USDC, price impact: ${quote.priceImpactPct}%`)
|
|
|
+ }
|
|
|
+
|
|
|
+ const swapData = await this.executeSwap(quoteData)
|
|
|
+ const swapTransactionBuf = Buffer.from(swapData.swapTransaction, 'base64')
|
|
|
+ const transaction = VersionedTransaction.deserialize(swapTransactionBuf)
|
|
|
+ transaction.sign([this.keypair])
|
|
|
+
|
|
|
+ const signature = await this.connection.sendTransaction(transaction, {
|
|
|
+ maxRetries: 3,
|
|
|
+ skipPreflight: false,
|
|
|
+ })
|
|
|
+ console.log(`[Jupiter] Swap transaction sent: ${signature}`)
|
|
|
+
|
|
|
+ const confirmation = await this.connection.confirmTransaction(signature, 'confirmed')
|
|
|
+ if (confirmation.value.err) {
|
|
|
+ throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(`[Jupiter] Swap confirmed: https://solscan.io/tx/${signature}`)
|
|
|
+
|
|
|
+ swaps.push({
|
|
|
+ mint,
|
|
|
+ symbol,
|
|
|
+ amount: swapValueUsd / priceUsd,
|
|
|
+ swappedUsd: swapValueUsd,
|
|
|
+ txSignature: signature,
|
|
|
+ })
|
|
|
+ } catch (swapError) {
|
|
|
+ console.error(
|
|
|
+ `[Jupiter] Swap failed for ${mint.slice(0, 8)}...: ${swapError instanceof Error ? swapError.message : swapError}`
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('[Jupiter] Swap analysis completed\n')
|
|
|
+ return { success: true, swaps }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('[Jupiter] Error analyzing close transaction:', error)
|
|
|
+ return { success: false, swaps }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|