@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
@@ -0,0 +1,535 @@
1
+ /**
2
+ * 交易模拟(子路径 `@w3lib/solana-batch-call/simulate`):simulateTransaction(不验签名,换最新 blockhash)+ 钱包和交易里可写账户的前后状态,
3
+ * 算出余额变化、授权变化(delegate / SetAuthority / 关闭账户)、指令说明、程序错误、手续费和警告。支持 legacy 和 v0(地址查找表)交易
4
+ */
5
+ import { base64 } from '@scure/base'
6
+
7
+ import { encodeAddress, isAddress } from './address.js'
8
+ import type { RequestOptions, SolanaClient } from './client.js'
9
+ import { NATIVE_MINT, SOL_DECIMALS, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from './constants.js'
10
+ import { decodeInstruction, type DecodedInstruction } from './decode.js'
11
+ import { classify, describe, type AssetDelta, type TxKind } from './describe.js'
12
+ import { formatUnits, parseMint, type AccountInfo } from './layout.js'
13
+ import { BPF_LOADER_UPGRADEABLE, COMPUTE_BUDGET, PROGRAMS, programErrorName } from './programs.js'
14
+ import type { SolanaPriceSource } from './price.js'
15
+ import {
16
+ MAX_ACCOUNT_LOCKS,
17
+ PACKET_DATA_SIZE,
18
+ compileMessage,
19
+ parseMessage,
20
+ serializeTransaction,
21
+ splitTransaction,
22
+ transactionSize,
23
+ normalizeInstruction,
24
+ type InstructionInput,
25
+ } from './write/transaction.js'
26
+
27
+ export interface SimulatedBalanceChange extends AssetDelta {
28
+ /** 谁的余额(钱包地址);asset 原生 SOL 为 'native'(Wrapped SOL 账户的变化也并入这里),其他是 mint */
29
+ address: string
30
+ /** 美元价值(prices 选项开启时;没有价格为 null) */
31
+ usd?: number | null
32
+ }
33
+
34
+ export type ApprovalChangeKind = 'delegate-set' | 'delegate-changed' | 'delegate-revoked' | 'authority-changed' | 'account-closed'
35
+
36
+ export interface ApprovalChange {
37
+ kind: ApprovalChangeKind
38
+ /** 代币账户(或 mint、钱包账户) */
39
+ account: string
40
+ mint?: string
41
+ /** 代币账户的 owner */
42
+ owner?: string
43
+ /** delegate-*:新 / 旧的被授权方,和授权数量(最小单位;unlimited:接近 u64 上限) */
44
+ delegate?: string | null
45
+ previousDelegate?: string | null
46
+ amount?: string
47
+ unlimited?: boolean
48
+ /** authority-changed:权限类型(AccountOwner、CloseAccount、MintTokens、FreezeAccount)和前后的地址 */
49
+ authorityType?: string
50
+ authority?: string | null
51
+ previousAuthority?: string | null
52
+ /** account-closed:关闭时账户里的数量,和租金退给谁(从指令里读) */
53
+ balance?: string
54
+ destination?: string
55
+ }
56
+
57
+ export type WarningSeverity = 'info' | 'warn' | 'danger'
58
+
59
+ export interface SimulationWarning {
60
+ code: string
61
+ severity: WarningSeverity
62
+ message: string
63
+ address?: string
64
+ }
65
+
66
+ export interface SimulationResult {
67
+ success: boolean
68
+ /** 失败时的原因:程序错误码解码后的名字 + 日志里的说明 */
69
+ error?: string
70
+ /** 消耗的计算单元(十进制字符串) */
71
+ gasUsed: string
72
+ /** 手续费(lamports):每个签名 5000 + 计算单元上限 × 单价;不含新建账户的租金 */
73
+ fee?: string
74
+ balanceChanges: SimulatedBalanceChange[]
75
+ approvalChanges: ApprovalChange[]
76
+ decoded: {
77
+ type: TxKind
78
+ protocol?: string
79
+ method?: string
80
+ summary: string
81
+ /** 没有认得的指令、按余额变化推断出来的类型 */
82
+ inferred?: boolean
83
+ instructions: DecodedInstruction[]
84
+ }
85
+ warnings: SimulationWarning[]
86
+ logs: string[]
87
+ /** 交易需要签名的地址和版本 */
88
+ signers: string[]
89
+ version: 'legacy' | 0
90
+ /** risk: true 时:变化的代币的风险标记(mint → flags) */
91
+ risks?: Record<string, string[]>
92
+ }
93
+
94
+ export interface SimulateOptions extends RequestOptions {
95
+ /** 站在哪个钱包的角度算余额变化,默认交易的第一个签名者(手续费支付者);构建好的计划 / 指令用它们自己的 owner */
96
+ owner?: string
97
+ /** 预期还会签名的地址(如新建仓位的 NFT mint);交易需要别的签名者时给警告 */
98
+ signers?: readonly string[]
99
+ /** 额外要看余额变化的地址(如收款人) */
100
+ watch?: readonly string[]
101
+ /** 计划里有多步(setup + 主指令)放不进一笔交易时,只模拟这一步(下标) */
102
+ step?: number
103
+ /** 算 usd:true 用默认价格源,或传价格源;默认不查 */
104
+ prices?: boolean | readonly SolanaPriceSource[]
105
+ /** 变化的代币的风险检查(/risk),危险的标记加进 warnings;默认不查 */
106
+ risk?: boolean
107
+ fetch?: typeof fetch
108
+ }
109
+
110
+ /** 本库构建器的输出:兑换 / 流动性计划(steps)、指令列表、一组 ApprovalCall */
111
+ export type SimulateInput =
112
+ | string
113
+ | Uint8Array
114
+ | { owner: string; steps: ReadonlyArray<{ instructions: readonly InstructionInput[] }>; signers?: ReadonlyArray<{ address: string }> }
115
+ | { owner?: string; feePayer?: string; instructions: InstructionInput | readonly InstructionInput[] }
116
+ | ReadonlyArray<{ owner: string; instructions: readonly InstructionInput[] }>
117
+
118
+ const U64_HALF = 1n << 63n
119
+ const HIGH_FEE_LAMPORTS = 10_000_000n
120
+ const short = (a: string) => `${a.slice(0, 4)}…${a.slice(-4)}`
121
+
122
+ interface Built {
123
+ bytes: Uint8Array
124
+ owner: string | undefined
125
+ expected: string[]
126
+ }
127
+
128
+ function build(input: SimulateInput, options: SimulateOptions): Built {
129
+ if (typeof input === 'string') return { bytes: base64.decode(input), owner: options.owner, expected: [...(options.signers ?? [])] }
130
+ if (input instanceof Uint8Array) return { bytes: input, owner: options.owner, expected: [...(options.signers ?? [])] }
131
+ let payer: string | undefined
132
+ let list: InstructionInput[]
133
+ let expected = [...(options.signers ?? [])]
134
+ if (Array.isArray(input)) {
135
+ const calls = input as ReadonlyArray<{ owner: string; instructions: readonly InstructionInput[] }>
136
+ payer = calls[0]?.owner
137
+ list = calls.flatMap((c) => [...c.instructions])
138
+ expected.push(...calls.map((c) => c.owner))
139
+ } else if ('steps' in input) {
140
+ payer = input.owner
141
+ const steps = options.step === undefined ? input.steps : input.steps.filter((_, i) => i === options.step)
142
+ list = steps.flatMap((s) => [...s.instructions])
143
+ expected.push(...(input.signers ?? []).map((s) => s.address))
144
+ } else {
145
+ payer = (input as { feePayer?: string }).feePayer ?? (input as { owner?: string }).owner
146
+ const ix = (input as { instructions: InstructionInput | readonly InstructionInput[] }).instructions
147
+ list = Array.isArray(ix) ? [...ix] : [ix as InstructionInput]
148
+ }
149
+ payer = options.owner ?? payer
150
+ if (!payer || !isAddress(payer)) throw new Error('Simulation needs a valid owner / feePayer address')
151
+ const instructions = list.map(normalizeInstruction)
152
+ const hasLimit = instructions.some((ix) => ix.programId === COMPUTE_BUDGET && ix.data[0] === 2)
153
+ const withLimit = hasLimit ? instructions : [{ programId: COMPUTE_BUDGET, keys: [], data: new Uint8Array([2, 0xc0, 0x5c, 0x15, 0x00]) }, ...instructions]
154
+ const message = compileMessage(payer, withLimit, SYSTEM_PROGRAM_ID)
155
+ if (transactionSize(message) > PACKET_DATA_SIZE || message.accountKeys.length > MAX_ACCOUNT_LOCKS) {
156
+ throw new Error('The instructions do not fit in one transaction; simulate the steps one by one with the `step` option')
157
+ }
158
+ return { bytes: serializeTransaction(message, message.signers.map(() => null)), owner: payer, expected: [payer, ...expected] }
159
+ }
160
+
161
+ interface Resolved {
162
+ version: 'legacy' | 0
163
+ signers: string[]
164
+ keys: string[]
165
+ writable: Set<string>
166
+ instructions: Array<{ programId: string; accounts: string[]; data: Uint8Array }>
167
+ }
168
+
169
+ /** 解析交易:地址查找表里的账户读表补上(静态账户 + 表里可写的 + 表里只读的) */
170
+ async function resolveTransaction(client: SolanaClient, bytes: Uint8Array, signal: AbortSignal | undefined): Promise<Resolved> {
171
+ if (((bytes[0] ?? 0) & 0x80) !== 0) throw new Error('Unsupported transaction format (v1): only legacy and v0 transactions can be simulated')
172
+ const { message } = splitTransaction(bytes)
173
+ const parsed = parseMessage(message)
174
+ const loadedWritable: string[] = []
175
+ const loadedReadonly: string[] = []
176
+ if (parsed.lookups.length) {
177
+ const tables = await client.accounts(parsed.lookups.map((l) => l.table), { signal })
178
+ parsed.lookups.forEach((l, i) => {
179
+ const data = tables[i]?.data
180
+ if (!data) throw new Error(`Address lookup table not found: ${l.table}`)
181
+ const at = (index: number) => {
182
+ const start = 56 + 32 * index
183
+ if (start + 32 > data.length) throw new Error(`Address lookup table ${l.table} has no entry ${index}`)
184
+ return encodeAddress(data.slice(start, start + 32))
185
+ }
186
+ loadedWritable.push(...l.writable.map(at))
187
+ loadedReadonly.push(...l.readonly.map(at))
188
+ })
189
+ }
190
+ const keys = [...parsed.staticKeys, ...loadedWritable, ...loadedReadonly]
191
+ const key = (i: number) => {
192
+ const k = keys[i]
193
+ if (k === undefined) throw new Error('Invalid transaction: bad account index')
194
+ return k
195
+ }
196
+ return {
197
+ version: parsed.version,
198
+ signers: parsed.staticKeys.slice(0, parsed.signatures),
199
+ keys,
200
+ writable: new Set([...parsed.writable, ...loadedWritable]),
201
+ instructions: parsed.instructions.map((ix) => ({ programId: ix.programId, accounts: ix.accounts.map(key), data: ix.data })),
202
+ }
203
+ }
204
+
205
+ // ---- 账户状态 ----
206
+
207
+ interface Snap {
208
+ lamports: bigint
209
+ owner: string
210
+ data: Uint8Array
211
+ }
212
+
213
+ interface TokenState {
214
+ mint: string
215
+ owner: string
216
+ amount: bigint
217
+ delegate: string | null
218
+ delegatedAmount: bigint
219
+ closeAuthority: string | null
220
+ }
221
+
222
+ function tokenState(snap: Snap | null): TokenState | null {
223
+ const d = snap?.data
224
+ if (!snap || !d || (snap.owner !== TOKEN_PROGRAM_ID && snap.owner !== TOKEN_2022_PROGRAM_ID)) return null
225
+ if (d.length !== 165 && !(d.length > 165 && d[165] === 2)) return null
226
+ const view = new DataView(d.buffer, d.byteOffset, d.byteLength)
227
+ const key = (o: number) => encodeAddress(d.slice(o, o + 32))
228
+ if (d[108] === 0) return null
229
+ return {
230
+ mint: key(0),
231
+ owner: key(32),
232
+ amount: view.getBigUint64(64, true),
233
+ delegate: view.getUint32(72, true) === 1 ? key(76) : null,
234
+ delegatedAmount: view.getBigUint64(121, true),
235
+ closeAuthority: view.getUint32(129, true) === 1 ? key(133) : null,
236
+ }
237
+ }
238
+
239
+ function mintAuthorities(snap: Snap | null): { mintAuthority: string | null; freezeAuthority: string | null } | null {
240
+ if (!snap || (snap.owner !== TOKEN_PROGRAM_ID && snap.owner !== TOKEN_2022_PROGRAM_ID)) return null
241
+ try {
242
+ const m = parseMint(snap.data)
243
+ return { mintAuthority: m.mintAuthority, freezeAuthority: m.freezeAuthority }
244
+ } catch {
245
+ return null
246
+ }
247
+ }
248
+
249
+ // ---- 错误 ----
250
+
251
+ const TX_ERRORS: Record<string, string> = {
252
+ InsufficientFundsForFee: 'insufficient SOL for the transaction fee',
253
+ AccountNotFound: 'fee payer account not found (it has no SOL)',
254
+ BlockhashNotFound: 'blockhash not found',
255
+ AccountInUse: 'account in use',
256
+ }
257
+
258
+ /** 失败原因:Anchor 日志的错误名 > 程序错误码(按失败的程序查表)> 日志里程序打印的说明 > 错误本身 */
259
+ export function failureText(err: unknown, logs: readonly string[], ixs: readonly DecodedInstruction[]): string {
260
+ if (typeof err === 'string') return TX_ERRORS[err] ?? err
261
+ const ie = (err as { InstructionError?: [number, unknown] } | null)?.InstructionError
262
+ if (!ie) {
263
+ if ((err as { InsufficientFundsForRent?: unknown } | null)?.InsufficientFundsForRent) return 'account would be left with less SOL than the rent-exempt minimum'
264
+ return JSON.stringify(err)
265
+ }
266
+ const [index, reason] = ie
267
+ const at = ixs[index]
268
+ const where = at ? `instruction ${index} (${at.program ?? short(at.programId)}${at.method ? ` ${at.method}` : ''})` : `instruction ${index}`
269
+ const reversed = [...logs].reverse()
270
+ const anchor = reversed.find((l) => /AnchorError/.test(l))
271
+ const anchorText = anchor && /Error Code: (\w+)\. Error Number: (\d+)\. Error Message: (.*?)\.?$/.exec(anchor)
272
+ if (anchorText) return `${where} failed: ${anchorText[1]} (${anchorText[2]}): ${anchorText[3]}`
273
+ const custom = (reason as { Custom?: number } | null)?.Custom
274
+ if (custom !== undefined) {
275
+ const failed = reversed.map((l) => /^Program (\w+) failed: custom program error/.exec(l)?.[1]).find(Boolean) ?? at?.programId
276
+ const name = programErrorName(failed, custom)
277
+ const detail = reversed.find((l) => /^Program log: (Error|error)/.test(l))?.replace(/^Program log: /, '')
278
+ return `${where} failed: ${name ?? `custom program error 0x${custom.toString(16)}`}${name ? ` (0x${custom.toString(16)})` : ''}${detail ? ` - ${detail}` : ''}`
279
+ }
280
+ const plain = reversed.find((l) => !l.startsWith('Program ') || /^Program log: /.test(l))?.replace(/^Program log: /, '')
281
+ return `${where} failed: ${typeof reason === 'string' ? reason : JSON.stringify(reason)}${plain ? ` - ${plain}` : ''}`
282
+ }
283
+
284
+ interface RawSimulation {
285
+ err: unknown
286
+ logs: string[] | null
287
+ unitsConsumed?: number
288
+ accounts: Array<{ lamports: number | string; owner: string; data: [string, string] } | null> | null
289
+ }
290
+
291
+ /**
292
+ * 模拟一笔交易,不需要签名、不会发送。输入:base64 / 字节的交易,或本库构建器的输出
293
+ * (buildSwap / buildAddLiquidity… 的计划、`{ owner, instructions }`、buildRevokes / buildCloses 的结果)
294
+ */
295
+ export async function simulate(client: SolanaClient, input: SimulateInput, options: SimulateOptions = {}): Promise<SimulationResult> {
296
+ const { signal } = options
297
+ const built = build(input, options)
298
+ const tx = await resolveTransaction(client, built.bytes, signal)
299
+ const wallet = built.owner ?? tx.signers[0]
300
+ if (!wallet) throw new Error('Transaction has no signer')
301
+ const decodedIxs = tx.instructions.map((ix, i) => decodeInstruction(i, ix.programId, ix.accounts, ix.data))
302
+
303
+ // 要看前后状态的账户:钱包、额外要看的、交易里可写的里和钱包有关的(新建的账户、钱包的代币账户、mint);
304
+ // 公共节点的 simulateTransaction 一次最多返回 10 个账户(publicnode),所以先读前状态筛一遍,不够再分批模拟
305
+ const candidates = [...new Set([wallet, ...(options.watch ?? []).filter(isAddress), ...tx.keys.filter((k) => tx.writable.has(k))])]
306
+ const preAll = await client.accounts(candidates, { signal })
307
+ const keep = (address: string, i: number) => {
308
+ const before = preAll[i]
309
+ if (address === wallet || options.watch?.includes(address) || !before) return true
310
+ return tokenState(before)?.owner === wallet || mintAuthorities(before) !== null
311
+ }
312
+ const watch = candidates.filter(keep)
313
+ const pre = watch.map((a) => preAll[candidates.indexOf(a)] ?? null)
314
+ const raw = base64.encode(built.bytes)
315
+ const run = (addresses: string[]) =>
316
+ client.request<{ value: RawSimulation }>(
317
+ 'simulateTransaction',
318
+ [raw, { encoding: 'base64', sigVerify: false, replaceRecentBlockhash: true, commitment: 'confirmed', accounts: { encoding: 'base64', addresses } }],
319
+ { signal },
320
+ )
321
+ let sim: { value: RawSimulation }
322
+ try {
323
+ sim = await run(watch)
324
+ } catch (err) {
325
+ const max = Number(/maximum of (\d+) accounts/i.exec(err instanceof Error ? err.message : '')?.[1])
326
+ if (!(max >= 1)) throw err
327
+ const chunks = Array.from({ length: Math.ceil(watch.length / max) }, (_, i) => watch.slice(i * max, (i + 1) * max))
328
+ const parts = await Promise.all(chunks.map(run))
329
+ sim = { value: { ...(parts[0] as { value: RawSimulation }).value, accounts: parts.every((x) => x.value.accounts) ? parts.flatMap((x) => x.value.accounts ?? []) : null } }
330
+ }
331
+ const value = sim.value
332
+ const logs = value.logs ?? []
333
+ const success = !value.err
334
+ const post: Array<Snap | null> | null = success && value.accounts ? value.accounts.map((a) => (a ? { lamports: BigInt(a.lamports), owner: a.owner, data: base64.decode(a.data[0]) } : null)) : null
335
+ const preAt = (i: number): Snap | null => {
336
+ const a: AccountInfo | null | undefined = pre[i]
337
+ return a ? { lamports: a.lamports, owner: a.owner, data: a.data } : null
338
+ }
339
+
340
+ // 手续费:签名费 + 计算单元上限 × 单价
341
+ let limit: bigint | null = null
342
+ let price = 0n
343
+ for (const ix of tx.instructions) {
344
+ if (ix.programId !== COMPUTE_BUDGET) continue
345
+ const view = new DataView(ix.data.buffer, ix.data.byteOffset, ix.data.byteLength)
346
+ if (ix.data[0] === 2 && ix.data.length >= 5) limit = BigInt(view.getUint32(1, true))
347
+ if (ix.data[0] === 3 && ix.data.length >= 9) price = view.getBigUint64(1, true)
348
+ }
349
+ const nonBudget = tx.instructions.filter((ix) => ix.programId !== COMPUTE_BUDGET).length
350
+ const units = BigInt(Math.min(1_400_000, limit !== null ? Number(limit) : 200_000 * nonBudget))
351
+ const fee = 5000n * BigInt(tx.signers.length) + (price * units + 999_999n) / 1_000_000n
352
+ const feePayer = tx.signers[0]
353
+
354
+ const warnings: SimulationWarning[] = []
355
+ const warn = (code: string, severity: WarningSeverity, message: string, address?: string) => warnings.push({ code, severity, message, address })
356
+
357
+ const approvalChanges: ApprovalChange[] = []
358
+ const tokenDeltas = new Map<string, bigint>()
359
+ let nativeDelta = 0n
360
+ const mints = new Set<string>()
361
+
362
+ if (post) {
363
+ watch.forEach((address, i) => {
364
+ const before = preAt(i)
365
+ const after = post[i] ?? null
366
+ if (address === wallet) {
367
+ nativeDelta = (after?.lamports ?? 0n) - (before?.lamports ?? 0n)
368
+ if (before && after && before.owner === SYSTEM_PROGRAM_ID && after.owner !== SYSTEM_PROGRAM_ID) {
369
+ approvalChanges.push({ kind: 'authority-changed', account: address, authorityType: 'AccountOwner', authority: after.owner, previousAuthority: before.owner })
370
+ }
371
+ }
372
+ const a = tokenState(before)
373
+ const b = tokenState(after)
374
+ const mine = (t: TokenState | null) => t?.owner === wallet
375
+ const mint = (a ?? b)?.mint
376
+ if (mint && (mine(a) || mine(b))) {
377
+ mints.add(mint)
378
+ tokenDeltas.set(mint, (tokenDeltas.get(mint) ?? 0n) + (mine(b) ? (b as TokenState).amount : 0n) - (mine(a) ? (a as TokenState).amount : 0n))
379
+ const common = { account: address, mint, owner: wallet }
380
+ if (a && !b) {
381
+ approvalChanges.push({ ...common, kind: 'account-closed', balance: a.amount.toString() })
382
+ } else if (b) {
383
+ const unlimited = b.delegatedAmount >= U64_HALF
384
+ if (b.delegate && !a?.delegate) approvalChanges.push({ ...common, kind: 'delegate-set', delegate: b.delegate, previousDelegate: null, amount: b.delegatedAmount.toString(), unlimited })
385
+ else if (b.delegate && a?.delegate && (a.delegate !== b.delegate || b.delegatedAmount > a.delegatedAmount)) {
386
+ approvalChanges.push({ ...common, kind: 'delegate-changed', delegate: b.delegate, previousDelegate: a.delegate, amount: b.delegatedAmount.toString(), unlimited })
387
+ } else if (!b.delegate && a?.delegate) approvalChanges.push({ ...common, kind: 'delegate-revoked', delegate: null, previousDelegate: a.delegate, amount: '0' })
388
+ if (a && a.owner !== b.owner) approvalChanges.push({ ...common, kind: 'authority-changed', authorityType: 'AccountOwner', authority: b.owner, previousAuthority: a.owner })
389
+ if (a && a.closeAuthority !== b.closeAuthority) {
390
+ approvalChanges.push({ ...common, kind: 'authority-changed', authorityType: 'CloseAccount', authority: b.closeAuthority, previousAuthority: a.closeAuthority })
391
+ }
392
+ }
393
+ }
394
+ const ma = mintAuthorities(before)
395
+ const mb = mintAuthorities(after)
396
+ if (ma && mb) {
397
+ for (const [type, field] of [['MintTokens', 'mintAuthority'], ['FreezeAccount', 'freezeAuthority']] as const) {
398
+ if (ma[field] !== mb[field]) approvalChanges.push({ kind: 'authority-changed', account: address, mint: address, authorityType: type, authority: mb[field], previousAuthority: ma[field] })
399
+ }
400
+ }
401
+ })
402
+ } else {
403
+ // 没有模拟后的状态(模拟失败):只能按指令判断授权
404
+ for (const ix of decodedIxs) {
405
+ const args = ix.args as Record<string, string | null> | undefined
406
+ if (ix.method === 'approve' || ix.method === 'approveChecked') {
407
+ approvalChanges.push({ kind: 'delegate-set', account: args?.account as string, mint: args?.mint ?? undefined, owner: args?.owner as string, delegate: args?.delegate, amount: args?.amount as string, unlimited: BigInt(args?.amount ?? 0) >= U64_HALF })
408
+ } else if (ix.method === 'setAuthority') {
409
+ approvalChanges.push({ kind: 'authority-changed', account: args?.account as string, authorityType: args?.authorityType as string, authority: args?.newAuthority })
410
+ }
411
+ }
412
+ }
413
+ for (const ix of decodedIxs) {
414
+ const args = ix.args as Record<string, string> | undefined
415
+ if (ix.method === 'closeAccount') {
416
+ const closed = approvalChanges.find((c) => c.kind === 'account-closed' && c.account === args?.account)
417
+ if (closed) closed.destination = args?.destination
418
+ }
419
+ }
420
+
421
+ // 余额变化:钱包的 SOL(不含手续费)和代币,Wrapped SOL 并入 SOL
422
+ const balanceChanges: SimulatedBalanceChange[] = []
423
+ if (post) {
424
+ const wsol = tokenDeltas.get(NATIVE_MINT) ?? 0n
425
+ tokenDeltas.delete(NATIVE_MINT)
426
+ mints.delete(NATIVE_MINT)
427
+ const sol = nativeDelta + (feePayer === wallet ? fee : 0n) + wsol
428
+ if (sol !== 0n) balanceChanges.push({ address: wallet, asset: 'native', symbol: 'SOL', decimals: SOL_DECIMALS, delta: sol.toString(), formatted: formatUnits(sol, SOL_DECIMALS) })
429
+ const ids = [...tokenDeltas.entries()].filter(([, v]) => v !== 0n)
430
+ const details = ids.length
431
+ ? await client.tokens(ids.map(([m]) => m), { fields: ['symbol', 'decimals'], signal }).catch(() => null)
432
+ : []
433
+ if (!details) warn('metadata-unavailable', 'info', 'Token symbols / decimals could not be read')
434
+ ids.forEach(([mint, delta], i) => {
435
+ const detail = details?.[i]
436
+ const decimals = detail?.decimals ?? 0
437
+ balanceChanges.push({ address: wallet, asset: mint, symbol: detail?.symbol ?? short(mint), decimals, delta: delta.toString(), formatted: formatUnits(delta, decimals) })
438
+ })
439
+ }
440
+
441
+ const kind = classify(decodedIxs, balanceChanges)
442
+ const recipient = decodedIxs.find((ix) => ix.type === 'transfer')?.args as Record<string, string> | undefined
443
+ const decoded: SimulationResult['decoded'] = {
444
+ type: kind?.type ?? 'unknown',
445
+ protocol: kind?.protocol,
446
+ method: kind?.method,
447
+ summary: describe(kind, decodedIxs, balanceChanges, recipient?.to ?? recipient?.destination),
448
+ inferred: kind?.inferred,
449
+ instructions: decodedIxs,
450
+ }
451
+
452
+ // ---- 警告 ----
453
+ if (!success) warn('simulation-failed', 'warn', 'The transaction fails in simulation')
454
+ for (const c of approvalChanges) {
455
+ if (c.kind === 'delegate-set' || c.kind === 'delegate-changed') {
456
+ warn(c.unlimited ? 'unlimited-delegate' : 'delegate-approved', c.unlimited ? 'danger' : 'warn', `${short(c.delegate as string)} can spend ${c.unlimited ? 'an unlimited amount of' : c.amount} of the tokens in ${short(c.account)}`, c.delegate as string)
457
+ } else if (c.kind === 'authority-changed') {
458
+ const sev = c.authorityType === 'AccountOwner' || c.authorityType === 'CloseAccount' ? 'danger' : 'warn'
459
+ warn('authority-changed', sev, `${c.authorityType} authority of ${short(c.account)} changes to ${c.authority ? short(c.authority) : 'none'}`, c.authority ?? undefined)
460
+ } else if (c.kind === 'account-closed' && c.destination && c.destination !== wallet) {
461
+ warn('close-to-other', 'warn', `Closing ${short(c.account)}${c.balance && c.balance !== '0' ? ` (balance ${c.balance})` : ''} sends its lamports to ${short(c.destination)}`, c.destination)
462
+ }
463
+ }
464
+ for (const ix of decodedIxs) {
465
+ if (ix.method === 'assign' || ix.method === 'assignWithSeed') warn('assign-owner', 'danger', `Assigns ${short(String(ix.args?.account))} to another program`, String(ix.args?.account))
466
+ if (ix.programId === BPF_LOADER_UPGRADEABLE && (ix.method === 'upgrade' || ix.method === 'setAuthority')) warn('program-upgrade', 'danger', `Program loader instruction: ${ix.method}`)
467
+ }
468
+ const unknown = new Set(decodedIxs.filter((ix) => !PROGRAMS[ix.programId]).map((ix) => ix.programId))
469
+ for (const program of unknown) warn('unknown-program', 'warn', `Calls an unrecognized program ${short(program)}`, program)
470
+ const expected = new Set(built.expected.length ? built.expected : [wallet])
471
+ expected.add(wallet)
472
+ for (const s of tx.signers) {
473
+ if (!expected.has(s)) warn('unexpected-signer', 'warn', `The transaction also needs a signature from ${short(s)}`, s)
474
+ }
475
+ if (!tx.signers.includes(wallet)) warn('wallet-not-signer', 'info', `${short(wallet)} does not sign this transaction`, wallet)
476
+ if (fee > HIGH_FEE_LAMPORTS) warn('high-fee', 'warn', `Network fee is ${formatUnits(fee, SOL_DECIMALS)} SOL`)
477
+ if (post) {
478
+ const walletPre = preAt(watch.indexOf(wallet))?.lamports ?? 0n
479
+ const spent = -(nativeDelta + (feePayer === wallet ? fee : 0n))
480
+ if (walletPre > 0n && spent > 0n && spent * 10n >= walletPre * 9n) warn('drains-balance', 'warn', 'Spends 90% or more of the wallet SOL balance', wallet)
481
+ watch.forEach((address, i) => {
482
+ if (address === wallet) return
483
+ const before = preAt(i)
484
+ const after = post[i] ?? null
485
+ const isDestination = decodedIxs.some((ix) => (ix.method === 'transfer' || ix.method === 'transferWithSeed') && ix.programId === SYSTEM_PROGRAM_ID && ix.args?.to === address)
486
+ if (isDestination && (!before || before.lamports === 0n)) warn('new-recipient', 'warn', `${short(address)} has never held SOL (new account)`, address)
487
+ const t = tokenState(after)
488
+ if (!before && t && t.owner !== wallet && decodedIxs.some((ix) => ix.type === 'transfer' && ix.args?.destination === address)) {
489
+ warn('new-recipient', 'warn', `${short(t.owner)} did not have a token account for this mint yet`, t.owner)
490
+ }
491
+ })
492
+ }
493
+
494
+ const result: SimulationResult = {
495
+ success,
496
+ ...(success ? {} : { error: failureText(value.err, logs, decodedIxs) }),
497
+ gasUsed: String(value.unitsConsumed ?? 0),
498
+ fee: fee.toString(),
499
+ balanceChanges,
500
+ approvalChanges,
501
+ decoded,
502
+ warnings,
503
+ logs,
504
+ signers: tx.signers,
505
+ version: tx.version,
506
+ }
507
+
508
+ // ---- 可选:价格、风险(按需加载) ----
509
+ const assets = balanceChanges.map((c) => (c.asset === 'native' ? NATIVE_MINT : c.asset))
510
+ if (options.prices && balanceChanges.length) {
511
+ const { tokenPrices } = await import('./price.js')
512
+ const sources = Array.isArray(options.prices) ? { sources: options.prices as readonly SolanaPriceSource[] } : {}
513
+ const prices = await tokenPrices(client, assets, { ...sources, signal, fetch: options.fetch }).catch(() => null)
514
+ balanceChanges.forEach((c, i) => {
515
+ const p = prices?.[i]?.price
516
+ c.usd = p == null ? null : Number(c.formatted) * p
517
+ })
518
+ }
519
+ if (options.risk) {
520
+ const tokens = [...mints].filter((m) => balanceChanges.some((c) => c.asset === m))
521
+ if (tokens.length) {
522
+ const { tokenRisks } = await import('./risk.js')
523
+ const risks = await tokenRisks(client, tokens, { signal }).catch(() => null)
524
+ if (risks) {
525
+ result.risks = Object.fromEntries(risks.map((r) => [r.mint, r.flags]))
526
+ const severe = ['permanent-delegate', 'transfer-hook', 'high-transfer-fee', 'non-transferable', 'default-frozen', 'paused', 'not-found']
527
+ for (const r of risks) {
528
+ const hit = r.flags.filter((f) => severe.includes(f))
529
+ if (hit.length) warn('token-risk', 'warn', `${short(r.mint)}: ${hit.join(', ')}`, r.mint)
530
+ }
531
+ }
532
+ }
533
+ }
534
+ return result
535
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 批量领取(子路径 `@w3lib/solana-batch-call/claim`):Orca Whirlpool、Raydium CLMM 仓位的未领取手续费和挖矿奖励(农场奖励)。
3
+ * 先只读预览(每个仓位能领多少、美元价值),再把全部(或选中的)仓位的领取指令打包成交易,发送前用 /simulate 模拟。
4
+ * 不放在主入口,不用时打包不会带上这部分代码;指令来自 /liquidity 的 collectFees。
5
+ *
6
+ * ```ts
7
+ * import { claimAll, getClaimable } from '@w3lib/solana-batch-call/claim'
8
+ *
9
+ * const c = await getClaimable(owner, { provider: 'https://my-rpc…' }) // 预览,不发交易
10
+ * c.totalUsd // 全部能领的美元价值
11
+ * await claimAll(client, key, c.positions) // 批量领取
12
+ * ```
13
+ */
14
+ import { claimable, type Claimable, type ClaimQuery } from '../claim.js'
15
+ import { resolve, type ShortcutOptions } from '../shortcuts.js'
16
+
17
+ export {
18
+ buildClaimAll,
19
+ claimAll,
20
+ claimable,
21
+ simulateClaim,
22
+ type BuildClaimOptions,
23
+ type ClaimCall,
24
+ type ClaimCallResult,
25
+ type ClaimOptions,
26
+ type ClaimPlan,
27
+ type ClaimProtocol,
28
+ type ClaimQuery,
29
+ type ClaimResult,
30
+ type ClaimSimulation,
31
+ type ClaimTarget,
32
+ type Claimable,
33
+ type ClaimableAmount,
34
+ type ClaimablePosition,
35
+ } from '../claim.js'
36
+ export type { PlanSimulation } from '../batchPlan.js'
37
+
38
+ export interface GetClaimableOptions extends ClaimQuery, ShortcutOptions {}
39
+
40
+ /**
41
+ * 预览一个地址能领取的手续费和挖矿奖励(只读取),节点参数与 getBalances 相同。默认列出全部代币账户(需要支持 getTokenAccountsByOwner 的节点),
42
+ * 免费公共节点传 `mints`(仓位 NFT 的 mint)只看指定仓位。
43
+ *
44
+ * ```ts
45
+ * const c = await getClaimable(owner, { provider: 'https://my-rpc…' })
46
+ * // { totalUsd: 345.6, tokens: [{ symbol: 'SOL', formatted: '1.627', usd: 290 }, { symbol: 'RAY', formatted: '0.0427', usd: 0.08 }, …],
47
+ * // positions: [{ protocol: 'raydium-clmm', positionMint, fees: [{ symbol: 'SOL', … }, { symbol: 'USDC', … }], rewards: [{ index: 0, symbol: 'RAY', … }], totalUsd: 345.6 }] }
48
+ * ```
49
+ */
50
+ export function getClaimable(owner: string, options: GetClaimableOptions = {}): Promise<Claimable> {
51
+ const { client, own } = resolve(options, ['mints', 'protocols', 'prices', 'now', 'signal'])
52
+ return claimable(client, owner, { ...own, ...(options.fetch ? { fetch: options.fetch } : {}) })
53
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 交易历史(子路径 `@w3lib/solana-batch-call/history`):地址的交易整理成统一的 Activity(兑换、转账、授权、流动性、铸造、失败……),
3
+ * 数据源可换(默认节点:getSignaturesForAddress + getTransaction;可选 Helius 增强历史)。不放在主入口,不用时打包不会带上这部分代码。
4
+ *
5
+ * ```ts
6
+ * import { getHistory, heliusHistory } from '@w3lib/solana-batch-call/history'
7
+ *
8
+ * const page = await getHistory(address, { limit: 20 })
9
+ * const next = await getHistory(address, { cursor: page.nextCursor, types: ['swap'] })
10
+ * await getHistory(address, { source: [heliusHistory({ apiKey }), rpcHistory()] }) // 依次尝试
11
+ * ```
12
+ */
13
+ import { history, type HistoryOptions, type HistoryPage } from '../history.js'
14
+ import { resolve, type ShortcutOptions } from '../shortcuts.js'
15
+
16
+ export {
17
+ MAX_HISTORY_LIMIT,
18
+ activityFromTransaction,
19
+ history,
20
+ rpcHistory,
21
+ type Activity,
22
+ type ActivityChange,
23
+ type ActivityType,
24
+ type HistoryContext,
25
+ type HistoryOptions,
26
+ type HistoryPage,
27
+ type HistorySource,
28
+ type RpcHistoryOptions,
29
+ } from '../history.js'
30
+ export { activityFromHelius, heliusHistory, type HeliusHistoryOptions } from '../historyHelius.js'
31
+
32
+ export interface GetHistoryOptions extends HistoryOptions, ShortcutOptions {}
33
+
34
+ /** 查地址的交易历史,节点参数与 getBalances 相同 */
35
+ export function getHistory(address: string, options: GetHistoryOptions = {}): Promise<HistoryPage> {
36
+ const { client, own } = resolve(options, ['limit', 'cursor', 'before', 'after', 'types', 'source', 'symbols', 'fetch', 'signal'])
37
+ return history(client, address, own)
38
+ }
@@ -25,6 +25,7 @@ export {
25
25
  type SolanaLpClmmPosition,
26
26
  type SolanaLpPosition,
27
27
  type SolanaLpProtocol,
28
+ type SolanaLpReward,
28
29
  type SolanaLpToken,
29
30
  type SolanaPriceSource,
30
31
  } from '../lp.js'
@@ -38,15 +39,18 @@ export {
38
39
  getMaxAmounts,
39
40
  getMinAmounts,
40
41
  getMintAmounts,
42
+ getRewardGrowthInside,
41
43
  nearestUsableTick,
42
44
  orcaSqrtPriceAtTick,
43
45
  priceToTick,
44
46
  raydiumSqrtPriceAtTick,
47
+ rewardGrowthGlobal,
45
48
  sqrtPriceAtTick,
46
49
  sqrtPriceToPrice,
47
50
  tickAtSqrtPrice,
48
51
  tickToPrice,
49
52
  type ClmmProgram,
53
+ type RewardEmission,
50
54
  type TickFeeData,
51
55
  } from '../lpMath.js'
52
56
 
@@ -66,6 +70,6 @@ export interface GetLpPositionsOptions extends LpPositionsOptions, ShortcutOptio
66
70
  */
67
71
  export function getLpPositions(owner: string, options: GetLpPositionsOptions = {}): Promise<SolanaLpPosition[]> {
68
72
  // fetch 同时给节点请求(客户端配置)和查价用,与其他快捷函数一致
69
- const { client, own } = resolve(options, ['protocols', 'mints', 'pools', 'includeClosed', 'prices', 'onPriceError', 'signal'])
73
+ const { client, own } = resolve(options, ['protocols', 'mints', 'pools', 'includeClosed', 'rewards', 'now', 'prices', 'onPriceError', 'signal'])
70
74
  return lpPositions(client, owner, { ...own, ...(options.fetch ? { fetch: options.fetch } : {}) })
71
75
  }