@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +205 -2
  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/client.d.ts +4 -0
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +8 -0
  10. package/dist/client.js.map +1 -1
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/layout.d.ts +5 -0
  16. package/dist/layout.d.ts.map +1 -1
  17. package/dist/layout.js +16 -0
  18. package/dist/layout.js.map +1 -1
  19. package/dist/liquidity/amm.d.ts +7 -0
  20. package/dist/liquidity/amm.d.ts.map +1 -0
  21. package/dist/liquidity/amm.js +307 -0
  22. package/dist/liquidity/amm.js.map +1 -0
  23. package/dist/liquidity/clmm.d.ts +18 -0
  24. package/dist/liquidity/clmm.d.ts.map +1 -0
  25. package/dist/liquidity/clmm.js +85 -0
  26. package/dist/liquidity/clmm.js.map +1 -0
  27. package/dist/liquidity/common.d.ts +94 -0
  28. package/dist/liquidity/common.d.ts.map +1 -0
  29. package/dist/liquidity/common.js +228 -0
  30. package/dist/liquidity/common.js.map +1 -0
  31. package/dist/liquidity/index.d.ts +34 -0
  32. package/dist/liquidity/index.d.ts.map +1 -0
  33. package/dist/liquidity/index.js +157 -0
  34. package/dist/liquidity/index.js.map +1 -0
  35. package/dist/liquidity/orca.d.ts +6 -0
  36. package/dist/liquidity/orca.d.ts.map +1 -0
  37. package/dist/liquidity/orca.js +303 -0
  38. package/dist/liquidity/orca.js.map +1 -0
  39. package/dist/liquidity/raydiumClmm.d.ts +6 -0
  40. package/dist/liquidity/raydiumClmm.d.ts.map +1 -0
  41. package/dist/liquidity/raydiumClmm.js +307 -0
  42. package/dist/liquidity/raydiumClmm.js.map +1 -0
  43. package/dist/liquidity/types.d.ts +150 -0
  44. package/dist/liquidity/types.d.ts.map +1 -0
  45. package/dist/liquidity/types.js +2 -0
  46. package/dist/liquidity/types.js.map +1 -0
  47. package/dist/lp.d.ts +168 -0
  48. package/dist/lp.d.ts.map +1 -0
  49. package/dist/lp.js +632 -0
  50. package/dist/lp.js.map +1 -0
  51. package/dist/lpMath.d.ts +82 -0
  52. package/dist/lpMath.d.ts.map +1 -0
  53. package/dist/lpMath.js +365 -0
  54. package/dist/lpMath.js.map +1 -0
  55. package/dist/shortcuts.d.ts +8 -0
  56. package/dist/shortcuts.d.ts.map +1 -1
  57. package/dist/shortcuts.js +1 -1
  58. package/dist/shortcuts.js.map +1 -1
  59. package/dist/subpaths/liquidity.d.ts +23 -0
  60. package/dist/subpaths/liquidity.d.ts.map +1 -0
  61. package/dist/subpaths/liquidity.js +19 -0
  62. package/dist/subpaths/liquidity.js.map +1 -0
  63. package/dist/subpaths/lp.d.ts +31 -0
  64. package/dist/subpaths/lp.d.ts.map +1 -0
  65. package/dist/subpaths/lp.js +33 -0
  66. package/dist/subpaths/lp.js.map +1 -0
  67. package/dist/subpaths/write.d.ts +22 -0
  68. package/dist/subpaths/write.d.ts.map +1 -0
  69. package/dist/subpaths/write.js +19 -0
  70. package/dist/subpaths/write.js.map +1 -0
  71. package/dist/write/batch.d.ts +41 -0
  72. package/dist/write/batch.d.ts.map +1 -0
  73. package/dist/write/batch.js +46 -0
  74. package/dist/write/batch.js.map +1 -0
  75. package/dist/write/collect.d.ts +50 -0
  76. package/dist/write/collect.d.ts.map +1 -0
  77. package/dist/write/collect.js +220 -0
  78. package/dist/write/collect.js.map +1 -0
  79. package/dist/write/instructions.d.ts +16 -0
  80. package/dist/write/instructions.d.ts.map +1 -0
  81. package/dist/write/instructions.js +89 -0
  82. package/dist/write/instructions.js.map +1 -0
  83. package/dist/write/send.d.ts +115 -0
  84. package/dist/write/send.d.ts.map +1 -0
  85. package/dist/write/send.js +720 -0
  86. package/dist/write/send.js.map +1 -0
  87. package/dist/write/signer.d.ts +94 -0
  88. package/dist/write/signer.d.ts.map +1 -0
  89. package/dist/write/signer.js +197 -0
  90. package/dist/write/signer.js.map +1 -0
  91. package/dist/write/transaction.d.ts +75 -0
  92. package/dist/write/transaction.d.ts.map +1 -0
  93. package/dist/write/transaction.js +133 -0
  94. package/dist/write/transaction.js.map +1 -0
  95. package/dist/write/transfer.d.ts +72 -0
  96. package/dist/write/transfer.d.ts.map +1 -0
  97. package/dist/write/transfer.js +127 -0
  98. package/dist/write/transfer.js.map +1 -0
  99. package/package.json +23 -5
  100. package/src/address.ts +9 -0
  101. package/src/client.ts +10 -0
  102. package/src/index.ts +2 -1
  103. package/src/layout.ts +17 -0
  104. package/src/liquidity/amm.ts +353 -0
  105. package/src/liquidity/clmm.ts +108 -0
  106. package/src/liquidity/common.ts +285 -0
  107. package/src/liquidity/index.ts +177 -0
  108. package/src/liquidity/orca.ts +344 -0
  109. package/src/liquidity/raydiumClmm.ts +348 -0
  110. package/src/liquidity/types.ts +158 -0
  111. package/src/lp.ts +850 -0
  112. package/src/lpMath.ts +420 -0
  113. package/src/shortcuts.ts +1 -1
  114. package/src/subpaths/liquidity.ts +45 -0
  115. package/src/subpaths/lp.ts +71 -0
  116. package/src/subpaths/write.ts +29 -0
  117. package/src/write/batch.ts +78 -0
  118. package/src/write/collect.ts +286 -0
  119. package/src/write/instructions.ts +98 -0
  120. package/src/write/send.ts +876 -0
  121. package/src/write/signer.ts +314 -0
  122. package/src/write/transaction.ts +181 -0
  123. package/src/write/transfer.ts +189 -0
@@ -0,0 +1,285 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js'
2
+
3
+ import { encodeAddress, getAssociatedTokenAddress, isAddress } from '../address.js'
4
+ import type { SolanaClient } from '../client.js'
5
+ import { NATIVE_MINT, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'
6
+ import { formatUnits, parseMint, parseTokenAccount, parseTransferFeeConfig, parseUnits, type AccountInfo } from '../layout.js'
7
+ import type { SolanaLpPosition, SolanaLpToken } from '../lp.js'
8
+ import { closeAccount, createAssociatedTokenAccountIdempotent, systemTransfer } from '../write/instructions.js'
9
+ import type { Instruction } from '../write/transaction.js'
10
+ import type { SolanaLiquidityOptions, SolanaLiquidityPlan, SolanaPositionRef } from './types.js'
11
+
12
+ export const MEMO_PROGRAM_ID = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'
13
+ export const RENT_SYSVAR_ID = 'SysvarRent111111111111111111111111111111111'
14
+ export const DEFAULT_ADDRESS = SYSTEM_PROGRAM_ID
15
+
16
+ export interface Ctx {
17
+ client: SolanaClient
18
+ owner: string
19
+ recipient: string
20
+ slippage: number
21
+ native: boolean
22
+ signal: AbortSignal | undefined
23
+ }
24
+
25
+ export function createCtx(client: SolanaClient, owner: string, options: SolanaLiquidityOptions): Ctx {
26
+ for (const address of [owner, options.recipient ?? owner]) {
27
+ if (!isAddress(address)) {
28
+ throw new Error(`Invalid address: ${address}`)
29
+ }
30
+ }
31
+ const slippage = options.slippage ?? 0.005
32
+ if (!(slippage >= 0 && slippage < 1)) {
33
+ throw new RangeError(`slippage must be in [0, 1): ${slippage}`)
34
+ }
35
+ return { client, owner, recipient: options.recipient ?? owner, slippage, native: options.native ?? true, signal: options.signal }
36
+ }
37
+
38
+ export { Bytes } from '../lp.js'
39
+
40
+ /** 按字段顺序写指令数据(小端) */
41
+ export class Writer {
42
+ readonly #parts: number[] = []
43
+ bytes(values: ArrayLike<number>): this {
44
+ for (let i = 0; i < values.length; i++) this.#parts.push(values[i] as number)
45
+ return this
46
+ }
47
+ u8(v: number): this {
48
+ return this.bytes([v & 0xff])
49
+ }
50
+ bool(v: boolean): this {
51
+ return this.u8(v ? 1 : 0)
52
+ }
53
+ i32(v: number): this {
54
+ const b = new Uint8Array(4)
55
+ new DataView(b.buffer).setInt32(0, v, true)
56
+ return this.bytes(b)
57
+ }
58
+ u64(v: bigint): this {
59
+ if (v < 0n || v >= 1n << 64n) throw new RangeError(`u64 out of range: ${v}`)
60
+ const b = new Uint8Array(8)
61
+ new DataView(b.buffer).setBigUint64(0, v, true)
62
+ return this.bytes(b)
63
+ }
64
+ u128(v: bigint): this {
65
+ if (v < 0n || v >= 1n << 128n) throw new RangeError(`u128 out of range: ${v}`)
66
+ return this.u64(v & ((1n << 64n) - 1n)).u64(v >> 64n)
67
+ }
68
+ build(): Uint8Array {
69
+ return new Uint8Array(this.#parts)
70
+ }
71
+ }
72
+
73
+ /** Anchor 指令判别符:sha256("global:<名称>") 的前 8 字节 */
74
+ export function anchorIx(name: string): Uint8Array {
75
+ return sha256(new TextEncoder().encode(`global:${name}`)).subarray(0, 8)
76
+ }
77
+
78
+ export const w = (pubkey: string, isSigner = false) => ({ pubkey, isSigner, isWritable: true })
79
+ export const r = (pubkey: string, isSigner = false) => ({ pubkey, isSigner, isWritable: false })
80
+
81
+ /** 读账户,缺失时报错 */
82
+ export async function mustAccounts(ctx: Ctx, addresses: readonly string[], what: (i: number) => string): Promise<AccountInfo[]> {
83
+ const accounts = await ctx.client.accounts(addresses, { signal: ctx.signal })
84
+ return accounts.map((a, i) => {
85
+ if (!a) {
86
+ throw new Error(`${what(i)} not found: ${addresses[i]}`)
87
+ }
88
+ return a
89
+ })
90
+ }
91
+
92
+ /** 代币的 mint 信息:所属的代币程序、精度 */
93
+ export interface MintMeta {
94
+ mint: string
95
+ program: string
96
+ decimals: number
97
+ }
98
+
99
+ /**
100
+ * pool:池子里的两种代币。带转账手续费的 Token-2022 代币不支持(程序按扣费前 / 后的数量检查上下限,计划里的数量会对不上,
101
+ * 手续费超过滑点时交易一定失败),构建时报错;挖矿奖励的代币不检查(只影响到账数量)
102
+ */
103
+ export function mintMeta(mint: string, account: AccountInfo, pool = true): MintMeta {
104
+ if (account.owner !== TOKEN_PROGRAM_ID && account.owner !== TOKEN_2022_PROGRAM_ID) {
105
+ throw new Error(`${mint} is not a token mint`)
106
+ }
107
+ if (pool && account.owner === TOKEN_2022_PROGRAM_ID) {
108
+ const fee = parseTransferFeeConfig(account.data)
109
+ if (fee && (fee.older.basisPoints > 0 || fee.newer.basisPoints > 0)) {
110
+ throw new Error(`${mint} is a Token-2022 token with a transfer fee, which is not supported`)
111
+ }
112
+ }
113
+ return { mint, program: account.owner, decimals: parseMint(account.data).decimals }
114
+ }
115
+
116
+ /** 代币信息(symbol / 精度),与 getLpPositions 的格式一致 */
117
+ export async function tokenInfo(ctx: Ctx, mints: readonly MintMeta[]): Promise<SolanaLpToken[]> {
118
+ const details = await ctx.client.tokens(
119
+ mints.map((m) => m.mint),
120
+ { fields: ['symbol', 'decimals'], signal: ctx.signal },
121
+ )
122
+ return mints.map((m, i) => ({ mint: m.mint, symbol: details[i]?.symbol ?? null, decimals: details[i]?.decimals ?? m.decimals }))
123
+ }
124
+
125
+ export function fmt(value: bigint, token: SolanaLpToken): string | null {
126
+ return token.decimals === null ? null : formatUnits(value, token.decimals)
127
+ }
128
+
129
+ /** 解析输入的数量:amount(最小单位)和 formatted 二选一,都没给时返回 undefined */
130
+ export function parseAmount(raw: bigint | number | string | undefined, formatted: string | undefined, decimals: number, name: string): bigint | undefined {
131
+ if (raw !== undefined && formatted !== undefined) {
132
+ throw new Error(`Pass either ${name} or ${name}Formatted, not both`)
133
+ }
134
+ let value: bigint
135
+ if (formatted !== undefined) {
136
+ value = parseUnits(formatted, decimals)
137
+ } else if (raw !== undefined) {
138
+ if (typeof raw === 'number' && !Number.isSafeInteger(raw)) {
139
+ throw new RangeError(`${name} must be an integer (raw units); use ${name}Formatted for decimals`)
140
+ }
141
+ value = BigInt(raw)
142
+ } else {
143
+ return undefined
144
+ }
145
+ if (value < 0n) {
146
+ throw new RangeError(`${name} must not be negative`)
147
+ }
148
+ return value
149
+ }
150
+
151
+ /** 按滑点向下取:value × (1 − slippage) */
152
+ export function minus(value: bigint, slippage: number): bigint {
153
+ return (value * (1_000_000n - BigInt(Math.round(slippage * 1e6)))) / 1_000_000n
154
+ }
155
+
156
+ /** 按滑点向上取:value × (1 + slippage),向上取整 */
157
+ export function plus(value: bigint, slippage: number): bigint {
158
+ return (value * (1_000_000n + BigInt(Math.round(slippage * 1e6))) + 999_999n) / 1_000_000n
159
+ }
160
+
161
+ /** 移除的数量:percent(百分数)或 liquidity,二选一,默认全部 */
162
+ export function removeAmount(total: bigint, percent: number | undefined, liquidity: bigint | number | string | undefined): bigint {
163
+ if (percent !== undefined && liquidity !== undefined) {
164
+ throw new Error('Pass either percent or liquidity, not both')
165
+ }
166
+ if (liquidity !== undefined) {
167
+ const value = BigInt(liquidity)
168
+ if (value <= 0n || value > total) {
169
+ throw new RangeError(`liquidity must be in (0, ${total}]: ${value}`)
170
+ }
171
+ return value
172
+ }
173
+ const p = percent ?? 100
174
+ if (!(p > 0 && p <= 100)) {
175
+ throw new RangeError(`percent must be in (0, 100]: ${p}`)
176
+ }
177
+ const amount = p === 100 ? total : (total * BigInt(Math.round(p * 10_000))) / 1_000_000n
178
+ if (amount === 0n) {
179
+ throw new RangeError(`${p}% of ${total} rounds to 0`)
180
+ }
181
+ return amount
182
+ }
183
+
184
+ /** 用 SOL 的是哪一边 */
185
+ export const nativeSide = (index: number | null) => (index === 0 ? 'token0' : index === 1 ? 'token1' : null)
186
+
187
+ /** SPL Token SyncNative:把 wSOL 账户的 SOL 余额同步成代币余额 */
188
+ function syncNative(account: string): Instruction {
189
+ return { programId: TOKEN_PROGRAM_ID, keys: [w(account)], data: new Uint8Array([17]) }
190
+ }
191
+
192
+ /**
193
+ * owner 的代币账户(ATA):添加时要付出的、移除时要收到的。
194
+ * 不存在时创建(setup);wSOL 且 native 时:添加前把差额 SOL 包装进去,最后(原来没有的)关闭换回 SOL
195
+ */
196
+ export interface TokenAccounts {
197
+ /** 每个 mint 的 ATA */
198
+ accounts: string[]
199
+ /** 创建缺少的代币账户 */
200
+ setup: Instruction[]
201
+ /** 包装 SOL(转入 + SyncNative):放在主指令前、同一步里(主指令失败时不会留下包装好的 SOL) */
202
+ wrap: Instruction[]
203
+ cleanup: Instruction[]
204
+ /** 哪一边用 SOL(下标),没有时为 null */
205
+ nativeIndex: number | null
206
+ }
207
+
208
+ export async function prepareTokenAccounts(ctx: Ctx, mints: readonly MintMeta[], pay: ReadonlyArray<bigint | null>): Promise<TokenAccounts> {
209
+ const accounts = mints.map((m) => getAssociatedTokenAddress(ctx.owner, m.mint, m.program))
210
+ const existing = await ctx.client.accounts(accounts, { signal: ctx.signal })
211
+ const setup: Instruction[] = []
212
+ const wrap: Instruction[] = []
213
+ const cleanup: Instruction[] = []
214
+ let nativeIndex: number | null = null
215
+ mints.forEach((m, i) => {
216
+ const account = accounts[i] as string
217
+ const info = existing[i]
218
+ if (!info) {
219
+ setup.push(createAssociatedTokenAccountIdempotent(ctx.owner, account, ctx.owner, m.mint, m.program))
220
+ }
221
+ if (m.mint === NATIVE_MINT && ctx.native) {
222
+ // 添加时用 SOL 支付(包装差额);收取时只有原来没有 wSOL 账户(用完关闭)才是收到 SOL,否则留在原有的 wSOL 账户里
223
+ if (pay[i] != null || !info) {
224
+ nativeIndex = i
225
+ }
226
+ const need = pay[i] ?? 0n
227
+ const balance = info ? safeAmount(info) : 0n
228
+ if (need > balance) {
229
+ wrap.push(systemTransfer(ctx.owner, account, need - balance), syncNative(account))
230
+ }
231
+ if (!info) {
232
+ // 原来没有 wSOL 账户:用完关闭,剩余的 wSOL 和租金换回 SOL
233
+ cleanup.push(closeAccount(account, ctx.owner, ctx.owner, TOKEN_PROGRAM_ID))
234
+ }
235
+ }
236
+ })
237
+ return { accounts, setup, wrap, cleanup, nativeIndex }
238
+ }
239
+
240
+ function safeAmount(account: AccountInfo): bigint {
241
+ try {
242
+ return parseTokenAccount(account.data).amount
243
+ } catch {
244
+ return 0n
245
+ }
246
+ }
247
+
248
+ /** 仓位引用里的字段 */
249
+ export function refMint(ref: SolanaPositionRef | SolanaLpPosition): string {
250
+ const mint = 'positionMint' in ref ? ref.positionMint : undefined
251
+ if (!mint || !isAddress(mint)) {
252
+ throw new Error('position.positionMint is required for Raydium CLMM / Orca Whirlpool positions')
253
+ }
254
+ return mint
255
+ }
256
+
257
+ export function refPool(ref: SolanaPositionRef | SolanaLpPosition): string {
258
+ if (!ref.pool || !isAddress(ref.pool)) {
259
+ throw new Error('position.pool is required for Raydium AMM v4 / CPMM positions')
260
+ }
261
+ return ref.pool
262
+ }
263
+
264
+ /** 新的仓位 NFT mint:WebCrypto 生成 Ed25519 密钥,secretKey 是 32 字节种子(PKCS#8 的最后 32 字节) */
265
+ export async function newMintKeypair(): Promise<{ address: string; secretKey: Uint8Array }> {
266
+ const pair = (await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify'])) as CryptoKeyPair
267
+ const [publicKey, pkcs8] = await Promise.all([crypto.subtle.exportKey('raw', pair.publicKey), crypto.subtle.exportKey('pkcs8', pair.privateKey)])
268
+ return { address: encodeAddress(new Uint8Array(publicKey)), secretKey: new Uint8Array(pkcs8).slice(-32) }
269
+ }
270
+
271
+ type PlanInput = Omit<SolanaLiquidityPlan, 'owner' | 'amount0Formatted' | 'amount1Formatted' | 'fees0Formatted' | 'fees1Formatted'>
272
+
273
+ export function makePlan(ctx: Ctx, input: PlanInput): SolanaLiquidityPlan {
274
+ const { token0, token1 } = input
275
+ return {
276
+ ...input,
277
+ owner: ctx.owner,
278
+ amount0Formatted: fmt(BigInt(input.amount0), token0),
279
+ amount1Formatted: fmt(BigInt(input.amount1), token1),
280
+ fees0Formatted: fmt(BigInt(input.fees0), token0),
281
+ fees1Formatted: fmt(BigInt(input.fees1), token1),
282
+ // 空的步骤去掉(如不需要创建账户时的 setup)
283
+ steps: input.steps.filter((s) => s.instructions.length > 0),
284
+ }
285
+ }
@@ -0,0 +1,177 @@
1
+ import type { SolanaClient } from '../client.js'
2
+ import { getAssociatedTokenAddress } from '../address.js'
3
+ import { NATIVE_MINT, TOKEN_PROGRAM_ID } from '../constants.js'
4
+ import { parseTokenAccount } from '../layout.js'
5
+ import type { SolanaLpPosition, SolanaLpProtocol } from '../lp.js'
6
+ import { batchCall } from '../write/batch.js'
7
+ import { MAX_COMPUTE_UNITS, setComputeUnitLimit, setComputeUnitPrice } from '../write/instructions.js'
8
+ import type { WriteResult } from '../write/send.js'
9
+ import { PACKET_DATA_SIZE, compileMessage, transactionSize } from '../write/transaction.js'
10
+ import { resolveSigner, type SignerInput } from '../write/signer.js'
11
+ import { ammAdd, ammRemove } from './amm.js'
12
+ import { createCtx } from './common.js'
13
+ import { orcaAdd, orcaRemove } from './orca.js'
14
+ import { raydiumAdd, raydiumRemove } from './raydiumClmm.js'
15
+ import type {
16
+ SolanaAddLiquidityParams,
17
+ SolanaCollectFeesParams,
18
+ SolanaLiquidityOptions,
19
+ SolanaLiquidityPlan,
20
+ SolanaLiquidityResult,
21
+ SolanaLiquiditySendOptions,
22
+ SolanaLiquidityStep,
23
+ SolanaLiquidityStepResult,
24
+ SolanaPositionRef,
25
+ SolanaRemoveLiquidityParams,
26
+ } from './types.js'
27
+
28
+ function protocolOf(params: SolanaAddLiquidityParams | { position: SolanaPositionRef | SolanaLpPosition }): SolanaLpProtocol {
29
+ const protocol = 'position' in params ? params.position.protocol : params.protocol
30
+ if (protocol !== 'raydium-clmm' && protocol !== 'orca-whirlpool' && protocol !== 'raydium-amm' && protocol !== 'raydium-cpmm') {
31
+ throw new Error(`Unknown LP protocol: ${String(protocol)}`)
32
+ }
33
+ return protocol
34
+ }
35
+
36
+ /**
37
+ * 添加流动性的计划(不签名、不发送):新建仓位(集中流动性的新 NFT / AMM 的 LP 代币),或往已有仓位追加。
38
+ * 返回预计的数量和按顺序执行的指令;只做读取,也可以当作报价
39
+ *
40
+ * ```ts
41
+ * // Raydium CLMM:价格区间按 token1 / token0(池子的方向),给一边的数量
42
+ * const plan = await buildAddLiquidity(client, owner, { protocol: 'raydium-clmm', pool, priceLower: 120, priceUpper: 180, amount0Formatted: '1' })
43
+ * plan.amount1Formatted // 需要的另一种代币
44
+ * plan.steps // [{ kind: 'setup', instructions }, { kind: 'add', instructions }];plan.signers 是新仓位 NFT 的临时密钥
45
+ * ```
46
+ */
47
+ export async function buildAddLiquidity(client: SolanaClient, owner: string, params: SolanaAddLiquidityParams, options: SolanaLiquidityOptions = {}): Promise<SolanaLiquidityPlan> {
48
+ const ctx = createCtx(client, owner, options)
49
+ const protocol = protocolOf(params)
50
+ if (protocol === 'raydium-clmm') return raydiumAdd(ctx, params)
51
+ if (protocol === 'orca-whirlpool') return orcaAdd(ctx, params)
52
+ return ammAdd(ctx, protocol, params)
53
+ }
54
+
55
+ /** 移除流动性的计划(不签名、不发送):按比例或指定数量移除;集中流动性同时领取手续费和挖矿奖励 */
56
+ export async function buildRemoveLiquidity(client: SolanaClient, owner: string, params: SolanaRemoveLiquidityParams, options: SolanaLiquidityOptions = {}): Promise<SolanaLiquidityPlan> {
57
+ const ctx = createCtx(client, owner, options)
58
+ const protocol = protocolOf(params)
59
+ if (protocol === 'raydium-clmm') return raydiumRemove(ctx, params)
60
+ if (protocol === 'orca-whirlpool') return orcaRemove(ctx, params)
61
+ return ammRemove(ctx, protocol, params)
62
+ }
63
+
64
+ /** 只领取集中流动性仓位的手续费和挖矿奖励(不动流动性)的计划 */
65
+ export async function buildCollectFees(client: SolanaClient, owner: string, params: SolanaCollectFeesParams, options: SolanaLiquidityOptions = {}): Promise<SolanaLiquidityPlan> {
66
+ const ctx = createCtx(client, owner, options)
67
+ const protocol = protocolOf(params)
68
+ if (protocol === 'raydium-clmm') return raydiumRemove(ctx, { position: params.position }, true)
69
+ if (protocol === 'orca-whirlpool') return orcaRemove(ctx, { position: params.position }, true)
70
+ throw new Error('Raydium AMM v4 / CPMM have no separate fees: they are part of the LP tokens, remove liquidity to get them')
71
+ }
72
+
73
+ /**
74
+ * 添加流动性:构建计划后用 from 签名发送(钱包同 /write 的 batchCall:私钥、Keypair、Wallet Standard 钱包等)。
75
+ * 准备工作(创建代币账户、初始化 tick 数组、包装 SOL)和主指令能放进同一笔交易时合并成一笔
76
+ *
77
+ * ```ts
78
+ * const { status, plan } = await addLiquidity(client, secretKey, { protocol: 'orca-whirlpool', pool, fullRange: true, amount0Formatted: '0.5' })
79
+ * plan.positionMint // 新仓位的 NFT
80
+ * ```
81
+ */
82
+ export async function addLiquidity(client: SolanaClient, from: SignerInput, params: SolanaAddLiquidityParams, options: SolanaLiquiditySendOptions = {}): Promise<SolanaLiquidityResult> {
83
+ const plan = await buildAddLiquidity(client, (await resolveSigner(from)).address, params, options)
84
+ if (options.checkBalance ?? true) {
85
+ await checkBalance(client, plan, options.signal)
86
+ }
87
+ return execute(client, from, plan, options)
88
+ }
89
+
90
+ /** 移除流动性:构建计划后用 from 签名发送 */
91
+ export async function removeLiquidity(client: SolanaClient, from: SignerInput, params: SolanaRemoveLiquidityParams, options: SolanaLiquiditySendOptions = {}): Promise<SolanaLiquidityResult> {
92
+ const plan = await buildRemoveLiquidity(client, (await resolveSigner(from)).address, params, options)
93
+ return execute(client, from, plan, options)
94
+ }
95
+
96
+ /** 领取集中流动性仓位的手续费和挖矿奖励 */
97
+ export async function collectFees(client: SolanaClient, from: SignerInput, params: SolanaCollectFeesParams, options: SolanaLiquiditySendOptions = {}): Promise<SolanaLiquidityResult> {
98
+ const plan = await buildCollectFees(client, (await resolveSigner(from)).address, params, options)
99
+ return execute(client, from, plan, options)
100
+ }
101
+
102
+ /** 添加前检查余额:代币按预计数量;SOL 那一边按上限减去已有的 wSOL(只包装差额),不含手续费和租金 */
103
+ async function checkBalance(client: SolanaClient, plan: SolanaLiquidityPlan, signal: AbortSignal | undefined): Promise<void> {
104
+ const needs: Array<{ mint: string; amount: bigint; native: boolean }> = []
105
+ for (const [token, amount, max, native] of [
106
+ [plan.token0, plan.amount0, plan.amount0Max, plan.native === 'token0'],
107
+ [plan.token1, plan.amount1, plan.amount1Max, plan.native === 'token1'],
108
+ ] as const) {
109
+ const need = native ? BigInt(max ?? amount) : BigInt(amount)
110
+ if (need > 0n) {
111
+ needs.push({ mint: token.mint, amount: need, native })
112
+ }
113
+ }
114
+ if (!needs.length) {
115
+ return
116
+ }
117
+ const wsol = getAssociatedTokenAddress(plan.owner, NATIVE_MINT, TOKEN_PROGRAM_ID)
118
+ const [balances, [wsolAccount]] = await Promise.all([
119
+ // NATIVE_MINT 在 balances 里读的是原生 SOL(lamports)
120
+ client.balances(plan.owner, needs.map((n) => n.mint), { signal }),
121
+ needs.some((n) => n.mint === NATIVE_MINT) ? client.accounts([wsol], { signal }) : Promise.resolve([null]),
122
+ ])
123
+ needs.forEach((n, i) => {
124
+ const b = balances[i]
125
+ if (!b?.success) {
126
+ return // 读不到的不拦(模拟时也会失败)
127
+ }
128
+ let have = BigInt(b.balance)
129
+ if (n.mint === NATIVE_MINT && !n.native) {
130
+ // 不包装 SOL 时用的是 wSOL 账户里的代币余额(balances 对 NATIVE_MINT 读的是原生 SOL)
131
+ have = 0n
132
+ }
133
+ if (n.mint === NATIVE_MINT && wsolAccount) {
134
+ try {
135
+ have += parseTokenAccount(wsolAccount.data).amount
136
+ } catch {
137
+ // 不是代币账户:只算原生 SOL
138
+ }
139
+ }
140
+ if (have < n.amount) {
141
+ throw new Error(`Insufficient balance of ${n.native ? 'SOL' : n.mint}: need ${n.amount}, have ${have}`)
142
+ }
143
+ })
144
+ }
145
+
146
+ async function execute(client: SolanaClient, from: SignerInput, plan: SolanaLiquidityPlan, options: SolanaLiquiditySendOptions): Promise<SolanaLiquidityResult> {
147
+ const { steps } = plan
148
+ const nftSigners = plan.signers.map((s) => s.secretKey)
149
+ // 准备工作和主指令能放进同一笔交易时作为一个调用发送(原子执行:主指令模拟失败时准备工作也不发);
150
+ // 放不下时分开发送(准备工作只有创建代币账户、初始化 tick 数组,SOL 的包装和解包都在主指令那一步)
151
+ const payer = (await resolveSigner(options.feePayer ?? from)).address
152
+ const all = steps.flatMap((s) => s.instructions)
153
+ const probe = compileMessage(payer, [setComputeUnitLimit(MAX_COMPUTE_UNITS), setComputeUnitPrice(1n), ...all], '11111111111111111111111111111111')
154
+ const merged = steps.length > 1 && transactionSize(probe) <= PACKET_DATA_SIZE
155
+ const groups: number[][] = merged ? [steps.map((_, i) => i)] : steps.map((_, i) => [i])
156
+ const main = steps.length - 1
157
+ const kindOf = (i: number) => (steps[i] as SolanaLiquidityStep).kind
158
+ const raw = await batchCall(
159
+ client,
160
+ groups.map((group) => ({
161
+ from,
162
+ instructions: group.flatMap((i) => (steps[i] as SolanaLiquidityStep).instructions),
163
+ // 新仓位 NFT 的 mint 要在主指令所在的交易里签名
164
+ signers: group.includes(main) ? nftSigners : [],
165
+ })),
166
+ {
167
+ ...options,
168
+ mode: 'pack',
169
+ // 准备工作失败时不发主指令
170
+ continueOnError: false,
171
+ onUpdate: options.onUpdate ? (r) => (groups[r.index] as number[]).forEach((i) => options.onUpdate?.({ ...r, index: i, kind: kindOf(i) })) : undefined,
172
+ },
173
+ )
174
+ const results = groups.flatMap((group, g) => group.map((i): SolanaLiquidityStepResult => ({ ...(raw[g] as WriteResult), index: i, kind: kindOf(i) })))
175
+ const last = results[main] as SolanaLiquidityStepResult
176
+ return { plan, results, status: last.status, signature: last.signature }
177
+ }