@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,464 @@
1
+ /**
2
+ * Raydium CLMM 的兑换:swap_v2(按输入数量)。报价照搬程序的 swap_internal(instructions/swap.rs,2026-09 的版本):
3
+ * 按 tick 数组位图找下一个已初始化的 tick,逐个区间计算;包括 fee_on(手续费从输入 / 输出扣)、动态费率(按波动累计)、
4
+ * tick 上的限价单(先按 tick 价格成交)、两边的 Token-2022 转账手续费。
5
+ * 不支持需要位图扩展账户的池子(当前价格或兑换路径超出池子自带位图覆盖的范围,只会出现在 tick spacing < 15 的池子)
6
+ */
7
+ import { MEMO_PROGRAM_ID, Writer, anchorIx, r, w } from '../liquidity/common.js'
8
+ import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'
9
+ import type { AccountInfo } from '../layout.js'
10
+ import { RAYDIUM_CLMM_PROGRAM_ID, raydiumTickArray } from '../lp.js'
11
+ import { MAX_TICK, MIN_TICK, RAYDIUM_MAX_SQRT_PRICE, RAYDIUM_MIN_SQRT_PRICE, raydiumSqrtPriceAtTick, tickAtSqrtPrice } from '../lpMath.js'
12
+ import type { Instruction } from '../write/transaction.js'
13
+ import { FEE_RATE_DENOMINATOR, SwapMathError, computeSwapStep } from './clmmMath.js'
14
+ import { Bytes, NeedAccounts, UnsupportedPool, U64_MAX, mulDivCeil, need, swapMint, transferFee, type AccountGetter, type HopQuote, type SwapEnv, type SwapMint, type SwapPool } from './common.js'
15
+
16
+ const POOL_SIZE = 1544
17
+ const TICK_ARRAY_LEN = 10240
18
+ const TICK_ARRAY_SIZE = 60
19
+ const TICK_SIZE = 168
20
+ const BITMAP_SIZE = 512
21
+ const Q64 = 1n << 64n
22
+ /** 先读几个 tick 数组(当前的和兑换方向上已初始化的),不够时每次再多读 3 个,最多 MAX_TICK_ARRAYS 个 */
23
+ const INITIAL_TICK_ARRAYS = 2
24
+ const MAX_TICK_ARRAYS = 10
25
+ const MAX_FEE_RATE = 100_000n
26
+ const DYNAMIC_FEE_CONTROL_DENOMINATOR = 100_000n
27
+ const VOLATILITY_SCALE = 10_000n
28
+ const REDUCTION_FACTOR_DENOMINATOR = 10_000n
29
+
30
+ interface DynamicFee {
31
+ filterPeriod: bigint
32
+ decayPeriod: bigint
33
+ reductionFactor: bigint
34
+ control: bigint
35
+ maxVolatility: bigint
36
+ indexReference: number
37
+ volatilityReference: bigint
38
+ volatility: bigint
39
+ lastUpdate: bigint
40
+ }
41
+
42
+ export interface RaydiumClmmState {
43
+ address: string
44
+ config: string
45
+ mint0: string
46
+ mint1: string
47
+ vault0: string
48
+ vault1: string
49
+ observation: string
50
+ tickSpacing: number
51
+ liquidity: bigint
52
+ sqrtPrice: bigint
53
+ tick: number
54
+ status: number
55
+ feeOn: number
56
+ bitmap: bigint
57
+ openTime: bigint
58
+ dynamicFee: DynamicFee | null
59
+ }
60
+
61
+ /**
62
+ * PoolState:amm_config 9、mint 73 / 105、vault 137 / 169、observation 201、tick_spacing 235、liquidity 237、sqrt_price 253、tick 269、
63
+ * status 389、fee_on 390、tick_array_bitmap 904(16 个 u64)、open_time 1080、dynamic_fee_info 1096(80 字节,全 0 表示没有)
64
+ */
65
+ export function parseRaydiumClmmPool(account: AccountInfo): RaydiumClmmState | null {
66
+ if (account.owner !== RAYDIUM_CLMM_PROGRAM_ID || account.data.length !== POOL_SIZE) return null
67
+ const b = new Bytes(account.data)
68
+ let bitmap = 0n
69
+ for (let i = 15; i >= 0; i--) bitmap = (bitmap << 64n) | b.u64(904 + i * 8)
70
+ const fee = account.data.subarray(1096, 1176)
71
+ const dynamicFee = fee.some((x) => x !== 0)
72
+ ? {
73
+ filterPeriod: BigInt(b.u16(1096)),
74
+ decayPeriod: BigInt(b.u16(1098)),
75
+ reductionFactor: BigInt(b.u16(1100)),
76
+ control: BigInt(b.u32(1102)),
77
+ maxVolatility: BigInt(b.u32(1106)),
78
+ indexReference: b.i32(1110),
79
+ volatilityReference: BigInt(b.u32(1114)),
80
+ volatility: BigInt(b.u32(1118)),
81
+ lastUpdate: b.u64(1122),
82
+ }
83
+ : null
84
+ return {
85
+ address: account.address,
86
+ config: b.key(9),
87
+ mint0: b.key(73),
88
+ mint1: b.key(105),
89
+ vault0: b.key(137),
90
+ vault1: b.key(169),
91
+ observation: b.key(201),
92
+ tickSpacing: b.u16(235),
93
+ liquidity: b.u128(237),
94
+ sqrtPrice: b.u128(253),
95
+ tick: b.i32(269),
96
+ status: b.u8(389),
97
+ feeOn: b.u8(390),
98
+ bitmap,
99
+ openTime: b.u64(1080),
100
+ dynamicFee,
101
+ }
102
+ }
103
+
104
+ const floorDiv = (a: number, b: number) => Math.floor(a / b)
105
+ const arrayStart = (tick: number, spacing: number) => floorDiv(tick, spacing * TICK_ARRAY_SIZE) * spacing * TICK_ARRAY_SIZE
106
+ const i128 = (v: bigint) => (v >= 1n << 127n ? v - (1n << 128n) : v)
107
+
108
+ /** 池子自带位图里这个 tick 数组是否已初始化;超出位图覆盖的范围时为 null */
109
+ function bitmapHas(state: RaydiumClmmState, start: number): boolean | null {
110
+ const multiplier = state.tickSpacing * TICK_ARRAY_SIZE
111
+ const bit = start / multiplier + BITMAP_SIZE
112
+ if (bit < 0 || bit >= 2 * BITMAP_SIZE) return null
113
+ return ((state.bitmap >> BigInt(bit)) & 1n) === 1n
114
+ }
115
+
116
+ /** 兑换会经过的已初始化 tick 数组(当前的,以及方向上接下来的),最多 count 个;需要位图扩展时报不支持 */
117
+ function tickArrayStarts(state: RaydiumClmmState, zeroForOne: boolean, count: number): number[] {
118
+ const step = state.tickSpacing * TICK_ARRAY_SIZE
119
+ let start = arrayStart(state.tick, state.tickSpacing)
120
+ const current = bitmapHas(state, start)
121
+ if (current === null) throw new UnsupportedPool('Pool price is outside the default tick array bitmap (needs the bitmap extension account)')
122
+ const out = current ? [start] : []
123
+ while (out.length < count) {
124
+ start += zeroForOne ? -step : step
125
+ const has = bitmapHas(state, start)
126
+ if (has === null) break
127
+ if (has) out.push(start)
128
+ }
129
+ return out
130
+ }
131
+
132
+ interface TickState {
133
+ tick: number
134
+ liquidityNet: bigint
135
+ liquidityGross: bigint
136
+ /** 限价单未成交的数量(orders_amount + part_filled_orders_remaining) */
137
+ unfilled: bigint
138
+ }
139
+
140
+ /** TickArrayState:pool_id 8、start_tick_index 40、ticks 44(60 个,每个 168 字节:tick +0、liquidity_net +4、liquidity_gross +20、orders_amount +124、part_filled +132) */
141
+ function readTickArray(state: RaydiumClmmState, address: string, start: number, account: AccountInfo | null | undefined): TickState[] {
142
+ if (!account || account.owner !== RAYDIUM_CLMM_PROGRAM_ID || account.data.length !== TICK_ARRAY_LEN) {
143
+ throw new UnsupportedPool(`Tick array not loaded: ${address}`)
144
+ }
145
+ const b = new Bytes(account.data)
146
+ if (b.key(8) !== state.address || b.i32(40) !== start) throw new UnsupportedPool(`Unexpected tick array: ${address}`)
147
+ return Array.from({ length: TICK_ARRAY_SIZE }, (_, i) => {
148
+ const o = 44 + i * TICK_SIZE
149
+ return { tick: start + i * state.tickSpacing, liquidityNet: i128(b.u128(o + 4)), liquidityGross: b.u128(o + 20), unfilled: b.u64(o + 124) + b.u64(o + 132) }
150
+ })
151
+ }
152
+
153
+ /** 兑换走到了没有读的 tick 数组 */
154
+ export class MissingTickArray extends Error {
155
+ constructor(readonly start: number) {
156
+ super(`Tick array ${start} is not loaded`)
157
+ }
158
+ }
159
+
160
+ const initialized = (t: TickState) => t.liquidityGross > 0n || t.unfilled > 0n
161
+
162
+ /** 与程序 tick_spacing_index_from_tick 一致:向负无穷取整 */
163
+ function spacingIndex(tick: number, spacing: number): number {
164
+ return tick % spacing === 0 || tick >= 0 ? Math.trunc(tick / spacing) : Math.trunc(tick / spacing) - 1
165
+ }
166
+
167
+ /** 动态费率(与程序 compute_dynamic_fee_rate 一致) */
168
+ function dynamicFeeRate(fee: DynamicFee, spacing: number): bigint {
169
+ const crossed = fee.volatility * BigInt(spacing)
170
+ const rate = mulDivCeil(fee.control, crossed * crossed, DYNAMIC_FEE_CONTROL_DENOMINATOR * VOLATILITY_SCALE * VOLATILITY_SCALE)
171
+ return rate > MAX_FEE_RATE ? MAX_FEE_RATE : rate
172
+ }
173
+
174
+ /** tick 的价格(Q64.64,price = sqrt² / 2^64) */
175
+ const priceOf = (sqrt: bigint, roundUp: boolean) => (sqrt * sqrt + (roundUp ? Q64 - 1n : 0n)) >> 64n
176
+
177
+ /**
178
+ * 本地报价(swap_internal 的按输入数量部分)。amount 是扣掉输入转账手续费后进入池子的数量;
179
+ * 返回池子付出的输出(未扣输出转账手续费)和经过的 tick 数组
180
+ */
181
+ export function raydiumSwap(
182
+ state: RaydiumClmmState,
183
+ arrays: ReadonlyMap<number, TickState[]>,
184
+ amount: bigint,
185
+ zeroForOne: boolean,
186
+ baseFeeRate: bigint,
187
+ timestamp: bigint,
188
+ ): { amountIn: bigint; amountOut: bigint; starts: number[]; crossed: number; orders: number } {
189
+ const spacing = state.tickSpacing
190
+ const limit = zeroForOne ? RAYDIUM_MIN_SQRT_PRICE + 1n : RAYDIUM_MAX_SQRT_PRICE - 1n
191
+ if (amount === 0n) throw new SwapMathError('zero amount')
192
+ if (zeroForOne ? !(limit < state.sqrtPrice) : !(limit > state.sqrtPrice)) throw new SwapMathError('price is at the limit')
193
+ // 只读一次的 tick 数组按顺序取(与程序一样按位图找下一个)
194
+ const step = spacing * TICK_ARRAY_SIZE
195
+ const getArray = (start: number) => {
196
+ const ticks = arrays.get(start)
197
+ if (!ticks) throw new MissingTickArray(start)
198
+ return ticks
199
+ }
200
+ const nextArrayStart = (from: number): number => {
201
+ let s = from
202
+ for (;;) {
203
+ s += zeroForOne ? -step : step
204
+ const has = bitmapHas(state, s)
205
+ if (has === null) {
206
+ if (spacing >= 15) throw new UnsupportedPool('Insufficient liquidity')
207
+ throw new UnsupportedPool('Swap goes beyond the default tick array bitmap (needs the bitmap extension account)')
208
+ }
209
+ if (has) return s
210
+ }
211
+ }
212
+ const currentStart = arrayStart(state.tick, spacing)
213
+ const currentInit = bitmapHas(state, currentStart)
214
+ if (currentInit === null) throw new UnsupportedPool('Pool price is outside the default tick array bitmap (needs the bitmap extension account)')
215
+ let firstContains = currentInit
216
+ let validStart = currentInit ? currentStart : nextArrayStart(currentStart)
217
+ let ticks = getArray(validStart)
218
+ const starts = [validStart]
219
+
220
+ const feeOnInput = state.feeOn === 1 ? zeroForOne : state.feeOn === 2 ? !zeroForOne : true
221
+ let remaining = amount
222
+ let calculated = 0n
223
+ let sqrt = state.sqrtPrice
224
+ let tick = state.tick
225
+ let liquidity = state.liquidity
226
+ // 动态费率:开始时按时间更新参考值
227
+ const fee = state.dynamicFee ? { ...state.dynamicFee } : null
228
+ let index = 0
229
+ if (fee) {
230
+ index = spacingIndex(tick, spacing)
231
+ const elapsed = timestamp > fee.lastUpdate ? timestamp - fee.lastUpdate : 0n
232
+ if (elapsed >= fee.filterPeriod) {
233
+ fee.indexReference = index
234
+ fee.volatilityReference = elapsed < fee.decayPeriod ? (fee.volatility * fee.reductionFactor) / REDUCTION_FACTOR_DENOMINATOR : 0n
235
+ fee.lastUpdate = timestamp
236
+ }
237
+ }
238
+ const updateVolatility = () => {
239
+ if (!fee) return
240
+ const v = fee.volatilityReference + BigInt(Math.abs(fee.indexReference - index)) * VOLATILITY_SCALE
241
+ fee.volatility = v < fee.maxVolatility ? v : fee.maxVolatility
242
+ }
243
+ const apply = (amountIn: bigint, amountOut: bigint, feeAmount: bigint) => {
244
+ remaining -= feeOnInput ? amountIn + feeAmount : amountIn
245
+ calculated += amountOut
246
+ if (remaining < 0n) throw new SwapMathError('amount remaining overflow')
247
+ }
248
+ // 限价单剩余(按 tick):同一笔里可能被吃掉一部分
249
+ const unfilled = new Map<number, bigint>()
250
+ let crossed = 0
251
+ let orders = 0
252
+
253
+ while (remaining !== 0n && sqrt !== limit) {
254
+ // 下一个已初始化的 tick
255
+ let next: TickState | undefined
256
+ const inCurrent = arrayStart(tick, spacing) === validStart
257
+ if (inCurrent) {
258
+ const offset = Math.floor((tick - validStart) / spacing)
259
+ if (zeroForOne) {
260
+ for (let i = offset; i >= 0 && !next; i--) if (initialized(ticks[i] as TickState)) next = ticks[i]
261
+ } else {
262
+ for (let i = offset + 1; i < TICK_ARRAY_SIZE && !next; i++) if (initialized(ticks[i] as TickState)) next = ticks[i]
263
+ }
264
+ }
265
+ if (!next) {
266
+ if (!firstContains) {
267
+ firstContains = true
268
+ } else {
269
+ validStart = nextArrayStart(validStart)
270
+ ticks = getArray(validStart)
271
+ starts.push(validStart)
272
+ }
273
+ next = zeroForOne ? [...ticks].reverse().find(initialized) : ticks.find(initialized)
274
+ if (!next) throw new UnsupportedPool('Invalid tick array (no initialized tick)')
275
+ }
276
+ const nextState: TickState = { ...next, unfilled: unfilled.get(next.tick) ?? next.unfilled }
277
+ if (!initialized(nextState)) throw new SwapMathError('tick is not initialized')
278
+ const tickNext = Math.min(Math.max(nextState.tick, MIN_TICK), MAX_TICK)
279
+ const sqrtNextTick = raydiumSqrtPriceAtTick(tickNext)
280
+ const target = (zeroForOne && sqrtNextTick < limit) || (!zeroForOne && sqrtNextTick > limit) ? limit : sqrtNextTick
281
+
282
+ let liquidityNext = liquidity
283
+ for (;;) {
284
+ updateVolatility()
285
+ let totalFee = baseFeeRate
286
+ if (fee) {
287
+ totalFee = baseFeeRate + dynamicFeeRate(fee, spacing)
288
+ if (totalFee > MAX_FEE_RATE) totalFee = MAX_FEE_RATE
289
+ }
290
+ // 动态费率:每次最多走到当前 tick 组(tickSpacing 个 tick)的边界
291
+ let skipped = true
292
+ let bounded = target
293
+ let boundedTick: number | null = null
294
+ if (fee && liquidity !== 0n && fee.volatility !== fee.maxVolatility) {
295
+ skipped = false
296
+ const bt = Math.min(Math.max(zeroForOne ? index * spacing : (index + 1) * spacing, MIN_TICK), MAX_TICK)
297
+ const bs = raydiumSqrtPriceAtTick(bt)
298
+ if (zeroForOne ? target <= bs : target >= bs) {
299
+ bounded = bs
300
+ boundedTick = bt
301
+ }
302
+ }
303
+ let stepPrice = bounded
304
+ if (sqrt !== bounded) {
305
+ const s = computeSwapStep(sqrt, bounded, liquidity, remaining, totalFee, zeroForOne, feeOnInput, 'raydium')
306
+ apply(s.amountIn, s.amountOut, s.feeAmount)
307
+ stepPrice = s.sqrtPrice
308
+ }
309
+ if (sqrtNextTick === stepPrice) {
310
+ // 走到 tick:先按 tick 价格和限价单成交
311
+ const total = nextState.unfilled
312
+ if (remaining !== 0n && total !== 0n) {
313
+ const price0 = priceOf(sqrtNextTick, !zeroForOne)
314
+ let lin: bigint
315
+ let lfee = 0n
316
+ let lout: bigint
317
+ if (feeOnInput) {
318
+ lfee = mulDivCeil(remaining, totalFee, FEE_RATE_DENOMINATOR)
319
+ lin = remaining - lfee
320
+ } else {
321
+ lin = remaining
322
+ }
323
+ const matched = zeroForOne ? (lin * price0) / Q64 : (lin * Q64) / price0
324
+ if (matched > total) {
325
+ lout = total
326
+ lin = zeroForOne ? mulDivCeil(total, Q64, price0) : mulDivCeil(total, price0, Q64)
327
+ if (lin > U64_MAX) throw new SwapMathError('limit order input overflow')
328
+ if (feeOnInput) lfee = mulDivCeil(lin, totalFee, FEE_RATE_DENOMINATOR - totalFee)
329
+ } else {
330
+ lout = matched
331
+ }
332
+ nextState.unfilled = total - lout
333
+ unfilled.set(nextState.tick, nextState.unfilled)
334
+ if (!feeOnInput) {
335
+ lfee = mulDivCeil(lout, totalFee, FEE_RATE_DENOMINATOR)
336
+ lout -= lfee
337
+ }
338
+ if (lin !== 0n || lout !== 0n || lfee !== 0n) {
339
+ apply(lin, lout, lfee)
340
+ orders++
341
+ }
342
+ }
343
+ const hasOrders = nextState.unfilled > 0n
344
+ if (nextState.liquidityGross > 0n && !hasOrders) {
345
+ liquidityNext = liquidity + (zeroForOne ? -nextState.liquidityNet : nextState.liquidityNet)
346
+ crossed++
347
+ if (liquidityNext < 0n) throw new SwapMathError('liquidity underflow')
348
+ }
349
+ tick = (zeroForOne && !hasOrders) || (!zeroForOne && hasOrders) ? tickNext - 1 : tickNext
350
+ } else if (sqrt !== stepPrice) {
351
+ tick = boundedTick !== null && stepPrice === bounded ? boundedTick : tickAtSqrtPrice('raydium', stepPrice)
352
+ }
353
+ sqrt = stepPrice
354
+ if (fee) {
355
+ if (skipped) {
356
+ const t = sqrt === sqrtNextTick ? tickNext : tick
357
+ let idx = spacingIndex(t, spacing)
358
+ if (!zeroForOne && t % spacing === 0) idx -= 1
359
+ index = idx
360
+ if (fee.volatility !== fee.maxVolatility) updateVolatility()
361
+ }
362
+ index += zeroForOne ? -1 : 1
363
+ }
364
+ if (remaining === 0n || sqrt === target) break
365
+ }
366
+ liquidity = liquidityNext
367
+ }
368
+ // 没有给价格上限时不允许部分成交
369
+ if (remaining !== 0n) throw new UnsupportedPool('Insufficient liquidity')
370
+ return { amountIn: amount, amountOut: calculated, starts, crossed, orders }
371
+ }
372
+
373
+ export function raydiumClmmPool(state: RaydiumClmmState): SwapPool {
374
+ const mints = (get: AccountGetter): [SwapMint, SwapMint] => [
375
+ swapMint(state.mint0, need(get, state.mint0, 'Token mint')),
376
+ swapMint(state.mint1, need(get, state.mint1, 'Token mint')),
377
+ ]
378
+ const arrayAddress = (start: number) => raydiumTickArray(state.address, start, state.tickSpacing).address
379
+ return {
380
+ protocol: 'raydium-clmm',
381
+ address: state.address,
382
+ mint0: state.mint0,
383
+ mint1: state.mint1,
384
+ mints,
385
+ accounts(zeroForOne) {
386
+ let starts: number[] = []
387
+ try {
388
+ starts = tickArrayStarts(state, zeroForOne, INITIAL_TICK_ARRAYS)
389
+ } catch {
390
+ // 报价时再报不支持
391
+ }
392
+ return [state.config, state.mint0, state.mint1, ...starts.map(arrayAddress)]
393
+ },
394
+ quote(get, amountIn, zeroForOne, env: SwapEnv): HopQuote {
395
+ // status 第 4 位(Swap)为 1 表示禁止兑换
396
+ if (state.status & 16) throw new UnsupportedPool('Swap is disabled on this pool')
397
+ if (!(env.timestamp > state.openTime)) throw new UnsupportedPool('Pool is not open yet')
398
+ const [m0, m1] = mints(get)
399
+ const config = need(get, state.config, 'AMM config')
400
+ if (config.data.length < 57) throw new UnsupportedPool('Unexpected AMM config')
401
+ // AmmConfig:protocol_fee_rate 43、trade_fee_rate 47、fund_fee_rate 53(u32)
402
+ const baseFee = BigInt(new Bytes(config.data).u32(47))
403
+ // 已经读到的 tick 数组(按位图顺序,读到第一个没读的为止)
404
+ const all = tickArrayStarts(state, zeroForOne, MAX_TICK_ARRAYS)
405
+ const arrays = new Map<number, TickState[]>()
406
+ for (const start of all) {
407
+ const address = arrayAddress(start)
408
+ const account = get(address)
409
+ if (account === undefined) break
410
+ arrays.set(start, readTickArray(state, address, start, account))
411
+ }
412
+ const [mintIn, mintOut] = zeroForOne ? [m0, m1] : [m1, m0]
413
+ const into = amountIn - transferFee(mintIn, amountIn, env.epoch)
414
+ if (into <= 0n) return { amountIn, amountOut: 0n, accounts: [] }
415
+ let result: ReturnType<typeof raydiumSwap>
416
+ try {
417
+ result = raydiumSwap(state, arrays, into, zeroForOne, baseFee, env.timestamp)
418
+ } catch (err) {
419
+ if (!(err instanceof MissingTickArray)) throw err
420
+ const more = all.filter((s) => !arrays.has(s)).slice(0, 3)
421
+ if (!more.length) throw new UnsupportedPool(`The swap needs more than ${MAX_TICK_ARRAYS} tick arrays (amount too large for this pool)`)
422
+ throw new NeedAccounts(more.map(arrayAddress))
423
+ }
424
+ if (result.amountOut === 0n) return { amountIn, amountOut: 0n, accounts: [] }
425
+ // 交易里传经过的 tick 数组,再多带一个(价格在发送前变动时用)
426
+ const loaded = [...arrays.keys()]
427
+ const extra = loaded.find((s) => !result.starts.includes(s) && (zeroForOne ? s < (result.starts.at(-1) as number) : s > (result.starts.at(-1) as number)))
428
+ const used = extra === undefined ? result.starts : [...result.starts, extra]
429
+ return {
430
+ amountIn,
431
+ amountOut: result.amountOut - transferFee(mintOut, result.amountOut, env.epoch),
432
+ accounts: used.map(arrayAddress),
433
+ crossed: result.crossed,
434
+ orders: result.orders,
435
+ }
436
+ },
437
+ instruction(get, owner, input, output, zeroForOne, quote, minOut): Instruction {
438
+ const [m0, m1] = mints(get)
439
+ const [mintIn, mintOut] = zeroForOne ? [m0, m1] : [m1, m0]
440
+ const [vaultIn, vaultOut] = zeroForOne ? [state.vault0, state.vault1] : [state.vault1, state.vault0]
441
+ return {
442
+ programId: RAYDIUM_CLMM_PROGRAM_ID,
443
+ keys: [
444
+ r(owner, true),
445
+ r(state.config),
446
+ w(state.address),
447
+ w(input),
448
+ w(output),
449
+ w(vaultIn),
450
+ w(vaultOut),
451
+ w(state.observation),
452
+ r(TOKEN_PROGRAM_ID),
453
+ r(TOKEN_2022_PROGRAM_ID),
454
+ r(MEMO_PROGRAM_ID),
455
+ r(mintIn.mint),
456
+ r(mintOut.mint),
457
+ ...quote.accounts.map((a) => w(a)),
458
+ ],
459
+ // sqrt_price_limit 0:不限价(程序要求全部成交);is_base_input true
460
+ data: new Writer().bytes(anchorIx('swap_v2')).u64(quote.amountIn).u64(minOut).u128(0n).bool(true).build(),
461
+ }
462
+ },
463
+ }
464
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 能不能买、能不能卖(/risk 的 tradeChecks):用 /swap 构建“SOL 买入 → 全部卖回 SOL”,放进一笔交易在主网模拟(simulateTransaction,不验签名、不上链),
3
+ * 看哪一步失败、来回损失多少。和 tokenRisks 分开:不用时打包不会带上 /swap 的代码
4
+ */
5
+ import { base64 } from '@scure/base'
6
+
7
+ import { getAssociatedTokenAddress, isAddress } from './address.js'
8
+ import type { RequestOptions, SolanaClient } from './client.js'
9
+ import { NATIVE_MINT, TOKEN_PROGRAM_ID } from './constants.js'
10
+ import { parseTokenAccount, parseUnits } from './layout.js'
11
+ import { buildSwap, type SolanaSwapOptions, type SolanaSwapPlan } from './swap/index.js'
12
+ import { MAX_COMPUTE_UNITS, setComputeUnitLimit } from './write/instructions.js'
13
+ import { transactionErrorText } from './write/send.js'
14
+ import { MAX_ACCOUNT_LOCKS, PACKET_DATA_SIZE, compileMessage, serializeTransaction, transactionSize, type Instruction } from './write/transaction.js'
15
+
16
+ export type TradeStage = 'ok' | 'no-pool' | 'buy-failed' | 'sell-failed'
17
+
18
+ export interface SolanaTradeCheck {
19
+ mint: string
20
+ /** ok:买入、卖出都成功;no-pool:内置协议里找不到能报价的路线;buy-failed:买不进;sell-failed:买得进、卖不出(貔貅盘);模拟没做成时为 null */
21
+ stage: TradeStage | null
22
+ /** 卖不出 */
23
+ honeypot: boolean | null
24
+ /** 买入用的路线(池子、协议) */
25
+ buyRoute: Array<{ protocol: string; pool: string }> | null
26
+ /** 花的 SOL、买到的代币、卖回的 SOL(最小单位) */
27
+ spent: string
28
+ bought: string | null
29
+ sold: string | null
30
+ /** 来回损失(0~1):1 − 卖回的 SOL / 花的 SOL(卖出的是买入的最少收到,按卖出的比例折算),含两次的池子手续费、价格影响和转账手续费 */
31
+ roundTripLoss: number | null
32
+ /** 失败原因(模拟的报错或报价的报错) */
33
+ error: string | null
34
+ /** 汇总的标记:no-pool、cannot-buy、honeypot、high-round-trip-loss(> 10%) */
35
+ flags: string[]
36
+ }
37
+
38
+ export interface TradeCheckOptions extends RequestOptions, Pick<SolanaSwapOptions, 'protocols' | 'intermediates' | 'pools' | 'discover'> {
39
+ /**
40
+ * 模拟用的钱包(要有足够的 SOL;不验签名,可以是任何有 SOL 的系统账户,如自己的地址)。
41
+ * 模拟时它创建代币账户的租金和手续费都不会真的扣
42
+ */
43
+ owner: string
44
+ /** 买入花多少 SOL:amount(lamports)和 amountFormatted 二选一,默认 0.05 SOL */
45
+ amount?: bigint | number | string
46
+ amountFormatted?: string
47
+ }
48
+
49
+ /**
50
+ * 模拟买入再卖出,判断能不能交易(每个代币两次报价 + 一次 simulateTransaction)。
51
+ *
52
+ * ```ts
53
+ * const [check] = await tradeChecks(client, [mint], { owner: myAddress })
54
+ * // { stage: 'ok', honeypot: false, roundTripLoss: 0.0061, flags: [] }
55
+ * ```
56
+ */
57
+ export async function tradeChecks(client: SolanaClient, mints: readonly string[], options: TradeCheckOptions): Promise<SolanaTradeCheck[]> {
58
+ if (!isAddress(options.owner)) throw new Error(`Invalid owner address: ${options.owner}`)
59
+ if (options.amount !== undefined && options.amountFormatted !== undefined) throw new Error('Pass either amount or amountFormatted, not both')
60
+ const spent = options.amountFormatted !== undefined ? parseUnits(options.amountFormatted, 9) : options.amount !== undefined ? BigInt(options.amount) : 50_000_000n
61
+ if (spent <= 0n) throw new RangeError('amount must be greater than 0')
62
+ const [sol] = await client.solBalances([options.owner], { signal: options.signal })
63
+ // 留出两个代币账户的租金和手续费
64
+ if (!sol?.success || BigInt(sol.balance) < spent + 10_000_000n) {
65
+ throw new Error(`${options.owner} does not have enough SOL to simulate the trades (needs about ${spent + 10_000_000n} lamports)`)
66
+ }
67
+ return Promise.all(mints.map((mint) => checkOne(client, mint, spent, options)))
68
+ }
69
+
70
+ async function checkOne(client: SolanaClient, mint: string, spent: bigint, options: TradeCheckOptions): Promise<SolanaTradeCheck> {
71
+ const base: SolanaTradeCheck = { mint, stage: null, honeypot: null, buyRoute: null, spent: spent.toString(), bought: null, sold: null, roundTripLoss: null, error: null, flags: [] }
72
+ const swapOptions: SolanaSwapOptions = { protocols: options.protocols, intermediates: options.intermediates, pools: options.pools, discover: options.discover, signal: options.signal }
73
+ let buy: SolanaSwapPlan
74
+ try {
75
+ // 先只找直接兑换(买入、卖出放进一笔交易更容易放得下),没有时再找两跳
76
+ buy = await buildSwap(client, options.owner, { tokenIn: NATIVE_MINT, tokenOut: mint, amountIn: spent }, { ...swapOptions, intermediates: options.intermediates ?? [] }).catch((err: unknown) => {
77
+ if (options.intermediates || options.signal?.aborted) throw err
78
+ return buildSwap(client, options.owner, { tokenIn: NATIVE_MINT, tokenOut: mint, amountIn: spent }, swapOptions)
79
+ })
80
+ } catch (err) {
81
+ if (options.signal?.aborted) throw err
82
+ return { ...base, stage: 'no-pool', error: errorText(err), flags: ['no-pool'] }
83
+ }
84
+ const buyRoute = buy.route.hops.map((h) => ({ protocol: h.protocol, pool: h.pool }))
85
+ let sell: SolanaSwapPlan
86
+ try {
87
+ // 卖出按买入的最少收到算,走买入的同一条路线(反过来),最少换回不设下限(只看能不能卖、卖回多少)
88
+ sell = await buildSwap(
89
+ client,
90
+ options.owner,
91
+ { tokenIn: mint, tokenOut: NATIVE_MINT, amountIn: buy.amountOutMin },
92
+ { protocols: options.protocols, discover: false, pools: buy.route.hops.map((h) => h.pool), intermediates: buy.route.path.slice(1, -1), slippage: 0.999999, signal: options.signal },
93
+ )
94
+ } catch (err) {
95
+ if (options.signal?.aborted) throw err
96
+ // 同一条路线反过来报不了价(如走得超出 Orca 的 3 个 tick 数组):不能断定卖不出
97
+ return { ...base, buyRoute, error: `Cannot quote the sell: ${errorText(err)}` }
98
+ }
99
+ const tokenProgram = (await client.accounts([mint], { signal: options.signal }))[0]?.owner ?? TOKEN_PROGRAM_ID
100
+ const tokenAccount = getAssociatedTokenAddress(options.owner, mint, tokenProgram)
101
+ const wsol = getAssociatedTokenAddress(options.owner, NATIVE_MINT, TOKEN_PROGRAM_ID)
102
+ const buyIxs = buy.steps.flatMap((s) => s.instructions)
103
+ // 卖出最后不关闭 wSOL 账户:读模拟后的余额就是卖回的 SOL
104
+ const sellIxs = sell.steps.flatMap((s) => s.instructions).filter((ix, i, all) => !(i === all.length - 1 && ix.programId === TOKEN_PROGRAM_ID && ix.data[0] === 9))
105
+ const instructions: Instruction[] = [setComputeUnitLimit(MAX_COMPUTE_UNITS), ...buyIxs, ...sellIxs]
106
+ const message = compileMessage(options.owner, instructions, '11111111111111111111111111111111')
107
+ if (transactionSize(message) > PACKET_DATA_SIZE || message.accountKeys.length > MAX_ACCOUNT_LOCKS) {
108
+ return { ...base, buyRoute, error: 'The buy and sell routes do not fit in one transaction' }
109
+ }
110
+ const [[before, tokenBefore], sim] = await Promise.all([
111
+ client.accounts([wsol, tokenAccount], { signal: options.signal }),
112
+ client.request<{ value: { err: unknown; logs: string[] | null; accounts: Array<{ data: [string, string] } | null> | null } }>(
113
+ 'simulateTransaction',
114
+ [
115
+ base64.encode(serializeTransaction(message, message.signers.map(() => null))),
116
+ { encoding: 'base64', sigVerify: false, replaceRecentBlockhash: true, commitment: 'confirmed', accounts: { encoding: 'base64', addresses: [tokenAccount, wsol] } },
117
+ ],
118
+ { signal: options.signal },
119
+ ),
120
+ ])
121
+ if (sim.value.err) {
122
+ const failed = (sim.value.err as { InstructionError?: [number, unknown] }).InstructionError?.[0]
123
+ const error = transactionErrorText(sim.value.err, sim.value.logs)
124
+ // 第 0 条是计算单元上限;之后是买入的指令
125
+ if (failed === undefined) return { ...base, buyRoute, error }
126
+ if (failed <= buyIxs.length) return { ...base, buyRoute, stage: 'buy-failed', error, flags: ['cannot-buy'] }
127
+ return { ...base, buyRoute, stage: 'sell-failed', honeypot: true, error, flags: ['honeypot'] }
128
+ }
129
+ const amountOf = (a: { data: [string, string] } | null | undefined) => {
130
+ try {
131
+ return a ? parseTokenAccount(base64.decode(a.data[0])).amount : 0n
132
+ } catch {
133
+ return 0n
134
+ }
135
+ }
136
+ const [afterToken, afterWsol] = sim.value.accounts ?? []
137
+ const prior = before ? safeAmount(before.data) : 0n
138
+ // 原来就有 wSOL:买入先用掉已有的 wSOL,不够的部分包装
139
+ const sold = amountOf(afterWsol) - (prior > spent ? prior - spent : 0n)
140
+ // 实际买到的 = 卖出的(买入的最少收到)+ 卖完后代币账户里多出来的
141
+ const bought = BigInt(buy.amountOutMin) + amountOf(afterToken) - (tokenBefore ? safeAmount(tokenBefore.data) : 0n)
142
+ // 只卖出了买入的最少收到(买到的一部分):按卖出的比例折算花掉的 SOL
143
+ const sellAmount = BigInt(buy.amountOutMin)
144
+ const roundTripLoss = bought > 0n ? Math.max(0, 1 - (Number(sold) * Number(bought)) / (Number(spent) * Number(sellAmount))) : null
145
+ const flags = roundTripLoss !== null && roundTripLoss > 0.1 ? ['high-round-trip-loss'] : []
146
+ return {
147
+ ...base,
148
+ buyRoute,
149
+ stage: 'ok',
150
+ honeypot: false,
151
+ bought: bought.toString(),
152
+ sold: sold.toString(),
153
+ roundTripLoss,
154
+ flags,
155
+ }
156
+ }
157
+
158
+ function safeAmount(data: Uint8Array): bigint {
159
+ try {
160
+ return parseTokenAccount(data).amount
161
+ } catch {
162
+ return 0n
163
+ }
164
+ }
165
+
166
+ const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err))
@@ -1,4 +1,4 @@
1
- import { ASSOCIATED_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID } from '../constants.js'
1
+ import { ASSOCIATED_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from '../constants.js'
2
2
  import type { Instruction } from './transaction.js'
3
3
 
4
4
  export const COMPUTE_BUDGET_PROGRAM_ID = 'ComputeBudget111111111111111111111111111111'
@@ -74,6 +74,31 @@ export function closeAccount(account: string, destination: string, owner: string
74
74
  }
75
75
  }
76
76
 
77
+ /** 撤销代币账户的授权(Approve 设置的 delegate),owner 签名 */
78
+ export function revoke(account: string, owner: string, tokenProgram: string): Instruction {
79
+ return {
80
+ programId: tokenProgram,
81
+ keys: [
82
+ { pubkey: account, isSigner: false, isWritable: true },
83
+ { pubkey: owner, isSigner: true, isWritable: false },
84
+ ],
85
+ data: new Uint8Array([5]),
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Token-2022 HarvestWithheldTokensToMint:把代币账户里暂存的转账手续费收到 mint 上(任何人都可以调用,不需要签名)。
91
+ * 账户里有未提取的手续费时关不掉,关闭前先调用它
92
+ */
93
+ export function harvestWithheldTokensToMint(mint: string, accounts: readonly string[]): Instruction {
94
+ return {
95
+ programId: TOKEN_2022_PROGRAM_ID,
96
+ keys: [{ pubkey: mint, isSigner: false, isWritable: true }, ...accounts.map((pubkey) => ({ pubkey, isSigner: false, isWritable: true }))],
97
+ // TransferFeeExtension(26)的 HarvestWithheldTokensToMint(4)
98
+ data: new Uint8Array([26, 4]),
99
+ }
100
+ }
101
+
77
102
  export function setComputeUnitLimit(units: number): Instruction {
78
103
  return {
79
104
  programId: COMPUTE_BUDGET_PROGRAM_ID,