Переглянути джерело

fix: harden Jupiter API integration per skill audit

- Extract shared getJupiterHeaders() and withRetry() into jupiter-client.ts,
  eliminating duplicated header logic across swap.ts and price.ts
- Add exponential backoff + jitter retry on HTTP 429 for all Jupiter API calls
- Fix signAndExecuteSwapV1: fetch blockhash before sendRawTransaction (not after)
  to correctly anchor the confirmation window; set maxRetries: 0 to prevent
  duplicate submissions
- Handle Ultra execute retryable negative codes (-1, -1000, -1001, -1005, -1006,
  -2000, -2003, -2005) with up to 2 idempotent retries; surface error code in
  thrown message for non-retryable failures
- Track Ultra order TTL: warn when signed payload exceeds 90s (TTL ~2 min)
- Fix estimateExactInQuote: compute reference amount from inputDecimals param
  (default 6) instead of hardcoding 10_000_000
- Add PRICE_API_CHUNK_SIZE=50 batching in getTokenPrices to respect Jupiter
  Price API v3 limit; failed chunks log and continue rather than abort

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
zhangchunrui 2 тижнів тому
батько
коміт
4a086a3f3e
3 змінених файлів з 230 додано та 116 видалено
  1. 49 0
      src/lib/copier/jupiter-client.ts
  2. 48 37
      src/lib/copier/price.ts
  3. 133 79
      src/lib/copier/swap.ts

+ 49 - 0
src/lib/copier/jupiter-client.ts

@@ -0,0 +1,49 @@
+import { HTTPError } from 'ky'
+import { config } from '../config'
+
+/**
+ * Returns Jupiter API authentication headers.
+ * Shared across all Jupiter API callers (swap, price, etc.).
+ * Ref: https://dev.jup.ag/portal/setup.md
+ */
+export function getJupiterHeaders(): Record<string, string> {
+  const headers: Record<string, string> = {
+    Accept: 'application/json',
+  }
+  if (config.jupiterApiKey) {
+    headers['x-api-key'] = config.jupiterApiKey
+  }
+  return headers
+}
+
+/**
+ * Retry wrapper with exponential backoff + jitter for Jupiter API calls.
+ * Only retries on HTTP 429 (rate limited); all other errors propagate immediately.
+ * Formula: delay = min(baseDelayMs * 2^attempt + jitter(0..500ms), maxDelayMs)
+ * Ref: https://dev.jup.ag/portal/rate-limit.md
+ */
+export async function withRetry<T>(
+  fn: () => Promise<T>,
+  maxAttempts = 3,
+  baseDelayMs = 1000,
+  maxDelayMs = 10000,
+): Promise<T> {
+  let lastError: unknown
+  for (let attempt = 0; attempt < maxAttempts; attempt++) {
+    try {
+      return await fn()
+    } catch (error) {
+      lastError = error
+      const status = error instanceof HTTPError ? error.response.status : null
+      const isRateLimited = status === 429
+      if (!isRateLimited || attempt >= maxAttempts - 1) throw error
+      const jitter = Math.random() * 500
+      const delay = Math.min(baseDelayMs * Math.pow(2, attempt) + jitter, maxDelayMs)
+      console.warn(
+        `[Jupiter] Rate limited (HTTP 429), retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${maxAttempts})`,
+      )
+      await new Promise((resolve) => setTimeout(resolve, delay))
+    }
+  }
+  throw lastError
+}

+ 48 - 37
src/lib/copier/price.ts

@@ -1,57 +1,68 @@
 import ky from 'ky'
 import { config } from '../config'
+import { getJupiterHeaders, withRetry } from './jupiter-client'
 
 const JUPITER_PRICE_API_URL = 'https://api.jup.ag/price/v3'
 
-/** v3 返回扁平结构: { [mint]: { price?, usdPrice? } } */
-type PriceApiResponse = Record<string, { price?: number; usdPrice?: number }>
+/** Jupiter Price API v3 limit: max 50 mint IDs per request */
+const PRICE_API_CHUNK_SIZE = 50
 
-function getJupiterHeaders(): Record<string, string> {
-  const headers: Record<string, string> = {
-    Accept: 'application/json',
-  }
-  if (config.jupiterApiKey) {
-    headers['x-api-key'] = config.jupiterApiKey
-  }
-  return headers
-}
+/** v3 返回扁平结构: { [mint]: { usdPrice?, price? } } */
+type PriceApiResponse = Record<string, { usdPrice?: number; price?: number }>
 
 /**
- * 从 Jupiter Price API v3 获取代币 USD 价格
+ * 从 Jupiter Price API v3 获取代币 USD 价格。
+ * 自动分批处理超过 50 个 mint 的请求,每批独立重试。
+ * Ref: https://dev.jup.ag/docs/price/v3.md
  */
 export async function getTokenPrices(...mints: string[]): Promise<Record<string, number>> {
   const uniqueMints = [...new Set(mints)]
-  const ids = uniqueMints.join(',')
 
-  try {
-    if (!config.jupiterApiKey) {
-      console.warn('[Price] Jupiter Price API v3 需要 API Key,请设置 JUPITER_API_KEY')
-      return {}
-    }
+  if (uniqueMints.length === 0) return {}
+
+  if (!config.jupiterApiKey) {
+    console.warn('[Price] Jupiter Price API v3 需要 API Key,请设置 JUPITER_API_KEY')
+    return {}
+  }
 
-    const data = await ky
-      .get(JUPITER_PRICE_API_URL, {
-        searchParams: { ids },
-        timeout: 10000,
-        headers: getJupiterHeaders(),
-      })
-      .json<PriceApiResponse>()
-
-    const prices: Record<string, number> = {}
-    for (const mint of uniqueMints) {
-      const entry = data[mint]
-      if (entry) {
-        const p = entry.usdPrice ?? entry.price
-        if (p != null && p > 0) {
-          prices[mint] = p
+  // Chunk into batches of max 50 mints (API limit)
+  const chunks: string[][] = []
+  for (let i = 0; i < uniqueMints.length; i += PRICE_API_CHUNK_SIZE) {
+    chunks.push(uniqueMints.slice(i, i + PRICE_API_CHUNK_SIZE))
+  }
+
+  const prices: Record<string, number> = {}
+
+  for (const chunk of chunks) {
+    const ids = chunk.join(',')
+    try {
+      const data = await withRetry(() =>
+        ky
+          .get(JUPITER_PRICE_API_URL, {
+            searchParams: { ids },
+            timeout: 10000,
+            headers: getJupiterHeaders(),
+          })
+          .json<PriceApiResponse>(),
+      )
+
+      for (const mint of chunk) {
+        const entry = data[mint]
+        if (entry) {
+          // usdPrice is the primary field in v3; fall back to price for compatibility
+          const p = entry.usdPrice ?? entry.price
+          if (p != null && p > 0) {
+            prices[mint] = p
+          }
         }
       }
+    } catch (error) {
+      console.error(`[Price] Jupiter price fetch failed for chunk [${chunk[0]}...]:`, error)
+      // Continue with remaining chunks rather than aborting entirely
     }
-    return prices
-  } catch (error) {
-    console.error('[Price] Jupiter price fetch failed:', error)
-    return {}
   }
+
+  return prices
 }
 
 /**

+ 133 - 79
src/lib/copier/swap.ts

@@ -2,6 +2,7 @@ import { Connection, PublicKey, VersionedTransaction } from '@solana/web3.js'
 import ky from 'ky'
 import { config } from '../config'
 import { getKeypair, getUserAddress } from '../solana/wallet'
+import { getJupiterHeaders, withRetry } from './jupiter-client'
 
 export const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
 const USDT_MINT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
@@ -17,6 +18,16 @@ const SKIP_SWAP_BACK_MINTS = new Set([USDC_MINT, USDT_MINT, SOL_MINT, USD1_MINT]
 const ULTRA_API_URL = 'https://api.jup.ag/ultra/v1'
 const SWAP_API_URL = 'https://api.jup.ag/swap/v1'
 
+/**
+ * Ultra execute negative error codes that are safe to retry with the same
+ * signedTransaction + requestId (idempotent within the ~2 min TTL).
+ * Ref: https://dev.jup.ag/docs/ultra/response.md
+ */
+const ULTRA_RETRYABLE_CODES = new Set([-1, -1000, -1001, -1005, -1006, -2000, -2003, -2005])
+
+/** Warn when a signed Ultra order payload approaches its ~2 min TTL. */
+const ULTRA_ORDER_TTL_WARN_MS = 90_000
+
 interface UltraOrderResponse {
   requestId: string
   inputMint: string
@@ -52,16 +63,6 @@ interface UltraExecuteResponse {
   code: number
 }
 
-function getJupiterHeaders(): Record<string, string> {
-  const headers: Record<string, string> = {
-    Accept: 'application/json',
-  }
-  if (config.jupiterApiKey) {
-    headers['x-api-key'] = config.jupiterApiKey
-  }
-  return headers
-}
-
 /**
  * 获取 Jupiter Ultra Order(GET 请求,返回 unsigned transaction)
  */
@@ -75,20 +76,22 @@ async function fetchUltraOrder(
 ): Promise<UltraOrderResponse> {
   const rawAmount = typeof amount === 'string' ? amount : String(Math.floor(Number(amount)))
 
-  return ky
-    .get(`${ULTRA_API_URL}/order`, {
-      searchParams: {
-        inputMint,
-        outputMint,
-        amount: rawAmount,
-        taker,
-        slippageBps: String(slippageBps),
-        swapMode,
-      },
-      timeout: 30000,
-      headers: getJupiterHeaders(),
-    })
-    .json<UltraOrderResponse>()
+  return withRetry(() =>
+    ky
+      .get(`${ULTRA_API_URL}/order`, {
+        searchParams: {
+          inputMint,
+          outputMint,
+          amount: rawAmount,
+          taker,
+          slippageBps: String(slippageBps),
+          swapMode,
+        },
+        timeout: 30000,
+        headers: getJupiterHeaders(),
+      })
+      .json<UltraOrderResponse>(),
+  )
 }
 
 /**
@@ -98,16 +101,18 @@ async function executeUltraSwap(
   signedTransaction: string,
   requestId: string,
 ): Promise<UltraExecuteResponse> {
-  return ky
-    .post(`${ULTRA_API_URL}/execute`, {
-      json: { signedTransaction, requestId },
-      timeout: 30000,
-      headers: {
-        ...getJupiterHeaders(),
-        'Content-Type': 'application/json',
-      },
-    })
-    .json<UltraExecuteResponse>()
+  return withRetry(() =>
+    ky
+      .post(`${ULTRA_API_URL}/execute`, {
+        json: { signedTransaction, requestId },
+        timeout: 30000,
+        headers: {
+          ...getJupiterHeaders(),
+          'Content-Type': 'application/json',
+        },
+      })
+      .json<UltraExecuteResponse>(),
+  )
 }
 
 /**
@@ -120,19 +125,21 @@ async function fetchSwapQuote(
   slippageBps: number,
   swapMode: 'ExactIn' | 'ExactOut',
 ): Promise<Record<string, unknown>> {
-  return ky
-    .get(`${SWAP_API_URL}/quote`, {
-      searchParams: {
-        inputMint,
-        outputMint,
-        amount,
-        slippageBps: String(slippageBps),
-        swapMode,
-      },
-      timeout: 30000,
-      headers: getJupiterHeaders(),
-    })
-    .json<Record<string, unknown>>()
+  return withRetry(() =>
+    ky
+      .get(`${SWAP_API_URL}/quote`, {
+        searchParams: {
+          inputMint,
+          outputMint,
+          amount,
+          slippageBps: String(slippageBps),
+          swapMode,
+        },
+        timeout: 30000,
+        headers: getJupiterHeaders(),
+      })
+      .json<Record<string, unknown>>(),
+  )
 }
 
 /**
@@ -142,26 +149,28 @@ async function fetchSwapTransaction(
   quoteResponse: Record<string, unknown>,
   userPublicKey: string,
 ): Promise<{ swapTransaction: string; lastValidBlockHeight: number }> {
-  return ky
-    .post(`${SWAP_API_URL}/swap`, {
-      json: {
-        quoteResponse,
-        userPublicKey,
-        wrapAndUnwrapSol: true,
-        dynamicComputeUnitLimit: true,
-      },
-      timeout: 30000,
-      headers: {
-        ...getJupiterHeaders(),
-        'Content-Type': 'application/json',
-      },
-    })
-    .json<{ swapTransaction: string; lastValidBlockHeight: number }>()
+  return withRetry(() =>
+    ky
+      .post(`${SWAP_API_URL}/swap`, {
+        json: {
+          quoteResponse,
+          userPublicKey,
+          wrapAndUnwrapSol: true,
+          dynamicComputeUnitLimit: true,
+        },
+        timeout: 30000,
+        headers: {
+          ...getJupiterHeaders(),
+          'Content-Type': 'application/json',
+        },
+      })
+      .json<{ swapTransaction: string; lastValidBlockHeight: number }>(),
+  )
 }
 
 /**
  * 签名并执行 swap
- * - ExactIn: 使用 Jupiter Ultra API(更快)
+ * - ExactIn: 使用 Jupiter Ultra API(更快,含幂等重试
  * - ExactOut: 使用 Jupiter Swap v1 API(Ultra 不支持 ExactOut)
  */
 async function signAndExecuteSwap(
@@ -179,7 +188,15 @@ async function signAndExecuteSwap(
     }
 
     // ExactIn: use Ultra API
-    const orderData = await fetchUltraOrder(inputMint, outputMint, amount, keypair.publicKey.toBase58(), slippageBps, swapMode)
+    const orderData = await fetchUltraOrder(
+      inputMint,
+      outputMint,
+      amount,
+      keypair.publicKey.toBase58(),
+      slippageBps,
+      swapMode,
+    )
+    const orderFetchedAt = Date.now()
 
     if (!orderData.transaction) {
       const errorMsg = orderData.errorMessage || 'No transaction returned'
@@ -189,7 +206,9 @@ async function signAndExecuteSwap(
 
     if (orderData.routePlan?.length > 0) {
       const routeLabels = orderData.routePlan.map((r) => r.swapInfo?.label || '?').join(' -> ')
-      console.log(`[Swap] ${swapMode} Route: ${routeLabels}, in: ${orderData.inAmount}, out: ${orderData.outAmount}`)
+      console.log(
+        `[Swap] ${swapMode} Route: ${routeLabels}, in: ${orderData.inAmount}, out: ${orderData.outAmount}`,
+      )
     }
 
     const txBuf = Buffer.from(orderData.transaction, 'base64')
@@ -197,10 +216,37 @@ async function signAndExecuteSwap(
     transaction.sign([keypair])
     const signedTx = Buffer.from(transaction.serialize()).toString('base64')
 
-    const executeResult = await executeUltraSwap(signedTx, orderData.requestId)
+    // Warn if signed payload is approaching the ~2 min TTL
+    const elapsed = Date.now() - orderFetchedAt
+    if (elapsed > ULTRA_ORDER_TTL_WARN_MS) {
+      console.warn(
+        `[Swap] Ultra order payload is ${Math.round(elapsed / 1000)}s old (TTL ~2 min), may be stale`,
+      )
+    }
+
+    // Execute with retry on retryable negative codes.
+    // Ultra /execute is idempotent for the same signedTransaction + requestId within ~2 min TTL.
+    let executeResult = await executeUltraSwap(signedTx, orderData.requestId)
+
+    for (
+      let retry = 0;
+      retry < 2 &&
+      executeResult.status !== 'Success' &&
+      ULTRA_RETRYABLE_CODES.has(executeResult.code);
+      retry++
+    ) {
+      const delay = 1000 * Math.pow(2, retry)
+      console.warn(
+        `[Swap] Ultra execute retryable code ${executeResult.code}, retrying in ${delay}ms (retry ${retry + 1}/2)`,
+      )
+      await new Promise((r) => setTimeout(r, delay))
+      executeResult = await executeUltraSwap(signedTx, orderData.requestId)
+    }
 
     if (executeResult.status !== 'Success') {
-      throw new Error(executeResult.error || 'Execute failed')
+      throw new Error(
+        `Execute failed [code=${executeResult.code}]: ${executeResult.error || 'Unknown error'}`,
+      )
     }
 
     const signature = executeResult.signature!
@@ -244,10 +290,19 @@ async function signAndExecuteSwapV1(
       quoteResponse = await fetchSwapQuote(inputMint, outputMint, amount, slippageBps, swapMode)
     }
 
-    console.log(`[Swap] v1 quote: in=${quoteResponse.inAmount}, out=${quoteResponse.outAmount}, mode=${quoteResponse.swapMode}`)
+    console.log(
+      `[Swap] v1 quote: in=${quoteResponse.inAmount}, out=${quoteResponse.outAmount}, mode=${quoteResponse.swapMode}`,
+    )
 
     // Get swap transaction
-    const { swapTransaction } = await fetchSwapTransaction(quoteResponse, keypair.publicKey.toBase58())
+    const { swapTransaction } = await fetchSwapTransaction(
+      quoteResponse,
+      keypair.publicKey.toBase58(),
+    )
+
+    // Fetch blockhash BEFORE sending so the confirmation window is correctly anchored.
+    // Using maxRetries: 0 (per Jupiter quickstart) to avoid duplicate submissions.
+    const latestBlockhash = await connection.getLatestBlockhash('confirmed')
 
     // Sign and send
     const txBuf = Buffer.from(swapTransaction, 'base64')
@@ -256,15 +311,11 @@ async function signAndExecuteSwapV1(
 
     const signature = await connection.sendRawTransaction(transaction.serialize(), {
       skipPreflight: true,
-      maxRetries: 3,
+      maxRetries: 0,
     })
 
-    // Confirm
-    const latestBlockhash = await connection.getLatestBlockhash('confirmed')
-    await connection.confirmTransaction(
-      { signature, ...latestBlockhash },
-      'confirmed',
-    )
+    // Confirm using the blockhash fetched before send
+    await connection.confirmTransaction({ signature, ...latestBlockhash }, 'confirmed')
 
     console.log(`[Swap] v1 Confirmed: ${signature}`)
     return { success: true, txid: signature }
@@ -280,16 +331,19 @@ async function signAndExecuteSwapV1(
  * 1. 用小额 ExactIn 报价获取汇率
  * 2. 按汇率反推所需输入量,加 15% buffer
  * 3. 用计算出的输入量获取正式 ExactIn 报价
+ *
+ * @param inputDecimals - input token decimals (default 6 for USDC/USDT)
  */
 async function estimateExactInQuote(
   inputMint: string,
   outputMint: string,
   desiredOutputAmount: string,
   slippageBps: number,
+  inputDecimals: number = 6,
 ): Promise<Record<string, unknown>> {
-  // Use 10 USDC (10_000_000 raw) as reference to get the exchange rate.
-  // For non-USDC input tokens, use 10 units of whatever the input is (6-decimal assumed).
-  const refInputAmount = '10000000'
+  // Use 10 units of input token as reference to get the exchange rate.
+  // Respects actual token decimals (e.g. 10 USDC = 10_000_000 for 6-decimal; 10 SOL = 10_000_000_000 for 9-decimal).
+  const refInputAmount = String(Math.round(10 * Math.pow(10, inputDecimals)))
   const refQuote = await fetchSwapQuote(inputMint, outputMint, refInputAmount, slippageBps, 'ExactIn')
 
   const refIn = BigInt(refQuote.inAmount as string)