Преглед на файлове

feat: 关仓后自动将多余代币swap为USDC

- closePosition 返回 txSignature
- 新增 analyzeCloseTxAndSwapRemains 分析关仓tx并swap
- 超过10u价值的代币自动swap到USDC,保留10u
- 稳定币(USDC/USDT/USD1)和SOL跳过swap
zhangchunrui преди 1 месец
родител
ревизия
b7bb9672d5
променени са 3 файла, в които са добавени 198 реда и са изтрити 8 реда
  1. 11 5
      src/core/sniper.js
  2. 4 3
      src/services/byreal.js
  3. 183 0
      src/services/jupiter.js

+ 11 - 5
src/core/sniper.js

@@ -441,7 +441,6 @@ export class SniperEngine {
         }
         logger.info(`Closing our position: ${myPositionAddress} (parent was ${parentPositionAddress})`);
 
-        // 获取我的仓位详情,拿到 nftMintAddress(关仓接口需要)
         const myPositionDetail = await ByrealAPI.getPositionDetail(myPositionAddress);
         const nftMintAddress = myPositionDetail?.nftMintAddress || myPositionDetail?.nftMint;
         if (!nftMintAddress) {
@@ -449,17 +448,24 @@ export class SniperEngine {
           continue;
         }
 
-        const success = await ByrealAPI.closePosition(nftMintAddress);
+        const closeResult = await ByrealAPI.closePosition(nftMintAddress);
         
-        if (success) {
+        if (closeResult.success) {
+          const closeTxSignature = closeResult.txSignature;
+          
+          if (closeTxSignature) {
+            logger.info(`Close transaction: ${closeTxSignature}`);
+            await this.swapper.analyzeCloseTxAndSwapRemains(closeTxSignature, 10);
+          }
+
           this.closedCache.add(parentPositionAddress, {
             closedAt: new Date().toISOString(),
             reason: `Target closed their position ${targetPositionAddress}`,
+            closeTxSignature: closeTxSignature,
           });
           this.copiedCache.remove(parentPositionAddress);
           this.targetToParentMap.delete(targetPositionAddress);
 
-          // Send Discord notification
           this.discord.notifyCloseSuccess({
             positionAddress: myPositionAddress,
             poolName: copiedData?.calculation?.tokenA?.symbol && copiedData?.calculation?.tokenB?.symbol 
@@ -467,7 +473,7 @@ export class SniperEngine {
               : 'Unknown Pool',
             closedAt: new Date().toISOString(),
             reason: `Target closed their position`,
-            txSignature: null,
+            txSignature: closeTxSignature,
           }).catch(err => logger.error('Discord notification failed:', err));
         }
       }

+ 4 - 3
src/services/byreal.js

@@ -234,17 +234,18 @@ export class ByrealAPI {
 
       if (response.data?.success) {
         logger.success(`Position closed successfully: ${nftMintAddress}`);
-        return true;
+        const txSignature = response.data?.txid || null;
+        return { success: true, txSignature };
       } else {
         logger.error('Close request failed:', JSON.stringify(response.data));
-        return false;
+        return { success: false, txSignature: null };
       }
     } catch (error) {
       logger.error('Error closing position:', error.message);
       if (error.response) {
         logger.error(`Close response status: ${error.response.status}, data: ${JSON.stringify(error.response.data)}`);
       }
-      return false;
+      return { success: false, txSignature: null };
     }
   }
 }

+ 183 - 0
src/services/jupiter.js

@@ -214,6 +214,7 @@ export class JupiterSwapper {
     'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC
     'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', // USDT
     'USD1ttGY1N17NEEHLmELoaybftRBUSErhqYiQzvEmuB',  // USD1
+    'So11111111111111111111111111111111111111112',  // SOL
   ];
 
   /**
@@ -344,6 +345,188 @@ export class JupiterSwapper {
       throw error;
     }
   }
+
+  /**
+   * Analyze close transaction and swap excess tokens to USDC
+   * @param {string} txSignature - Transaction signature from close position
+   * @param {number} keepUsdValue - USD value to keep (default: 10)
+   * @returns {Promise<boolean>}
+   */
+  async analyzeCloseTxAndSwapRemains(txSignature, keepUsdValue = 10) {
+    const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
+    
+    if (!txSignature) {
+      logger.warn('No tx signature provided, skipping swap analysis');
+      return false;
+    }
+
+    logger.info(`\n═══════════════════════════════════════════`);
+    logger.info(`🔍 Analyzing close transaction: ${txSignature.slice(0, 20)}...`);
+
+    try {
+      const tx = await this.connection.getTransaction(txSignature, {
+        commitment: 'confirmed',
+        maxSupportedTransactionVersion: 0
+      });
+
+      if (!tx || !tx.meta) {
+        logger.error('Transaction not found or invalid');
+        return false;
+      }
+
+      const changedTokens = this._extractBalanceChanges(tx);
+      if (changedTokens.length === 0) {
+        logger.info('No token balance changes found');
+        return true;
+      }
+
+      logger.info(`Found ${changedTokens.length} tokens with balance changes:`);
+      for (const token of changedTokens) {
+        logger.info(`  ${token.mint.slice(0, 8)}... : ${token.amount.toFixed(6)}`);
+      }
+
+      const mints = changedTokens.map(t => t.mint);
+      const prices = await this.getTokenPrices(mints);
+
+      for (const token of changedTokens) {
+        const mint = token.mint;
+        const isStablecoin = JupiterSwapper.STABLECOIN_MINTS.includes(mint);
+        
+        if (isStablecoin) {
+          logger.info(`  ${mint.slice(0, 8)}... is stablecoin/SOL, skipping swap`);
+          continue;
+        }
+
+        const currentBalance = await this.getTokenBalance(mint);
+        if (currentBalance <= 0) {
+          logger.info(`  ${mint.slice(0, 8)}... balance is 0, skipping`);
+          continue;
+        }
+
+        const priceUsd = prices[mint]?.price || 0;
+        if (priceUsd <= 0) {
+          logger.warn(`  ${mint.slice(0, 8)}... price not available, skipping`);
+          continue;
+        }
+
+        const totalValueUsd = currentBalance * priceUsd;
+        logger.info(`  ${mint.slice(0, 8)}... : balance=${currentBalance.toFixed(4)}, price=$${priceUsd.toFixed(4)}, value=$${totalValueUsd.toFixed(2)}`);
+
+        if (totalValueUsd <= keepUsdValue) {
+          logger.info(`  Value $${totalValueUsd.toFixed(2)} <= $${keepUsdValue}, keeping all`);
+          continue;
+        }
+
+        const swapValueUsd = totalValueUsd - keepUsdValue;
+        logger.info(`  Swapping $${swapValueUsd.toFixed(2)} worth to USDC (keeping $${keepUsdValue})`);
+
+        const swapAmountRaw = Math.floor(swapValueUsd / priceUsd * Math.pow(10, 6));
+        const decimals = 6;
+
+        try {
+          const tryQuote = async (restrictIntermediate = true) => {
+            try {
+              return await this.fetchQuote(mint, USDC_MINT, swapAmountRaw, 2, 'ExactIn', restrictIntermediate);
+            } catch (e) {
+              if (e.message && e.message.includes('No routes found') && restrictIntermediate) {
+                logger.warn('No routes with restrictIntermediate=true, retrying with allow all intermediates...');
+                return await this.fetchQuote(mint, USDC_MINT, swapAmountRaw, 2, 'ExactIn', false);
+              }
+              throw e;
+            }
+          };
+
+          const quoteData = await tryQuote();
+
+          if (quoteData.routePlan && quoteData.routePlan.length > 0) {
+            const routeLabels = quoteData.routePlan.map(r => r.swapInfo?.label || 'Unknown').join(' -> ');
+            logger.info(`  Route: ${routeLabels}`);
+            logger.info(`  Expected output: ${quoteData.outAmount} USDC, price impact: ${quoteData.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,
+          });
+          logger.info(`  Swap transaction sent: ${signature}`);
+
+          const confirmation = await this.connection.confirmTransaction(signature, 'confirmed');
+          if (confirmation.value.err) {
+            throw new Error(`Transaction failed: ${confirmation.value.err}`);
+          }
+
+          logger.success(`  Swap confirmed: https://solscan.io/tx/${signature}`);
+        } catch (swapError) {
+          logger.error(`  Swap failed for ${mint.slice(0, 8)}...: ${swapError.message}`);
+        }
+      }
+
+      logger.info(`═══════════════════════════════════════════\n`);
+      return true;
+    } catch (error) {
+      logger.error('Error analyzing close transaction:', error.message);
+      return false;
+    }
+  }
+
+  /**
+   * Extract token balance changes from transaction
+   * @private
+   */
+  _extractBalanceChanges(tx) {
+    const changedTokens = [];
+    
+    if (!tx.meta?.preTokenBalances || !tx.meta?.postTokenBalances) {
+      return changedTokens;
+    }
+
+    const myWallet = this.walletAddress;
+    const preBalances = {};
+    
+    tx.meta.preTokenBalances.forEach(balance => {
+      if (balance.owner === myWallet) {
+        const key = `${balance.accountIndex}-${balance.mint}`;
+        preBalances[key] = {
+          amount: parseFloat(balance.uiTokenAmount?.uiAmountString || 0),
+          decimals: balance.decimals
+        };
+      }
+    });
+
+    tx.meta.postTokenBalances.forEach(balance => {
+      if (balance.owner === myWallet) {
+        const key = `${balance.accountIndex}-${balance.mint}`;
+        const pre = preBalances[key];
+        const postAmount = parseFloat(balance.uiTokenAmount?.uiAmountString || 0);
+        
+        if (pre) {
+          const diff = postAmount - pre.amount;
+          if (Math.abs(diff) > 0.000001) {
+            changedTokens.push({
+              mint: balance.mint,
+              amount: postAmount,
+              change: diff,
+              decimals: balance.decimals
+            });
+          }
+        } else if (postAmount > 0.000001) {
+          changedTokens.push({
+            mint: balance.mint,
+            amount: postAmount,
+            change: postAmount,
+            decimals: balance.decimals
+          });
+        }
+      }
+    });
+
+    return changedTokens;
+  }
 }
 
 export default JupiterSwapper;