@w3lib/solana-batch-call 0.5.0 → 0.6.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 (102) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +113 -1
  3. package/dist/batchPlan.d.ts +52 -0
  4. package/dist/batchPlan.d.ts.map +1 -0
  5. package/dist/batchPlan.js +85 -0
  6. package/dist/batchPlan.js.map +1 -0
  7. package/dist/claim.d.ts +161 -0
  8. package/dist/claim.d.ts.map +1 -0
  9. package/dist/claim.js +197 -0
  10. package/dist/claim.js.map +1 -0
  11. package/dist/decode.d.ts +22 -0
  12. package/dist/decode.d.ts.map +1 -0
  13. package/dist/decode.js +187 -0
  14. package/dist/decode.js.map +1 -0
  15. package/dist/describe.d.ts +26 -0
  16. package/dist/describe.d.ts.map +1 -0
  17. package/dist/describe.js +58 -0
  18. package/dist/describe.js.map +1 -0
  19. package/dist/history.d.ts +125 -0
  20. package/dist/history.d.ts.map +1 -0
  21. package/dist/history.js +231 -0
  22. package/dist/history.js.map +1 -0
  23. package/dist/historyHelius.d.ts +52 -0
  24. package/dist/historyHelius.d.ts.map +1 -0
  25. package/dist/historyHelius.js +125 -0
  26. package/dist/historyHelius.js.map +1 -0
  27. package/dist/liquidity/orca.d.ts +6 -0
  28. package/dist/liquidity/orca.d.ts.map +1 -1
  29. package/dist/liquidity/orca.js +14 -3
  30. package/dist/liquidity/orca.js.map +1 -1
  31. package/dist/liquidity/raydiumClmm.d.ts +6 -0
  32. package/dist/liquidity/raydiumClmm.d.ts.map +1 -1
  33. package/dist/liquidity/raydiumClmm.js +12 -5
  34. package/dist/liquidity/raydiumClmm.js.map +1 -1
  35. package/dist/lp.d.ts +47 -0
  36. package/dist/lp.d.ts.map +1 -1
  37. package/dist/lp.js +91 -15
  38. package/dist/lp.js.map +1 -1
  39. package/dist/lpMath.d.ts +22 -0
  40. package/dist/lpMath.d.ts.map +1 -1
  41. package/dist/lpMath.js +21 -0
  42. package/dist/lpMath.js.map +1 -1
  43. package/dist/programs.d.ts +19 -0
  44. package/dist/programs.d.ts.map +1 -0
  45. package/dist/programs.js +175 -0
  46. package/dist/programs.js.map +1 -0
  47. package/dist/reclaim.d.ts +190 -0
  48. package/dist/reclaim.d.ts.map +1 -0
  49. package/dist/reclaim.js +399 -0
  50. package/dist/reclaim.js.map +1 -0
  51. package/dist/rpc.d.ts.map +1 -1
  52. package/dist/rpc.js +4 -2
  53. package/dist/rpc.js.map +1 -1
  54. package/dist/simulate.d.ts +106 -0
  55. package/dist/simulate.d.ts.map +1 -0
  56. package/dist/simulate.js +418 -0
  57. package/dist/simulate.js.map +1 -0
  58. package/dist/subpaths/claim.d.ts +31 -0
  59. package/dist/subpaths/claim.d.ts.map +1 -0
  60. package/dist/subpaths/claim.js +31 -0
  61. package/dist/subpaths/claim.js.map +1 -0
  62. package/dist/subpaths/history.d.ts +21 -0
  63. package/dist/subpaths/history.d.ts.map +1 -0
  64. package/dist/subpaths/history.js +22 -0
  65. package/dist/subpaths/history.js.map +1 -0
  66. package/dist/subpaths/lp.d.ts +2 -2
  67. package/dist/subpaths/lp.d.ts.map +1 -1
  68. package/dist/subpaths/lp.js +2 -2
  69. package/dist/subpaths/lp.js.map +1 -1
  70. package/dist/subpaths/reclaim.d.ts +34 -0
  71. package/dist/subpaths/reclaim.d.ts.map +1 -0
  72. package/dist/subpaths/reclaim.js +34 -0
  73. package/dist/subpaths/reclaim.js.map +1 -0
  74. package/dist/subpaths/simulate.d.ts +22 -0
  75. package/dist/subpaths/simulate.d.ts.map +1 -0
  76. package/dist/subpaths/simulate.js +23 -0
  77. package/dist/subpaths/simulate.js.map +1 -0
  78. package/dist/write/instructions.d.ts +2 -0
  79. package/dist/write/instructions.d.ts.map +1 -1
  80. package/dist/write/instructions.js +16 -0
  81. package/dist/write/instructions.js.map +1 -1
  82. package/package.json +21 -1
  83. package/src/batchPlan.ts +137 -0
  84. package/src/claim.ts +323 -0
  85. package/src/decode.ts +202 -0
  86. package/src/describe.ts +77 -0
  87. package/src/history.ts +336 -0
  88. package/src/historyHelius.ts +136 -0
  89. package/src/liquidity/orca.ts +15 -5
  90. package/src/liquidity/raydiumClmm.ts +13 -5
  91. package/src/lp.ts +140 -15
  92. package/src/lpMath.ts +38 -0
  93. package/src/programs.ts +216 -0
  94. package/src/reclaim.ts +547 -0
  95. package/src/rpc.ts +4 -2
  96. package/src/simulate.ts +535 -0
  97. package/src/subpaths/claim.ts +53 -0
  98. package/src/subpaths/history.ts +38 -0
  99. package/src/subpaths/lp.ts +5 -1
  100. package/src/subpaths/reclaim.ts +57 -0
  101. package/src/subpaths/simulate.ts +37 -0
  102. package/src/write/instructions.ts +17 -0
package/src/history.ts ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * 交易历史(子路径 `@w3lib/solana-batch-call/history`):把地址的交易整理成统一的 Activity(类型、方向、对手方、资产变化、手续费)。
3
+ * 数据源可以换:默认 rpcHistory(getSignaturesForAddress 翻页 + getTransaction,免费节点就能用,节点失败自动切换),
4
+ * 也可以传 heliusHistory 等;传多个时按顺序尝试。游标就是最后一条签名,换数据源也能接着翻
5
+ */
6
+ import { base58 } from '@scure/base'
7
+
8
+ import { isAddress } from './address.js'
9
+ import type { RequestOptions, SolanaClient } from './client.js'
10
+ import { NATIVE_MINT, SOL_DECIMALS, SYSTEM_PROGRAM_ID } from './constants.js'
11
+ import { decodeInstruction, type DecodedInstruction } from './decode.js'
12
+ import { classify, describe, type AssetDelta, type Classified } from './describe.js'
13
+ import { formatUnits } from './layout.js'
14
+
15
+ export type ActivityType = 'transfer' | 'swap' | 'approve' | 'liquidity' | 'mint' | 'contract' | 'other'
16
+
17
+ export interface ActivityChange extends AssetDelta {}
18
+
19
+ export interface Activity {
20
+ /** 交易签名 */
21
+ hash: string
22
+ /** 区块时间(unix 秒);节点没有给时为 0 */
23
+ time: number
24
+ /** slot */
25
+ block?: number
26
+ status: 'success' | 'failed'
27
+ type: ActivityType
28
+ /** 对这个地址来说:资产只增加 in、只减少 out、转给自己 self;兑换等有增有减时不填 */
29
+ direction?: 'in' | 'out' | 'self'
30
+ /** 转账的对手方 */
31
+ counterparty?: string
32
+ /** 一句话说明,如 “Swap 1 SOL for 150 USDC on Jupiter” */
33
+ label?: string
34
+ /** 这个地址的资产变化(SOL 不含手续费,Wrapped SOL 并入 SOL;asset 原生为 'native',其他是 mint) */
35
+ changes: ActivityChange[]
36
+ /** 这个地址付的手续费(lamports,十进制字符串);不是手续费支付者时不填 */
37
+ fee?: string
38
+ protocol?: string
39
+ }
40
+
41
+ export interface HistoryPage {
42
+ items: Activity[]
43
+ /** 还有更多时:下一页的游标(最后一条处理过的签名);没有更多时不返回 */
44
+ nextCursor?: string
45
+ }
46
+
47
+ export interface HistoryContext {
48
+ client: SolanaClient
49
+ address: string
50
+ limit: number
51
+ cursor?: string
52
+ /** 只要这个时间(unix 秒)之前 / 之后的 */
53
+ before?: number
54
+ after?: number
55
+ types?: readonly ActivityType[]
56
+ fetch: typeof fetch
57
+ signal?: AbortSignal
58
+ }
59
+
60
+ /** 历史数据源:返回统一格式的 Activity(代币的 symbol 先用缩写的 mint,getHistory 统一补全);游标是签名 */
61
+ export interface HistorySource {
62
+ readonly name: string
63
+ getHistory(ctx: HistoryContext): Promise<HistoryPage>
64
+ }
65
+
66
+ export interface HistoryOptions extends RequestOptions {
67
+ /** 每页条数,默认 20,最多 100 */
68
+ limit?: number
69
+ /** 上一页的 nextCursor */
70
+ cursor?: string
71
+ /** 区块时间上限(不含)/ 下限(含),unix 秒 */
72
+ before?: number
73
+ after?: number
74
+ /** 只要这些类型(不够 limit 时会往后多找,最多看 limit × 5 笔) */
75
+ types?: readonly ActivityType[]
76
+ /** 数据源,默认 rpcHistory();传数组时按顺序尝试,前一个失败换下一个 */
77
+ source?: HistorySource | readonly HistorySource[]
78
+ /** 补全代币符号(读 mint 的元数据),默认 true */
79
+ symbols?: boolean
80
+ fetch?: typeof fetch
81
+ }
82
+
83
+ export const MAX_HISTORY_LIMIT = 100
84
+ /** getTransaction 支持的交易版本:主网已经有 v1 格式的交易(2026-10),只给 0 时遇到它们整个请求报错(-32015);json 编码下 v1 的账户和指令字段与 v0 相同 */
85
+ const MAX_TX_VERSION = 1
86
+
87
+ const short = (a: string) => `${a.slice(0, 4)}…${a.slice(-4)}`
88
+
89
+ /**
90
+ * 查地址的交易历史。
91
+ *
92
+ * ```ts
93
+ * const page = await history(client, address, { limit: 20 })
94
+ * const next = await history(client, address, { cursor: page.nextCursor })
95
+ * ```
96
+ */
97
+ export async function history(client: SolanaClient, address: string, options: HistoryOptions = {}): Promise<HistoryPage> {
98
+ if (!isAddress(address)) throw new Error(`Invalid address: ${address}`)
99
+ const limit = options.limit ?? 20
100
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_HISTORY_LIMIT) throw new RangeError(`limit must be an integer between 1 and ${MAX_HISTORY_LIMIT}`)
101
+ const sources = options.source === undefined ? [rpcHistory()] : Array.isArray(options.source) ? options.source : [options.source as HistorySource]
102
+ if (!sources.length) throw new Error('No history source')
103
+ const ctx: HistoryContext = {
104
+ client,
105
+ address,
106
+ limit,
107
+ cursor: options.cursor,
108
+ before: options.before,
109
+ after: options.after,
110
+ types: options.types?.length ? options.types : undefined,
111
+ fetch: options.fetch ?? ((...args) => fetch(...args)),
112
+ signal: options.signal,
113
+ }
114
+ const errors: string[] = []
115
+ for (const source of sources) {
116
+ try {
117
+ const page = await (source as HistorySource).getHistory(ctx)
118
+ if (options.symbols !== false) await fillSymbols(client, page.items, options.signal)
119
+ return page
120
+ } catch (err) {
121
+ if (options.signal?.aborted) throw err
122
+ errors.push(`${(source as HistorySource).name}: ${err instanceof Error ? err.message : String(err)}`)
123
+ }
124
+ }
125
+ throw new Error(`All history sources failed (${errors.join('; ')})`)
126
+ }
127
+
128
+ /** 补全还是缩写 mint 的 symbol(一次 tokens 查询,label 里的同步替换);读不到的用缩写的 mint */
129
+ async function fillSymbols(client: SolanaClient, items: Activity[], signal: AbortSignal | undefined): Promise<void> {
130
+ const pending = (c: ActivityChange) => c.symbol === short(c.asset)
131
+ const missing = [...new Set(items.flatMap((a) => a.changes.filter(pending).map((c) => c.asset)))]
132
+ if (!missing.length) return
133
+ const details = await client.tokens(missing, { fields: ['symbol'], signal }).catch(() => null)
134
+ const symbols = new Map(missing.map((m, i) => [m, details?.[i]?.symbol ?? short(m)]))
135
+ for (const a of items) {
136
+ for (const c of a.changes) {
137
+ if (!pending(c)) continue
138
+ const symbol = symbols.get(c.asset) as string
139
+ if (a.label) a.label = a.label.split(c.symbol).join(symbol)
140
+ c.symbol = symbol
141
+ }
142
+ }
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // RPC 数据源
147
+ // ---------------------------------------------------------------------------
148
+
149
+ export interface RpcHistoryOptions {
150
+ /** 同时取多少笔交易的详情,默认 6(公共节点限频很严,节点自己会对 429 退避重试) */
151
+ concurrency?: number
152
+ /** 一次翻页最多检查多少笔签名(含被 types / 时间过滤掉的),默认 limit × 5,最多 500 */
153
+ maxScan?: number
154
+ }
155
+
156
+ interface RawTransaction {
157
+ slot: number
158
+ blockTime: number | null
159
+ meta: {
160
+ err: unknown
161
+ fee: number
162
+ preBalances: number[]
163
+ postBalances: number[]
164
+ preTokenBalances?: RawTokenBalance[]
165
+ postTokenBalances?: RawTokenBalance[]
166
+ loadedAddresses?: { writable: string[]; readonly: string[] }
167
+ } | null
168
+ transaction: {
169
+ signatures: string[]
170
+ message: { accountKeys: string[]; instructions: Array<{ programIdIndex: number; accounts: number[]; data: string }> }
171
+ }
172
+ }
173
+
174
+ interface RawTokenBalance {
175
+ accountIndex: number
176
+ mint: string
177
+ owner?: string
178
+ uiTokenAmount: { amount: string; decimals: number }
179
+ }
180
+
181
+ interface SignatureInfo {
182
+ signature: string
183
+ slot: number
184
+ err: unknown
185
+ blockTime: number | null
186
+ }
187
+
188
+ async function pool<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
189
+ const results = new Array<R>(items.length)
190
+ let next = 0
191
+ const worker = async () => {
192
+ while (next < items.length) {
193
+ const i = next++
194
+ results[i] = await fn(items[i] as T)
195
+ }
196
+ }
197
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker))
198
+ return results
199
+ }
200
+
201
+ /**
202
+ * 默认数据源:getSignaturesForAddress(用 before 翻页)+ getTransaction(逐笔,json 编码、支持 v0)。
203
+ * 先按区块时间过滤签名再取详情,只取需要的笔数;失败的交易也列出(status 为 failed)。每页的请求数 ≈ limit + 1
204
+ */
205
+ export function rpcHistory(options: RpcHistoryOptions = {}): HistorySource {
206
+ const concurrency = options.concurrency ?? 6
207
+ return {
208
+ name: 'rpc',
209
+ async getHistory(ctx) {
210
+ const { client, address, limit, signal } = ctx
211
+ const maxScan = Math.min(options.maxScan ?? limit * 5, 500)
212
+ const items: Activity[] = []
213
+ let cursor = ctx.cursor
214
+ let scanned = 0
215
+ let more = true
216
+ while (items.length < limit && scanned < maxScan && more) {
217
+ const pageSize = Math.min(1000, Math.max(limit, 20) * (ctx.types ? 3 : 1))
218
+ const commitment = client.commitment === 'processed' ? 'confirmed' : client.commitment
219
+ const sigs = await client.request<SignatureInfo[]>('getSignaturesForAddress', [address, { limit: pageSize, ...(cursor ? { before: cursor } : {}), commitment }], { signal })
220
+ if (!sigs.length) {
221
+ more = false
222
+ break
223
+ }
224
+ // 按时间过滤:比 before 新的跳过;比 after 旧的之后都不用看了(签名按从新到旧排列)
225
+ let rows = sigs.filter((s) => ctx.before === undefined || s.blockTime === null || s.blockTime < ctx.before)
226
+ const old = ctx.after === undefined ? -1 : rows.findIndex((s) => s.blockTime !== null && s.blockTime < (ctx.after as number))
227
+ if (old >= 0) rows = rows.slice(0, old)
228
+ let i = 0
229
+ while (i < rows.length && items.length < limit && scanned < maxScan) {
230
+ const chunk = rows.slice(i, i + Math.max(1, Math.min(limit - items.length, maxScan - scanned)))
231
+ const txs = await pool(chunk, concurrency, (row) => client.request<RawTransaction | null>('getTransaction', [row.signature, { encoding: 'json', maxSupportedTransactionVersion: MAX_TX_VERSION, commitment }], { signal }))
232
+ for (const [j, tx] of txs.entries()) {
233
+ i++
234
+ scanned++
235
+ cursor = (chunk[j] as SignatureInfo).signature
236
+ const activity = tx ? activityFromTransaction(address, tx) : null
237
+ if (activity && (!ctx.types || ctx.types.includes(activity.type))) items.push(activity)
238
+ if (items.length >= limit) break
239
+ }
240
+ }
241
+ if (i >= rows.length) {
242
+ // 这一页看完了:接着从这一页最后一条往后翻;没有更多(不足一页、或已经到 after)时结束
243
+ cursor = (sigs[sigs.length - 1] as SignatureInfo).signature
244
+ more = sigs.length >= pageSize && old < 0
245
+ }
246
+ }
247
+ return { items, ...(more && cursor ? { nextCursor: cursor } : {}) }
248
+ },
249
+ }
250
+ }
251
+
252
+ const SOL: Pick<AssetDelta, 'asset' | 'symbol' | 'decimals'> = { asset: 'native', symbol: 'SOL', decimals: SOL_DECIMALS }
253
+
254
+ /** 合并资产变化:同一资产相加,去掉 0 */
255
+ function toChanges(native: bigint, tokens: Map<string, { delta: bigint; decimals: number }>): ActivityChange[] {
256
+ const out: ActivityChange[] = []
257
+ if (native !== 0n) out.push({ ...SOL, delta: native.toString(), formatted: formatUnits(native, SOL_DECIMALS) })
258
+ for (const [asset, { delta, decimals }] of tokens) {
259
+ if (delta !== 0n) out.push({ asset, symbol: short(asset), decimals, delta: delta.toString(), formatted: formatUnits(delta, decimals) })
260
+ }
261
+ return out
262
+ }
263
+
264
+ function directionOf(changes: readonly ActivityChange[], counterparty: string | undefined, address: string): Activity['direction'] {
265
+ if (!changes.length) return counterparty === address ? 'self' : undefined
266
+ const negative = changes.filter((c) => c.delta.startsWith('-')).length
267
+ if (negative === 0) return 'in'
268
+ return negative === changes.length ? 'out' : undefined
269
+ }
270
+
271
+ const toActivityType = (kind: Classified | null): ActivityType => (kind ? (kind.type === 'unknown' ? 'contract' : kind.type) : 'other')
272
+
273
+ /** 一笔 getTransaction(json 编码)整理成 Activity,address 的角度;导出供测试和自己取到交易的场景使用 */
274
+ export function activityFromTransaction(address: string, tx: RawTransaction): Activity | null {
275
+ const meta = tx.meta
276
+ const hash = tx.transaction.signatures[0]
277
+ if (!meta || !hash) return null
278
+ const message = tx.transaction.message
279
+ const keys = [...message.accountKeys, ...(meta.loadedAddresses?.writable ?? []), ...(meta.loadedAddresses?.readonly ?? [])]
280
+ const failed = meta.err !== null && meta.err !== undefined
281
+ const ixs: DecodedInstruction[] = message.instructions.map((ix, i) => {
282
+ const programId = keys[ix.programIdIndex] as string
283
+ return decodeInstruction(i, programId, ix.accounts.map((a) => keys[a] as string), base58.decode(ix.data))
284
+ })
285
+
286
+ const index = keys.indexOf(address)
287
+ const isPayer = index === 0
288
+ const fee = BigInt(meta.fee)
289
+ let native = index >= 0 ? BigInt(meta.postBalances[index] ?? 0) - BigInt(meta.preBalances[index] ?? 0) + (isPayer ? fee : 0n) : 0n
290
+ const tokens = new Map<string, { delta: bigint; decimals: number }>()
291
+ const ownerOf = new Map<number, string>()
292
+ for (const [list, sign] of [[meta.preTokenBalances ?? [], -1n], [meta.postTokenBalances ?? [], 1n]] as const) {
293
+ for (const b of list) {
294
+ if (b.owner) ownerOf.set(b.accountIndex, b.owner)
295
+ if (b.owner !== address) continue
296
+ const prev = tokens.get(b.mint)
297
+ tokens.set(b.mint, { delta: (prev?.delta ?? 0n) + sign * BigInt(b.uiTokenAmount.amount), decimals: b.uiTokenAmount.decimals })
298
+ }
299
+ }
300
+ // Wrapped SOL 并入 SOL
301
+ const wsol = tokens.get(NATIVE_MINT)
302
+ if (wsol) {
303
+ native += wsol.delta
304
+ tokens.delete(NATIVE_MINT)
305
+ }
306
+ const changes = failed ? [] : toChanges(native, tokens)
307
+
308
+ // 对手方:第一笔转账的另一头(代币转账取代币账户的 owner)
309
+ let counterparty: string | undefined
310
+ for (const ix of ixs) {
311
+ if (ix.type !== 'transfer') continue
312
+ const args = ix.args as Record<string, string> | undefined
313
+ const owner = (account: string | undefined) => (account ? (ownerOf.get(keys.indexOf(account)) ?? (ix.programId === SYSTEM_PROGRAM_ID ? account : undefined)) : undefined)
314
+ const from = owner(args?.from ?? args?.source)
315
+ const to = owner(args?.to ?? args?.destination)
316
+ counterparty = from === address ? to : from
317
+ if (counterparty) break
318
+ }
319
+
320
+ const kind = classify(ixs, changes)
321
+ const type = toActivityType(kind)
322
+ const label = describe(kind, ixs, changes, counterparty)
323
+ return {
324
+ hash,
325
+ time: tx.blockTime ?? 0,
326
+ block: tx.slot,
327
+ status: failed ? 'failed' : 'success',
328
+ type,
329
+ direction: type === 'transfer' || type === 'other' ? directionOf(changes, counterparty, address) : undefined,
330
+ counterparty,
331
+ label: failed ? `Failed: ${label}` : label,
332
+ changes,
333
+ ...(isPayer ? { fee: fee.toString() } : {}),
334
+ protocol: kind?.protocol,
335
+ }
336
+ }
@@ -0,0 +1,136 @@
1
+ /** Helius 增强交易历史数据源(需要应用自己的 API key):把 `/v0/addresses/{address}/transactions` 的结果整理成和 rpcHistory 一样的 Activity */
2
+ import { NATIVE_MINT, SOL_DECIMALS } from './constants.js'
3
+ import { formatUnits } from './layout.js'
4
+ import type { Activity, ActivityChange, ActivityType, HistorySource } from './history.js'
5
+
6
+ export interface HeliusHistoryOptions {
7
+ apiKey: string
8
+ /** 默认 https://api.helius.xyz */
9
+ baseUrl?: string
10
+ }
11
+
12
+ interface HeliusItem {
13
+ signature: string
14
+ timestamp?: number
15
+ slot?: number
16
+ fee?: number
17
+ feePayer?: string
18
+ type?: string
19
+ source?: string
20
+ description?: string
21
+ transactionError?: unknown
22
+ nativeTransfers?: Array<{ fromUserAccount: string; toUserAccount: string; amount: number }>
23
+ tokenTransfers?: Array<{ fromUserAccount: string | null; toUserAccount: string | null; tokenAmount: number; mint: string }>
24
+ accountData?: Array<{
25
+ account: string
26
+ nativeBalanceChange?: number
27
+ tokenBalanceChanges?: Array<{ userAccount: string; mint: string; rawTokenAmount: { tokenAmount: string; decimals: number } }>
28
+ }>
29
+ }
30
+
31
+ const short = (a: string) => `${a.slice(0, 4)}…${a.slice(-4)}`
32
+
33
+ /** Helius 的交易类型 → Activity 类型(认不出的按资产变化判断) */
34
+ function typeOf(item: HeliusItem, changes: readonly ActivityChange[]): ActivityType {
35
+ const t = item.type ?? ''
36
+ if (/SWAP/.test(t)) return 'swap'
37
+ if (t === 'TRANSFER') return 'transfer'
38
+ if (/LIQUIDITY|POSITION|POOL/.test(t)) return 'liquidity'
39
+ if (/MINT/.test(t)) return 'mint'
40
+ if (/APPROVE|REVOKE|DELEGATE/.test(t)) return 'approve'
41
+ const out = changes.filter((c) => c.delta.startsWith('-'))
42
+ if (out.length && changes.some((c) => !c.delta.startsWith('-') && out.some((o) => o.asset !== c.asset))) return 'swap'
43
+ return t && t !== 'UNKNOWN' ? 'contract' : 'other'
44
+ }
45
+
46
+ export function activityFromHelius(address: string, item: HeliusItem): Activity {
47
+ const me = item.accountData?.find((a) => a.account === address)
48
+ const isPayer = item.feePayer === address
49
+ const fee = BigInt(item.fee ?? 0)
50
+ let native = BigInt(me?.nativeBalanceChange ?? 0) + (isPayer ? fee : 0n)
51
+ const tokens = new Map<string, { delta: bigint; decimals: number }>()
52
+ for (const entry of item.accountData ?? []) {
53
+ for (const t of entry.tokenBalanceChanges ?? []) {
54
+ if (t.userAccount !== address) continue
55
+ const prev = tokens.get(t.mint)
56
+ tokens.set(t.mint, { delta: (prev?.delta ?? 0n) + BigInt(t.rawTokenAmount.tokenAmount), decimals: t.rawTokenAmount.decimals })
57
+ }
58
+ }
59
+ const wsol = tokens.get(NATIVE_MINT)
60
+ if (wsol) {
61
+ native += wsol.delta
62
+ tokens.delete(NATIVE_MINT)
63
+ }
64
+ const failed = item.transactionError !== null && item.transactionError !== undefined
65
+ const changes: ActivityChange[] = []
66
+ if (!failed) {
67
+ if (native !== 0n) changes.push({ asset: 'native', symbol: 'SOL', decimals: SOL_DECIMALS, delta: native.toString(), formatted: formatUnits(native, SOL_DECIMALS) })
68
+ for (const [asset, { delta, decimals }] of tokens) {
69
+ if (delta !== 0n) changes.push({ asset, symbol: short(asset), decimals, delta: delta.toString(), formatted: formatUnits(delta, decimals) })
70
+ }
71
+ }
72
+ const type = typeOf(item, changes)
73
+ let counterparty: string | undefined
74
+ for (const t of [...(item.nativeTransfers ?? []), ...(item.tokenTransfers ?? []).map((x) => ({ fromUserAccount: x.fromUserAccount ?? '', toUserAccount: x.toUserAccount ?? '' }))]) {
75
+ if (t.fromUserAccount === address && t.toUserAccount) counterparty = t.toUserAccount
76
+ else if (t.toUserAccount === address && t.fromUserAccount) counterparty = t.fromUserAccount
77
+ if (counterparty) break
78
+ }
79
+ const negative = changes.filter((c) => c.delta.startsWith('-')).length
80
+ const protocol = item.source && !/^(SYSTEM_PROGRAM|UNKNOWN)$/.test(item.source) ? item.source[0] + item.source.slice(1).toLowerCase().replace(/_/g, ' ') : undefined
81
+ return {
82
+ hash: item.signature,
83
+ time: item.timestamp ?? 0,
84
+ block: item.slot,
85
+ status: failed ? 'failed' : 'success',
86
+ type,
87
+ direction: (type === 'transfer' || type === 'other') && changes.length ? (negative === 0 ? 'in' : negative === changes.length ? 'out' : undefined) : undefined,
88
+ counterparty,
89
+ label: item.description || undefined,
90
+ changes,
91
+ ...(isPayer ? { fee: fee.toString() } : {}),
92
+ protocol,
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Helius 增强交易历史(`GET /v0/addresses/{address}/transactions`,按 before 签名翻页)。apiKey 由应用传入,库里不带。
98
+ * 按交易类型过滤时翻页最多再取 3 页
99
+ *
100
+ * ```ts
101
+ * await getHistory(address, { source: [heliusHistory({ apiKey }), rpcHistory()] }) // Helius 失败时用节点
102
+ * ```
103
+ */
104
+ export function heliusHistory(options: HeliusHistoryOptions): HistorySource {
105
+ if (!options.apiKey) throw new Error('heliusHistory needs an apiKey')
106
+ const base = (options.baseUrl ?? 'https://api.helius.xyz').replace(/\/+$/, '')
107
+ return {
108
+ name: 'helius',
109
+ async getHistory(ctx) {
110
+ const items: Activity[] = []
111
+ let cursor = ctx.cursor
112
+ let more = true
113
+ for (let page = 0; page < (ctx.types ? 4 : 1) && items.length < ctx.limit && more; page++) {
114
+ const url = `${base}/v0/addresses/${ctx.address}/transactions?api-key=${encodeURIComponent(options.apiKey)}&limit=${Math.min(100, ctx.limit)}${cursor ? `&before=${encodeURIComponent(cursor)}` : ''}`
115
+ const res = await ctx.fetch(url, { signal: ctx.signal })
116
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
117
+ const raw = (await res.json()) as HeliusItem[]
118
+ if (!Array.isArray(raw)) throw new Error('Unexpected response')
119
+ more = raw.length >= Math.min(100, ctx.limit)
120
+ for (const item of raw) {
121
+ cursor = item.signature
122
+ const t = item.timestamp ?? 0
123
+ if (ctx.before !== undefined && t >= ctx.before) continue
124
+ if (ctx.after !== undefined && t < ctx.after) {
125
+ more = false
126
+ break
127
+ }
128
+ const activity = activityFromHelius(ctx.address, item)
129
+ if (!ctx.types || ctx.types.includes(activity.type)) items.push(activity)
130
+ if (items.length >= ctx.limit) break
131
+ }
132
+ }
133
+ return { items, ...(more && cursor ? { nextCursor: cursor } : {}) }
134
+ },
135
+ }
136
+ }
@@ -223,6 +223,20 @@ export async function orcaAdd(ctx: Ctx, params: SolanaIncreaseLiquidityParams |
223
223
  })
224
224
  }
225
225
 
226
+ /**
227
+ * 关闭已清空的仓位(流动性、手续费、奖励都为 0):销毁仓位 NFT、关闭仓位账户和 NFT 的代币账户(nftAccount,默认 owner 的 ATA),租金退给 receiver。
228
+ * NFT 是 Token-2022(带元数据扩展)时连 mint 一起关闭,否则(Metaplex 时代的 SPL Token NFT)mint 留着
229
+ */
230
+ export function orcaClosePosition(owner: string, receiver: string, positionMint: string, nftProgram: string, nftAccount = getAssociatedTokenAddress(owner, positionMint, nftProgram)): Instruction {
231
+ const position = positionAddress(positionMint)
232
+ const token2022 = nftProgram === TOKEN_2022_PROGRAM_ID
233
+ return {
234
+ programId: PROGRAM,
235
+ keys: [r(owner, true), w(receiver), w(position), w(positionMint), w(nftAccount), r(nftProgram)],
236
+ data: anchorIx(token2022 ? 'close_position_with_token_extensions' : 'close_position'),
237
+ }
238
+ }
239
+
226
240
  /** 移除(decrease + 领取手续费和奖励)/ 只领取(collectOnly) */
227
241
  export async function orcaRemove(ctx: Ctx, params: SolanaRemoveLiquidityParams, collectOnly = false): Promise<SolanaLiquidityPlan> {
228
242
  const mint = refMint(params.position)
@@ -304,11 +318,7 @@ export async function orcaRemove(ctx: Ctx, params: SolanaRemoveLiquidityParams,
304
318
  })
305
319
  })
306
320
  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
- )
321
+ main.push(orcaClosePosition(ctx.owner, ctx.owner, mint, nftProgram))
312
322
  }
313
323
  return makePlan(ctx, {
314
324
  action: collectOnly ? 'collect' : 'remove',
@@ -249,6 +249,18 @@ export async function raydiumAdd(ctx: Ctx, params: SolanaIncreaseLiquidityParams
249
249
  })
250
250
  }
251
251
 
252
+ /**
253
+ * 关闭已清空的仓位(流动性、手续费、奖励都为 0):销毁仓位 NFT、关闭 NFT 的代币账户和仓位账户,租金退给 owner(程序固定,不能指定别人)。
254
+ * pool 是仓位所在的池子(官方 SDK 也带着它)
255
+ */
256
+ export function raydiumClosePosition(owner: string, positionMint: string, nftProgram: string, pool: string, nftAccount = getAssociatedTokenAddress(owner, positionMint, nftProgram)): Instruction {
257
+ return {
258
+ programId: PROGRAM,
259
+ keys: [w(owner, true), w(positionMint), w(nftAccount), w(personalPositionAddress(positionMint)), r(SYSTEM_PROGRAM_ID), r(nftProgram), w(pool)],
260
+ data: anchorIx('close_position'),
261
+ }
262
+ }
263
+
252
264
  /** 移除(liquidity > 0)/ 只领取手续费和奖励(collectOnly:流动性为 0 的 decrease) */
253
265
  export async function raydiumRemove(ctx: Ctx, params: SolanaRemoveLiquidityParams, collectOnly = false): Promise<SolanaLiquidityPlan> {
254
266
  const mint = refMint(params.position)
@@ -308,11 +320,7 @@ export async function raydiumRemove(ctx: Ctx, params: SolanaRemoveLiquidityParam
308
320
  }
309
321
  const main: Instruction[] = [decrease]
310
322
  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
- })
323
+ main.push(raydiumClosePosition(ctx.owner, mint, nftProgram, pool.address))
316
324
  }
317
325
  return makePlan(ctx, {
318
326
  action: collectOnly ? 'collect' : 'remove',