@w3lib/solana-batch-call 0.3.0 → 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.
Files changed (68) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +74 -1
  3. package/dist/address.d.ts +2 -0
  4. package/dist/address.d.ts.map +1 -1
  5. package/dist/address.js +8 -0
  6. package/dist/address.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/liquidity/amm.d.ts +7 -0
  12. package/dist/liquidity/amm.d.ts.map +1 -0
  13. package/dist/liquidity/amm.js +307 -0
  14. package/dist/liquidity/amm.js.map +1 -0
  15. package/dist/liquidity/clmm.d.ts +18 -0
  16. package/dist/liquidity/clmm.d.ts.map +1 -0
  17. package/dist/liquidity/clmm.js +85 -0
  18. package/dist/liquidity/clmm.js.map +1 -0
  19. package/dist/liquidity/common.d.ts +94 -0
  20. package/dist/liquidity/common.d.ts.map +1 -0
  21. package/dist/liquidity/common.js +228 -0
  22. package/dist/liquidity/common.js.map +1 -0
  23. package/dist/liquidity/index.d.ts +34 -0
  24. package/dist/liquidity/index.d.ts.map +1 -0
  25. package/dist/liquidity/index.js +157 -0
  26. package/dist/liquidity/index.js.map +1 -0
  27. package/dist/liquidity/orca.d.ts +6 -0
  28. package/dist/liquidity/orca.d.ts.map +1 -0
  29. package/dist/liquidity/orca.js +303 -0
  30. package/dist/liquidity/orca.js.map +1 -0
  31. package/dist/liquidity/raydiumClmm.d.ts +6 -0
  32. package/dist/liquidity/raydiumClmm.d.ts.map +1 -0
  33. package/dist/liquidity/raydiumClmm.js +307 -0
  34. package/dist/liquidity/raydiumClmm.js.map +1 -0
  35. package/dist/liquidity/types.d.ts +150 -0
  36. package/dist/liquidity/types.d.ts.map +1 -0
  37. package/dist/liquidity/types.js +2 -0
  38. package/dist/liquidity/types.js.map +1 -0
  39. package/dist/lp.d.ts +45 -0
  40. package/dist/lp.d.ts.map +1 -1
  41. package/dist/lp.js +8 -7
  42. package/dist/lp.js.map +1 -1
  43. package/dist/lpMath.d.ts +45 -0
  44. package/dist/lpMath.d.ts.map +1 -1
  45. package/dist/lpMath.js +189 -0
  46. package/dist/lpMath.js.map +1 -1
  47. package/dist/subpaths/liquidity.d.ts +23 -0
  48. package/dist/subpaths/liquidity.d.ts.map +1 -0
  49. package/dist/subpaths/liquidity.js +19 -0
  50. package/dist/subpaths/liquidity.js.map +1 -0
  51. package/dist/subpaths/lp.d.ts +1 -1
  52. package/dist/subpaths/lp.d.ts.map +1 -1
  53. package/dist/subpaths/lp.js +1 -1
  54. package/dist/subpaths/lp.js.map +1 -1
  55. package/package.json +17 -10
  56. package/src/address.ts +9 -0
  57. package/src/index.ts +1 -1
  58. package/src/liquidity/amm.ts +353 -0
  59. package/src/liquidity/clmm.ts +108 -0
  60. package/src/liquidity/common.ts +285 -0
  61. package/src/liquidity/index.ts +177 -0
  62. package/src/liquidity/orca.ts +344 -0
  63. package/src/liquidity/raydiumClmm.ts +348 -0
  64. package/src/liquidity/types.ts +158 -0
  65. package/src/lp.ts +9 -8
  66. package/src/lpMath.ts +215 -0
  67. package/src/subpaths/liquidity.ts +45 -0
  68. package/src/subpaths/lp.ts +9 -0
@@ -0,0 +1,344 @@
1
+ import { decodeAddress, findProgramAddress, getAssociatedTokenAddress } from '../address.js'
2
+ import { ASSOCIATED_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'
3
+ import type { AccountInfo } from '../layout.js'
4
+ import { ORCA_WHIRLPOOL_PROGRAM_ID, SOLANA_LP_PROTOCOLS, orcaTickArray, utf8, type SolanaLpToken } from '../lp.js'
5
+ import { getAmountsForLiquidity, getMaxAmounts, getMinAmounts, getMintAmounts, sqrtPriceAtTick } from '../lpMath.js'
6
+ import type { Instruction } from '../write/transaction.js'
7
+ import { liquidityFor, priceInfo, queryPosition, resolveRange } from './clmm.js'
8
+ import {
9
+ Bytes,
10
+ DEFAULT_ADDRESS,
11
+ MEMO_PROGRAM_ID,
12
+ Writer,
13
+ anchorIx,
14
+ makePlan,
15
+ mintMeta,
16
+ mustAccounts,
17
+ nativeSide,
18
+ newMintKeypair,
19
+ parseAmount,
20
+ prepareTokenAccounts,
21
+ r,
22
+ refMint,
23
+ removeAmount,
24
+ tokenInfo,
25
+ w,
26
+ type Ctx,
27
+ type MintMeta,
28
+ } from './common.js'
29
+ import type { SolanaIncreaseLiquidityParams, SolanaLiquidityPlan, SolanaNewPositionParams, SolanaRemoveLiquidityParams } from './types.js'
30
+
31
+ const PROGRAM = ORCA_WHIRLPOOL_PROGRAM_ID
32
+ /** 带 Token-2022 元数据扩展的仓位 NFT 的元数据更新权限(Orca 固定地址) */
33
+ const METADATA_UPDATE_AUTH = '3axbTs2z5GBy6usVbNVoqEgZMng3vZvMnAoX29BFfwhr'
34
+
35
+ interface Whirlpool {
36
+ address: string
37
+ tickSpacing: number
38
+ sqrtPrice: bigint
39
+ tick: number
40
+ mint0: string
41
+ vault0: string
42
+ mint1: string
43
+ vault1: string
44
+ rewards: Array<{ index: number; mint: string; vault: string }>
45
+ }
46
+
47
+ /** Whirlpool:tick_spacing 41、sqrt_price 65、tick 81、mint_a 101、vault_a 133、mint_b 181、vault_b 213、reward_infos 269(3 个,每个 128 字节:mint +0、vault +32) */
48
+ function parsePool(address: string, data: Uint8Array): Whirlpool {
49
+ if (data.length < 653) {
50
+ throw new Error(`Not an Orca Whirlpool: ${address}`)
51
+ }
52
+ const b = new Bytes(data)
53
+ const rewards: Whirlpool['rewards'] = []
54
+ for (let i = 0; i < 3; i++) {
55
+ const mint = b.key(269 + i * 128)
56
+ if (mint !== DEFAULT_ADDRESS) {
57
+ rewards.push({ index: i, mint, vault: b.key(269 + i * 128 + 32) })
58
+ }
59
+ }
60
+ return { address, tickSpacing: b.u16(41), sqrtPrice: b.u128(65), tick: b.i32(81), mint0: b.key(101), vault0: b.key(133), mint1: b.key(181), vault1: b.key(213), rewards }
61
+ }
62
+
63
+ /** 读池子和两种代币的 mint;已知 mint 时(已有仓位,查询结果里有)与池子一次读完 */
64
+ async function readPool(ctx: Ctx, address: string, knownMints?: readonly [string, string], extra: readonly string[] = []): Promise<{ extra: AccountInfo[]; pool: Whirlpool; mints: [MintMeta, MintMeta] }> {
65
+ // 已知 mint 时与池子、额外的账户(如仓位 NFT 的 mint)一次读完
66
+ const first = await mustAccounts(ctx, [address, ...(knownMints ?? []), ...extra], (i) => (i > (knownMints?.length ?? 0) ? 'Account' : i ? 'Token mint' : 'Orca Whirlpool'))
67
+ if (first[0]?.owner !== PROGRAM) {
68
+ throw new Error(`Not an Orca Whirlpool: ${address}`)
69
+ }
70
+ const pool = parsePool(address, (first[0] as AccountInfo).data)
71
+ const [m0, m1] = knownMints && knownMints[0] === pool.mint0 && knownMints[1] === pool.mint1 ? first.slice(1, 3) : await mustAccounts(ctx, [pool.mint0, pool.mint1], () => 'Token mint')
72
+ return { pool, mints: [mintMeta(pool.mint0, m0 as AccountInfo), mintMeta(pool.mint1, m1 as AccountInfo)], extra: first.slice(1 + (knownMints?.length ?? 0)) }
73
+ }
74
+
75
+ const positionAddress = (mint: string) => findProgramAddress([utf8('position'), decodeAddress(mint)], PROGRAM)
76
+
77
+ function tickArrays(pool: Whirlpool, tickLower: number, tickUpper: number) {
78
+ const lower = orcaTickArray(pool.address, tickLower, pool.tickSpacing)
79
+ const upper = orcaTickArray(pool.address, tickUpper, pool.tickSpacing)
80
+ return { lowerStart: lower.start, upperStart: upper.start, lower: lower.address, upper: upper.address }
81
+ }
82
+
83
+ /** increase_liquidity_v2 / decrease_liquidity_v2 共用的账户 */
84
+ function liquidityKeys(ctx: Ctx, pool: Whirlpool, mints: readonly MintMeta[], position: string, nftAccount: string, owner: readonly string[], lower: string, upper: string) {
85
+ return [
86
+ w(pool.address),
87
+ r((mints[0] as MintMeta).program),
88
+ r((mints[1] as MintMeta).program),
89
+ r(MEMO_PROGRAM_ID),
90
+ r(ctx.owner, true),
91
+ w(position),
92
+ r(nftAccount),
93
+ r(pool.mint0),
94
+ r(pool.mint1),
95
+ w(owner[0] as string),
96
+ w(owner[1] as string),
97
+ w(pool.vault0),
98
+ w(pool.vault1),
99
+ w(lower),
100
+ w(upper),
101
+ ]
102
+ }
103
+
104
+ export async function orcaAdd(ctx: Ctx, params: SolanaIncreaseLiquidityParams | SolanaNewPositionParams): Promise<SolanaLiquidityPlan> {
105
+ let pool: Whirlpool
106
+ let mints: [MintMeta, MintMeta]
107
+ let tickLower: number
108
+ let tickUpper: number
109
+ let existing: { mint: string; program: string } | null = null
110
+ /** 已有仓位:代币信息用查询结果里的,不再读一次 */
111
+ let known: [SolanaLpToken, SolanaLpToken] | null = null
112
+ if ('position' in params) {
113
+ const mint = refMint(params.position)
114
+ const position = await queryPosition(ctx, 'orca-whirlpool', mint)
115
+ known = [position.token0, position.token1]
116
+ let nft: AccountInfo[]
117
+ ;({ pool, mints, extra: nft } = await readPool(ctx, position.pool, [position.token0.mint, position.token1.mint], [mint]))
118
+ ;[tickLower, tickUpper] = [position.tickLower, position.tickUpper]
119
+ existing = { mint, program: (nft[0] as AccountInfo).owner }
120
+ } else {
121
+ if (ctx.recipient !== ctx.owner) {
122
+ // 新建后要由仓位 NFT 的持有者签名才能加流动性:同一笔交易里只能是 owner 自己
123
+ throw new Error('Orca Whirlpool: recipient must be the owner when opening a position (transfer the NFT afterwards)')
124
+ }
125
+ ;({ pool, mints } = await readPool(ctx, params.pool))
126
+ tickLower = 0
127
+ tickUpper = 0
128
+ }
129
+ const [token0, token1] = known ?? ((await tokenInfo(ctx, mints)) as [SolanaLpToken, SolanaLpToken])
130
+ if (!existing) {
131
+ ;[tickLower, tickUpper] = resolveRange('orca', params as SolanaNewPositionParams, token0, token1, pool.tickSpacing)
132
+ }
133
+ const amount0 = parseAmount(params.amount0, params.amount0Formatted, mints[0].decimals, 'amount0')
134
+ const amount1 = parseAmount(params.amount1, params.amount1Formatted, mints[1].decimals, 'amount1')
135
+ const liquidity = liquidityFor('orca', pool.tick, pool.sqrtPrice, tickLower, tickUpper, amount0, amount1, token0, token1)
136
+ const [expected0, expected1] = getMintAmounts(pool.tick, pool.sqrtPrice, sqrtPriceAtTick('orca', tickLower), sqrtPriceAtTick('orca', tickUpper), liquidity, tickLower, tickUpper)
137
+ const [max0, max1] = getMaxAmounts('orca', pool.sqrtPrice, tickLower, tickUpper, liquidity, ctx.slippage)
138
+
139
+ const tokens = await prepareTokenAccounts(ctx, mints, [max0, max1])
140
+ const ticks = tickArrays(pool, tickLower, tickUpper)
141
+ const setup = [...tokens.setup]
142
+ const main: Instruction[] = []
143
+ const signers: SolanaLiquidityPlan['signers'] = []
144
+ let positionMint: string
145
+ let nftAccount: string
146
+ if (existing) {
147
+ positionMint = existing.mint
148
+ nftAccount = getAssociatedTokenAddress(ctx.owner, existing.mint, existing.program)
149
+ } else {
150
+ // 区间两端的 tick 数组不存在时先初始化(idempotent:别人同时初始化了也不失败)
151
+ const arrays = await ctx.client.accounts([ticks.lower, ticks.upper], { signal: ctx.signal })
152
+ // 地址上可能只是别人转过 SOL 的系统账户:按是否归 Whirlpool 程序判断
153
+ const starts = [...new Set([ticks.lowerStart, ticks.upperStart])]
154
+ for (const start of starts) {
155
+ const address = start === ticks.lowerStart ? ticks.lower : ticks.upper
156
+ if (arrays[start === ticks.lowerStart ? 0 : 1]?.owner !== PROGRAM) {
157
+ setup.push({
158
+ programId: PROGRAM,
159
+ keys: [r(pool.address), w(ctx.owner, true), w(address), r(SYSTEM_PROGRAM_ID)],
160
+ data: new Writer().bytes(anchorIx('initialize_dynamic_tick_array')).i32(start).bool(true).build(),
161
+ })
162
+ }
163
+ }
164
+ const nft = await newMintKeypair()
165
+ signers.push(nft)
166
+ positionMint = nft.address
167
+ nftAccount = getAssociatedTokenAddress(ctx.owner, nft.address, TOKEN_2022_PROGRAM_ID)
168
+ main.push({
169
+ programId: PROGRAM,
170
+ keys: [
171
+ w(ctx.owner, true),
172
+ r(ctx.owner),
173
+ w(positionAddress(nft.address)),
174
+ w(nft.address, true),
175
+ w(nftAccount),
176
+ r(pool.address),
177
+ r(TOKEN_2022_PROGRAM_ID),
178
+ r(SYSTEM_PROGRAM_ID),
179
+ r(ASSOCIATED_TOKEN_PROGRAM_ID),
180
+ r(METADATA_UPDATE_AUTH),
181
+ ],
182
+ // 仓位 NFT 用 Token-2022 并带元数据扩展(钱包里能显示,与 Orca 网页一致)
183
+ data: new Writer().bytes(anchorIx('open_position_with_token_extensions')).i32(tickLower).i32(tickUpper).bool(true).build(),
184
+ })
185
+ }
186
+ main.push({
187
+ programId: PROGRAM,
188
+ keys: liquidityKeys(ctx, pool, mints, positionAddress(positionMint), nftAccount, tokens.accounts, ticks.lower, ticks.upper),
189
+ // remaining_accounts_info: None
190
+ data: new Writer().bytes(anchorIx('increase_liquidity_v2')).u128(liquidity).u64(max0).u64(max1).u8(0).build(),
191
+ })
192
+ return makePlan(ctx, {
193
+ action: 'add',
194
+ protocol: 'orca-whirlpool',
195
+ protocolName: SOLANA_LP_PROTOCOLS['orca-whirlpool'].name,
196
+ type: 'clmm',
197
+ pool: pool.address,
198
+ token0,
199
+ token1,
200
+ positionMint,
201
+ position: positionAddress(positionMint),
202
+ lpMint: null,
203
+ tickLower,
204
+ tickUpper,
205
+ ...priceInfo(pool.tick, pool.sqrtPrice, token0, token1, tickLower, tickUpper),
206
+ liquidity: liquidity.toString(),
207
+ amount0: expected0.toString(),
208
+ amount1: expected1.toString(),
209
+ amount0Max: max0.toString(),
210
+ amount1Max: max1.toString(),
211
+ amount0Min: null,
212
+ amount1Min: null,
213
+ // 追加时 Orca 只更新手续费记账,不转出
214
+ fees0: '0',
215
+ fees1: '0',
216
+ rewardMints: [],
217
+ native: nativeSide(tokens.nativeIndex),
218
+ steps: [
219
+ { kind: 'setup', instructions: setup },
220
+ { kind: 'add', instructions: [...tokens.wrap, ...main, ...tokens.cleanup] },
221
+ ],
222
+ signers,
223
+ })
224
+ }
225
+
226
+ /** 移除(decrease + 领取手续费和奖励)/ 只领取(collectOnly) */
227
+ export async function orcaRemove(ctx: Ctx, params: SolanaRemoveLiquidityParams, collectOnly = false): Promise<SolanaLiquidityPlan> {
228
+ const mint = refMint(params.position)
229
+ const position = await queryPosition(ctx, 'orca-whirlpool', mint)
230
+ const { pool, mints, extra } = await readPool(ctx, position.pool, [position.token0.mint, position.token1.mint], [mint])
231
+ const total = BigInt(position.liquidity)
232
+ let liquidity = 0n
233
+ if (!collectOnly) {
234
+ if (total === 0n) {
235
+ throw new Error(`Position ${mint} has no liquidity; use collectFees to collect what is owed`)
236
+ }
237
+ liquidity = removeAmount(total, params.percent, params.liquidity)
238
+ }
239
+ const burn = !collectOnly && !!params.burn
240
+ if (burn && liquidity !== total) {
241
+ throw new Error('burn: true needs the whole position to be removed')
242
+ }
243
+ const [token0, token1] = [position.token0, position.token1]
244
+ const { tickLower, tickUpper } = position
245
+ const [amount0, amount1] = getAmountsForLiquidity(pool.tick, pool.sqrtPrice, sqrtPriceAtTick('orca', tickLower), sqrtPriceAtTick('orca', tickUpper), liquidity, tickLower, tickUpper)
246
+ const [min0, min1] = liquidity === 0n ? [0n, 0n] : getMinAmounts('orca', pool.sqrtPrice, tickLower, tickUpper, liquidity, ctx.slippage)
247
+
248
+ const nftMintAccount = extra[0] as AccountInfo
249
+ const rewardMintAccounts = pool.rewards.length ? await mustAccounts(ctx, pool.rewards.map((x) => x.mint), () => 'Reward mint') : []
250
+ const nftProgram = (nftMintAccount as AccountInfo).owner
251
+ const rewardMints = pool.rewards.map((x, i) => mintMeta(x.mint, rewardMintAccounts[i] as AccountInfo, false))
252
+ // 两种代币和奖励的代币都收到 owner 的关联代币账户:没有时创建;wSOL(池子的一边或奖励)原来没有账户时用完关闭、换回 SOL
253
+ const rewardOnly = rewardMints.filter((m) => !mints.some((x) => x.mint === m.mint))
254
+ const tokens = await prepareTokenAccounts(ctx, [...mints, ...rewardOnly], [null, null, ...rewardOnly.map(() => null)])
255
+ const ticks = tickArrays(pool, tickLower, tickUpper)
256
+ const positionAccount = positionAddress(mint)
257
+ const nftAccount = getAssociatedTokenAddress(ctx.owner, mint, nftProgram)
258
+ const main: Instruction[] = []
259
+ if (liquidity > 0n) {
260
+ main.push({
261
+ programId: PROGRAM,
262
+ keys: liquidityKeys(ctx, pool, mints, positionAccount, nftAccount, tokens.accounts, ticks.lower, ticks.upper),
263
+ data: new Writer().bytes(anchorIx('decrease_liquidity_v2')).u128(liquidity).u64(min0).u64(min1).u8(0).build(),
264
+ })
265
+ } else if (total > 0n) {
266
+ // 只领取:先按最新的价格更新手续费和奖励的记账(流动性为 0 的仓位不需要)
267
+ main.push({ programId: PROGRAM, keys: [w(pool.address), w(positionAccount), r(ticks.lower), r(ticks.upper)], data: anchorIx('update_fees_and_rewards') })
268
+ }
269
+ main.push({
270
+ programId: PROGRAM,
271
+ keys: [
272
+ r(pool.address),
273
+ r(ctx.owner, true),
274
+ w(positionAccount),
275
+ r(nftAccount),
276
+ r(pool.mint0),
277
+ r(pool.mint1),
278
+ w(tokens.accounts[0] as string),
279
+ w(pool.vault0),
280
+ w(tokens.accounts[1] as string),
281
+ w(pool.vault1),
282
+ r((mints[0] as MintMeta).program),
283
+ r((mints[1] as MintMeta).program),
284
+ r(MEMO_PROGRAM_ID),
285
+ ],
286
+ data: new Writer().bytes(anchorIx('collect_fees_v2')).u8(0).build(),
287
+ })
288
+ pool.rewards.forEach((reward, i) => {
289
+ const m = rewardMints[i] as MintMeta
290
+ main.push({
291
+ programId: PROGRAM,
292
+ keys: [
293
+ r(pool.address),
294
+ r(ctx.owner, true),
295
+ w(positionAccount),
296
+ r(nftAccount),
297
+ w(getAssociatedTokenAddress(ctx.owner, m.mint, m.program)),
298
+ r(m.mint),
299
+ w(reward.vault),
300
+ r(m.program),
301
+ r(MEMO_PROGRAM_ID),
302
+ ],
303
+ data: new Writer().bytes(anchorIx('collect_reward_v2')).u8(reward.index).u8(0).build(),
304
+ })
305
+ })
306
+ if (burn) {
307
+ main.push(
308
+ nftProgram === TOKEN_2022_PROGRAM_ID
309
+ ? { programId: PROGRAM, keys: [r(ctx.owner, true), w(ctx.owner), w(positionAccount), w(mint), w(nftAccount), r(TOKEN_2022_PROGRAM_ID)], data: anchorIx('close_position_with_token_extensions') }
310
+ : { programId: PROGRAM, keys: [r(ctx.owner, true), w(ctx.owner), w(positionAccount), w(mint), w(nftAccount), r(TOKEN_PROGRAM_ID)], data: anchorIx('close_position') },
311
+ )
312
+ }
313
+ return makePlan(ctx, {
314
+ action: collectOnly ? 'collect' : 'remove',
315
+ protocol: 'orca-whirlpool',
316
+ protocolName: SOLANA_LP_PROTOCOLS['orca-whirlpool'].name,
317
+ type: 'clmm',
318
+ pool: pool.address,
319
+ token0,
320
+ token1,
321
+ positionMint: mint,
322
+ position: positionAccount,
323
+ lpMint: null,
324
+ tickLower,
325
+ tickUpper,
326
+ ...priceInfo(pool.tick, pool.sqrtPrice, token0, token1, tickLower, tickUpper),
327
+ liquidity: liquidity.toString(),
328
+ amount0: amount0.toString(),
329
+ amount1: amount1.toString(),
330
+ amount0Max: null,
331
+ amount1Max: null,
332
+ amount0Min: min0.toString(),
333
+ amount1Min: min1.toString(),
334
+ fees0: position.fees0 ?? '0',
335
+ fees1: position.fees1 ?? '0',
336
+ rewardMints: rewardMints.map((m) => m.mint),
337
+ native: nativeSide(tokens.nativeIndex),
338
+ steps: [
339
+ { kind: 'setup', instructions: tokens.setup },
340
+ { kind: collectOnly ? 'collect' : 'remove', instructions: [...tokens.wrap, ...main, ...tokens.cleanup] },
341
+ ],
342
+ signers: [],
343
+ })
344
+ }
@@ -0,0 +1,348 @@
1
+ import { decodeAddress, findProgramAddress, getAssociatedTokenAddress } from '../address.js'
2
+ import { ASSOCIATED_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'
3
+ import type { AccountInfo } from '../layout.js'
4
+ import { RAYDIUM_CLMM_PROGRAM_ID, SOLANA_LP_PROTOCOLS, raydiumTickArray, utf8, type SolanaLpToken } from '../lp.js'
5
+ import { getAmountsForLiquidity, getMaxAmounts, getMinAmounts, getMintAmounts, sqrtPriceAtTick } from '../lpMath.js'
6
+ import type { Instruction } from '../write/transaction.js'
7
+ import { liquidityFor, priceInfo, queryPosition, resolveRange } from './clmm.js'
8
+ import {
9
+ Bytes,
10
+ DEFAULT_ADDRESS,
11
+ MEMO_PROGRAM_ID,
12
+ RENT_SYSVAR_ID,
13
+ Writer,
14
+ anchorIx,
15
+ makePlan,
16
+ mintMeta,
17
+ mustAccounts,
18
+ nativeSide,
19
+ newMintKeypair,
20
+ parseAmount,
21
+ prepareTokenAccounts,
22
+ r,
23
+ refMint,
24
+ removeAmount,
25
+ tokenInfo,
26
+ w,
27
+ type Ctx,
28
+ type MintMeta,
29
+ } from './common.js'
30
+ import type { SolanaIncreaseLiquidityParams, SolanaLiquidityPlan, SolanaNewPositionParams, SolanaRemoveLiquidityParams } from './types.js'
31
+
32
+ const PROGRAM = RAYDIUM_CLMM_PROGRAM_ID
33
+ const TICK_ARRAY_SIZE = 60
34
+ /** 池子账户里的 tick 数组位图覆盖的范围:± tickSpacing × 60 × 512,超出时要带上位图扩展账户 */
35
+ const BITMAP_SIZE = 512
36
+
37
+ interface RaydiumPool {
38
+ address: string
39
+ mint0: string
40
+ mint1: string
41
+ vault0: string
42
+ vault1: string
43
+ tickSpacing: number
44
+ sqrtPrice: bigint
45
+ tick: number
46
+ /** 已初始化的奖励:mint 和池子的奖励金库 */
47
+ rewards: Array<{ mint: string; vault: string }>
48
+ }
49
+
50
+ /** PoolState:mint 73 / 105、vault 137 / 169、tick_spacing 235、sqrt_price 253、tick 269、reward_infos 397(3 个,每个 169 字节:mint +57、vault +89) */
51
+ function parsePool(address: string, data: Uint8Array): RaydiumPool {
52
+ if (data.length < 904) {
53
+ throw new Error(`Not a Raydium CLMM pool: ${address}`)
54
+ }
55
+ const b = new Bytes(data)
56
+ const rewards: RaydiumPool['rewards'] = []
57
+ for (let i = 0; i < 3; i++) {
58
+ const offset = 397 + i * 169
59
+ const mint = b.key(offset + 57)
60
+ // 与程序一致:奖励的 mint 不为默认地址就算已初始化
61
+ if (mint !== DEFAULT_ADDRESS) {
62
+ rewards.push({ mint, vault: b.key(offset + 89) })
63
+ }
64
+ }
65
+ return { address, mint0: b.key(73), mint1: b.key(105), vault0: b.key(137), vault1: b.key(169), tickSpacing: b.u16(235), sqrtPrice: b.u128(253), tick: b.i32(269), rewards }
66
+ }
67
+
68
+ /** 读池子和两种代币的 mint;已知 mint 时(已有仓位,查询结果里有)与池子一次读完 */
69
+ async function readPool(ctx: Ctx, address: string, knownMints?: readonly [string, string], extra: readonly string[] = []): Promise<{ extra: AccountInfo[]; pool: RaydiumPool; mints: [MintMeta, MintMeta] }> {
70
+ // 已知 mint 时与池子、额外的账户(如仓位 NFT 的 mint)一次读完
71
+ const first = await mustAccounts(ctx, [address, ...(knownMints ?? []), ...extra], (i) => (i > (knownMints?.length ?? 0) ? 'Account' : i ? 'Token mint' : 'Raydium CLMM pool'))
72
+ if (first[0]?.owner !== PROGRAM) {
73
+ throw new Error(`Not a Raydium CLMM pool: ${address}`)
74
+ }
75
+ const pool = parsePool(address, (first[0] as AccountInfo).data)
76
+ const [m0, m1] = knownMints && knownMints[0] === pool.mint0 && knownMints[1] === pool.mint1 ? first.slice(1, 3) : await mustAccounts(ctx, [pool.mint0, pool.mint1], () => 'Token mint')
77
+ return { pool, mints: [mintMeta(pool.mint0, m0 as AccountInfo), mintMeta(pool.mint1, m1 as AccountInfo)], extra: first.slice(1 + (knownMints?.length ?? 0)) }
78
+ }
79
+
80
+ const be32 = (n: number) => {
81
+ const b = new Uint8Array(4)
82
+ new DataView(b.buffer).setInt32(0, n, false)
83
+ return b
84
+ }
85
+ const personalPositionAddress = (nftMint: string) => findProgramAddress([utf8('position'), decodeAddress(nftMint)], PROGRAM)
86
+ /**
87
+ * ProtocolPositionState:程序的种子是 tick 的大端字节(to_be_bytes)。当前版本的程序已不再校验这个账户(官方 SDK 传的是小端推导出的地址,
88
+ * 主网照样成功),按程序的种子推导,以后恢复校验也对
89
+ */
90
+ const protocolPositionAddress = (pool: string, lower: number, upper: number) => findProgramAddress([utf8('position'), decodeAddress(pool), be32(lower), be32(upper)], PROGRAM)
91
+
92
+ /** 区间两端的 tick 数组;超出池子位图覆盖的范围时还要位图扩展账户 */
93
+ function tickAccounts(pool: RaydiumPool, tickLower: number, tickUpper: number) {
94
+ const lower = raydiumTickArray(pool.address, tickLower, pool.tickSpacing)
95
+ const upper = raydiumTickArray(pool.address, tickUpper, pool.tickSpacing)
96
+ const [lowerStart, upperStart] = [lower.start, upper.start]
97
+ const max = pool.tickSpacing * TICK_ARRAY_SIZE * BITMAP_SIZE
98
+ const overflow = [lowerStart, upperStart].some((s) => s >= max || s < -max)
99
+ return {
100
+ lowerStart,
101
+ upperStart,
102
+ lower: lower.address,
103
+ upper: upper.address,
104
+ bitmap: overflow ? [w(findProgramAddress([utf8('pool_tick_array_bitmap_extension'), decodeAddress(pool.address)], PROGRAM))] : [],
105
+ }
106
+ }
107
+
108
+ export async function raydiumAdd(ctx: Ctx, params: SolanaIncreaseLiquidityParams | SolanaNewPositionParams): Promise<SolanaLiquidityPlan> {
109
+ let pool: RaydiumPool
110
+ let mints: [MintMeta, MintMeta]
111
+ let tickLower: number
112
+ let tickUpper: number
113
+ /** 已有仓位:NFT mint 和它的代币程序 */
114
+ let existing: { mint: string; program: string } | null = null
115
+ /** 已有仓位:代币信息用查询结果里的,不再读一次 */
116
+ let known: [SolanaLpToken, SolanaLpToken] | null = null
117
+ if ('position' in params) {
118
+ const mint = refMint(params.position)
119
+ const position = await queryPosition(ctx, 'raydium-clmm', mint)
120
+ known = [position.token0, position.token1]
121
+ let nft: AccountInfo[]
122
+ ;({ pool, mints, extra: nft } = await readPool(ctx, position.pool, [position.token0.mint, position.token1.mint], [mint]))
123
+ ;[tickLower, tickUpper] = [position.tickLower, position.tickUpper]
124
+ existing = { mint, program: (nft[0] as AccountInfo).owner }
125
+ } else {
126
+ ;({ pool, mints } = await readPool(ctx, params.pool))
127
+ tickLower = 0
128
+ tickUpper = 0
129
+ }
130
+ const [token0, token1] = known ?? ((await tokenInfo(ctx, mints)) as [SolanaLpToken, SolanaLpToken])
131
+ if (!existing) {
132
+ ;[tickLower, tickUpper] = resolveRange('raydium', params as SolanaNewPositionParams, token0, token1, pool.tickSpacing)
133
+ }
134
+ const amount0 = parseAmount(params.amount0, params.amount0Formatted, mints[0].decimals, 'amount0')
135
+ const amount1 = parseAmount(params.amount1, params.amount1Formatted, mints[1].decimals, 'amount1')
136
+ const liquidity = liquidityFor('raydium', pool.tick, pool.sqrtPrice, tickLower, tickUpper, amount0, amount1, token0, token1)
137
+ const sqrtA = sqrtPriceAtTick('raydium', tickLower)
138
+ const sqrtB = sqrtPriceAtTick('raydium', tickUpper)
139
+ const [expected0, expected1] = getMintAmounts(pool.tick, pool.sqrtPrice, sqrtA, sqrtB, liquidity, tickLower, tickUpper)
140
+ const [max0, max1] = getMaxAmounts('raydium', pool.sqrtPrice, tickLower, tickUpper, liquidity, ctx.slippage)
141
+
142
+ const tokens = await prepareTokenAccounts(ctx, mints, [max0, max1])
143
+ const ticks = tickAccounts(pool, tickLower, tickUpper)
144
+ const protocolPosition = protocolPositionAddress(pool.address, tickLower, tickUpper)
145
+ const signers: SolanaLiquidityPlan['signers'] = []
146
+ let positionMint: string
147
+ let ix: Instruction
148
+ if (existing) {
149
+ positionMint = existing.mint
150
+ ix = {
151
+ programId: PROGRAM,
152
+ keys: [
153
+ r(ctx.owner, true),
154
+ r(getAssociatedTokenAddress(ctx.owner, existing.mint, existing.program)),
155
+ w(pool.address),
156
+ r(protocolPosition),
157
+ w(personalPositionAddress(existing.mint)),
158
+ w(ticks.lower),
159
+ w(ticks.upper),
160
+ w(tokens.accounts[0] as string),
161
+ w(tokens.accounts[1] as string),
162
+ w(pool.vault0),
163
+ w(pool.vault1),
164
+ r(TOKEN_PROGRAM_ID),
165
+ r(TOKEN_2022_PROGRAM_ID),
166
+ r(pool.mint0),
167
+ r(pool.mint1),
168
+ ...ticks.bitmap,
169
+ ],
170
+ // base_flag: None(按给定的流动性)
171
+ data: new Writer().bytes(anchorIx('increase_liquidity_v2')).u128(liquidity).u64(max0).u64(max1).u8(0).bool(false).build(),
172
+ }
173
+ } else {
174
+ const nft = await newMintKeypair()
175
+ signers.push(nft)
176
+ positionMint = nft.address
177
+ ix = {
178
+ programId: PROGRAM,
179
+ keys: [
180
+ w(ctx.owner, true),
181
+ r(ctx.recipient),
182
+ w(nft.address, true),
183
+ w(getAssociatedTokenAddress(ctx.recipient, nft.address, TOKEN_2022_PROGRAM_ID)),
184
+ w(pool.address),
185
+ r(protocolPosition),
186
+ w(ticks.lower),
187
+ w(ticks.upper),
188
+ w(personalPositionAddress(nft.address)),
189
+ w(tokens.accounts[0] as string),
190
+ w(tokens.accounts[1] as string),
191
+ w(pool.vault0),
192
+ w(pool.vault1),
193
+ r(RENT_SYSVAR_ID),
194
+ r(SYSTEM_PROGRAM_ID),
195
+ r(TOKEN_PROGRAM_ID),
196
+ r(ASSOCIATED_TOKEN_PROGRAM_ID),
197
+ r(TOKEN_2022_PROGRAM_ID),
198
+ r(pool.mint0),
199
+ r(pool.mint1),
200
+ ...ticks.bitmap,
201
+ ],
202
+ // 仓位 NFT 用 Token-2022(不建 Metaplex 元数据,租金更少);tick 数组不存在时程序自动创建
203
+ data: new Writer()
204
+ .bytes(anchorIx('open_position_with_token22_nft'))
205
+ .i32(tickLower)
206
+ .i32(tickUpper)
207
+ .i32(ticks.lowerStart)
208
+ .i32(ticks.upperStart)
209
+ .u128(liquidity)
210
+ .u64(max0)
211
+ .u64(max1)
212
+ .bool(false)
213
+ .u8(0)
214
+ .bool(false)
215
+ .build(),
216
+ }
217
+ }
218
+ return makePlan(ctx, {
219
+ action: 'add',
220
+ protocol: 'raydium-clmm',
221
+ protocolName: SOLANA_LP_PROTOCOLS['raydium-clmm'].name,
222
+ type: 'clmm',
223
+ pool: pool.address,
224
+ token0,
225
+ token1,
226
+ positionMint,
227
+ position: personalPositionAddress(positionMint),
228
+ lpMint: null,
229
+ tickLower,
230
+ tickUpper,
231
+ ...priceInfo(pool.tick, pool.sqrtPrice, token0, token1, tickLower, tickUpper),
232
+ liquidity: liquidity.toString(),
233
+ amount0: expected0.toString(),
234
+ amount1: expected1.toString(),
235
+ amount0Max: max0.toString(),
236
+ amount1Max: max1.toString(),
237
+ amount0Min: null,
238
+ amount1Min: null,
239
+ // 追加流动性时 Raydium 不领取手续费(留在仓位里)
240
+ fees0: '0',
241
+ fees1: '0',
242
+ rewardMints: [],
243
+ native: nativeSide(tokens.nativeIndex),
244
+ steps: [
245
+ { kind: 'setup', instructions: tokens.setup },
246
+ { kind: 'add', instructions: [...tokens.wrap, ix, ...tokens.cleanup] },
247
+ ],
248
+ signers,
249
+ })
250
+ }
251
+
252
+ /** 移除(liquidity > 0)/ 只领取手续费和奖励(collectOnly:流动性为 0 的 decrease) */
253
+ export async function raydiumRemove(ctx: Ctx, params: SolanaRemoveLiquidityParams, collectOnly = false): Promise<SolanaLiquidityPlan> {
254
+ const mint = refMint(params.position)
255
+ const position = await queryPosition(ctx, 'raydium-clmm', mint)
256
+ const { pool, mints, extra } = await readPool(ctx, position.pool, [position.token0.mint, position.token1.mint], [mint])
257
+ const total = BigInt(position.liquidity)
258
+ let liquidity = 0n
259
+ if (!collectOnly) {
260
+ if (total === 0n) {
261
+ throw new Error(`Position ${mint} has no liquidity; use collectFees to collect what is owed`)
262
+ }
263
+ liquidity = removeAmount(total, params.percent, params.liquidity)
264
+ }
265
+ const burn = !collectOnly && !!params.burn
266
+ if (burn && liquidity !== total) {
267
+ throw new Error('burn: true needs the whole position to be removed')
268
+ }
269
+ const [token0, token1] = [position.token0, position.token1]
270
+ const { tickLower, tickUpper } = position
271
+ const [amount0, amount1] = getAmountsForLiquidity(pool.tick, pool.sqrtPrice, sqrtPriceAtTick('raydium', tickLower), sqrtPriceAtTick('raydium', tickUpper), liquidity, tickLower, tickUpper)
272
+ const [min0, min1] = liquidity === 0n ? [0n, 0n] : getMinAmounts('raydium', pool.sqrtPrice, tickLower, tickUpper, liquidity, ctx.slippage)
273
+
274
+ const nftAccount = extra[0] as AccountInfo
275
+ const rewardMintAccounts = pool.rewards.length ? await mustAccounts(ctx, pool.rewards.map((x) => x.mint), () => 'Reward mint') : []
276
+ const nftProgram = (nftAccount as AccountInfo).owner
277
+ const rewardMints = pool.rewards.map((x, i) => mintMeta(x.mint, rewardMintAccounts[i] as AccountInfo, false))
278
+ // 两种代币和奖励的代币都收到 owner 的关联代币账户:没有时创建;wSOL(池子的一边或奖励)原来没有账户时用完关闭、换回 SOL
279
+ const rewardOnly = rewardMints.filter((m) => !mints.some((x) => x.mint === m.mint))
280
+ const tokens = await prepareTokenAccounts(ctx, [...mints, ...rewardOnly], [null, null, ...rewardOnly.map(() => null)])
281
+ const rewardAccounts = rewardMints.map((m) => getAssociatedTokenAddress(ctx.owner, m.mint, m.program))
282
+ const ticks = tickAccounts(pool, tickLower, tickUpper)
283
+ const personal = personalPositionAddress(mint)
284
+ const nftAta = getAssociatedTokenAddress(ctx.owner, mint, nftProgram)
285
+ const decrease: Instruction = {
286
+ programId: PROGRAM,
287
+ keys: [
288
+ r(ctx.owner, true),
289
+ r(nftAta),
290
+ w(personal),
291
+ w(pool.address),
292
+ r(protocolPositionAddress(pool.address, tickLower, tickUpper)),
293
+ w(pool.vault0),
294
+ w(pool.vault1),
295
+ w(ticks.lower),
296
+ w(ticks.upper),
297
+ w(tokens.accounts[0] as string),
298
+ w(tokens.accounts[1] as string),
299
+ r(TOKEN_PROGRAM_ID),
300
+ r(TOKEN_2022_PROGRAM_ID),
301
+ r(MEMO_PROGRAM_ID),
302
+ r(pool.mint0),
303
+ r(pool.mint1),
304
+ ...ticks.bitmap,
305
+ ...pool.rewards.flatMap((x, i) => [w(x.vault), w(rewardAccounts[i] as string), r(x.mint)]),
306
+ ],
307
+ data: new Writer().bytes(anchorIx('decrease_liquidity_v2')).u128(liquidity).u64(min0).u64(min1).build(),
308
+ }
309
+ const main: Instruction[] = [decrease]
310
+ if (burn) {
311
+ main.push({
312
+ programId: PROGRAM,
313
+ keys: [w(ctx.owner, true), w(mint), w(nftAta), w(personal), r(SYSTEM_PROGRAM_ID), r(nftProgram), w(pool.address)],
314
+ data: anchorIx('close_position'),
315
+ })
316
+ }
317
+ return makePlan(ctx, {
318
+ action: collectOnly ? 'collect' : 'remove',
319
+ protocol: 'raydium-clmm',
320
+ protocolName: SOLANA_LP_PROTOCOLS['raydium-clmm'].name,
321
+ type: 'clmm',
322
+ pool: pool.address,
323
+ token0,
324
+ token1,
325
+ positionMint: mint,
326
+ position: personal,
327
+ lpMint: null,
328
+ tickLower,
329
+ tickUpper,
330
+ ...priceInfo(pool.tick, pool.sqrtPrice, token0, token1, tickLower, tickUpper),
331
+ liquidity: liquidity.toString(),
332
+ amount0: amount0.toString(),
333
+ amount1: amount1.toString(),
334
+ amount0Max: null,
335
+ amount1Max: null,
336
+ amount0Min: min0.toString(),
337
+ amount1Min: min1.toString(),
338
+ fees0: position.fees0 ?? '0',
339
+ fees1: position.fees1 ?? '0',
340
+ rewardMints: rewardMints.map((m) => m.mint),
341
+ native: nativeSide(tokens.nativeIndex),
342
+ steps: [
343
+ { kind: 'setup', instructions: tokens.setup },
344
+ { kind: collectOnly ? 'collect' : 'remove', instructions: [...tokens.wrap, ...main, ...tokens.cleanup] },
345
+ ],
346
+ signers: [],
347
+ })
348
+ }