swap.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. import { Connection, PublicKey, VersionedTransaction } from '@solana/web3.js'
  2. import ky from 'ky'
  3. import { config } from '../config'
  4. import { getKeypair, getUserAddress } from '../solana/wallet'
  5. import { getJupiterHeaders, withRetry } from './jupiter-client'
  6. export const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
  7. const USDT_MINT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
  8. const SOL_MINT = 'So11111111111111111111111111111111111111112'
  9. const USD1_MINT = 'USD1ttGY1N17NEEHLmELoaybftRBUSErhqYiQzvEmuB'
  10. /** 稳定币 mint:不需要 swap 的 */
  11. const STABLECOIN_MINTS = new Set([USDC_MINT, USDT_MINT, USD1_MINT])
  12. /** 不需要 swap 回 USDC 的 mint 列表 */
  13. const SKIP_SWAP_BACK_MINTS = new Set([USDC_MINT, USDT_MINT, SOL_MINT, USD1_MINT])
  14. const ULTRA_API_URL = 'https://api.jup.ag/ultra/v1'
  15. const SWAP_API_URL = 'https://api.jup.ag/swap/v1'
  16. /**
  17. * Ultra execute negative error codes that are safe to retry with the same
  18. * signedTransaction + requestId (idempotent within the ~2 min TTL).
  19. * Ref: https://dev.jup.ag/docs/ultra/response.md
  20. */
  21. const ULTRA_RETRYABLE_CODES = new Set([-1, -1000, -1001, -1005, -1006, -2000, -2003, -2005])
  22. /** Warn when a signed Ultra order payload approaches its ~2 min TTL. */
  23. const ULTRA_ORDER_TTL_WARN_MS = 90_000
  24. interface UltraOrderResponse {
  25. requestId: string
  26. inputMint: string
  27. outputMint: string
  28. inAmount: string
  29. outAmount: string
  30. otherAmountThreshold: string
  31. swapMode: string
  32. slippageBps: number
  33. priceImpactPct: string
  34. routePlan: Array<{
  35. swapInfo: {
  36. ammKey: string
  37. label: string
  38. inputMint: string
  39. outputMint: string
  40. inAmount: string
  41. outAmount: string
  42. }
  43. percent: number
  44. }>
  45. transaction: string | null
  46. router: string
  47. gasless: boolean
  48. errorCode?: number
  49. errorMessage?: string
  50. }
  51. interface UltraExecuteResponse {
  52. status: 'Success' | 'Failed'
  53. signature?: string
  54. error?: string
  55. code: number
  56. }
  57. /**
  58. * 获取 Jupiter Ultra Order(GET 请求,返回 unsigned transaction)
  59. */
  60. async function fetchUltraOrder(
  61. inputMint: string,
  62. outputMint: string,
  63. amount: string | number,
  64. taker: string,
  65. slippageBps: number = 200,
  66. swapMode: 'ExactIn' | 'ExactOut' = 'ExactIn',
  67. ): Promise<UltraOrderResponse> {
  68. const rawAmount = typeof amount === 'string' ? amount : String(Math.floor(Number(amount)))
  69. return withRetry(() =>
  70. ky
  71. .get(`${ULTRA_API_URL}/order`, {
  72. searchParams: {
  73. inputMint,
  74. outputMint,
  75. amount: rawAmount,
  76. taker,
  77. slippageBps: String(slippageBps),
  78. swapMode,
  79. },
  80. timeout: 30000,
  81. headers: getJupiterHeaders(),
  82. })
  83. .json<UltraOrderResponse>(),
  84. )
  85. }
  86. /**
  87. * 执行 Jupiter Ultra swap(POST 签名后的交易)
  88. */
  89. async function executeUltraSwap(
  90. signedTransaction: string,
  91. requestId: string,
  92. ): Promise<UltraExecuteResponse> {
  93. return withRetry(() =>
  94. ky
  95. .post(`${ULTRA_API_URL}/execute`, {
  96. json: { signedTransaction, requestId },
  97. timeout: 30000,
  98. headers: {
  99. ...getJupiterHeaders(),
  100. 'Content-Type': 'application/json',
  101. },
  102. })
  103. .json<UltraExecuteResponse>(),
  104. )
  105. }
  106. /**
  107. * Jupiter Swap v1 API: GET /quote (supports ExactOut)
  108. */
  109. async function fetchSwapQuote(
  110. inputMint: string,
  111. outputMint: string,
  112. amount: string,
  113. slippageBps: number,
  114. swapMode: 'ExactIn' | 'ExactOut',
  115. ): Promise<Record<string, unknown>> {
  116. return withRetry(() =>
  117. ky
  118. .get(`${SWAP_API_URL}/quote`, {
  119. searchParams: {
  120. inputMint,
  121. outputMint,
  122. amount,
  123. slippageBps: String(slippageBps),
  124. swapMode,
  125. },
  126. timeout: 30000,
  127. headers: getJupiterHeaders(),
  128. })
  129. .json<Record<string, unknown>>(),
  130. )
  131. }
  132. /**
  133. * Jupiter Swap v1 API: POST /swap (returns unsigned transaction)
  134. */
  135. async function fetchSwapTransaction(
  136. quoteResponse: Record<string, unknown>,
  137. userPublicKey: string,
  138. ): Promise<{ swapTransaction: string; lastValidBlockHeight: number }> {
  139. return withRetry(() =>
  140. ky
  141. .post(`${SWAP_API_URL}/swap`, {
  142. json: {
  143. quoteResponse,
  144. userPublicKey,
  145. wrapAndUnwrapSol: true,
  146. dynamicComputeUnitLimit: true,
  147. },
  148. timeout: 30000,
  149. headers: {
  150. ...getJupiterHeaders(),
  151. 'Content-Type': 'application/json',
  152. },
  153. })
  154. .json<{ swapTransaction: string; lastValidBlockHeight: number }>(),
  155. )
  156. }
  157. /**
  158. * 签名并执行 swap
  159. * - ExactIn: 使用 Jupiter Ultra API(更快,含幂等重试)
  160. * - ExactOut: 使用 Jupiter Swap v1 API(Ultra 不支持 ExactOut)
  161. */
  162. async function signAndExecuteSwap(
  163. inputMint: string,
  164. outputMint: string,
  165. amount: string,
  166. slippageBps: number = 200,
  167. swapMode: 'ExactIn' | 'ExactOut' = 'ExactIn',
  168. ): Promise<{ success: boolean; txid?: string; error?: string }> {
  169. const keypair = getKeypair()
  170. try {
  171. if (swapMode === 'ExactOut') {
  172. return await signAndExecuteSwapV1(inputMint, outputMint, amount, slippageBps, swapMode)
  173. }
  174. // ExactIn: use Ultra API
  175. const orderData = await fetchUltraOrder(
  176. inputMint,
  177. outputMint,
  178. amount,
  179. keypair.publicKey.toBase58(),
  180. slippageBps,
  181. swapMode,
  182. )
  183. const orderFetchedAt = Date.now()
  184. if (!orderData.transaction) {
  185. const errorMsg = orderData.errorMessage || 'No transaction returned'
  186. console.error(`[Swap] Ultra order error: ${errorMsg}`)
  187. return { success: false, error: errorMsg }
  188. }
  189. if (orderData.routePlan?.length > 0) {
  190. const routeLabels = orderData.routePlan.map((r) => r.swapInfo?.label || '?').join(' -> ')
  191. console.log(
  192. `[Swap] ${swapMode} Route: ${routeLabels}, in: ${orderData.inAmount}, out: ${orderData.outAmount}`,
  193. )
  194. }
  195. const txBuf = Buffer.from(orderData.transaction, 'base64')
  196. const transaction = VersionedTransaction.deserialize(txBuf)
  197. transaction.sign([keypair])
  198. const signedTx = Buffer.from(transaction.serialize()).toString('base64')
  199. // Warn if signed payload is approaching the ~2 min TTL
  200. const elapsed = Date.now() - orderFetchedAt
  201. if (elapsed > ULTRA_ORDER_TTL_WARN_MS) {
  202. console.warn(
  203. `[Swap] Ultra order payload is ${Math.round(elapsed / 1000)}s old (TTL ~2 min), may be stale`,
  204. )
  205. }
  206. // Execute with retry on retryable negative codes.
  207. // Ultra /execute is idempotent for the same signedTransaction + requestId within ~2 min TTL.
  208. let executeResult = await executeUltraSwap(signedTx, orderData.requestId)
  209. for (
  210. let retry = 0;
  211. retry < 2 &&
  212. executeResult.status !== 'Success' &&
  213. ULTRA_RETRYABLE_CODES.has(executeResult.code);
  214. retry++
  215. ) {
  216. const delay = 1000 * Math.pow(2, retry)
  217. console.warn(
  218. `[Swap] Ultra execute retryable code ${executeResult.code}, retrying in ${delay}ms (retry ${retry + 1}/2)`,
  219. )
  220. await new Promise((r) => setTimeout(r, delay))
  221. executeResult = await executeUltraSwap(signedTx, orderData.requestId)
  222. }
  223. if (executeResult.status !== 'Success') {
  224. throw new Error(
  225. `Execute failed [code=${executeResult.code}]: ${executeResult.error || 'Unknown error'}`,
  226. )
  227. }
  228. const signature = executeResult.signature!
  229. console.log(`[Swap] Confirmed: ${signature}`)
  230. return { success: true, txid: signature }
  231. } catch (error) {
  232. const errorMessage = error instanceof Error ? error.message : 'Unknown swap error'
  233. console.error(`[Swap] Failed:`, errorMessage)
  234. return { success: false, error: errorMessage }
  235. }
  236. }
  237. /**
  238. * ExactOut swap via Jupiter Swap v1 API.
  239. * If ExactOut is not supported for the pair (400), falls back to ExactIn
  240. * by estimating the input amount from a reference quote + 15% buffer.
  241. */
  242. async function signAndExecuteSwapV1(
  243. inputMint: string,
  244. outputMint: string,
  245. amount: string,
  246. slippageBps: number,
  247. swapMode: 'ExactIn' | 'ExactOut',
  248. ): Promise<{ success: boolean; txid?: string; error?: string }> {
  249. const keypair = getKeypair()
  250. const connection = (await import('../solana/connection')).getConnection()
  251. try {
  252. let quoteResponse: Record<string, unknown>
  253. if (swapMode === 'ExactOut') {
  254. try {
  255. // Try ExactOut first
  256. quoteResponse = await fetchSwapQuote(inputMint, outputMint, amount, slippageBps, 'ExactOut')
  257. } catch {
  258. // ExactOut not supported for this pair, fall back to ExactIn
  259. console.log('[Swap] ExactOut not available, estimating ExactIn amount via reference quote')
  260. quoteResponse = await estimateExactInQuote(inputMint, outputMint, amount, slippageBps)
  261. }
  262. } else {
  263. quoteResponse = await fetchSwapQuote(inputMint, outputMint, amount, slippageBps, swapMode)
  264. }
  265. console.log(
  266. `[Swap] v1 quote: in=${quoteResponse.inAmount}, out=${quoteResponse.outAmount}, mode=${quoteResponse.swapMode}`,
  267. )
  268. // Get swap transaction
  269. const { swapTransaction } = await fetchSwapTransaction(
  270. quoteResponse,
  271. keypair.publicKey.toBase58(),
  272. )
  273. // Fetch blockhash BEFORE sending so the confirmation window is correctly anchored.
  274. // Using maxRetries: 0 (per Jupiter quickstart) to avoid duplicate submissions.
  275. const latestBlockhash = await connection.getLatestBlockhash('confirmed')
  276. // Sign and send
  277. const txBuf = Buffer.from(swapTransaction, 'base64')
  278. const transaction = VersionedTransaction.deserialize(txBuf)
  279. transaction.sign([keypair])
  280. const signature = await connection.sendRawTransaction(transaction.serialize(), {
  281. skipPreflight: true,
  282. maxRetries: 0,
  283. })
  284. // Confirm using the blockhash fetched before send
  285. await connection.confirmTransaction({ signature, ...latestBlockhash }, 'confirmed')
  286. console.log(`[Swap] v1 Confirmed: ${signature}`)
  287. return { success: true, txid: signature }
  288. } catch (error) {
  289. const errorMessage = error instanceof Error ? error.message : 'Unknown swap error'
  290. console.error(`[Swap] v1 Failed:`, errorMessage)
  291. return { success: false, error: errorMessage }
  292. }
  293. }
  294. /**
  295. * ExactOut 不可用时,通过参考报价估算所需 ExactIn 输入量。
  296. * 1. 用小额 ExactIn 报价获取汇率
  297. * 2. 按汇率反推所需输入量,加 15% buffer
  298. * 3. 用计算出的输入量获取正式 ExactIn 报价
  299. *
  300. * @param inputDecimals - input token decimals (default 6 for USDC/USDT)
  301. */
  302. async function estimateExactInQuote(
  303. inputMint: string,
  304. outputMint: string,
  305. desiredOutputAmount: string,
  306. slippageBps: number,
  307. inputDecimals: number = 6,
  308. ): Promise<Record<string, unknown>> {
  309. // Use 10 units of input token as reference to get the exchange rate.
  310. // Respects actual token decimals (e.g. 10 USDC = 10_000_000 for 6-decimal; 10 SOL = 10_000_000_000 for 9-decimal).
  311. const refInputAmount = String(Math.round(10 * Math.pow(10, inputDecimals)))
  312. const refQuote = await fetchSwapQuote(inputMint, outputMint, refInputAmount, slippageBps, 'ExactIn')
  313. const refIn = BigInt(refQuote.inAmount as string)
  314. const refOut = BigInt(refQuote.outAmount as string)
  315. if (refOut === 0n) {
  316. throw new Error('Reference quote returned 0 output, cannot estimate rate')
  317. }
  318. // estimatedInput = desiredOutput * (refIn / refOut) * 1.15
  319. const desiredOut = BigInt(desiredOutputAmount)
  320. const estimatedInput = (desiredOut * refIn * 115n) / (refOut * 100n)
  321. console.log(
  322. `[Swap] Rate estimate: ${refIn}→${refOut}, need ${desiredOut} out, estimated input ${estimatedInput} (+15% buffer)`,
  323. )
  324. // Get actual ExactIn quote with estimated input amount
  325. return fetchSwapQuote(inputMint, outputMint, estimatedInput.toString(), slippageBps, 'ExactIn')
  326. }
  327. /**
  328. * 获取代币余额(raw amount)
  329. */
  330. async function getTokenBalance(connection: Connection, mint: string): Promise<bigint> {
  331. const userAddr = getUserAddress()
  332. if (mint === SOL_MINT) {
  333. const balance = await connection.getBalance(userAddr)
  334. return BigInt(balance)
  335. }
  336. try {
  337. const accounts = await connection.getParsedTokenAccountsByOwner(userAddr, {
  338. mint: new PublicKey(mint),
  339. })
  340. let balance = 0n
  341. for (const acc of accounts.value) {
  342. balance += BigInt(acc.account.data.parsed.info.tokenAmount.amount)
  343. }
  344. return balance
  345. } catch {
  346. return 0n
  347. }
  348. }
  349. /**
  350. * 确保某个代币有足够余额,不足则通过 USDC -> token swap 补齐(ExactOut 模式)
  351. * 参考 byreal-copy: 查余额 → 算差额 → 加 5% buffer → ExactOut swap
  352. * @returns true 如果执行了 swap
  353. */
  354. async function ensureTokenBalance(params: {
  355. connection: Connection
  356. tokenMint: string
  357. requiredAmount: bigint
  358. }): Promise<{ swapped: boolean; txid?: string; error?: string }> {
  359. const { connection, tokenMint, requiredAmount } = params
  360. if (requiredAmount <= 0n) {
  361. return { swapped: false }
  362. }
  363. // 稳定币不需要 swap(USDC 本身、USDT、USD1)
  364. if (STABLECOIN_MINTS.has(tokenMint)) {
  365. return { swapped: false }
  366. }
  367. const currentBalance = await getTokenBalance(connection, tokenMint)
  368. if (currentBalance >= requiredAmount) {
  369. console.log(
  370. `[Swap] ${tokenMint.slice(0, 8)}...: have ${currentBalance}, need ${requiredAmount}, sufficient`,
  371. )
  372. return { swapped: false }
  373. }
  374. const deficit = requiredAmount - currentBalance
  375. // 加 2% buffer 防止余额微小波动
  376. const swapAmount = (deficit * 102n) / 100n
  377. console.log(
  378. `[Swap] ${tokenMint.slice(0, 8)}...: have ${currentBalance}, need ${requiredAmount}, deficit ${deficit}, swap(ExactOut) ${swapAmount}`,
  379. )
  380. // 优先 USDC → token(ExactOut: amount = 需要获得的代币数量)
  381. let result = await signAndExecuteSwap(USDC_MINT, tokenMint, swapAmount.toString(), 300, 'ExactOut')
  382. if (result.success) {
  383. return { swapped: true, txid: result.txid }
  384. }
  385. // 失败则尝试 USDT → token
  386. console.log('[Swap] USDC failed, trying USDT...')
  387. result = await signAndExecuteSwap(USDT_MINT, tokenMint, swapAmount.toString(), 300, 'ExactOut')
  388. if (result.success) {
  389. return { swapped: true, txid: result.txid }
  390. }
  391. return { swapped: false, error: result.error }
  392. }
  393. /**
  394. * 确保 LP 所需的两种代币都有足够余额(ExactOut 模式,按实际代币数量 swap)
  395. * 查当前余额 → 计算差额 → 加 buffer → USDC/USDT swap 补齐
  396. */
  397. export async function ensureSufficientBalances(params: {
  398. connection: Connection
  399. tokenA: { mint: string; requiredAmount: string }
  400. tokenB: { mint: string; requiredAmount: string }
  401. }): Promise<{ success: boolean; swapTxids: string[]; error?: string }> {
  402. const { connection, tokenA, tokenB } = params
  403. const swapTxids: string[] = []
  404. // Token A
  405. const resultA = await ensureTokenBalance({
  406. connection,
  407. tokenMint: tokenA.mint,
  408. requiredAmount: BigInt(tokenA.requiredAmount || '0'),
  409. })
  410. if (resultA.error) {
  411. return { success: false, swapTxids, error: `Failed to get Token A: ${resultA.error}` }
  412. }
  413. if (resultA.txid) swapTxids.push(resultA.txid)
  414. // Token B
  415. const resultB = await ensureTokenBalance({
  416. connection,
  417. tokenMint: tokenB.mint,
  418. requiredAmount: BigInt(tokenB.requiredAmount || '0'),
  419. })
  420. if (resultB.error) {
  421. return { success: false, swapTxids, error: `Failed to get Token B: ${resultB.error}` }
  422. }
  423. if (resultB.txid) swapTxids.push(resultB.txid)
  424. return { success: true, swapTxids }
  425. }
  426. /**
  427. * 获取 USDC 余额(raw amount, 6 decimals)
  428. */
  429. export async function getUsdcBalance(connection: Connection): Promise<bigint> {
  430. return getTokenBalance(connection, USDC_MINT)
  431. }
  432. /**
  433. * 关仓后将收到的代币换回 USDC
  434. * 跳过 SOL、USDC、USDT、USD1
  435. */
  436. export async function swapTokensBackToUsdc(params: {
  437. connection: Connection
  438. mints: string[]
  439. }): Promise<{ swapTxids: string[] }> {
  440. const { connection, mints } = params
  441. const swapTxids: string[] = []
  442. for (const mint of mints) {
  443. if (SKIP_SWAP_BACK_MINTS.has(mint)) continue
  444. try {
  445. const balance = await getTokenBalance(connection, mint)
  446. if (balance <= 0n) continue
  447. console.log(`[Swap] Swapping ${balance.toString()} of ${mint.slice(0, 8)}... back to USDC`)
  448. const result = await signAndExecuteSwap(mint, USDC_MINT, balance.toString(), 300)
  449. if (result.success && result.txid) {
  450. console.log(`[Swap] Swapped ${mint.slice(0, 8)}... -> USDC: ${result.txid}`)
  451. swapTxids.push(result.txid)
  452. } else {
  453. console.warn(`[Swap] Swap ${mint.slice(0, 8)}... -> USDC failed: ${result.error}`)
  454. }
  455. } catch (e) {
  456. console.warn(`[Swap] Error swapping ${mint.slice(0, 8)}... -> USDC:`, e)
  457. }
  458. }
  459. return { swapTxids }
  460. }