@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/reclaim.ts ADDED
@@ -0,0 +1,547 @@
1
+ /**
2
+ * 账户回收(子路径 `@w3lib/solana-batch-call/reclaim`):扫描钱包里关闭后能退回租金(SOL)的账户,给出每项和合计能拿回多少 lamports,批量关闭。
3
+ *
4
+ * - 空的代币账户(SPL Token / Token-2022,有暂存转账手续费的先收到 mint 上);冻结的、关闭权限是别人的关不掉,单独列出
5
+ * - 垃圾币:有余额但没有价格 / 价值很小 / 垃圾特征(可疑名字、不可转让等)的代币,先销毁再关闭账户(一个账户的指令原子执行)。
6
+ * 默认只自动勾选有把握的(见 `selected`):有价格且价值超过 `maxJunkUsd` 的、NFT、仓位 NFT 一律不当垃圾币
7
+ * - Wrapped SOL 账户:关闭 = 解包,余额和租金一起退回
8
+ * - 已清空的集中流动性仓位(Orca Whirlpool、Raydium CLMM:流动性、手续费、奖励都是 0):关闭仓位账户并销毁 NFT。还有没领的手续费 / 奖励时列在 blocked,先用 /claim 领取
9
+ */
10
+ import { decodeAddress, findProgramAddress, isAddress } from './address.js'
11
+ import type { RequestOptions, SolanaClient } from './client.js'
12
+ import { TOKEN_2022_PROGRAM_ID } from './constants.js'
13
+ import { approvals, parseTokenAccountState, type TokenAccountApproval } from './approvals.js'
14
+ import { ORCA_WHIRLPOOL_PROGRAM_ID, RAYDIUM_CLMM_PROGRAM_ID, parseClmmPosition, utf8 } from './lp.js'
15
+ import { orcaClosePosition } from './liquidity/orca.js'
16
+ import { raydiumClosePosition } from './liquidity/raydiumClmm.js'
17
+ import { defillamaPrices, type SolanaPriceSource } from './price.js'
18
+ import { tokenRisks, type SolanaTokenRisk } from './risk.js'
19
+ import { simulatePlan, sendCalls, type PlanSimulation, type PlanSimulationOptions, type PlannedCall } from './batchPlan.js'
20
+ import type { BatchCallOptions } from './write/batch.js'
21
+ import { burnChecked, closeAccount, harvestWithheldTokensToMint } from './write/instructions.js'
22
+ import type { SignerInput } from './write/signer.js'
23
+ import type { WriteResult } from './write/send.js'
24
+
25
+ export type ReclaimKind = 'empty-token' | 'junk-token' | 'wrapped-sol' | 'orca-position' | 'raydium-position'
26
+
27
+ /**
28
+ * 关不掉的原因:frozen 账户被冻结;close-authority 关闭权限是别的地址;confidential-balance Token-2022 机密余额不为 0;
29
+ * paused / permissioned-burn 代币暂停了 / 只有指定的地址能销毁(垃圾币销毁不了);owed 仓位还有没领的手续费 / 奖励(先领取)
30
+ */
31
+ export type ReclaimBlocker = 'frozen' | 'close-authority' | 'confidential-balance' | 'paused' | 'permissioned-burn' | 'owed'
32
+
33
+ /** 能回收租金的一项(字段都是字符串 / 数字 / 布尔 / null,可以直接 JSON.stringify) */
34
+ export interface ReclaimItem {
35
+ kind: ReclaimKind
36
+ /** 要关闭的代币账户(仓位是持有仓位 NFT 的代币账户) */
37
+ account: string
38
+ /** 账户的 owner(签名者) */
39
+ owner: string
40
+ /** 代币的 mint(仓位是 NFT 的 mint) */
41
+ mint: string
42
+ tokenProgram: string
43
+ symbol: string | null
44
+ decimals: number | null
45
+ /** 账户里的余额(最小单位):垃圾币会被销毁,Wrapped SOL 会解包成 SOL */
46
+ amount: string
47
+ formatted: string | null
48
+ /** 余额的美元价值(查到价格时;没有价格为 null) */
49
+ usd: number | null
50
+ /** Token-2022 账户里暂存的转账手续费:不为 0 时关闭前先收到 mint 上(自动处理) */
51
+ withheldAmount: string
52
+ /** 预计退回的 lamports:账户里的全部(Wrapped SOL 含余额;仓位是仓位账户 + NFT 账户 + Token-2022 NFT 一并关闭的 mint),十进制字符串;以模拟的结果为准 */
53
+ lamports: string
54
+ /** 其中的租金(Wrapped SOL 不含包装的余额) */
55
+ rentLamports: string
56
+ /** 建议勾选:空账户、Wrapped SOL、空仓位总是 true;垃圾币只有“有把握”的才是 true(价格查询正常,且有价格但价值不超过 maxJunkUsd、/risk 有垃圾风险标记、或 isJunk 返回 true;光是名字可疑不够) */
57
+ selected: boolean
58
+ /** 判断依据:dust / no-price / spam-name / risk:<flag> / custom / empty-position / unwrap / … */
59
+ reasons: string[]
60
+ /** 仓位:协议、仓位账户和池子 */
61
+ position?: { protocol: 'orca-whirlpool' | 'raydium-clmm'; positionMint: string; position: string; pool: string }
62
+ /** 只在 blocked 里:关不掉的原因 */
63
+ blocker?: ReclaimBlocker
64
+ }
65
+
66
+ export interface Reclaimable {
67
+ owner: string
68
+ /** 能回收的 */
69
+ items: ReclaimItem[]
70
+ /** 暂时关不掉的(带 blocker):冻结的、关闭权限是别人的、还有没领的手续费的仓位等 */
71
+ blocked: ReclaimItem[]
72
+ /** items 的 lamports 之和 */
73
+ totalLamports: string
74
+ /** items 里 selected 的 lamports 之和 */
75
+ selectedLamports: string
76
+ /** 每种的数量和 lamports(只含 items) */
77
+ byKind: Partial<Record<ReclaimKind, { count: number; lamports: string }>>
78
+ /** 提示(价格服务出错时垃圾币不自动勾选等) */
79
+ warnings: string[]
80
+ }
81
+
82
+ /** isJunk 回调收到的信息 */
83
+ export interface JunkContext {
84
+ account: TokenAccountApproval
85
+ /** 美元价格;没有时为 null */
86
+ price: number | null
87
+ /** 余额的美元价值;没有价格时为 null */
88
+ usd: number | null
89
+ /** /risk 的检查结果(risk 选项关闭或读不到时为 null) */
90
+ risk: SolanaTokenRisk | null
91
+ }
92
+
93
+ export interface ReclaimQuery extends RequestOptions {
94
+ /** 只看这些 mint 的关联代币账户(免费公共节点也能用);不传时列出全部代币账户(getTokenAccountsByOwner,需要支持它的节点) */
95
+ mints?: readonly string[]
96
+ /** 找垃圾币(有余额的代币),默认 true;false 只找空账户、Wrapped SOL 和空仓位 */
97
+ junk?: boolean
98
+ /** 找已清空的集中流动性仓位,默认 true */
99
+ positions?: boolean
100
+ /** 价格:true(默认)用 DefiLlama;false 不查(这时垃圾币都不自动勾选);也可以传自己的价格源(如 combinePrices([defillamaPrices(), onchainPrices()])) */
101
+ prices?: boolean | SolanaPriceSource
102
+ /** 有价格且价值不超过它的代币算“尘埃”(美元,默认 0.01);超过它的代币不会当垃圾币(isJunk 明确返回 true 的除外) */
103
+ maxJunkUsd?: number
104
+ /** 查垃圾特征用 /risk(不可转让、转账手续费很高、已暂停等),默认 true */
105
+ risk?: boolean
106
+ /**
107
+ * 自己判断哪些是垃圾币:返回 true 一定算垃圾币并勾选(NFT 和仓位 NFT 除外),false 一定不算,undefined 用默认判断。
108
+ * 例如白名单:`({ account }) => (mine.has(account.mint) ? false : undefined)`
109
+ */
110
+ isJunk?: (context: JunkContext) => boolean | undefined
111
+ fetch?: typeof fetch
112
+ }
113
+
114
+ /** 垃圾特征里足以自动勾选的风险标记(只有这些:legit 的 RWA / 稳定币常有 permanent-delegate、transfer-hook,不能据此判断) */
115
+ const JUNK_RISK_FLAGS = ['non-transferable', 'high-transfer-fee', 'paused']
116
+ /** 垃圾币常见的名字:链接 / 域名、领奖广告。只作提示(reasons 里的 spam-name),不会让代币被自动勾选 */
117
+ const SPAM_SYMBOL = /https?:|www\.|\.(com|io|xyz|net|org|app|cc|vip|top|site|fun|live|me)\b|t\.me|claim|airdrop|visit|voucher|\$\d/i
118
+
119
+ const NFT_DECIMALS = 0
120
+ const ownedBy = (a: TokenAccountApproval, owner: string) => a.owner === owner
121
+
122
+ /** 能关闭的限制:冻结、关闭权限是别人、机密余额。没有时返回 undefined */
123
+ function closeBlock(a: TokenAccountApproval): ReclaimBlocker | undefined {
124
+ if (a.frozen) return 'frozen'
125
+ if (a.closeBlocker === 'close-authority') return 'close-authority'
126
+ if (a.closeBlocker === 'confidential-balance') return 'confidential-balance'
127
+ return undefined
128
+ }
129
+
130
+ const positionPda = (program: string, mint: string) => findProgramAddress([utf8('position'), decodeAddress(mint)], program)
131
+
132
+ function tokenItem(a: TokenAccountApproval, kind: ReclaimKind, extra: { selected: boolean; reasons: string[]; usd: number | null; blocker?: ReclaimBlocker }): ReclaimItem {
133
+ const lamports = BigInt(a.lamports)
134
+ return {
135
+ kind,
136
+ account: a.account,
137
+ owner: a.owner,
138
+ mint: a.mint,
139
+ tokenProgram: a.tokenProgram,
140
+ symbol: a.symbol,
141
+ decimals: a.decimals,
142
+ amount: a.amount,
143
+ formatted: a.formatted,
144
+ usd: extra.usd,
145
+ withheldAmount: a.withheldAmount,
146
+ lamports: lamports.toString(),
147
+ rentLamports: (a.native ? lamports - BigInt(a.amount) : lamports).toString(),
148
+ selected: extra.selected,
149
+ reasons: extra.reasons,
150
+ ...(extra.blocker ? { blocker: extra.blocker } : {}),
151
+ }
152
+ }
153
+
154
+ /**
155
+ * 扫描钱包里能回收租金的账户(只读取)。需要列出全部代币账户(getTokenAccountsByOwner),免费公共节点不支持时传 `mints` 只看指定代币的 ATA。
156
+ *
157
+ * ```ts
158
+ * const r = await reclaimable(client, owner)
159
+ * r.totalLamports // 全部能拿回的 lamports
160
+ * r.items.filter((i) => i.selected) // 建议勾选的
161
+ * ```
162
+ */
163
+ export async function reclaimable(client: SolanaClient, owner: string, query: ReclaimQuery = {}): Promise<Reclaimable> {
164
+ if (!isAddress(owner)) {
165
+ throw new Error(`Invalid owner address: ${owner}`)
166
+ }
167
+ const { signal } = query
168
+ const maxJunkUsd = query.maxJunkUsd ?? 0.01
169
+ if (!(maxJunkUsd >= 0)) {
170
+ throw new RangeError(`maxJunkUsd must be >= 0: ${maxJunkUsd}`)
171
+ }
172
+ const accounts = (await approvals(client, owner, { mints: query.mints, all: true, signal })).filter((a) => ownedBy(a, owner))
173
+ const items: ReclaimItem[] = []
174
+ const blocked: ReclaimItem[] = []
175
+ const warnings: string[] = []
176
+ const push = (item: ReclaimItem, blocker?: ReclaimBlocker) => (blocker ? blocked.push({ ...item, blocker }) : items.push(item))
177
+
178
+ const nft = (a: TokenAccountApproval) => a.decimals === NFT_DECIMALS && a.amount === '1' && !a.native
179
+ const rest: TokenAccountApproval[] = []
180
+ for (const a of accounts) {
181
+ const blocker = closeBlock(a)
182
+ if (a.native) {
183
+ // Wrapped SOL:空的算空账户,有余额的关闭即解包
184
+ const empty = BigInt(a.amount) === 0n
185
+ push(tokenItem(a, empty ? 'empty-token' : 'wrapped-sol', { selected: true, reasons: [empty ? 'empty' : 'unwrap'], usd: null }), blocker)
186
+ } else if (BigInt(a.amount) === 0n) {
187
+ push(tokenItem(a, 'empty-token', { selected: true, reasons: ['empty'], usd: null }), blocker)
188
+ } else if (!nft(a)) {
189
+ rest.push(a)
190
+ }
191
+ }
192
+
193
+ if (query.positions ?? true) {
194
+ await scanPositions(client, accounts.filter(nft), push, signal)
195
+ }
196
+ if ((query.junk ?? true) && rest.length) {
197
+ await scanJunk(client, rest, query, maxJunkUsd, push, warnings)
198
+ }
199
+
200
+ const byKind: Reclaimable['byKind'] = {}
201
+ let total = 0n
202
+ let selected = 0n
203
+ for (const item of items) {
204
+ const entry = byKind[item.kind] ?? { count: 0, lamports: '0' }
205
+ byKind[item.kind] = { count: entry.count + 1, lamports: (BigInt(entry.lamports) + BigInt(item.lamports)).toString() }
206
+ total += BigInt(item.lamports)
207
+ if (item.selected) selected += BigInt(item.lamports)
208
+ }
209
+ return { owner, items, blocked, totalLamports: total.toString(), selectedLamports: selected.toString(), byKind, warnings }
210
+ }
211
+
212
+ /** 仓位 NFT(精度 0、余额 1):Orca / Raydium CLMM 的仓位账户一次读出,清空了的列出来 */
213
+ async function scanPositions(client: SolanaClient, nfts: readonly TokenAccountApproval[], push: (item: ReclaimItem, blocker?: ReclaimBlocker) => void, signal: AbortSignal | undefined): Promise<void> {
214
+ if (!nfts.length) return
215
+ const protocols = [
216
+ { protocol: 'orca-whirlpool' as const, program: ORCA_WHIRLPOOL_PROGRAM_ID, kind: 'orca-position' as const },
217
+ { protocol: 'raydium-clmm' as const, program: RAYDIUM_CLMM_PROGRAM_ID, kind: 'raydium-position' as const },
218
+ ]
219
+ const addresses = nfts.flatMap((a) => [...protocols.map((p) => positionPda(p.program, a.mint)), a.mint])
220
+ const read = await client.accounts(addresses, { signal })
221
+ nfts.forEach((a, i) => {
222
+ const base = i * (protocols.length + 1)
223
+ const mintAccount = read[base + protocols.length]
224
+ protocols.forEach((p, j) => {
225
+ const address = addresses[base + j] as string
226
+ const account = read[base + j]
227
+ const state = account ? parseClmmPosition(p.protocol, address, account) : null
228
+ if (!state || !account || state.mint !== a.mint || state.liquidity > 0n) return
229
+ // 仓位 NFT 是 Token-2022(带元数据扩展)时两个程序都连 mint 一起关闭;SPL Token 的 NFT mint 留着(主网模拟核对过)
230
+ const closesMint = a.tokenProgram === TOKEN_2022_PROGRAM_ID
231
+ const rent = account.lamports + BigInt(a.lamports) + (closesMint && mintAccount ? mintAccount.lamports : 0n)
232
+ const owed = state.owed0 > 0n || state.owed1 > 0n || state.rewards.some((r) => r.owed > 0n)
233
+ const item: ReclaimItem = {
234
+ ...tokenItem(a, p.kind, { selected: true, reasons: [owed ? 'owed' : 'empty-position'], usd: null }),
235
+ lamports: rent.toString(),
236
+ rentLamports: rent.toString(),
237
+ position: { protocol: p.protocol, positionMint: a.mint, position: address, pool: state.pool },
238
+ }
239
+ push(item, closeBlock(a) ?? (owed ? 'owed' : undefined))
240
+ })
241
+ })
242
+ }
243
+
244
+ /** 有余额的代币:查价格和风险,按保守的规则判断哪些是垃圾币 */
245
+ async function scanJunk(
246
+ client: SolanaClient,
247
+ list: readonly TokenAccountApproval[],
248
+ query: ReclaimQuery,
249
+ maxJunkUsd: number,
250
+ push: (item: ReclaimItem, blocker?: ReclaimBlocker) => void,
251
+ warnings: string[],
252
+ ): Promise<void> {
253
+ const { signal } = query
254
+ const mints = [...new Set(list.map((a) => a.mint))]
255
+ const source = query.prices === false ? null : query.prices && query.prices !== true ? query.prices : defillamaPrices()
256
+ // 价格没查到(关闭 / 服务出错)时不知道哪些值钱:不自动勾选任何垃圾币
257
+ let pricesOk = source !== null
258
+ let priceMap = new Map<string, number>()
259
+ if (source) {
260
+ try {
261
+ const fetchFn = query.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
262
+ priceMap = await source.prices({ mints, fetch: fetchFn, signal, client })
263
+ } catch (err) {
264
+ if (signal?.aborted) throw err
265
+ pricesOk = false
266
+ warnings.push(`Price lookup failed (${err instanceof Error ? err.message : String(err)}): junk tokens are not auto-selected`)
267
+ }
268
+ } else {
269
+ warnings.push('Prices are off: junk tokens are not auto-selected')
270
+ }
271
+ const risks = new Map<string, SolanaTokenRisk>()
272
+ if (query.risk ?? true) {
273
+ try {
274
+ const found = await tokenRisks(client, mints, { signal })
275
+ found.forEach((r, i) => risks.set(mints[i] as string, r))
276
+ } catch (err) {
277
+ if (signal?.aborted) throw err
278
+ warnings.push(`Risk check failed (${err instanceof Error ? err.message : String(err)})`)
279
+ }
280
+ }
281
+ for (const a of list) {
282
+ const price = priceMap.get(a.mint) ?? null
283
+ const usd = price !== null && a.formatted !== null ? Number(a.formatted) * price : null
284
+ const risk = risks.get(a.mint) ?? null
285
+ const custom = query.isJunk?.({ account: a, price, usd, risk })
286
+ const valuable = usd !== null && usd > maxJunkUsd
287
+ if (custom === false || (valuable && custom !== true)) continue
288
+ const reasons: string[] = []
289
+ let sure = false
290
+ if (custom === true) {
291
+ reasons.push('custom')
292
+ sure = true
293
+ } else {
294
+ if (usd !== null) {
295
+ reasons.push('dust')
296
+ sure = true
297
+ } else {
298
+ reasons.push('no-price')
299
+ }
300
+ if (a.symbol && (SPAM_SYMBOL.test(a.symbol) || a.symbol.length > 24)) {
301
+ reasons.push('spam-name') // 只是提示:名字不能单独作为销毁的依据
302
+ }
303
+ for (const flag of risk?.flags ?? []) {
304
+ if (JUNK_RISK_FLAGS.includes(flag)) {
305
+ reasons.push(`risk:${flag}`)
306
+ sure = true
307
+ }
308
+ }
309
+ sure &&= pricesOk
310
+ }
311
+ // 销毁不了的:暂停了、只有指定地址能销毁
312
+ const blocker = closeBlock(a) ?? (risk?.pausable?.paused ? 'paused' : risk?.burnAuthority ? 'permissioned-burn' : undefined)
313
+ if (blocker && !sure) continue // 不确定是垃圾币又关不掉的不列
314
+ push(tokenItem(a, 'junk-token', { selected: sure, reasons, usd }), blocker)
315
+ }
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // 构建、模拟、发送
320
+ // ---------------------------------------------------------------------------
321
+
322
+ /** 一个账户要执行的指令(作为一个调用,原子执行) */
323
+ export interface ReclaimCall extends PlannedCall {
324
+ kind: ReclaimKind
325
+ account: string
326
+ item: ReclaimItem
327
+ }
328
+
329
+ export interface BuildReclaimOptions extends RequestOptions {
330
+ /** 退回的 SOL 给谁,默认 owner。Raydium CLMM 的仓位账户租金程序固定退给 owner(不能指定),这时 destination 对它们无效并给出警告 */
331
+ destination?: string
332
+ }
333
+
334
+ export interface ReclaimPlan {
335
+ owner: string
336
+ destination: string
337
+ calls: ReclaimCall[]
338
+ /** 重新读取后发现状态变了、没有构建的(余额变了、有人转入了代币、仓位有新的手续费等):每项给出原因 */
339
+ skipped: Array<{ item: ReclaimItem; reason: string }>
340
+ /** 预计退回的 lamports 之和(按重新读到的账户),十进制字符串 */
341
+ lamports: string
342
+ warnings: string[]
343
+ }
344
+
345
+ /**
346
+ * 构建回收的指令(只读取、不签名):发送前重新读取每个账户的当前状态再核对(owner、mint、余额、冻结、关闭权限、仓位是否清空),
347
+ * 状态变了的不构建、列在 skipped。每个账户一个调用(垃圾币:销毁 → 收暂存手续费 → 关闭,原子执行)。
348
+ * 绝不关闭有余额的账户:空账户余额不为 0 时跳过;垃圾币只销毁和列表里一致的余额(有人又转入了就跳过);只有 Wrapped SOL 带余额关闭(解包)
349
+ *
350
+ * ```ts
351
+ * const r = await reclaimable(client, owner)
352
+ * const plan = await buildReclaim(client, owner, r.items.filter((i) => i.selected))
353
+ * ```
354
+ */
355
+ export async function buildReclaim(client: SolanaClient, owner: string, items: readonly ReclaimItem[], options: BuildReclaimOptions = {}): Promise<ReclaimPlan> {
356
+ if (!isAddress(owner)) {
357
+ throw new Error(`Invalid owner address: ${owner}`)
358
+ }
359
+ const destination = options.destination ?? owner
360
+ if (!isAddress(destination)) {
361
+ throw new Error(`Invalid destination: ${destination}`)
362
+ }
363
+ const seen = new Set<string>()
364
+ const list: ReclaimItem[] = []
365
+ for (const item of items) {
366
+ if (item.owner !== owner) {
367
+ throw new Error(`Account ${item.account} belongs to ${item.owner}, not ${owner}`)
368
+ }
369
+ if (item.blocker) {
370
+ throw new Error(`Account ${item.account} cannot be reclaimed yet (${item.blocker})`)
371
+ }
372
+ if (!seen.has(item.account)) {
373
+ seen.add(item.account)
374
+ list.push(item)
375
+ }
376
+ }
377
+ const addresses = list.flatMap((i) => (i.position ? [i.account, i.position.position] : [i.account]))
378
+ const read = addresses.length ? await client.accounts(addresses, { signal: options.signal }) : []
379
+ const at = new Map(addresses.map((a, i) => [a, read[i] ?? null]))
380
+
381
+ const calls: ReclaimCall[] = []
382
+ const skipped: ReclaimPlan['skipped'] = []
383
+ const warnings: string[] = []
384
+ let lamports = 0n
385
+ for (const item of list) {
386
+ const skip = (reason: string) => skipped.push({ item, reason })
387
+ const account = at.get(item.account)
388
+ const state = account && account.owner === item.tokenProgram ? parseTokenAccountState(account.data) : null
389
+ if (!account || !state) {
390
+ skip('account not found (already closed?)')
391
+ continue
392
+ }
393
+ if (state.owner !== owner || state.mint !== item.mint) {
394
+ skip('account owner or mint changed')
395
+ continue
396
+ }
397
+ if (state.frozen) {
398
+ skip('account is frozen')
399
+ continue
400
+ }
401
+ if (state.closeAuthority !== null && state.closeAuthority !== owner) {
402
+ skip('another address holds the close authority')
403
+ continue
404
+ }
405
+ if (state.confidentialBalance) {
406
+ skip('confidential balance is not zero')
407
+ continue
408
+ }
409
+ const instructions = []
410
+ let expected = account.lamports
411
+ if (item.kind === 'orca-position' || item.kind === 'raydium-position') {
412
+ const position = item.position
413
+ const raw = position ? at.get(position.position) : null
414
+ const parsed = position && raw ? parseClmmPosition(position.protocol, position.position, raw) : null
415
+ if (!position || !parsed || parsed.mint !== item.mint) {
416
+ skip('position account not found')
417
+ continue
418
+ }
419
+ if (state.amount !== 1n) {
420
+ skip('the position NFT is not in this account')
421
+ continue
422
+ }
423
+ if (parsed.liquidity > 0n || parsed.owed0 > 0n || parsed.owed1 > 0n || parsed.rewards.some((r) => r.owed > 0n)) {
424
+ skip('position is not empty (has liquidity or unclaimed fees / rewards)')
425
+ continue
426
+ }
427
+ if (item.kind === 'orca-position') {
428
+ instructions.push(orcaClosePosition(owner, destination, item.mint, item.tokenProgram, item.account))
429
+ } else {
430
+ instructions.push(raydiumClosePosition(owner, item.mint, item.tokenProgram, position.pool, item.account))
431
+ if (destination !== owner && !warnings.some((w) => w.startsWith('Raydium CLMM'))) {
432
+ warnings.push('Raydium CLMM position rent is always returned to the owner, not to the destination')
433
+ }
434
+ }
435
+ expected = BigInt(item.lamports)
436
+ } else {
437
+ if (item.kind === 'junk-token') {
438
+ if (state.native || state.amount === 0n || state.amount.toString() !== item.amount) {
439
+ skip('balance changed since the scan')
440
+ continue
441
+ }
442
+ if (item.decimals === null || (item.decimals === NFT_DECIMALS && state.amount === 1n)) {
443
+ skip('NFTs and tokens with unknown decimals are never burned')
444
+ continue
445
+ }
446
+ instructions.push(burnChecked(item.account, item.mint, owner, state.amount, item.decimals, item.tokenProgram))
447
+ } else if (item.kind === 'empty-token') {
448
+ if (state.amount !== 0n) {
449
+ skip('account is not empty anymore')
450
+ continue
451
+ }
452
+ } else if (item.kind === 'wrapped-sol') {
453
+ if (!state.native) {
454
+ skip('not a Wrapped SOL account anymore')
455
+ continue
456
+ }
457
+ }
458
+ if (item.tokenProgram === TOKEN_2022_PROGRAM_ID && state.withheldAmount > 0n) {
459
+ instructions.push(harvestWithheldTokensToMint(item.mint, [item.account]))
460
+ }
461
+ instructions.push(closeAccount(item.account, destination, owner, item.tokenProgram))
462
+ }
463
+ lamports += expected
464
+ calls.push({ kind: item.kind, account: item.account, owner, instructions, item })
465
+ }
466
+ return { owner, destination, calls, skipped, lamports: lamports.toString(), warnings }
467
+ }
468
+
469
+ export interface ReclaimSimulationOptions extends Omit<PlanSimulationOptions, 'watch'> {}
470
+
471
+ export interface ReclaimSimulation extends PlanSimulation {
472
+ /**
473
+ * 预计退回 destination 的 lamports(不含手续费)。destination 就是手续费支付者时取模拟里它的 SOL 变化(lamportsSource 为 'simulation',最准);
474
+ * 否则 /simulate 看不到收款地址的 SOL,按构建时读到的账户余额估算('estimate')
475
+ */
476
+ lamports: string
477
+ lamportsSource: 'simulation' | 'estimate'
478
+ }
479
+
480
+ /**
481
+ * 用 /simulate 模拟构建好的回收(不签名、不发送):逐笔交易的结果、哪些调用会失败、手续费、预计退回的 SOL
482
+ *
483
+ * ```ts
484
+ * const sim = await simulateReclaim(client, plan)
485
+ * if (!sim.success) console.log(sim.failed) // [{ index, error }]
486
+ * sim.lamports // 预计拿回的 lamports
487
+ * ```
488
+ */
489
+ export async function simulateReclaim(client: SolanaClient, plan: ReclaimPlan, options: ReclaimSimulationOptions = {}): Promise<ReclaimSimulation> {
490
+ const sim = await simulatePlan(client, plan.calls, { ...options, watch: [plan.destination] })
491
+ const payer = options.feePayer ?? plan.owner
492
+ const exact = payer === plan.destination && sim.success
493
+ return { ...sim, lamports: exact ? sim.payerNativeDelta : plan.lamports, lamportsSource: exact ? 'simulation' : 'estimate' }
494
+ }
495
+
496
+ export interface ReclaimOptions extends BatchCallOptions, BuildReclaimOptions {
497
+ /** 发送前先用 /simulate 模拟,模拟失败的调用不发送,默认 true */
498
+ simulate?: boolean
499
+ }
500
+
501
+ export interface ReclaimCallResult extends WriteResult {
502
+ kind: ReclaimKind
503
+ account: string
504
+ item: ReclaimItem
505
+ }
506
+
507
+ export interface ReclaimResult {
508
+ plan: ReclaimPlan
509
+ /** 发送前的模拟(simulate: false 时为 null) */
510
+ simulation: ReclaimSimulation | null
511
+ /** 与 plan.calls 一一对应;dryRun 时 status 为 'simulated' / 'failed',没有签名 */
512
+ results: ReclaimCallResult[]
513
+ }
514
+
515
+ /**
516
+ * 回收:构建(重新读取账户并核对)→ 模拟 → 用 from 签名发送(钱包同 /write 的 batchCall,多个账户打包进同一笔交易,feePayer 可以替 owner 付手续费)。
517
+ * 一个账户失败不影响其他的。dryRun: true 只构建和模拟(不签名,from 只用来取地址)
518
+ *
519
+ * ```ts
520
+ * const r = await reclaimable(client, owner)
521
+ * const { simulation, results } = await reclaim(client, key, r.items.filter((i) => i.selected), { destination: cold })
522
+ * ```
523
+ */
524
+ export async function reclaim(client: SolanaClient, from: SignerInput, items: readonly ReclaimItem[], options: ReclaimOptions = {}): Promise<ReclaimResult> {
525
+ const { resolveSigner } = await import('./write/signer.js')
526
+ const owner = (await resolveSigner(from)).address
527
+ const plan = await buildReclaim(client, owner, items, options)
528
+ const feePayer = options.feePayer === undefined ? undefined : (await resolveSigner(options.feePayer)).address
529
+ const simulation = options.simulate === false && !options.dryRun ? null : await simulateReclaim(client, plan, { feePayer, signal: options.signal, batchSize: (options.mode ?? 'pack') === 'tx' ? 1 : options.batchSize })
530
+ const failed = new Map(simulation?.failed.map((f) => [f.index, f.error]))
531
+ const results: ReclaimCallResult[] = []
532
+ const toSend = plan.calls.filter((_, i) => !failed.has(i))
533
+ const sent = options.dryRun ? [] : await sendCalls(client, from, toSend, options)
534
+ let next = 0
535
+ plan.calls.forEach((call, index) => {
536
+ const base = { kind: call.kind, account: call.account, item: call.item }
537
+ const error = failed.get(index)
538
+ if (error !== undefined) {
539
+ results.push({ index, from: owner, status: 'failed', signature: null, slot: null, fee: null, computeUnits: null, error, ...base })
540
+ } else if (options.dryRun) {
541
+ results.push({ index, from: owner, status: 'simulated', signature: null, slot: null, fee: null, computeUnits: null, error: null, ...base })
542
+ } else {
543
+ results.push({ ...(sent[next++] as WriteResult), index, ...base })
544
+ }
545
+ })
546
+ return { plan, simulation, results }
547
+ }
package/src/rpc.ts CHANGED
@@ -61,7 +61,7 @@ const RATE_LIMITED = /rate.?limit|too many requests/i
61
61
  const TRANSIENT = /\bbusy\b|overload|temporar(il)?y|unavailable|try again|timed? ?out|timeout/i
62
62
 
63
63
  /** 节点限制单次 getMultipleAccounts 的账户数(如 “Too many accounts requested”):缩小每次的账户数后重试,不是节点故障 */
64
- const TOO_MANY_ACCOUNTS = /too many accounts/i
64
+ const TOO_MANY_ACCOUNTS = /too many accounts|maximum of \d+ accounts/i
65
65
  /**
66
66
  * publicnode 对单次超过 10 个账户的 getMultipleAccounts 返回 HTTP 403 +“Request blocked”(2026-10,而且要等 3 秒多才返回):
67
67
  * 账户数超过 BLOCKED_LIMIT 时直接按它拆分(逐次减半要多等好几轮),还被拒绝再减半;
@@ -261,7 +261,9 @@ export class HttpRpc implements RpcTransport {
261
261
  const tooMany = err instanceof RpcError && TOO_MANY_ACCOUNTS.test(err.message) && addresses.length > 1
262
262
  const blocked = err instanceof RpcError && REQUEST_BLOCKED.test(err.message) && addresses.length > BLOCKED_FLOOR
263
263
  if (tooMany || blocked) {
264
- const next = blocked && addresses.length > BLOCKED_LIMIT ? BLOCKED_LIMIT : Math.floor(addresses.length / 2)
264
+ // publicnode(2026-10):“A maximum of 10 accounts is allowed”,报错里直接给出上限
265
+ const stated = Number(/maximum of (\d+) accounts/i.exec(err.message)?.[1])
266
+ const next = stated >= 1 ? stated : blocked && addresses.length > BLOCKED_LIMIT ? BLOCKED_LIMIT : Math.floor(addresses.length / 2)
265
267
  this.#learned.maxAccounts = Math.max(1, Math.min(this.#learned.maxAccounts ?? Number.POSITIVE_INFINITY, next))
266
268
  return this.#getMultipleAccounts(params)
267
269
  }