@w3lib/solana-batch-call 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +231 -3
  3. package/dist/approvals.d.ts +129 -0
  4. package/dist/approvals.d.ts.map +1 -0
  5. package/dist/approvals.js +221 -0
  6. package/dist/approvals.js.map +1 -0
  7. package/dist/fees.d.ts +89 -0
  8. package/dist/fees.d.ts.map +1 -0
  9. package/dist/fees.js +166 -0
  10. package/dist/fees.js.map +1 -0
  11. package/dist/layout.d.ts +2 -0
  12. package/dist/layout.d.ts.map +1 -1
  13. package/dist/layout.js +2 -2
  14. package/dist/layout.js.map +1 -1
  15. package/dist/lp.d.ts +8 -15
  16. package/dist/lp.d.ts.map +1 -1
  17. package/dist/lp.js +7 -31
  18. package/dist/lp.js.map +1 -1
  19. package/dist/names.d.ts +68 -0
  20. package/dist/names.d.ts.map +1 -0
  21. package/dist/names.js +257 -0
  22. package/dist/names.js.map +1 -0
  23. package/dist/pools.d.ts +9 -0
  24. package/dist/pools.d.ts.map +1 -0
  25. package/dist/pools.js +45 -0
  26. package/dist/pools.js.map +1 -0
  27. package/dist/portfolio.d.ts +64 -0
  28. package/dist/portfolio.d.ts.map +1 -0
  29. package/dist/portfolio.js +57 -0
  30. package/dist/portfolio.js.map +1 -0
  31. package/dist/price.d.ts +90 -0
  32. package/dist/price.d.ts.map +1 -0
  33. package/dist/price.js +287 -0
  34. package/dist/price.js.map +1 -0
  35. package/dist/risk.d.ts +84 -0
  36. package/dist/risk.d.ts.map +1 -0
  37. package/dist/risk.js +211 -0
  38. package/dist/risk.js.map +1 -0
  39. package/dist/rpc.d.ts +2 -0
  40. package/dist/rpc.d.ts.map +1 -1
  41. package/dist/rpc.js +33 -9
  42. package/dist/rpc.js.map +1 -1
  43. package/dist/subpaths/approvals.d.ts +29 -0
  44. package/dist/subpaths/approvals.d.ts.map +1 -0
  45. package/dist/subpaths/approvals.js +30 -0
  46. package/dist/subpaths/approvals.js.map +1 -0
  47. package/dist/subpaths/fees.d.ts +23 -0
  48. package/dist/subpaths/fees.d.ts.map +1 -0
  49. package/dist/subpaths/fees.js +25 -0
  50. package/dist/subpaths/fees.js.map +1 -0
  51. package/dist/subpaths/names.d.ts +37 -0
  52. package/dist/subpaths/names.d.ts.map +1 -0
  53. package/dist/subpaths/names.js +27 -0
  54. package/dist/subpaths/names.js.map +1 -0
  55. package/dist/subpaths/price.d.ts +40 -0
  56. package/dist/subpaths/price.d.ts.map +1 -0
  57. package/dist/subpaths/price.js +43 -0
  58. package/dist/subpaths/price.js.map +1 -0
  59. package/dist/subpaths/risk.d.ts +28 -0
  60. package/dist/subpaths/risk.d.ts.map +1 -0
  61. package/dist/subpaths/risk.js +30 -0
  62. package/dist/subpaths/risk.js.map +1 -0
  63. package/dist/subpaths/swap.d.ts +22 -0
  64. package/dist/subpaths/swap.d.ts.map +1 -0
  65. package/dist/subpaths/swap.js +21 -0
  66. package/dist/subpaths/swap.js.map +1 -0
  67. package/dist/subpaths/write.d.ts +1 -1
  68. package/dist/subpaths/write.d.ts.map +1 -1
  69. package/dist/subpaths/write.js +1 -1
  70. package/dist/subpaths/write.js.map +1 -1
  71. package/dist/swap/amm.d.ts +20 -0
  72. package/dist/swap/amm.d.ts.map +1 -0
  73. package/dist/swap/amm.js +77 -0
  74. package/dist/swap/amm.js.map +1 -0
  75. package/dist/swap/clmmMath.d.ts +24 -0
  76. package/dist/swap/clmmMath.d.ts.map +1 -0
  77. package/dist/swap/clmmMath.js +82 -0
  78. package/dist/swap/clmmMath.js.map +1 -0
  79. package/dist/swap/common.d.ts +68 -0
  80. package/dist/swap/common.d.ts.map +1 -0
  81. package/dist/swap/common.js +65 -0
  82. package/dist/swap/common.js.map +1 -0
  83. package/dist/swap/cpmm.d.ts +28 -0
  84. package/dist/swap/cpmm.d.ts.map +1 -0
  85. package/dist/swap/cpmm.js +113 -0
  86. package/dist/swap/cpmm.js.map +1 -0
  87. package/dist/swap/index.d.ts +145 -0
  88. package/dist/swap/index.d.ts.map +1 -0
  89. package/dist/swap/index.js +349 -0
  90. package/dist/swap/index.js.map +1 -0
  91. package/dist/swap/orca.d.ts +50 -0
  92. package/dist/swap/orca.d.ts.map +1 -0
  93. package/dist/swap/orca.js +382 -0
  94. package/dist/swap/orca.js.map +1 -0
  95. package/dist/swap/raydiumClmm.d.ts +62 -0
  96. package/dist/swap/raydiumClmm.d.ts.map +1 -0
  97. package/dist/swap/raydiumClmm.js +447 -0
  98. package/dist/swap/raydiumClmm.js.map +1 -0
  99. package/dist/tradeCheck.d.ts +45 -0
  100. package/dist/tradeCheck.d.ts.map +1 -0
  101. package/dist/tradeCheck.js +131 -0
  102. package/dist/tradeCheck.js.map +1 -0
  103. package/dist/write/instructions.d.ts +7 -0
  104. package/dist/write/instructions.d.ts.map +1 -1
  105. package/dist/write/instructions.js +24 -1
  106. package/dist/write/instructions.js.map +1 -1
  107. package/dist/write/transaction.d.ts +23 -0
  108. package/dist/write/transaction.d.ts.map +1 -1
  109. package/dist/write/transaction.js +50 -0
  110. package/dist/write/transaction.js.map +1 -1
  111. package/package.json +46 -14
  112. package/src/approvals.ts +358 -0
  113. package/src/fees.ts +259 -0
  114. package/src/layout.ts +2 -2
  115. package/src/lp.ts +8 -44
  116. package/src/names.ts +329 -0
  117. package/src/pools.ts +43 -0
  118. package/src/portfolio.ts +124 -0
  119. package/src/price.ts +366 -0
  120. package/src/risk.ts +243 -0
  121. package/src/rpc.ts +36 -9
  122. package/src/subpaths/approvals.ts +47 -0
  123. package/src/subpaths/fees.ts +41 -0
  124. package/src/subpaths/names.ts +45 -0
  125. package/src/subpaths/price.ts +62 -0
  126. package/src/subpaths/risk.ts +36 -0
  127. package/src/subpaths/swap.ts +41 -0
  128. package/src/subpaths/write.ts +2 -0
  129. package/src/swap/amm.ts +88 -0
  130. package/src/swap/clmmMath.ts +96 -0
  131. package/src/swap/common.ts +113 -0
  132. package/src/swap/cpmm.ts +132 -0
  133. package/src/swap/index.ts +467 -0
  134. package/src/swap/orca.ts +397 -0
  135. package/src/swap/raydiumClmm.ts +464 -0
  136. package/src/tradeCheck.ts +166 -0
  137. package/src/write/instructions.ts +26 -1
  138. package/src/write/transaction.ts +62 -0
@@ -0,0 +1,467 @@
1
+ /**
2
+ * 兑换(子路径 `@w3lib/solana-batch-call/swap`):在 Raydium CLMM、Orca Whirlpool、Raydium CPMM、Raydium AMM v4 的池子里找路线
3
+ * (直接兑换,或经过 SOL / USDC / USDT 的两跳),按链上状态在本地报价(照搬各程序的计算),选输出最多的;
4
+ * 按滑点算最少收到的数量,构建指令(SOL 自动包装 / 解包、缺少的代币账户自动创建),发送同 /write
5
+ */
6
+ import { getAssociatedTokenAddress, isAddress } from '../address.js'
7
+ import type { SolanaClient } from '../client.js'
8
+ import { NATIVE_MINT, TOKEN_PROGRAM_ID } from '../constants.js'
9
+ import { formatUnits, parseTokenAccount, type AccountInfo } from '../layout.js'
10
+ import { createCtx, minus, parseAmount, prepareTokenAccounts, tokenInfo, type Ctx, type MintMeta } from '../liquidity/common.js'
11
+ import { SOLANA_LP_PROTOCOLS, type SolanaLpToken } from '../lp.js'
12
+ import { poolAddresses, type PdaPoolProtocol } from '../pools.js'
13
+ import { batchCall } from '../write/batch.js'
14
+ import { MAX_COMPUTE_UNITS, setComputeUnitLimit, setComputeUnitPrice } from '../write/instructions.js'
15
+ import type { SendOptions, WriteResult, WriteStatus } from '../write/send.js'
16
+ import { resolveSigner, type SignerInput } from '../write/signer.js'
17
+ import { MAX_ACCOUNT_LOCKS, PACKET_DATA_SIZE, compileMessage, transactionSize, type Instruction } from '../write/transaction.js'
18
+ import { ammV4Pool, parseAmmV4Pool } from './amm.js'
19
+ import { CLOCK_SYSVAR_ID, NeedAccounts, parseClock, swapMint, type AccountGetter, type HopQuote, type SolanaSwapProtocol, type SwapEnv, type SwapPool } from './common.js'
20
+ import { cpmmPool, parseCpmmPool } from './cpmm.js'
21
+ import { parseWhirlpool, whirlpoolPool } from './orca.js'
22
+ import { parseRaydiumClmmPool, raydiumClmmPool } from './raydiumClmm.js'
23
+
24
+ export type { SolanaSwapProtocol } from './common.js'
25
+
26
+ const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
27
+ const USDT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
28
+ /** 两跳默认的中间代币:Wrapped SOL、USDC、USDT */
29
+ export const SWAP_INTERMEDIATES: readonly string[] = [NATIVE_MINT, USDC, USDT]
30
+ /** 默认的协议:能按两个 mint 推导池子地址的三个,加上 pools 里给出的 AMM v4 池子 */
31
+ export const SWAP_PROTOCOLS: readonly SolanaSwapProtocol[] = ['raydium-clmm', 'orca-whirlpool', 'raydium-cpmm', 'raydium-amm']
32
+
33
+ type Amount = bigint | number | string
34
+ /** 兑换走得比预读的 tick 数组远时,最多再读几轮 */
35
+ const MAX_LOAD_ROUNDS = 3
36
+
37
+ export interface SolanaSwapParams {
38
+ /** 卖出 / 买入的代币 mint;原生 SOL 用 NATIVE_MINT(So111…112)或客户端的 nativeMints,池子里按 Wrapped SOL */
39
+ tokenIn: string
40
+ tokenOut: string
41
+ /** 卖出的数量:amountIn(最小单位)和 amountInFormatted(按精度换算,如 '1.5')二选一 */
42
+ amountIn?: Amount
43
+ amountInFormatted?: string
44
+ }
45
+
46
+ export interface SolanaSwapOptions {
47
+ /** 价格滑点(比例,0.005 = 0.5%),最少收到 = 报价 × (1 − slippage),默认 0.005 */
48
+ slippage?: number
49
+ /**
50
+ * Wrapped SOL 用 SOL 支付 / 收取(自动包装 / 解包),默认 true。
51
+ * 用的是 wSOL 的关联代币账户:原来没有时创建、用完关闭(换回 SOL);原来就有时只补差额、不关闭(买入的 SOL 留在 wSOL 账户里)
52
+ */
53
+ native?: boolean
54
+ /** 只用这些协议,默认 SWAP_PROTOCOLS(全部四个) */
55
+ protocols?: readonly SolanaSwapProtocol[]
56
+ /** 两跳的中间代币,默认 SWAP_INTERMEDIATES(Wrapped SOL、USDC、USDT);传 [] 只找直接兑换 */
57
+ intermediates?: readonly string[]
58
+ /**
59
+ * 额外的池子地址(四个协议都可以,按账户所属程序识别)。Raydium AMM v4 的池子地址不能从两个 mint 推导,要用的话在这里给出;
60
+ * 用 seed_index 建的 Raydium CLMM 池子、非默认配置的 Orca 池子同样
61
+ */
62
+ pools?: readonly string[]
63
+ /** 按两个 mint 推导池子地址(Raydium CLMM / CPMM 的每个 AmmConfig、Orca 的每个费率档位),默认 true;false 时只用 pools 里的 */
64
+ discover?: boolean
65
+ signal?: AbortSignal
66
+ }
67
+
68
+ export interface SolanaSwapHop {
69
+ protocol: SolanaSwapProtocol
70
+ protocolName: string
71
+ pool: string
72
+ tokenIn: string
73
+ tokenOut: string
74
+ /** 这一跳卖出的数量和预计收到的数量(已扣转账手续费) */
75
+ amountIn: string
76
+ amountOut: string
77
+ /** 这一跳的最少收到(按滑点),交易里的 other_amount_threshold */
78
+ amountOutMin: string
79
+ }
80
+
81
+ export interface SolanaSwapRoute {
82
+ /** 经过的代币(池子里的 mint,SOL 是 Wrapped SOL) */
83
+ path: string[]
84
+ hops: SolanaSwapHop[]
85
+ /** 预计收到的数量(最小单位) */
86
+ amountOut: string
87
+ /**
88
+ * 用于比较路线的数量:两跳时第二跳按第一跳的报价全部卖出能拿到的(实际第二跳只卖第一跳的最少收到,多出的中间代币留在账户里,见 leftover);
89
+ * 直接兑换等于 amountOut
90
+ */
91
+ value: string
92
+ }
93
+
94
+ export interface SolanaSwapStep {
95
+ /** setup:创建代币账户(能和兑换放进同一笔交易时会合并);swap:包装 SOL、兑换、解包 */
96
+ kind: 'setup' | 'swap'
97
+ instructions: Instruction[]
98
+ }
99
+
100
+ /** 兑换的计划:报价 + 按顺序执行的指令。只读取、不签名,可以当作报价 */
101
+ export interface SolanaSwapPlan {
102
+ owner: string
103
+ tokenIn: SolanaLpToken
104
+ tokenOut: SolanaLpToken
105
+ amountIn: string
106
+ amountInFormatted: string | null
107
+ /** 预计收到的数量(按当前链上状态本地计算,与程序的取整一致)和最少收到的数量(按滑点) */
108
+ amountOut: string
109
+ amountOutFormatted: string | null
110
+ amountOutMin: string
111
+ amountOutMinFormatted: string | null
112
+ /** 1 个 tokenIn 换多少 tokenOut(按这笔的数量,路线的 value) */
113
+ price: number | null
114
+ /** 价格影响(0~1):这笔的价格比极小数量(1 / 10000)的价格差多少;算不出时为 null */
115
+ priceImpact: number | null
116
+ route: SolanaSwapRoute
117
+ /** 所有候选路线,按 value 从高到低(含选中的) */
118
+ routes: SolanaSwapRoute[]
119
+ /** 两跳时留在账户里的中间代币(第一跳报价 − 第一跳最少收到,按当前状态);中间代币是 Wrapped SOL 且原来没有 wSOL 账户时随账户关闭换回 SOL */
120
+ leftover: { mint: string; amount: string } | null
121
+ /** 哪一边用 SOL(自动包装 / 解包) */
122
+ native: 'tokenIn' | 'tokenOut' | null
123
+ /** 没有用上的池子和原因(不支持的代币扩展、需要位图扩展、流动性不足等) */
124
+ skipped: Array<{ protocol: SolanaSwapProtocol; pool: string; reason: string }>
125
+ steps: SolanaSwapStep[]
126
+ }
127
+
128
+ interface Leg {
129
+ pool: SwapPool
130
+ zeroForOne: boolean
131
+ tokenIn: string
132
+ tokenOut: string
133
+ }
134
+
135
+ interface Candidate {
136
+ legs: Array<{ leg: Leg; quote: HopQuote; min: bigint }>
137
+ amountOut: bigint
138
+ value: bigint
139
+ }
140
+
141
+ /** 按账户所属程序解析池子(包内使用,测试也用) */
142
+ export function parseSwapPool(account: AccountInfo): SwapPool | null {
143
+ const cpmm = parseCpmmPool(account)
144
+ if (cpmm) return cpmmPool(cpmm)
145
+ const amm = parseAmmV4Pool(account)
146
+ if (amm) return ammV4Pool(amm)
147
+ const clmm = parseRaydiumClmmPool(account)
148
+ if (clmm) return raydiumClmmPool(clmm)
149
+ const orca = parseWhirlpool(account)
150
+ return orca ? whirlpoolPool(orca) : null
151
+ }
152
+
153
+ const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err))
154
+
155
+ /** 读到的账户:多轮读取合并 */
156
+ class Snapshot {
157
+ readonly map = new Map<string, AccountInfo | null>()
158
+ get: AccountGetter = (address) => this.map.get(address)
159
+ async load(ctx: Ctx, addresses: readonly string[]): Promise<void> {
160
+ const missing = [...new Set(addresses)].filter((a) => !this.map.has(a))
161
+ if (!missing.length) return
162
+ const accounts = await ctx.client.accounts(missing, { signal: ctx.signal })
163
+ missing.forEach((a, i) => this.map.set(a, accounts[i] ?? null))
164
+ }
165
+ }
166
+
167
+ /**
168
+ * 报价并构建兑换的指令(不签名):选输出最多的路线,最少收到 = 报价 × (1 − slippage)。
169
+ *
170
+ * ```ts
171
+ * const plan = await buildSwap(client, owner, { tokenIn: NATIVE_MINT, tokenOut: USDC, amountInFormatted: '0.5' })
172
+ * plan.amountOutFormatted // '93.41'
173
+ * plan.route.hops // [{ protocol: 'raydium-clmm', pool: '3ucNos4N…', … }]
174
+ * ```
175
+ */
176
+ export async function buildSwap(client: SolanaClient, owner: string, params: SolanaSwapParams, options: SolanaSwapOptions = {}): Promise<SolanaSwapPlan> {
177
+ const ctx = createCtx(client, owner, { slippage: options.slippage, native: options.native, signal: options.signal })
178
+ const nativeIn = client.isNativeMint(params.tokenIn)
179
+ const nativeOut = client.isNativeMint(params.tokenOut)
180
+ const tokenIn = nativeIn ? NATIVE_MINT : params.tokenIn
181
+ const tokenOut = nativeOut ? NATIVE_MINT : params.tokenOut
182
+ for (const mint of [tokenIn, tokenOut]) {
183
+ if (!isAddress(mint)) throw new Error(`Invalid mint address: ${mint}`)
184
+ }
185
+ if (tokenIn === tokenOut) throw new Error(nativeIn || nativeOut ? 'Wrapping / unwrapping SOL is not a swap' : 'tokenIn and tokenOut are the same')
186
+ const protocols = new Set(options.protocols ?? SWAP_PROTOCOLS)
187
+ for (const p of protocols) {
188
+ if (!SWAP_PROTOCOLS.includes(p)) throw new Error(`Unknown swap protocol: ${String(p)}`)
189
+ }
190
+ const mids = [...new Set(options.intermediates ?? SWAP_INTERMEDIATES)].filter((m) => m !== tokenIn && m !== tokenOut)
191
+ for (const m of mids) {
192
+ if (!isAddress(m)) throw new Error(`Invalid intermediate mint: ${m}`)
193
+ }
194
+ for (const p of options.pools ?? []) {
195
+ if (!isAddress(p)) throw new Error(`Invalid pool address: ${p}`)
196
+ }
197
+
198
+ // 1. 一次读:候选池子(PDA 推导 + 传入的)、Clock、各代币的 mint
199
+ const pda = new Set([...protocols].filter((p): p is PdaPoolProtocol => p !== 'raydium-amm'))
200
+ const pairs: Array<[string, string]> = [[tokenIn, tokenOut], ...mids.flatMap((m): Array<[string, string]> => [[tokenIn, m], [m, tokenOut]])]
201
+ const derived = options.discover === false ? [] : pairs.flatMap(([a, b]) => poolAddresses(a, b, pda).map((p) => p.address))
202
+ const snapshot = new Snapshot()
203
+ await snapshot.load(ctx, [CLOCK_SYSVAR_ID, tokenIn, tokenOut, ...mids, ...derived, ...(options.pools ?? [])])
204
+ const clock = snapshot.get(CLOCK_SYSVAR_ID)
205
+ if (!clock) throw new Error('Cannot read the Clock sysvar')
206
+ const env: SwapEnv = parseClock(clock.data)
207
+ for (const mint of [tokenIn, tokenOut]) {
208
+ if (!snapshot.get(mint)) throw new Error(`Token mint not found: ${mint}`)
209
+ }
210
+ const metaIn = swapMint(tokenIn, snapshot.get(tokenIn) as AccountInfo)
211
+ const metaOut = swapMint(tokenOut, snapshot.get(tokenOut) as AccountInfo)
212
+ const amountIn = parseAmount(params.amountIn, params.amountInFormatted, metaIn.decimals, 'amountIn')
213
+ if (amountIn === undefined || amountIn <= 0n) throw new Error('amountIn must be greater than 0')
214
+ if (amountIn > (1n << 64n) - 1n) throw new RangeError('amountIn exceeds u64')
215
+
216
+ // 2. 解析池子,按代币对分组
217
+ const tokens = new Set([tokenIn, tokenOut, ...mids])
218
+ const legs: Leg[] = []
219
+ const seen = new Set<string>()
220
+ for (const address of [...derived, ...(options.pools ?? [])]) {
221
+ const account = snapshot.get(address)
222
+ if (!account || seen.has(address)) continue
223
+ const pool = parseSwapPool(account)
224
+ if (!pool || !protocols.has(pool.protocol) || !tokens.has(pool.mint0) || !tokens.has(pool.mint1)) continue
225
+ seen.add(address)
226
+ for (const [a, b] of pairs) {
227
+ if ((pool.mint0 === a && pool.mint1 === b) || (pool.mint0 === b && pool.mint1 === a)) legs.push({ pool, zeroForOne: pool.mint0 === a, tokenIn: a, tokenOut: b })
228
+ }
229
+ }
230
+ if (!legs.length) throw new Error('No pool found for this pair (directly or through the intermediate tokens) in the supported protocols')
231
+ // 3. 一次读:报价要用的账户(配置、金库、tick 数组、Oracle 等)
232
+ await snapshot.load(ctx, legs.flatMap((l) => l.pool.accounts(l.zeroForOne)))
233
+
234
+ const skipped: SolanaSwapPlan['skipped'] = []
235
+ const skip = (leg: Leg, err: unknown) => {
236
+ if (!skipped.some((s) => s.pool === leg.pool.address)) skipped.push({ protocol: leg.pool.protocol, pool: leg.pool.address, reason: errorText(err) })
237
+ }
238
+ /** 报价还要读的账户(兑换走得比预读的 tick 数组远):读完后重新报价 */
239
+ const pending = new Set<string>()
240
+ let lastRound = false
241
+ const quoteLeg = (leg: Leg, amount: bigint): HopQuote | null => {
242
+ if (amount <= 0n) return null
243
+ try {
244
+ const q = leg.pool.quote(snapshot.get, amount, leg.zeroForOne, env)
245
+ return q.amountOut > 0n ? q : null
246
+ } catch (err) {
247
+ if (err instanceof NeedAccounts && !lastRound) {
248
+ err.addresses.forEach((a) => pending.add(a))
249
+ } else {
250
+ skip(leg, err instanceof NeedAccounts ? new Error('The swap needs too many tick arrays (amount too large for this pool)') : err)
251
+ }
252
+ return null
253
+ }
254
+ }
255
+
256
+ // 4. 报价:直接兑换的每个池子;两跳的每个第一跳池子配上最好的第二跳
257
+ const collect = (): Candidate[] => {
258
+ const list: Candidate[] = []
259
+ for (const leg of legs.filter((l) => l.tokenIn === tokenIn && l.tokenOut === tokenOut)) {
260
+ const q = quoteLeg(leg, amountIn)
261
+ if (q) list.push({ legs: [{ leg, quote: q, min: minus(q.amountOut, ctx.slippage) }], amountOut: q.amountOut, value: q.amountOut })
262
+ }
263
+ for (const mid of mids) {
264
+ const seconds = legs.filter((l) => l.tokenIn === mid && l.tokenOut === tokenOut)
265
+ if (!seconds.length) continue
266
+ for (const first of legs.filter((l) => l.tokenIn === tokenIn && l.tokenOut === mid)) {
267
+ const q1 = quoteLeg(first, amountIn)
268
+ if (!q1) continue
269
+ const min1 = minus(q1.amountOut, ctx.slippage)
270
+ let best: Candidate | null = null
271
+ for (const second of seconds) {
272
+ const q2 = quoteLeg(second, min1)
273
+ if (!q2) continue
274
+ const value = quoteLeg(second, q1.amountOut)?.amountOut ?? q2.amountOut
275
+ if (!best || value > best.value) {
276
+ best = {
277
+ legs: [
278
+ { leg: first, quote: q1, min: min1 },
279
+ { leg: second, quote: q2, min: minus(q2.amountOut, ctx.slippage) },
280
+ ],
281
+ amountOut: q2.amountOut,
282
+ value,
283
+ }
284
+ }
285
+ }
286
+ if (best) list.push(best)
287
+ }
288
+ }
289
+ return list
290
+ }
291
+ let candidates: Candidate[] = []
292
+ for (let round = 0; ; round++) {
293
+ pending.clear()
294
+ skipped.length = 0
295
+ lastRound = round >= MAX_LOAD_ROUNDS
296
+ candidates = collect()
297
+ if (!pending.size) break
298
+ await snapshot.load(ctx, [...pending])
299
+ }
300
+ candidates.sort((a, b) => (a.value < b.value ? 1 : a.value > b.value ? -1 : 0))
301
+ if (!candidates.length) {
302
+ const reasons = skipped.map((s) => `${s.protocol} ${s.pool}: ${s.reason}`).join('; ')
303
+ throw new Error(`No route can be quoted for this amount${reasons ? ` (${reasons})` : ''}`)
304
+ }
305
+
306
+ // 5. 代币账户:卖出的、买入的、两跳的中间代币
307
+ const metaOf = (mint: string): MintMeta => {
308
+ const m = mint === tokenIn ? metaIn : mint === tokenOut ? metaOut : swapMint(mint, snapshot.get(mint) as AccountInfo)
309
+ return { mint: m.mint, program: m.program, decimals: m.decimals }
310
+ }
311
+ const [infoIn, infoOut] = (await tokenInfo(ctx, [metaOf(tokenIn), metaOf(tokenOut)])) as [SolanaLpToken, SolanaLpToken]
312
+
313
+ // 6. 按顺序取第一条能放进一笔交易的路线
314
+ let chosen: { candidate: Candidate; steps: SolanaSwapStep[]; native: SolanaSwapPlan['native'] } | null = null
315
+ for (const candidate of candidates) {
316
+ const path = [tokenIn, ...candidate.legs.map((l) => l.leg.tokenOut)]
317
+ const metas = path.map(metaOf)
318
+ const accounts = await prepareTokenAccounts(ctx, metas, path.map((_, i) => (i === 0 ? amountIn : null)))
319
+ const swaps = candidate.legs.map(({ leg, quote, min }, i) =>
320
+ leg.pool.instruction(snapshot.get, owner, accounts.accounts[i] as string, accounts.accounts[i + 1] as string, leg.zeroForOne, quote, min),
321
+ )
322
+ const main = [...accounts.wrap, ...swaps, ...accounts.cleanup]
323
+ const probe = compileMessage(owner, [setComputeUnitLimit(MAX_COMPUTE_UNITS), setComputeUnitPrice(1n), ...main], '11111111111111111111111111111111')
324
+ if (transactionSize(probe) > PACKET_DATA_SIZE || probe.accountKeys.length > MAX_ACCOUNT_LOCKS) {
325
+ skipped.push(...candidate.legs.filter((l) => !skipped.some((s) => s.pool === l.leg.pool.address)).map((l) => ({ protocol: l.leg.pool.protocol, pool: l.leg.pool.address, reason: 'The route does not fit in one transaction' })))
326
+ continue
327
+ }
328
+ const nativeIndex = accounts.nativeIndex
329
+ const native = nativeIndex === 0 ? 'tokenIn' : nativeIndex !== null && nativeIndex === path.length - 1 ? 'tokenOut' : null
330
+ chosen = {
331
+ candidate,
332
+ native,
333
+ steps: [
334
+ { kind: 'setup' as const, instructions: accounts.setup },
335
+ { kind: 'swap' as const, instructions: main },
336
+ ].filter((s) => s.instructions.length > 0),
337
+ }
338
+ break
339
+ }
340
+ if (!chosen) throw new Error('No route fits in one transaction')
341
+ const best = chosen.candidate
342
+
343
+ // 7. 价格影响:同一路线卖出 1 / 10000 的数量
344
+ let priceImpact: number | null = null
345
+ const small = amountIn / 10_000n
346
+ if (small > 0n) {
347
+ let amount: bigint | null = small
348
+ for (const { leg } of best.legs) amount = amount === null ? null : (quoteLeg(leg, amount)?.amountOut ?? null)
349
+ if (amount) priceImpact = Math.max(0, 1 - Number(best.value) / Number(amountIn) / (Number(amount) / Number(small)))
350
+ }
351
+
352
+ const amountOutMin = minus(best.amountOut, ctx.slippage)
353
+ const fmt = (v: bigint, t: SolanaLpToken) => (t.decimals === null ? null : formatUnits(v, t.decimals))
354
+ const route = (c: Candidate): SolanaSwapRoute => ({
355
+ path: [tokenIn, ...c.legs.map((l) => l.leg.tokenOut)],
356
+ hops: c.legs.map(({ leg, quote, min }) => ({
357
+ protocol: leg.pool.protocol,
358
+ protocolName: SOLANA_LP_PROTOCOLS[leg.pool.protocol].name,
359
+ pool: leg.pool.address,
360
+ tokenIn: leg.tokenIn,
361
+ tokenOut: leg.tokenOut,
362
+ amountIn: quote.amountIn.toString(),
363
+ amountOut: quote.amountOut.toString(),
364
+ amountOutMin: min.toString(),
365
+ })),
366
+ amountOut: c.amountOut.toString(),
367
+ value: c.value.toString(),
368
+ })
369
+ const first = best.legs[0] as Candidate['legs'][number]
370
+ return {
371
+ owner,
372
+ tokenIn: infoIn,
373
+ tokenOut: infoOut,
374
+ amountIn: amountIn.toString(),
375
+ amountInFormatted: fmt(amountIn, infoIn),
376
+ amountOut: best.amountOut.toString(),
377
+ amountOutFormatted: fmt(best.amountOut, infoOut),
378
+ amountOutMin: amountOutMin.toString(),
379
+ amountOutMinFormatted: fmt(amountOutMin, infoOut),
380
+ price: infoIn.decimals === null || infoOut.decimals === null ? null : Number(best.value) / 10 ** infoOut.decimals / (Number(amountIn) / 10 ** infoIn.decimals),
381
+ priceImpact,
382
+ route: route(best),
383
+ routes: candidates.map(route),
384
+ leftover: best.legs.length > 1 ? { mint: first.leg.tokenOut, amount: (first.quote.amountOut - first.min).toString() } : null,
385
+ native: chosen.native,
386
+ skipped,
387
+ steps: chosen.steps,
388
+ }
389
+ }
390
+
391
+ export interface SolanaSwapSendOptions extends SolanaSwapOptions, Omit<SendOptions, 'onUpdate' | 'signal'> {
392
+ /** 发送前检查卖出的余额够不够,默认 true */
393
+ checkBalance?: boolean
394
+ onUpdate?: (result: SolanaSwapStepResult) => void
395
+ }
396
+
397
+ export interface SolanaSwapStepResult extends WriteResult {
398
+ kind: SolanaSwapStep['kind']
399
+ }
400
+
401
+ export interface SolanaSwapResult {
402
+ plan: SolanaSwapPlan
403
+ /** 与 plan.steps 一一对应 */
404
+ results: SolanaSwapStepResult[]
405
+ /** 兑换那一步的状态和签名 */
406
+ status: WriteStatus
407
+ signature: string | null
408
+ }
409
+
410
+ /**
411
+ * 兑换:构建(同 buildSwap)后用 from 签名发送(钱包同 /write 的 batchCall)。创建代币账户和兑换能放进同一笔交易时合并成一笔
412
+ *
413
+ * ```ts
414
+ * const r = await swap(client, secretKey, { tokenIn: USDC, tokenOut: NATIVE_MINT, amountInFormatted: '100' }, { slippage: 0.01 })
415
+ * r.status // 'confirmed'
416
+ * ```
417
+ */
418
+ export async function swap(client: SolanaClient, from: SignerInput, params: SolanaSwapParams, options: SolanaSwapSendOptions = {}): Promise<SolanaSwapResult> {
419
+ const plan = await buildSwap(client, (await resolveSigner(from)).address, params, options)
420
+ if (options.checkBalance ?? true) {
421
+ await checkBalance(client, plan, options.native ?? true, options.signal)
422
+ }
423
+ const { steps } = plan
424
+ const payer = (await resolveSigner(options.feePayer ?? from)).address
425
+ const probe = compileMessage(payer, [setComputeUnitLimit(MAX_COMPUTE_UNITS), setComputeUnitPrice(1n), ...steps.flatMap((s) => s.instructions)], '11111111111111111111111111111111')
426
+ const merged = steps.length > 1 && transactionSize(probe) <= PACKET_DATA_SIZE && probe.accountKeys.length <= MAX_ACCOUNT_LOCKS
427
+ const groups: number[][] = merged ? [steps.map((_, i) => i)] : steps.map((_, i) => [i])
428
+ const kindOf = (i: number) => (steps[i] as SolanaSwapStep).kind
429
+ const raw = await batchCall(
430
+ client,
431
+ groups.map((group) => ({ from, instructions: group.flatMap((i) => (steps[i] as SolanaSwapStep).instructions) })),
432
+ {
433
+ ...options,
434
+ mode: 'pack',
435
+ continueOnError: false,
436
+ onUpdate: options.onUpdate ? (r) => (groups[r.index] as number[]).forEach((i) => options.onUpdate?.({ ...r, index: i, kind: kindOf(i) })) : undefined,
437
+ },
438
+ )
439
+ const results = groups.flatMap((group, g) => group.map((i): SolanaSwapStepResult => ({ ...(raw[g] as WriteResult), index: i, kind: kindOf(i) })))
440
+ const last = results[results.length - 1] as SolanaSwapStepResult
441
+ return { plan, results, status: last.status, signature: last.signature }
442
+ }
443
+
444
+ /** 卖出 SOL 时给手续费、wSOL 和收到代币的 ATA 租金留的 lamports(约 0.01 SOL) */
445
+ export const SWAP_SOL_RESERVE = 10_000_000n
446
+
447
+ /** 卖出的余额:代币读关联代币账户;SOL 读原生余额(扣掉 SWAP_SOL_RESERVE)+ 已有的 wSOL(只包装差额) */
448
+ async function checkBalance(client: SolanaClient, plan: SolanaSwapPlan, native: boolean, signal: AbortSignal | undefined): Promise<void> {
449
+ const mint = plan.tokenIn.mint
450
+ const need = BigInt(plan.amountIn)
451
+ const program = (await client.accounts([mint], { signal }))[0]?.owner ?? TOKEN_PROGRAM_ID
452
+ const ata = getAssociatedTokenAddress(plan.owner, mint, program)
453
+ const [[account], sol] = await Promise.all([client.accounts([ata], { signal }), mint === NATIVE_MINT && native ? client.solBalances([plan.owner], { signal }) : Promise.resolve(null)])
454
+ let have = 0n
455
+ try {
456
+ if (account) have = parseTokenAccount(account.data).amount
457
+ } catch {
458
+ // 不是代币账户
459
+ }
460
+ if (sol?.[0]?.balance) {
461
+ const lamports = BigInt(sol[0].balance)
462
+ have += lamports > SWAP_SOL_RESERVE ? lamports - SWAP_SOL_RESERVE : 0n
463
+ }
464
+ if (have < need) {
465
+ throw new Error(`Insufficient balance of ${mint === NATIVE_MINT && native ? `SOL (keeping ${SWAP_SOL_RESERVE} lamports for fees and rent)` : mint}: need ${need}, have ${have}`)
466
+ }
467
+ }