|
@@ -2,6 +2,7 @@ import { Connection, PublicKey, VersionedTransaction } from '@solana/web3.js'
|
|
|
import ky from 'ky'
|
|
import ky from 'ky'
|
|
|
import { config } from '../config'
|
|
import { config } from '../config'
|
|
|
import { getKeypair, getUserAddress } from '../solana/wallet'
|
|
import { getKeypair, getUserAddress } from '../solana/wallet'
|
|
|
|
|
+import { getJupiterHeaders, withRetry } from './jupiter-client'
|
|
|
|
|
|
|
|
export const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
|
|
export const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
|
|
|
const USDT_MINT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
|
|
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 ULTRA_API_URL = 'https://api.jup.ag/ultra/v1'
|
|
|
const SWAP_API_URL = 'https://api.jup.ag/swap/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 {
|
|
interface UltraOrderResponse {
|
|
|
requestId: string
|
|
requestId: string
|
|
|
inputMint: string
|
|
inputMint: string
|
|
@@ -52,16 +63,6 @@ interface UltraExecuteResponse {
|
|
|
code: number
|
|
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)
|
|
* 获取 Jupiter Ultra Order(GET 请求,返回 unsigned transaction)
|
|
|
*/
|
|
*/
|
|
@@ -75,20 +76,22 @@ async function fetchUltraOrder(
|
|
|
): Promise<UltraOrderResponse> {
|
|
): Promise<UltraOrderResponse> {
|
|
|
const rawAmount = typeof amount === 'string' ? amount : String(Math.floor(Number(amount)))
|
|
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,
|
|
signedTransaction: string,
|
|
|
requestId: string,
|
|
requestId: string,
|
|
|
): Promise<UltraExecuteResponse> {
|
|
): 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,
|
|
slippageBps: number,
|
|
|
swapMode: 'ExactIn' | 'ExactOut',
|
|
swapMode: 'ExactIn' | 'ExactOut',
|
|
|
): Promise<Record<string, unknown>> {
|
|
): 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>,
|
|
quoteResponse: Record<string, unknown>,
|
|
|
userPublicKey: string,
|
|
userPublicKey: string,
|
|
|
): Promise<{ swapTransaction: string; lastValidBlockHeight: number }> {
|
|
): 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
|
|
* 签名并执行 swap
|
|
|
- * - ExactIn: 使用 Jupiter Ultra API(更快)
|
|
|
|
|
|
|
+ * - ExactIn: 使用 Jupiter Ultra API(更快,含幂等重试)
|
|
|
* - ExactOut: 使用 Jupiter Swap v1 API(Ultra 不支持 ExactOut)
|
|
* - ExactOut: 使用 Jupiter Swap v1 API(Ultra 不支持 ExactOut)
|
|
|
*/
|
|
*/
|
|
|
async function signAndExecuteSwap(
|
|
async function signAndExecuteSwap(
|
|
@@ -179,7 +188,15 @@ async function signAndExecuteSwap(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ExactIn: use Ultra API
|
|
// 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) {
|
|
if (!orderData.transaction) {
|
|
|
const errorMsg = orderData.errorMessage || 'No transaction returned'
|
|
const errorMsg = orderData.errorMessage || 'No transaction returned'
|
|
@@ -189,7 +206,9 @@ async function signAndExecuteSwap(
|
|
|
|
|
|
|
|
if (orderData.routePlan?.length > 0) {
|
|
if (orderData.routePlan?.length > 0) {
|
|
|
const routeLabels = orderData.routePlan.map((r) => r.swapInfo?.label || '?').join(' -> ')
|
|
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')
|
|
const txBuf = Buffer.from(orderData.transaction, 'base64')
|
|
@@ -197,10 +216,37 @@ async function signAndExecuteSwap(
|
|
|
transaction.sign([keypair])
|
|
transaction.sign([keypair])
|
|
|
const signedTx = Buffer.from(transaction.serialize()).toString('base64')
|
|
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') {
|
|
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!
|
|
const signature = executeResult.signature!
|
|
@@ -244,10 +290,19 @@ async function signAndExecuteSwapV1(
|
|
|
quoteResponse = await fetchSwapQuote(inputMint, outputMint, amount, slippageBps, swapMode)
|
|
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
|
|
// 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
|
|
// Sign and send
|
|
|
const txBuf = Buffer.from(swapTransaction, 'base64')
|
|
const txBuf = Buffer.from(swapTransaction, 'base64')
|
|
@@ -256,15 +311,11 @@ async function signAndExecuteSwapV1(
|
|
|
|
|
|
|
|
const signature = await connection.sendRawTransaction(transaction.serialize(), {
|
|
const signature = await connection.sendRawTransaction(transaction.serialize(), {
|
|
|
skipPreflight: true,
|
|
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}`)
|
|
console.log(`[Swap] v1 Confirmed: ${signature}`)
|
|
|
return { success: true, txid: signature }
|
|
return { success: true, txid: signature }
|
|
@@ -280,16 +331,19 @@ async function signAndExecuteSwapV1(
|
|
|
* 1. 用小额 ExactIn 报价获取汇率
|
|
* 1. 用小额 ExactIn 报价获取汇率
|
|
|
* 2. 按汇率反推所需输入量,加 15% buffer
|
|
* 2. 按汇率反推所需输入量,加 15% buffer
|
|
|
* 3. 用计算出的输入量获取正式 ExactIn 报价
|
|
* 3. 用计算出的输入量获取正式 ExactIn 报价
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param inputDecimals - input token decimals (default 6 for USDC/USDT)
|
|
|
*/
|
|
*/
|
|
|
async function estimateExactInQuote(
|
|
async function estimateExactInQuote(
|
|
|
inputMint: string,
|
|
inputMint: string,
|
|
|
outputMint: string,
|
|
outputMint: string,
|
|
|
desiredOutputAmount: string,
|
|
desiredOutputAmount: string,
|
|
|
slippageBps: number,
|
|
slippageBps: number,
|
|
|
|
|
+ inputDecimals: number = 6,
|
|
|
): Promise<Record<string, unknown>> {
|
|
): 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 refQuote = await fetchSwapQuote(inputMint, outputMint, refInputAmount, slippageBps, 'ExactIn')
|
|
|
|
|
|
|
|
const refIn = BigInt(refQuote.inAmount as string)
|
|
const refIn = BigInt(refQuote.inAmount as string)
|