@w3lib/solana-batch-call 0.2.2 → 0.3.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 (78) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +132 -2
  3. package/dist/client.d.ts +4 -0
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +8 -0
  6. package/dist/client.js.map +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/layout.d.ts +5 -0
  12. package/dist/layout.d.ts.map +1 -1
  13. package/dist/layout.js +16 -0
  14. package/dist/layout.js.map +1 -1
  15. package/dist/lp.d.ts +123 -0
  16. package/dist/lp.d.ts.map +1 -0
  17. package/dist/lp.js +631 -0
  18. package/dist/lp.js.map +1 -0
  19. package/dist/lpMath.d.ts +37 -0
  20. package/dist/lpMath.d.ts.map +1 -0
  21. package/dist/lpMath.js +176 -0
  22. package/dist/lpMath.js.map +1 -0
  23. package/dist/shortcuts.d.ts +8 -0
  24. package/dist/shortcuts.d.ts.map +1 -1
  25. package/dist/shortcuts.js +1 -1
  26. package/dist/shortcuts.js.map +1 -1
  27. package/dist/subpaths/lp.d.ts +31 -0
  28. package/dist/subpaths/lp.d.ts.map +1 -0
  29. package/dist/subpaths/lp.js +33 -0
  30. package/dist/subpaths/lp.js.map +1 -0
  31. package/dist/subpaths/write.d.ts +22 -0
  32. package/dist/subpaths/write.d.ts.map +1 -0
  33. package/dist/subpaths/write.js +19 -0
  34. package/dist/subpaths/write.js.map +1 -0
  35. package/dist/write/batch.d.ts +41 -0
  36. package/dist/write/batch.d.ts.map +1 -0
  37. package/dist/write/batch.js +46 -0
  38. package/dist/write/batch.js.map +1 -0
  39. package/dist/write/collect.d.ts +50 -0
  40. package/dist/write/collect.d.ts.map +1 -0
  41. package/dist/write/collect.js +220 -0
  42. package/dist/write/collect.js.map +1 -0
  43. package/dist/write/instructions.d.ts +16 -0
  44. package/dist/write/instructions.d.ts.map +1 -0
  45. package/dist/write/instructions.js +89 -0
  46. package/dist/write/instructions.js.map +1 -0
  47. package/dist/write/send.d.ts +115 -0
  48. package/dist/write/send.d.ts.map +1 -0
  49. package/dist/write/send.js +720 -0
  50. package/dist/write/send.js.map +1 -0
  51. package/dist/write/signer.d.ts +94 -0
  52. package/dist/write/signer.d.ts.map +1 -0
  53. package/dist/write/signer.js +197 -0
  54. package/dist/write/signer.js.map +1 -0
  55. package/dist/write/transaction.d.ts +75 -0
  56. package/dist/write/transaction.d.ts.map +1 -0
  57. package/dist/write/transaction.js +133 -0
  58. package/dist/write/transaction.js.map +1 -0
  59. package/dist/write/transfer.d.ts +72 -0
  60. package/dist/write/transfer.d.ts.map +1 -0
  61. package/dist/write/transfer.js +127 -0
  62. package/dist/write/transfer.js.map +1 -0
  63. package/package.json +23 -12
  64. package/src/client.ts +10 -0
  65. package/src/index.ts +1 -0
  66. package/src/layout.ts +17 -0
  67. package/src/lp.ts +849 -0
  68. package/src/lpMath.ts +205 -0
  69. package/src/shortcuts.ts +1 -1
  70. package/src/subpaths/lp.ts +62 -0
  71. package/src/subpaths/write.ts +29 -0
  72. package/src/write/batch.ts +78 -0
  73. package/src/write/collect.ts +286 -0
  74. package/src/write/instructions.ts +98 -0
  75. package/src/write/send.ts +876 -0
  76. package/src/write/signer.ts +314 -0
  77. package/src/write/transaction.ts +181 -0
  78. package/src/write/transfer.ts +189 -0
package/src/lp.ts ADDED
@@ -0,0 +1,849 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js'
2
+ import { base64 } from '@scure/base'
3
+
4
+ import { decodeAddress, encodeAddress, findProgramAddress, isAddress } from './address.js'
5
+ import type { RequestOptions, SolanaClient } from './client.js'
6
+ import { formatUnits, parseMint, parseTokenAccount, type AccountInfo } from './layout.js'
7
+ import { AllNodesFailedError, HttpError, RpcError } from './rpc.js'
8
+ import { getAmountsForLiquidity, getFeeGrowthInside, getFeesOwed, orcaSqrtPriceAtTick, raydiumSqrtPriceAtTick, sqrtPriceToPrice, tickToPrice, type TickFeeData } from './lpMath.js'
9
+
10
+ export const RAYDIUM_CLMM_PROGRAM_ID = 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK'
11
+ export const ORCA_WHIRLPOOL_PROGRAM_ID = 'whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc'
12
+ export const RAYDIUM_AMM_PROGRAM_ID = '675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8'
13
+ export const RAYDIUM_CPMM_PROGRAM_ID = 'CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C'
14
+ /** AMM v4 的 LP mint 权限(固定地址) */
15
+ const RAYDIUM_AMM_AUTHORITY = '5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1'
16
+ /** CPMM 的 LP mint 权限:PDA ["vault_and_lp_mint_auth_seed"] */
17
+ const RAYDIUM_CPMM_AUTHORITY = 'GpMZbSM2GgvTKHJirzeGfMFoaZ8UR2X7F4v8vHTvxFbL'
18
+
19
+ export type SolanaLpProtocol = 'raydium-clmm' | 'orca-whirlpool' | 'raydium-amm' | 'raydium-cpmm'
20
+
21
+ export const SOLANA_LP_PROTOCOLS: Readonly<Record<SolanaLpProtocol, { name: string; type: 'clmm' | 'amm'; programId: string }>> = {
22
+ 'raydium-clmm': { name: 'Raydium CLMM', type: 'clmm', programId: RAYDIUM_CLMM_PROGRAM_ID },
23
+ 'orca-whirlpool': { name: 'Orca Whirlpool', type: 'clmm', programId: ORCA_WHIRLPOOL_PROGRAM_ID },
24
+ 'raydium-amm': { name: 'Raydium AMM v4', type: 'amm', programId: RAYDIUM_AMM_PROGRAM_ID },
25
+ 'raydium-cpmm': { name: 'Raydium CPMM', type: 'amm', programId: RAYDIUM_CPMM_PROGRAM_ID },
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // 价格源
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export interface SolanaPriceSource {
33
+ readonly name: string
34
+ /** 返回 mint → 美元价格;没有价格的不返回 */
35
+ prices(ctx: { mints: readonly string[]; fetch: typeof fetch; signal?: AbortSignal }): Promise<Map<string, number>>
36
+ }
37
+
38
+ /** DefiLlama 价格(免费免 Key,按 100 个一批查询);confidence 低于 minConfidence(默认 0.9)的价格不用 */
39
+ export function defillamaPrices(options: { minConfidence?: number } = {}): SolanaPriceSource {
40
+ const minConfidence = options.minConfidence ?? 0.9
41
+ return {
42
+ name: 'defillama',
43
+ async prices({ mints, fetch: fetchFn, signal }) {
44
+ const result = new Map<string, number>()
45
+ const batches: string[][] = []
46
+ for (let i = 0; i < mints.length; i += 100) {
47
+ batches.push(mints.slice(i, i + 100))
48
+ }
49
+ await Promise.all(
50
+ batches.map(async (batch) => {
51
+ const res = await fetchFn(`https://coins.llama.fi/prices/current/${batch.map((m) => `solana:${m}`).join(',')}`, { signal })
52
+ if (!res.ok) {
53
+ throw new Error(`DefiLlama prices: HTTP ${res.status}`)
54
+ }
55
+ const body = (await res.json()) as { coins?: Record<string, { price?: number; confidence?: number }> }
56
+ for (const [key, coin] of Object.entries(body.coins ?? {})) {
57
+ if (typeof coin.price === 'number' && Number.isFinite(coin.price) && (coin.confidence === undefined || coin.confidence >= minConfidence)) {
58
+ result.set(key.slice(key.indexOf(':') + 1), coin.price)
59
+ }
60
+ }
61
+ }),
62
+ )
63
+ return result
64
+ },
65
+ }
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // 结果类型
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export interface SolanaLpToken {
73
+ mint: string
74
+ /** 读不到时为 null;Wrapped SOL 为 'SOL'(按客户端的 nativeMints 配置) */
75
+ symbol: string | null
76
+ /** 读不到时为 null(这时 formatted 字段也为 null) */
77
+ decimals: number | null
78
+ }
79
+
80
+ interface SolanaLpPositionBase {
81
+ protocol: SolanaLpProtocol
82
+ protocolName: string
83
+ /** 池子账户地址 */
84
+ pool: string
85
+ token0: SolanaLpToken
86
+ token1: SolanaLpToken
87
+ /** 撤出全部流动性能拿到的数量(最小单位,十进制字符串;不含未领取的手续费) */
88
+ amount0: string
89
+ amount1: string
90
+ amount0Formatted: string | null
91
+ amount1Formatted: string | null
92
+ /** 开启 prices 时:两种代币的美元价值(数量不为 0 的代币没有价格时为 null);没开启时不返回 */
93
+ valueUsd?: number | null
94
+ }
95
+
96
+ /** Raydium AMM v4 / CPMM:持有的 LP 代币 */
97
+ export interface SolanaLpAmmPosition extends SolanaLpPositionBase {
98
+ type: 'amm'
99
+ lpMint: string
100
+ /** 持有的 LP 数量(最小单位) */
101
+ balance: string
102
+ /** 池子记录的 LP 总量(程序按它计算撤出数量) */
103
+ totalSupply: string
104
+ /** 份额(0~1,浮点数) */
105
+ share: number
106
+ }
107
+
108
+ /** Raydium CLMM / Orca Whirlpool:仓位 NFT */
109
+ export interface SolanaLpClmmPosition extends SolanaLpPositionBase {
110
+ type: 'clmm'
111
+ /** 仓位 NFT 的 mint */
112
+ positionMint: string
113
+ /** 仓位账户(PDA ["position", positionMint]) */
114
+ position: string
115
+ /** 手续费率,百万分之一(如 2500 = 0.25%);读不到时为 null */
116
+ fee: number | null
117
+ tickSpacing: number
118
+ tickLower: number
119
+ tickUpper: number
120
+ /** 池子当前 tick */
121
+ tick: number
122
+ /** 当前价格在区间内(tickLower ≤ tick < tickUpper,在区间内才赚手续费) */
123
+ inRange: boolean
124
+ liquidity: string
125
+ /** 价格都是 token1 / token0(按精度换算,只用于展示);读不到精度时为 null */
126
+ price: number | null
127
+ priceLower: number | null
128
+ priceUpper: number | null
129
+ /** 未领取的手续费(最小单位);不含挖矿奖励。读不到两端的 tick 数据时(极少见,如数组刚好被关闭)为 null */
130
+ fees0: string | null
131
+ fees1: string | null
132
+ fees0Formatted: string | null
133
+ fees1Formatted: string | null
134
+ /** 开启 prices 时:未领取手续费的美元价值(手续费为 null 或缺价格时为 null) */
135
+ feesUsd?: number | null
136
+ }
137
+
138
+ export type SolanaLpPosition = SolanaLpAmmPosition | SolanaLpClmmPosition
139
+
140
+ export interface LpPositionsOptions extends RequestOptions {
141
+ /** 只查这些协议,默认全部 */
142
+ protocols?: readonly SolanaLpProtocol[]
143
+ /**
144
+ * 只看这些代币(仓位 NFT 的 mint、LP 代币的 mint),按关联代币账户(ATA)读余额,免费公共节点也能用。
145
+ * 不传时扫描地址的全部代币账户(getTokenAccountsByOwner,需要支持它的节点)
146
+ */
147
+ mints?: readonly string[]
148
+ /**
149
+ * Raydium AMM v4 / CPMM 的池子地址:这些池子的 LP 代币直接按池子识别。
150
+ * 不在这里的 LP 代币用 getProgramAccounts 按 LP mint 找池子(需要支持它的节点)
151
+ */
152
+ pools?: readonly string[]
153
+ /** 返回已清空的仓位(流动性为 0、手续费也领完,NFT 还在)。默认 false */
154
+ includeClosed?: boolean
155
+ /** 查美元价值:true 用 DefiLlama,也可以传自己的 SolanaPriceSource。默认不查 */
156
+ prices?: boolean | SolanaPriceSource
157
+ /**
158
+ * 价格源出错时调用(服务不可用、被拦截等):这时照样返回仓位,只是 valueUsd / feesUsd 为 null,
159
+ * 用它区分“价格服务出错”和“这个代币没有价格”。回调本身出错会被忽略
160
+ */
161
+ onPriceError?: (error: unknown) => void
162
+ /** 查价格用的 fetch,默认全局 fetch */
163
+ fetch?: typeof fetch
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // 账户布局(各程序的 Anchor / 原生账户,偏移量含 8 字节判别符)
168
+ // ---------------------------------------------------------------------------
169
+
170
+ class Bytes {
171
+ readonly #view: DataView
172
+ constructor(readonly data: Uint8Array) {
173
+ this.#view = new DataView(data.buffer, data.byteOffset, data.byteLength)
174
+ }
175
+ u8(o: number): number {
176
+ return this.#view.getUint8(o)
177
+ }
178
+ u16(o: number): number {
179
+ return this.#view.getUint16(o, true)
180
+ }
181
+ u32(o: number): number {
182
+ return this.#view.getUint32(o, true)
183
+ }
184
+ i32(o: number): number {
185
+ return this.#view.getInt32(o, true)
186
+ }
187
+ u64(o: number): bigint {
188
+ return this.#view.getBigUint64(o, true)
189
+ }
190
+ u128(o: number): bigint {
191
+ return (this.#view.getBigUint64(o + 8, true) << 64n) | this.#view.getBigUint64(o, true)
192
+ }
193
+ key(o: number): string {
194
+ return encodeAddress(this.data.subarray(o, o + 32))
195
+ }
196
+ }
197
+
198
+ const RAYDIUM_POSITION_SIZE = 281
199
+ const ORCA_POSITION_SIZE = 216
200
+ /** 池子账户至少要读到的长度(Raydium PoolState 实际 1544 字节,Orca Whirlpool 653 字节) */
201
+ const RAYDIUM_POOL_MIN_SIZE = 309
202
+ const ORCA_POOL_MIN_SIZE = 261
203
+ const AMM_POOL_SIZE = 752
204
+ const CPMM_POOL_SIZE = 637
205
+ const RAYDIUM_TICK_ARRAY_SIZE = 60
206
+ const RAYDIUM_TICK_SIZE = 168
207
+ const ORCA_TICK_ARRAY_SIZE = 88
208
+ const ORCA_TICK_SIZE = 113
209
+
210
+ interface ClmmPositionState {
211
+ protocol: 'raydium-clmm' | 'orca-whirlpool'
212
+ address: string
213
+ mint: string
214
+ pool: string
215
+ tickLower: number
216
+ tickUpper: number
217
+ liquidity: bigint
218
+ growthLast0: bigint
219
+ growthLast1: bigint
220
+ owed0: bigint
221
+ owed1: bigint
222
+ }
223
+
224
+ function raydiumPosition(address: string, b: Bytes): ClmmPositionState {
225
+ return {
226
+ protocol: 'raydium-clmm',
227
+ address,
228
+ mint: b.key(9),
229
+ pool: b.key(41),
230
+ tickLower: b.i32(73),
231
+ tickUpper: b.i32(77),
232
+ liquidity: b.u128(81),
233
+ growthLast0: b.u128(97),
234
+ growthLast1: b.u128(113),
235
+ owed0: b.u64(129),
236
+ owed1: b.u64(137),
237
+ }
238
+ }
239
+
240
+ function orcaPosition(address: string, b: Bytes): ClmmPositionState {
241
+ return {
242
+ protocol: 'orca-whirlpool',
243
+ address,
244
+ pool: b.key(8),
245
+ mint: b.key(40),
246
+ liquidity: b.u128(72),
247
+ tickLower: b.i32(88),
248
+ tickUpper: b.i32(92),
249
+ growthLast0: b.u128(96),
250
+ owed0: b.u64(112),
251
+ growthLast1: b.u128(120),
252
+ owed1: b.u64(136),
253
+ }
254
+ }
255
+
256
+ interface ClmmPoolState {
257
+ mint0: string
258
+ mint1: string
259
+ decimals0: number | null
260
+ decimals1: number | null
261
+ tickSpacing: number
262
+ sqrtPrice: bigint
263
+ tick: number
264
+ global0: bigint
265
+ global1: bigint
266
+ /** Orca:池子里的费率;Raydium:在 AmmConfig 里,ammConfig 为它的地址 */
267
+ fee: number | null
268
+ ammConfig: string | null
269
+ }
270
+
271
+ function raydiumPool(b: Bytes): ClmmPoolState {
272
+ return {
273
+ ammConfig: b.key(9),
274
+ mint0: b.key(73),
275
+ mint1: b.key(105),
276
+ decimals0: b.u8(233),
277
+ decimals1: b.u8(234),
278
+ tickSpacing: b.u16(235),
279
+ sqrtPrice: b.u128(253),
280
+ tick: b.i32(269),
281
+ global0: b.u128(277),
282
+ global1: b.u128(293),
283
+ fee: null,
284
+ }
285
+ }
286
+
287
+ function orcaPool(b: Bytes): ClmmPoolState {
288
+ return {
289
+ ammConfig: null,
290
+ tickSpacing: b.u16(41),
291
+ fee: b.u16(45),
292
+ sqrtPrice: b.u128(65),
293
+ tick: b.i32(81),
294
+ mint0: b.key(101),
295
+ global0: b.u128(165),
296
+ mint1: b.key(181),
297
+ global1: b.u128(245),
298
+ decimals0: null,
299
+ decimals1: null,
300
+ }
301
+ }
302
+
303
+ interface AmmPoolState {
304
+ protocol: 'raydium-amm' | 'raydium-cpmm'
305
+ address: string
306
+ lpMint: string
307
+ mint0: string
308
+ mint1: string
309
+ vault0: string
310
+ vault1: string
311
+ decimals0: number
312
+ decimals1: number
313
+ /** 从金库余额里扣掉的、不属于 LP 的部分(AMM v4 待提取的收益;CPMM 协议 / 基金 / 创建者手续费) */
314
+ reserved0: bigint
315
+ reserved1: bigint
316
+ lpSupply: bigint
317
+ }
318
+
319
+ function ammPool(address: string, b: Bytes): AmmPoolState {
320
+ return {
321
+ protocol: 'raydium-amm',
322
+ address,
323
+ decimals0: Number(b.u64(32)),
324
+ decimals1: Number(b.u64(40)),
325
+ reserved0: b.u64(192),
326
+ reserved1: b.u64(200),
327
+ vault0: b.key(336),
328
+ vault1: b.key(368),
329
+ mint0: b.key(400),
330
+ mint1: b.key(432),
331
+ lpMint: b.key(464),
332
+ lpSupply: b.u64(720),
333
+ }
334
+ }
335
+
336
+ function cpmmPool(address: string, b: Bytes): AmmPoolState {
337
+ return {
338
+ protocol: 'raydium-cpmm',
339
+ address,
340
+ vault0: b.key(72),
341
+ vault1: b.key(104),
342
+ lpMint: b.key(136),
343
+ mint0: b.key(168),
344
+ mint1: b.key(200),
345
+ decimals0: b.u8(331),
346
+ decimals1: b.u8(332),
347
+ lpSupply: b.u64(333),
348
+ reserved0: b.u64(341) + b.u64(357) + b.u64(397),
349
+ reserved1: b.u64(349) + b.u64(365) + b.u64(405),
350
+ }
351
+ }
352
+
353
+ function parseAmmPool(account: AccountInfo): AmmPoolState | null {
354
+ const b = new Bytes(account.data)
355
+ if (account.owner === RAYDIUM_AMM_PROGRAM_ID && account.data.length === AMM_POOL_SIZE) {
356
+ return ammPool(account.address, b)
357
+ }
358
+ if (account.owner === RAYDIUM_CPMM_PROGRAM_ID && account.data.length === CPMM_POOL_SIZE) {
359
+ return cpmmPool(account.address, b)
360
+ }
361
+ return null
362
+ }
363
+
364
+ const utf8 = (s: string) => new TextEncoder().encode(s)
365
+
366
+ function floorDiv(a: number, b: number): number {
367
+ return Math.floor(a / b) * b
368
+ }
369
+
370
+ /** Raydium tick 数组:PDA ["tick_array", pool, start(i32 大端)],每个数组 60 个 tick */
371
+ function raydiumTickArray(pool: string, tick: number, spacing: number): { address: string; start: number } {
372
+ const start = floorDiv(tick, spacing * RAYDIUM_TICK_ARRAY_SIZE)
373
+ const be = new Uint8Array(4)
374
+ new DataView(be.buffer).setInt32(0, start, false)
375
+ return { address: findProgramAddress([utf8('tick_array'), decodeAddress(pool), be], RAYDIUM_CLMM_PROGRAM_ID), start }
376
+ }
377
+
378
+ /** Orca tick 数组:PDA ["tick_array", whirlpool, start(十进制字符串)],每个数组 88 个 tick */
379
+ function orcaTickArray(pool: string, tick: number, spacing: number): { address: string; start: number } {
380
+ const start = floorDiv(tick, spacing * ORCA_TICK_ARRAY_SIZE)
381
+ return { address: findProgramAddress([utf8('tick_array'), decodeAddress(pool), utf8(String(start))], ORCA_WHIRLPOOL_PROGRAM_ID), start }
382
+ }
383
+
384
+ function raydiumTick(account: AccountInfo, start: number, tick: number, spacing: number): TickFeeData | null {
385
+ raydiumDiscriminator ??= discriminator('TickArrayState')
386
+ const offset = 44 + ((tick - start) / spacing) * RAYDIUM_TICK_SIZE
387
+ // 先认判别符、查长度再读字段(PDA 上可能是别的账户,如被转过 SOL 的系统账户)
388
+ if (!startsWith(account.data, raydiumDiscriminator) || offset + RAYDIUM_TICK_SIZE > account.data.length) {
389
+ return null
390
+ }
391
+ const b = new Bytes(account.data)
392
+ if (b.i32(40) !== start) {
393
+ return null
394
+ }
395
+ // Raydium 不区分是否初始化:没用过的 tick 各项都是 0,按程序的公式算出来就是对的
396
+ return { initialized: true, feeGrowthOutside0: b.u128(offset + 36), feeGrowthOutside1: b.u128(offset + 52) }
397
+ }
398
+
399
+ /** Anchor 账户判别符:sha256("account:<名称>") 的前 8 字节 */
400
+ const discriminator = (name: string) => sha256(utf8(`account:${name}`)).subarray(0, 8)
401
+ let orcaDiscriminators: { fixed: Uint8Array; dynamic: Uint8Array } | undefined
402
+ let raydiumDiscriminator: Uint8Array | undefined
403
+ const startsWith = (data: Uint8Array, prefix: Uint8Array) => data.length >= prefix.length && prefix.every((byte, i) => data[i] === byte)
404
+
405
+ function orcaTick(account: AccountInfo, start: number, tick: number, spacing: number): TickFeeData | null {
406
+ orcaDiscriminators ??= { fixed: discriminator('TickArray'), dynamic: discriminator('DynamicTickArray') }
407
+ if (account.data.length < 60) {
408
+ return null // 两种格式的头部(判别符 + start + …)都放不下
409
+ }
410
+ const b = new Bytes(account.data)
411
+ const index = (tick - start) / spacing
412
+ if (startsWith(account.data, orcaDiscriminators.fixed)) {
413
+ // 固定大小:start i32 | ticks [Tick; 88](每个 113 字节:initialized u8、liquidity_net、liquidity_gross、两个手续费增长…)| whirlpool
414
+ const offset = 12 + index * ORCA_TICK_SIZE
415
+ if (b.i32(8) !== start || offset + ORCA_TICK_SIZE > account.data.length) {
416
+ return null
417
+ }
418
+ return { initialized: b.u8(offset) === 1, feeGrowthOutside0: b.u128(offset + 33), feeGrowthOutside1: b.u128(offset + 49) }
419
+ }
420
+ if (startsWith(account.data, orcaDiscriminators.dynamic)) {
421
+ // 可变大小:start i32 | whirlpool | tick_bitmap u128 | ticks(未初始化 1 字节 0;初始化 1 字节 1 + 112 字节数据)
422
+ if (b.i32(8) !== start) {
423
+ return null
424
+ }
425
+ let offset = 60
426
+ for (let i = 0; i < index; i++) {
427
+ if (offset >= account.data.length) {
428
+ return null // 数据被截断
429
+ }
430
+ offset += b.u8(offset) === 1 ? 113 : 1
431
+ }
432
+ if (offset >= account.data.length) {
433
+ return null
434
+ }
435
+ if (b.u8(offset) !== 1) {
436
+ return { initialized: false, feeGrowthOutside0: 0n, feeGrowthOutside1: 0n }
437
+ }
438
+ if (offset + 113 > account.data.length) {
439
+ return null
440
+ }
441
+ return { initialized: true, feeGrowthOutside0: b.u128(offset + 33), feeGrowthOutside1: b.u128(offset + 49) }
442
+ }
443
+ return null
444
+ }
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // 查询
448
+ // ---------------------------------------------------------------------------
449
+
450
+ interface Holding {
451
+ mint: string
452
+ amount: bigint
453
+ decimals: number
454
+ }
455
+
456
+ interface RawClmm {
457
+ type: 'clmm'
458
+ position: ClmmPositionState
459
+ pool: ClmmPoolState
460
+ amount0: bigint
461
+ amount1: bigint
462
+ fees0: bigint | null
463
+ fees1: bigint | null
464
+ }
465
+
466
+ interface RawAmm {
467
+ type: 'amm'
468
+ pool: AmmPoolState
469
+ balance: bigint
470
+ amount0: bigint
471
+ amount1: bigint
472
+ }
473
+
474
+ /**
475
+ * 查一个地址的 LP 仓位:Raydium CLMM / Orca Whirlpool 的仓位 NFT,Raydium AMM v4 / CPMM 的 LP 代币。
476
+ * 返回两种代币的数量、未领取的手续费(集中流动性),可选美元价值。
477
+ *
478
+ * - 默认扫描地址的全部代币账户找仓位 NFT 和 LP 代币(需要支持 getTokenAccountsByOwner 的节点);传 mints 时只看这些代币(ATA,免费节点也能用)
479
+ * - Raydium AMM v4 / CPMM 的 LP 代币按 LP mint 用 getProgramAccounts 找池子(需要支持它的节点),传 pools 时这些池子不用找
480
+ * - 质押在农场里的 LP、Orca 的仓位组合(Position Bundle)、锁仓的 Raydium 仓位不在用户名下,查不到
481
+ */
482
+ export async function lpPositions(client: SolanaClient, owner: string, options: LpPositionsOptions = {}): Promise<SolanaLpPosition[]> {
483
+ if (!isAddress(owner)) {
484
+ throw new Error(`Invalid owner address: ${owner}`)
485
+ }
486
+ const protocols = new Set<SolanaLpProtocol>(options.protocols ?? (Object.keys(SOLANA_LP_PROTOCOLS) as SolanaLpProtocol[]))
487
+ for (const p of protocols) {
488
+ if (!Object.hasOwn(SOLANA_LP_PROTOCOLS, p)) {
489
+ throw new Error(`Unknown LP protocol: ${p}`)
490
+ }
491
+ }
492
+ for (const address of [...(options.mints ?? []), ...(options.pools ?? [])]) {
493
+ if (!isAddress(address)) {
494
+ throw new Error(`Invalid address: ${address}`)
495
+ }
496
+ }
497
+ const { signal } = options
498
+ const wantClmm = protocols.has('raydium-clmm') || protocols.has('orca-whirlpool')
499
+ const wantAmm = protocols.has('raydium-amm') || protocols.has('raydium-cpmm')
500
+ if (!wantClmm && !wantAmm) {
501
+ return []
502
+ }
503
+
504
+ // 1. 传入的池子(AMM v4 / CPMM),以及持有的代币
505
+ const knownPools: AmmPoolState[] = []
506
+ if (wantAmm && options.pools?.length) {
507
+ const accounts = await client.accounts(options.pools, { signal })
508
+ accounts.forEach((account, i) => {
509
+ const pool = account ? parseAmmPool(account) : null
510
+ if (!pool) {
511
+ throw new Error(`Not a Raydium AMM v4 / CPMM pool: ${options.pools?.[i]}`)
512
+ }
513
+ if (protocols.has(pool.protocol)) {
514
+ knownPools.push(pool)
515
+ }
516
+ })
517
+ }
518
+ const holdings = await listHoldings(client, owner, options.mints, knownPools.map((p) => p.lpMint), signal)
519
+
520
+ // 2. 一次读:可能是仓位 NFT 的代币(精度 0、数量 1)推导出的仓位账户,其余代币的 mint(看是不是 Raydium 的 LP)
521
+ const nfts = wantClmm ? holdings.filter((h) => h.decimals === 0 && h.amount === 1n) : []
522
+ const candidates = nfts.flatMap((h) => [
523
+ ...(protocols.has('raydium-clmm') ? [{ protocol: 'raydium-clmm' as const, address: findProgramAddress([utf8('position'), decodeAddress(h.mint)], RAYDIUM_CLMM_PROGRAM_ID) }] : []),
524
+ ...(protocols.has('orca-whirlpool') ? [{ protocol: 'orca-whirlpool' as const, address: findProgramAddress([utf8('position'), decodeAddress(h.mint)], ORCA_WHIRLPOOL_PROGRAM_ID) }] : []),
525
+ ])
526
+ const poolByLp = new Map(knownPools.map((p) => [p.lpMint, p]))
527
+ const fungible = wantAmm ? holdings.filter((h) => !(h.decimals === 0 && h.amount === 1n) && !poolByLp.has(h.mint)) : []
528
+ const first = await client.accounts([...candidates.map((c) => c.address), ...fungible.map((h) => h.mint)], { signal })
529
+
530
+ const positions: ClmmPositionState[] = []
531
+ candidates.forEach((c, i) => {
532
+ const account = first[i]
533
+ if (!account) {
534
+ return
535
+ }
536
+ const program = SOLANA_LP_PROTOCOLS[c.protocol].programId
537
+ if (c.protocol === 'raydium-clmm' && account.owner === program && account.data.length === RAYDIUM_POSITION_SIZE) {
538
+ positions.push(raydiumPosition(c.address, new Bytes(account.data)))
539
+ } else if (c.protocol === 'orca-whirlpool' && account.owner === program && account.data.length === ORCA_POSITION_SIZE) {
540
+ positions.push(orcaPosition(c.address, new Bytes(account.data)))
541
+ }
542
+ })
543
+ // LP mint 的权限是 AMM v4 / CPMM 的固定权限地址时才去找池子
544
+ const lookups: { protocol: 'raydium-amm' | 'raydium-cpmm'; lpMint: string }[] = []
545
+ fungible.forEach((h, i) => {
546
+ const account = first[candidates.length + i]
547
+ if (!account) {
548
+ return
549
+ }
550
+ let authority: string | null
551
+ try {
552
+ authority = parseMint(account.data).mintAuthority
553
+ } catch {
554
+ return
555
+ }
556
+ if (authority === RAYDIUM_AMM_AUTHORITY && protocols.has('raydium-amm')) {
557
+ lookups.push({ protocol: 'raydium-amm', lpMint: h.mint })
558
+ } else if (authority === RAYDIUM_CPMM_AUTHORITY && protocols.has('raydium-cpmm')) {
559
+ lookups.push({ protocol: 'raydium-cpmm', lpMint: h.mint })
560
+ }
561
+ })
562
+
563
+ // 3. 池子:集中流动性的池子账户;LP 代币按 LP mint 找池子
564
+ const poolAddresses = [...new Set(positions.map((p) => p.pool))]
565
+ const [poolAccounts, found] = await Promise.all([
566
+ client.accounts(poolAddresses, { signal }),
567
+ Promise.all(lookups.map((l) => findAmmPool(client, l.protocol, l.lpMint, signal))),
568
+ ])
569
+ for (const pool of found) {
570
+ if (pool) {
571
+ poolByLp.set(pool.lpMint, pool)
572
+ }
573
+ }
574
+ const pools = new Map<string, ClmmPoolState>()
575
+ poolAddresses.forEach((address, i) => {
576
+ const account = poolAccounts[i]
577
+ if (account?.owner === RAYDIUM_CLMM_PROGRAM_ID && account.data.length >= RAYDIUM_POOL_MIN_SIZE) {
578
+ pools.set(address, raydiumPool(new Bytes(account.data)))
579
+ } else if (account?.owner === ORCA_WHIRLPOOL_PROGRAM_ID && account.data.length >= ORCA_POOL_MIN_SIZE) {
580
+ pools.set(address, orcaPool(new Bytes(account.data)))
581
+ }
582
+ })
583
+ const held = new Map(holdings.map((h) => [h.mint, h]))
584
+ const ammHeld = [...poolByLp.values()].filter((p) => (held.get(p.lpMint)?.amount ?? 0n) > 0n)
585
+
586
+ // 4. 一次读:有流动性的仓位两端的 tick 数组、Raydium 的费率配置、AMM 池子的金库
587
+ const tickArrays = positions.flatMap((p) => {
588
+ const pool = pools.get(p.pool)
589
+ if (!pool || p.liquidity === 0n) {
590
+ return []
591
+ }
592
+ const derive = p.protocol === 'raydium-clmm' ? raydiumTickArray : orcaTickArray
593
+ return [{ position: p, lower: derive(p.pool, p.tickLower, pool.tickSpacing), upper: derive(p.pool, p.tickUpper, pool.tickSpacing) }]
594
+ })
595
+ const configs = [...new Set([...pools.values()].flatMap((p) => (p.ammConfig ? [p.ammConfig] : [])))]
596
+ const vaults = ammHeld.flatMap((p) => [p.vault0, p.vault1])
597
+ const tickAddresses = [...new Set(tickArrays.flatMap((t) => [t.lower.address, t.upper.address]))]
598
+ const second = await client.accounts([...tickAddresses, ...configs, ...vaults], { signal })
599
+ const byAddress = new Map<string, AccountInfo>()
600
+ ;[...tickAddresses, ...configs, ...vaults].forEach((address, i) => {
601
+ const account = second[i]
602
+ if (account) {
603
+ byAddress.set(address, account)
604
+ }
605
+ })
606
+ const feeRates = new Map<string, number>()
607
+ for (const config of configs) {
608
+ const account = byAddress.get(config)
609
+ // AmmConfig:bump u8 | index u16 | owner | protocol_fee_rate u32 | trade_fee_rate u32
610
+ if (account?.owner === RAYDIUM_CLMM_PROGRAM_ID && account.data.length >= 51) {
611
+ feeRates.set(config, new Bytes(account.data).u32(47))
612
+ }
613
+ }
614
+
615
+ const raw: (RawClmm | RawAmm)[] = []
616
+ for (const position of positions) {
617
+ const pool = pools.get(position.pool)
618
+ if (!pool) {
619
+ continue
620
+ }
621
+ const sqrtAt = position.protocol === 'raydium-clmm' ? raydiumSqrtPriceAtTick : orcaSqrtPriceAtTick
622
+ const [amount0, amount1] = getAmountsForLiquidity(pool.tick, pool.sqrtPrice, sqrtAt(position.tickLower), sqrtAt(position.tickUpper), position.liquidity, position.tickLower, position.tickUpper)
623
+ // 没有流动性时只有已记账的部分
624
+ let fees0: bigint | null = position.owed0
625
+ let fees1: bigint | null = position.owed1
626
+ const arrays = tickArrays.find((t) => t.position === position)
627
+ if (arrays) {
628
+ const read = position.protocol === 'raydium-clmm' ? raydiumTick : orcaTick
629
+ const lowerAccount = byAddress.get(arrays.lower.address)
630
+ const upperAccount = byAddress.get(arrays.upper.address)
631
+ const lower = lowerAccount ? read(lowerAccount, arrays.lower.start, position.tickLower, pool.tickSpacing) : null
632
+ const upper = upperAccount ? read(upperAccount, arrays.upper.start, position.tickUpper, pool.tickSpacing) : null
633
+ // 有流动性时两端的 tick 数组一定存在;读不到 / 解析不了时不猜,手续费为 null
634
+ if (lower && upper) {
635
+ const [inside0, inside1] = getFeeGrowthInside(pool.tick, position.tickLower, lower, position.tickUpper, upper, pool.global0, pool.global1)
636
+ fees0 = getFeesOwed(position.owed0, position.liquidity, inside0, position.growthLast0)
637
+ fees1 = getFeesOwed(position.owed1, position.liquidity, inside1, position.growthLast1)
638
+ } else {
639
+ fees0 = null
640
+ fees1 = null
641
+ }
642
+ }
643
+ if (!options.includeClosed && position.liquidity === 0n && fees0 === 0n && fees1 === 0n) {
644
+ continue
645
+ }
646
+ raw.push({ type: 'clmm', position, pool: { ...pool, fee: pool.fee ?? (pool.ammConfig ? (feeRates.get(pool.ammConfig) ?? null) : null) }, amount0, amount1, fees0, fees1 })
647
+ }
648
+ for (const pool of ammHeld) {
649
+ const balance = held.get(pool.lpMint)?.amount ?? 0n
650
+ const vault = (address: string) => {
651
+ const account = byAddress.get(address)
652
+ try {
653
+ return account ? parseTokenAccount(account.data).amount : null
654
+ } catch {
655
+ return null
656
+ }
657
+ }
658
+ const v0 = vault(pool.vault0)
659
+ const v1 = vault(pool.vault1)
660
+ if (v0 === null || v1 === null || pool.lpSupply === 0n) {
661
+ continue
662
+ }
663
+ const sub = (a: bigint, b: bigint) => (a > b ? a - b : 0n)
664
+ // 与程序撤出流动性的计算相同:份额 × (金库余额 − 不属于 LP 的部分),向下取整。
665
+ // AMM v4 的池子还能用 OpenBook 挂单时(status 不是 6 SwapOnly),挂单里的资金没算进来,数量会略少
666
+ raw.push({ type: 'amm', pool, balance, amount0: (balance * sub(v0, pool.reserved0)) / pool.lpSupply, amount1: (balance * sub(v1, pool.reserved1)) / pool.lpSupply })
667
+ }
668
+ return finalize(client, raw, options)
669
+ }
670
+
671
+ /**
672
+ * 节点不提供这个方法(方法不存在、被拦截、需要付费 / Token、403 等):这时报错里说明怎么绕过。
673
+ * 多节点时所有节点都是这个原因才算;限频、超时、网络不对等原样抛出
674
+ */
675
+ function methodUnavailable(err: unknown): boolean {
676
+ if (err instanceof AllNodesFailedError) {
677
+ return err.errors.length > 0 && err.errors.every((e) => methodUnavailable(e.error))
678
+ }
679
+ if (err instanceof HttpError) {
680
+ return err.status === 401 || err.status === 403 || err.status === 405
681
+ }
682
+ if (err instanceof RpcError) {
683
+ // 限频不算“不支持”(如 QuickNode:“… request limit reached - … upgrade your account”)
684
+ if (err.code === 429 || err.code === -32007 || /rate.?limit|too many requests|limit reached/i.test(err.message)) {
685
+ return false
686
+ }
687
+ return err.code === -32601 || err.code === 403 || /blocked|not (supported|available|allowed|enabled)|method not found|disabled|excluded|personal token|api key|upgrade|paid plan/i.test(err.message)
688
+ }
689
+ return false
690
+ }
691
+
692
+ /** 按 mint 列出持有的代币:传 mints 时读这些代币的 ATA,否则扫描全部代币账户 */
693
+ async function listHoldings(client: SolanaClient, owner: string, mints: readonly string[] | undefined, extra: readonly string[], signal: AbortSignal | undefined): Promise<Holding[]> {
694
+ if (mints) {
695
+ const list = [...new Set([...mints, ...extra])]
696
+ const balances = list.length ? await client.balances(owner, list, { signal }) : []
697
+ return balances.filter((b) => b.success && !b.native && b.balance !== '0').map((b) => ({ mint: b.token, amount: BigInt(b.balance), decimals: b.decimals }))
698
+ }
699
+ const tokens = await client.ownerTokens(owner, { signal, metadata: false, includeNative: false, includeNfts: true }).catch((err: unknown) => {
700
+ if (signal?.aborted || !methodUnavailable(err)) {
701
+ throw err // 取消、限频、超时等原样抛出(保留错误类型)
702
+ }
703
+ throw new Error(
704
+ `Scanning the token accounts of ${owner} failed (${err instanceof Error ? err.message : String(err)}). ` +
705
+ 'Scanning needs getTokenAccountsByOwner (most free public nodes do not support it): pass the position NFT / LP mints in `mints`, or use a node that supports it',
706
+ { cause: err },
707
+ )
708
+ })
709
+ return tokens.map((t) => ({ mint: t.token, amount: BigInt(t.balance), decimals: t.decimals }))
710
+ }
711
+
712
+ /** 按 LP mint 找 AMM v4 / CPMM 的池子(getProgramAccounts + memcmp),找不到时为 null */
713
+ async function findAmmPool(client: SolanaClient, protocol: 'raydium-amm' | 'raydium-cpmm', lpMint: string, signal: AbortSignal | undefined): Promise<AmmPoolState | null> {
714
+ const [programId, size, offset] = protocol === 'raydium-amm' ? [RAYDIUM_AMM_PROGRAM_ID, AMM_POOL_SIZE, 464] : [RAYDIUM_CPMM_PROGRAM_ID, CPMM_POOL_SIZE, 136]
715
+ const res = await client
716
+ .request<{ pubkey: string; account: { data: [string, string]; owner: string } }[]>(
717
+ 'getProgramAccounts',
718
+ [programId, { encoding: 'base64', commitment: client.commitment, filters: [{ dataSize: size }, { memcmp: { offset, bytes: lpMint } }] }],
719
+ { signal },
720
+ )
721
+ .catch((err: unknown) => {
722
+ if (signal?.aborted || !methodUnavailable(err)) {
723
+ throw err
724
+ }
725
+ // 免费公共节点大多不支持 getProgramAccounts:说明怎么绕过
726
+ throw new Error(
727
+ `Finding the ${SOLANA_LP_PROTOCOLS[protocol].name} pool of LP token ${lpMint} uses getProgramAccounts, which failed (${err instanceof Error ? err.message : String(err)}; most free public nodes do not support it). ` +
728
+ "Pass the pool address in `pools`, use a node that supports getProgramAccounts, or skip LP tokens with `protocols: ['raydium-clmm', 'orca-whirlpool']`",
729
+ { cause: err },
730
+ )
731
+ })
732
+ for (const item of res ?? []) {
733
+ const data = base64.decode(item.account.data[0])
734
+ const pool = parseAmmPool({ address: item.pubkey, owner: item.account.owner, data, lamports: 0n, executable: false })
735
+ if (pool?.lpMint === lpMint) {
736
+ return pool
737
+ }
738
+ }
739
+ return null
740
+ }
741
+
742
+ async function finalize(client: SolanaClient, raw: readonly (RawClmm | RawAmm)[], options: LpPositionsOptions): Promise<SolanaLpPosition[]> {
743
+ const { signal } = options
744
+ const mints = [...new Set(raw.flatMap((r) => [r.pool.mint0, r.pool.mint1]))]
745
+ const priceSource = options.prices === true ? defillamaPrices() : options.prices || null
746
+ const fetchFn = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
747
+ // 价格源出错(服务不可用、被拦截、同步抛错等)时照样返回仓位,只是不带价格;取消查询时照常 reject
748
+ const loadPrices = async (): Promise<Map<string, number>> => {
749
+ if (!priceSource || !mints.length) {
750
+ return new Map()
751
+ }
752
+ try {
753
+ // wSOL 的价格就是 SOL 的价格,DefiLlama 用 So111…112 查得到
754
+ return await priceSource.prices({ mints, fetch: fetchFn, signal })
755
+ } catch (err) {
756
+ if (signal?.aborted) {
757
+ throw err
758
+ }
759
+ try {
760
+ options.onPriceError?.(err)
761
+ } catch {
762
+ // 回调出错不影响查询结果
763
+ }
764
+ return new Map()
765
+ }
766
+ }
767
+ const [details, priceMap] = await Promise.all([
768
+ mints.length ? client.tokens(mints, { fields: ['symbol', 'decimals'], signal }) : Promise.resolve([]),
769
+ loadPrices(),
770
+ ])
771
+ const meta = new Map(details.map((d) => [d.address, d]))
772
+ const token = (mint: string, poolDecimals: number | null): SolanaLpToken => {
773
+ const d = meta.get(mint)
774
+ return { mint, symbol: d?.symbol ?? null, decimals: d?.decimals ?? poolDecimals }
775
+ }
776
+ const fmt = (value: bigint, t: SolanaLpToken) => (t.decimals === null ? null : formatUnits(value, t.decimals))
777
+ const usd = (t: SolanaLpToken, formatted: string | null) => {
778
+ const price = priceMap.get(t.mint)
779
+ return formatted === null || price === undefined ? null : Number(formatted) * price
780
+ }
781
+ const sum = (a: number | null, b: number | null, amountA: bigint, amountB: bigint) => {
782
+ // 数量为 0 的一边没有价格也不影响
783
+ if ((a === null && amountA > 0n) || (b === null && amountB > 0n)) {
784
+ return null
785
+ }
786
+ return (a ?? 0) + (b ?? 0)
787
+ }
788
+
789
+ const list = raw.map((r): SolanaLpPosition => {
790
+ const protocol = r.type === 'clmm' ? r.position.protocol : r.pool.protocol
791
+ const token0 = token(r.pool.mint0, r.pool.decimals0)
792
+ const token1 = token(r.pool.mint1, r.pool.decimals1)
793
+ const amount0Formatted = fmt(r.amount0, token0)
794
+ const amount1Formatted = fmt(r.amount1, token1)
795
+ const base = {
796
+ protocol,
797
+ protocolName: SOLANA_LP_PROTOCOLS[protocol].name,
798
+ pool: r.type === 'clmm' ? r.position.pool : r.pool.address,
799
+ token0,
800
+ token1,
801
+ amount0: r.amount0.toString(),
802
+ amount1: r.amount1.toString(),
803
+ amount0Formatted,
804
+ amount1Formatted,
805
+ ...(priceSource ? { valueUsd: sum(usd(token0, amount0Formatted), usd(token1, amount1Formatted), r.amount0, r.amount1) } : {}),
806
+ }
807
+ if (r.type === 'amm') {
808
+ return {
809
+ ...base,
810
+ type: 'amm',
811
+ lpMint: r.pool.lpMint,
812
+ balance: r.balance.toString(),
813
+ totalSupply: r.pool.lpSupply.toString(),
814
+ share: Number(r.balance) / Number(r.pool.lpSupply),
815
+ }
816
+ }
817
+ const { position: p, pool } = r
818
+ const d0 = token0.decimals
819
+ const d1 = token1.decimals
820
+ const fees0Formatted = r.fees0 === null ? null : fmt(r.fees0, token0)
821
+ const fees1Formatted = r.fees1 === null ? null : fmt(r.fees1, token1)
822
+ return {
823
+ ...base,
824
+ type: 'clmm',
825
+ positionMint: p.mint,
826
+ position: p.address,
827
+ fee: pool.fee,
828
+ tickSpacing: pool.tickSpacing,
829
+ tickLower: p.tickLower,
830
+ tickUpper: p.tickUpper,
831
+ tick: pool.tick,
832
+ inRange: pool.tick >= p.tickLower && pool.tick < p.tickUpper,
833
+ liquidity: p.liquidity.toString(),
834
+ price: d0 === null || d1 === null ? null : sqrtPriceToPrice(pool.sqrtPrice, d0, d1),
835
+ priceLower: d0 === null || d1 === null ? null : tickToPrice(p.tickLower, d0, d1),
836
+ priceUpper: d0 === null || d1 === null ? null : tickToPrice(p.tickUpper, d0, d1),
837
+ fees0: r.fees0 === null ? null : r.fees0.toString(),
838
+ fees1: r.fees1 === null ? null : r.fees1.toString(),
839
+ fees0Formatted,
840
+ fees1Formatted,
841
+ ...(priceSource ? { feesUsd: r.fees0 === null || r.fees1 === null ? null : sum(usd(token0, fees0Formatted), usd(token1, fees1Formatted), r.fees0, r.fees1) } : {}),
842
+ }
843
+ })
844
+ if (priceSource) {
845
+ const total = (p: SolanaLpPosition) => (p.valueUsd === null || p.valueUsd === undefined ? -1 : p.valueUsd + (p.type === 'clmm' ? (p.feesUsd ?? 0) : 0))
846
+ list.sort((a, b) => total(b) - total(a))
847
+ }
848
+ return list
849
+ }