jupiter.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. import {
  2. Connection,
  3. PublicKey,
  4. Keypair,
  5. VersionedTransaction,
  6. } from '@solana/web3.js'
  7. import ky from 'ky'
  8. import { Token } from '@/lib/byreal-clmm-sdk/src/client/token'
  9. // USDC 和 USDT 作为默认的输入代币(用于兑换)
  10. const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
  11. const USDT_MINT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
  12. /** 稳定币 mint:用 USDC 换这些币时无需 swap */
  13. const STABLECOIN_MINTS = [
  14. USDC_MINT,
  15. USDT_MINT,
  16. 'USD1ttGY1N17NEEHLmELoaybftRBUSErhqYiQzvEmuB', // USD1
  17. ]
  18. export interface JupiterQuote {
  19. inputMint: string
  20. inAmount: string
  21. outputMint: string
  22. outAmount: string
  23. swapMode: string
  24. slippageBps: number
  25. priceImpactPct: string
  26. routePlan: Array<{
  27. swapInfo: {
  28. label?: string
  29. [key: string]: unknown
  30. }
  31. }>
  32. }
  33. export interface JupiterSwapResponse {
  34. swapTransaction: string
  35. }
  36. export interface UltraOrderResponse {
  37. mode: string
  38. inputMint: string
  39. outputMint: string
  40. inAmount: string
  41. outAmount: string
  42. inUsdValue?: number
  43. outUsdValue?: number
  44. priceImpact?: number
  45. otherAmountThreshold: string
  46. swapMode: string
  47. slippageBps: number
  48. priceImpactPct: string
  49. routePlan: Array<{
  50. swapInfo: {
  51. ammKey: string
  52. label: string
  53. inputMint: string
  54. outputMint: string
  55. inAmount: string
  56. outAmount: string
  57. }
  58. percent: number
  59. bps: number
  60. usdValue?: number
  61. }>
  62. feeMint?: string
  63. feeBps?: number
  64. platformFee?: {
  65. amount: string
  66. feeBps: number
  67. }
  68. signatureFeeLamports: number
  69. signatureFeePayer: string | null
  70. prioritizationFeeLamports: number
  71. prioritizationFeePayer: string | null
  72. rentFeeLamports: number
  73. rentFeePayer: string | null
  74. router: string
  75. transaction: string | null
  76. gasless: boolean
  77. requestId: string
  78. totalTime: number
  79. taker: string | null
  80. quoteId?: string
  81. maker?: string
  82. expireAt?: string
  83. errorCode?: number
  84. errorMessage?: string
  85. }
  86. export interface UltraExecuteResponse {
  87. status: 'Success' | 'Failed'
  88. signature?: string
  89. slot?: string
  90. error?: string
  91. code: number
  92. totalInputAmount?: string
  93. totalOutputAmount?: string
  94. inputAmountResult?: string
  95. outputAmountResult?: string
  96. swapEvents?: Array<{
  97. inputMint: string
  98. inputAmount: string
  99. outputMint: string
  100. outputAmount: string
  101. }>
  102. }
  103. /**
  104. * 获取代币余额(兼容 Token-2022 和标准 SPL Token)
  105. * 使用 Token.detectTokenTypeAndGetBalance,避免 Token-2022 代币查询返回 0
  106. */
  107. export async function getTokenBalance(
  108. connection: Connection,
  109. walletAddress: PublicKey,
  110. mintAddress: string
  111. ): Promise<number> {
  112. try {
  113. const token = new Token(connection)
  114. const result = await token.detectTokenTypeAndGetBalance(
  115. walletAddress.toBase58(),
  116. mintAddress
  117. )
  118. return result.balance
  119. } catch (error) {
  120. console.error(`Error getting balance for ${mintAddress}:`, error)
  121. return 0
  122. }
  123. }
  124. /**
  125. * 获取 Jupiter API 请求头
  126. */
  127. function getJupiterHeaders(): Record<string, string> {
  128. const headers: Record<string, string> = {
  129. Accept: 'application/json',
  130. }
  131. const apiKey = process.env.JUPITER_API_KEY
  132. if (apiKey) {
  133. headers['x-api-key'] = apiKey
  134. }
  135. return headers
  136. }
  137. /** Jupiter Price API v3 返回的单个代币价格(需 API Key,从 https://portal.jup.ag/ 获取) */
  138. const JUPITER_PRICE_API_URL =
  139. process.env.JUPITER_PRICE_API_URL || 'https://api.jup.ag/price/v3'
  140. /**
  141. * 从 Jupiter Price API v3 获取代币 USD 价格(需配置环境变量 JUPITER_API_KEY)
  142. * @returns 成功时返回 { tokenAPriceUsd, tokenBPriceUsd },失败返回 null
  143. */
  144. export async function getTokenPricesFromJupiter(
  145. mintA: string,
  146. mintB: string
  147. ): Promise<{ tokenAPriceUsd: number; tokenBPriceUsd: number } | null> {
  148. try {
  149. if (!process.env.JUPITER_API_KEY) {
  150. console.warn(
  151. 'Jupiter Price API v3 需要 API Key,请在 .env 中设置 JUPITER_API_KEY(从 https://portal.jup.ag/ 获取)'
  152. )
  153. return null
  154. }
  155. const ids = [mintA, mintB]
  156. .filter((m, i, arr) => arr.indexOf(m) === i)
  157. .join(',')
  158. const data = await ky
  159. .get(`${JUPITER_PRICE_API_URL}?ids=${ids}`, {
  160. headers: getJupiterHeaders(),
  161. timeout: 10000,
  162. })
  163. .json<Record<string, { price?: number; usdPrice?: number }>>()
  164. const priceOf = (mint: string): number | undefined => {
  165. const row = data[mint]
  166. if (!row) return undefined
  167. return row.usdPrice ?? row.price
  168. }
  169. const pa = priceOf(mintA)
  170. const pb = priceOf(mintB)
  171. if (pa != null && pa > 0 && pb != null && pb > 0) {
  172. return { tokenAPriceUsd: pa, tokenBPriceUsd: pb }
  173. }
  174. return null
  175. } catch (error: unknown) {
  176. const is401 =
  177. error &&
  178. typeof error === 'object' &&
  179. 'response' in error &&
  180. (error as { response?: { status?: number } }).response?.status === 401
  181. if (is401) {
  182. console.warn(
  183. 'Jupiter Price API v3 返回 401:请在 .env 中设置有效的 JUPITER_API_KEY(从 https://portal.jup.ag/ 获取)'
  184. )
  185. } else {
  186. console.warn('Jupiter price v3 fetch failed:', error)
  187. }
  188. return null
  189. }
  190. }
  191. /**
  192. * 从 Jupiter API 获取 quote
  193. * @param restrictIntermediate - 为 false 时允许更多中间代币路由(用于 NO_ROUTES_FOUND 时重试)
  194. */
  195. export async function fetchJupiterQuote(
  196. inputMint: string,
  197. outputMint: string,
  198. amount: string | number,
  199. swapMode: 'ExactIn' | 'ExactOut' = 'ExactIn',
  200. slippageBps: number = 200, // 2%
  201. restrictIntermediate: boolean = true
  202. ): Promise<JupiterQuote> {
  203. const jupiterBaseUrl =
  204. process.env.JUPITER_API_URL || 'https://api.jup.ag/swap/v1'
  205. const rawAmount =
  206. typeof amount === 'string' ? amount : String(Math.floor(Number(amount)))
  207. const searchParams = new URLSearchParams({
  208. inputMint,
  209. outputMint,
  210. amount: rawAmount,
  211. swapMode,
  212. slippageBps: String(slippageBps),
  213. onlyDirectRoutes: 'false',
  214. restrictIntermediateTokens: restrictIntermediate ? 'true' : 'false',
  215. })
  216. const response = await ky
  217. .get(`${jupiterBaseUrl}/quote`, {
  218. searchParams,
  219. timeout: 30000,
  220. headers: getJupiterHeaders(),
  221. })
  222. .json<JupiterQuote>()
  223. return response
  224. }
  225. /**
  226. * 执行 Jupiter swap
  227. */
  228. export async function executeJupiterSwap(
  229. quoteData: JupiterQuote,
  230. walletAddress: string
  231. ): Promise<JupiterSwapResponse> {
  232. const jupiterBaseUrl =
  233. process.env.JUPITER_API_URL || 'https://api.jup.ag/swap/v1'
  234. const headers = {
  235. ...getJupiterHeaders(),
  236. 'Content-Type': 'application/json',
  237. }
  238. const response = await ky
  239. .post(`${jupiterBaseUrl}/swap`, {
  240. json: {
  241. quoteResponse: quoteData,
  242. userPublicKey: walletAddress,
  243. wrapAndUnwrapSol: true,
  244. prioritizationFeeLamports: 10000,
  245. dynamicSlippage: false,
  246. },
  247. timeout: 30000,
  248. headers,
  249. })
  250. .json<JupiterSwapResponse>()
  251. return response
  252. }
  253. const ULTRA_API_URL = 'https://api.jup.ag/ultra/v1/'
  254. /**
  255. * 获取 Jupiter Ultra Order(返回 unsigned transaction 和 requestId)
  256. */
  257. export async function fetchUltraOrder(
  258. inputMint: string,
  259. outputMint: string,
  260. amount: string | number,
  261. taker: string,
  262. slippageBps: number = 200
  263. ): Promise<UltraOrderResponse> {
  264. const rawAmount =
  265. typeof amount === 'string' ? amount : String(Math.floor(Number(amount)))
  266. const searchParams = new URLSearchParams({
  267. inputMint,
  268. outputMint,
  269. amount: rawAmount,
  270. taker,
  271. slippageBps: String(slippageBps),
  272. })
  273. const response = await ky
  274. .get(`${ULTRA_API_URL}/order`, {
  275. searchParams,
  276. timeout: 30000,
  277. headers: getJupiterHeaders(),
  278. })
  279. .json<UltraOrderResponse>()
  280. return response
  281. }
  282. /**
  283. * 执行 Jupiter Ultra swap
  284. */
  285. export async function executeUltraSwap(
  286. signedTransaction: string,
  287. requestId: string
  288. ): Promise<UltraExecuteResponse> {
  289. const headers = {
  290. ...getJupiterHeaders(),
  291. 'Content-Type': 'application/json',
  292. }
  293. const response = await ky
  294. .post(`${ULTRA_API_URL}/execute`, {
  295. json: {
  296. signedTransaction,
  297. requestId,
  298. },
  299. timeout: 30000,
  300. headers,
  301. })
  302. .json<UltraExecuteResponse>()
  303. return response
  304. }
  305. /**
  306. * 按 USD 金额执行 swap(使用 Jupiter Ultra API)
  307. * @param usdValue - 要换入的美元数(例如 5.5 表示花 $5.5 USDC)
  308. */
  309. export async function swapIfNeeded(
  310. connection: Connection,
  311. keypair: Keypair,
  312. outputMint: string,
  313. usdValue: number,
  314. inputMint: string = USDC_MINT
  315. ): Promise<{ success: boolean; txid?: string; error?: string }> {
  316. const walletAddress = keypair.publicKey
  317. if (!usdValue || Number(usdValue) <= 0) {
  318. console.log(`Skip swap: USD value is ${usdValue}`)
  319. return { success: true }
  320. }
  321. const mint = String(outputMint ?? '').trim()
  322. if (mint === USDC_MINT) {
  323. console.log(`Skip swap: output is USDC, no need to swap USDC -> USDC`)
  324. return { success: true }
  325. }
  326. const inputAmountRaw = Math.floor(Number(usdValue) * 1e6)
  327. if (inputAmountRaw < 5e4) {
  328. console.log(`Skip swap: USD value too small (${usdValue})`)
  329. return { success: true }
  330. }
  331. console.log(
  332. `Swap (Ultra): $${usdValue} USDC -> ${outputMint.slice(0, 8)}... (ExactIn)`
  333. )
  334. const slippageBps = 200
  335. try {
  336. const orderData = await fetchUltraOrder(
  337. inputMint,
  338. outputMint,
  339. inputAmountRaw,
  340. walletAddress.toBase58(),
  341. slippageBps
  342. )
  343. if (!orderData.transaction) {
  344. const errorMsg = orderData.errorMessage || 'No transaction returned'
  345. console.error(`Ultra order error: ${errorMsg}`)
  346. return { success: false, error: errorMsg }
  347. }
  348. if (orderData.routePlan && orderData.routePlan.length > 0) {
  349. const routeLabels = orderData.routePlan
  350. .map((r) => r.swapInfo?.label || 'Unknown')
  351. .join(' -> ')
  352. console.log(`Route: ${routeLabels}`)
  353. console.log(
  354. `Expected output: ${orderData.outAmount}, price impact: ${orderData.priceImpactPct}%`
  355. )
  356. console.log(`Router: ${orderData.router}, gasless: ${orderData.gasless}`)
  357. }
  358. const swapTransactionBuf = Buffer.from(orderData.transaction, 'base64')
  359. const transaction = VersionedTransaction.deserialize(swapTransactionBuf)
  360. transaction.sign([keypair])
  361. const signedTransaction = Buffer.from(transaction.serialize()).toString(
  362. 'base64'
  363. )
  364. const executeResult = await executeUltraSwap(
  365. signedTransaction,
  366. orderData.requestId
  367. )
  368. if (executeResult.status !== 'Success') {
  369. throw new Error(executeResult.error || 'Execute failed')
  370. }
  371. const signature = executeResult.signature!
  372. console.log(`Swap confirmed: https://solscan.io/tx/${signature}`)
  373. const newBalance = await getTokenBalance(
  374. connection,
  375. walletAddress,
  376. outputMint
  377. )
  378. console.log(`New balance: ${newBalance.toFixed(6)}`)
  379. return { success: true, txid: signature }
  380. } catch (error) {
  381. const errorMessage =
  382. error instanceof Error ? error.message : 'Unknown swap error'
  383. console.error('Swap failed:', errorMessage)
  384. return {
  385. success: false,
  386. error: errorMessage,
  387. }
  388. }
  389. }
  390. /**
  391. * 确保 LP 所需的两种代币都有足够余额
  392. * 按 valueUsd 换币:使用 ExactIn,花 $valueUsd USDC 换目标代币,不使用 amount
  393. * 会先检查用户现有余额,如有则扣除相应的 swap 金额
  394. */
  395. export async function ensureSufficientBalances(
  396. connection: Connection,
  397. keypair: Keypair,
  398. tokenA: {
  399. mint: string
  400. valueUsd: number
  401. priceUsd: number
  402. decimals: number
  403. },
  404. tokenB: { mint: string; valueUsd: number; priceUsd: number; decimals: number }
  405. ): Promise<{
  406. success: boolean
  407. swapTxids: string[]
  408. error?: string
  409. }> {
  410. const swapTxids: string[] = []
  411. const walletAddress = keypair.publicKey
  412. // Token A:检查现有余额,计算需要 swap 的金额
  413. const balanceA = await getTokenBalance(connection, walletAddress, tokenA.mint)
  414. const existingValueA = balanceA * tokenA.priceUsd
  415. const needSwapA = Math.max(0, tokenA.valueUsd - existingValueA)
  416. console.log(
  417. `\n--- Token A (${tokenA.mint.slice(0, 8)}...): need $${tokenA.valueUsd}, have $${existingValueA.toFixed(4)}, swap $${needSwapA.toFixed(4)} ---`
  418. )
  419. const resultA = await swapIfNeeded(
  420. connection,
  421. keypair,
  422. tokenA.mint,
  423. needSwapA,
  424. USDC_MINT
  425. )
  426. if (!resultA.success) {
  427. console.log('USDC swap failed for Token A, trying USDT...')
  428. const resultA_USDT = await swapIfNeeded(
  429. connection,
  430. keypair,
  431. tokenA.mint,
  432. needSwapA,
  433. USDT_MINT
  434. )
  435. if (!resultA_USDT.success) {
  436. return {
  437. success: false,
  438. swapTxids,
  439. error: `Failed to get Token A: ${resultA.error || resultA_USDT.error}`,
  440. }
  441. }
  442. if (resultA_USDT.txid) swapTxids.push(resultA_USDT.txid)
  443. } else if (resultA.txid) {
  444. swapTxids.push(resultA.txid)
  445. }
  446. // Token B:检查现有余额,计算需要 swap 的金额
  447. const balanceB = await getTokenBalance(connection, walletAddress, tokenB.mint)
  448. const existingValueB = balanceB * tokenB.priceUsd
  449. const needSwapB = Math.max(0, tokenB.valueUsd - existingValueB)
  450. console.log(
  451. `\n--- Token B (${tokenB.mint.slice(0, 8)}...): need $${tokenB.valueUsd}, have $${existingValueB.toFixed(4)}, swap $${needSwapB.toFixed(4)} ---`
  452. )
  453. const resultB = await swapIfNeeded(
  454. connection,
  455. keypair,
  456. tokenB.mint,
  457. needSwapB,
  458. USDC_MINT
  459. )
  460. if (!resultB.success) {
  461. console.log('USDC swap failed for Token B, trying USDT...')
  462. const resultB_USDT = await swapIfNeeded(
  463. connection,
  464. keypair,
  465. tokenB.mint,
  466. needSwapB,
  467. USDT_MINT
  468. )
  469. if (!resultB_USDT.success) {
  470. return {
  471. success: false,
  472. swapTxids,
  473. error: `Failed to get Token B: ${resultB.error || resultB_USDT.error}`,
  474. }
  475. }
  476. if (resultB_USDT.txid) swapTxids.push(resultB_USDT.txid)
  477. } else if (resultB.txid) {
  478. swapTxids.push(resultB.txid)
  479. }
  480. return { success: true, swapTxids }
  481. }