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