@w3lib/solana-batch-call 0.6.0 → 0.7.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 (165) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +16 -8
  3. package/dist/accountReader.d.ts +57 -0
  4. package/dist/accountReader.d.ts.map +1 -0
  5. package/dist/accountReader.js +149 -0
  6. package/dist/accountReader.js.map +1 -0
  7. package/dist/address.d.ts +9 -0
  8. package/dist/address.d.ts.map +1 -1
  9. package/dist/address.js +36 -6
  10. package/dist/address.js.map +1 -1
  11. package/dist/approvals.d.ts +5 -3
  12. package/dist/approvals.d.ts.map +1 -1
  13. package/dist/approvals.js +25 -36
  14. package/dist/approvals.js.map +1 -1
  15. package/dist/balances.d.ts +178 -0
  16. package/dist/balances.d.ts.map +1 -0
  17. package/dist/balances.js +524 -0
  18. package/dist/balances.js.map +1 -0
  19. package/dist/batchPlan.d.ts +2 -2
  20. package/dist/batchPlan.d.ts.map +1 -1
  21. package/dist/batchPlan.js +5 -26
  22. package/dist/batchPlan.js.map +1 -1
  23. package/dist/bytes.d.ts +31 -0
  24. package/dist/bytes.d.ts.map +1 -0
  25. package/dist/bytes.js +74 -0
  26. package/dist/bytes.js.map +1 -0
  27. package/dist/claim.d.ts +5 -3
  28. package/dist/claim.d.ts.map +1 -1
  29. package/dist/claim.js +10 -10
  30. package/dist/claim.js.map +1 -1
  31. package/dist/client.d.ts +26 -41
  32. package/dist/client.d.ts.map +1 -1
  33. package/dist/client.js +41 -986
  34. package/dist/client.js.map +1 -1
  35. package/dist/clientConfig.d.ts +11 -0
  36. package/dist/clientConfig.d.ts.map +1 -0
  37. package/dist/clientConfig.js +3 -0
  38. package/dist/clientConfig.js.map +1 -0
  39. package/dist/decode.d.ts.map +1 -1
  40. package/dist/decode.js +2 -29
  41. package/dist/decode.js.map +1 -1
  42. package/dist/fees.d.ts.map +1 -1
  43. package/dist/fees.js +2 -12
  44. package/dist/fees.js.map +1 -1
  45. package/dist/history.d.ts.map +1 -1
  46. package/dist/history.js +4 -15
  47. package/dist/history.js.map +1 -1
  48. package/dist/index.d.ts +3 -3
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/layout.d.ts +21 -0
  53. package/dist/layout.d.ts.map +1 -1
  54. package/dist/layout.js +50 -69
  55. package/dist/layout.js.map +1 -1
  56. package/dist/liquidity/common.js +2 -2
  57. package/dist/liquidity/common.js.map +1 -1
  58. package/dist/lp.d.ts +9 -13
  59. package/dist/lp.d.ts.map +1 -1
  60. package/dist/lp.js +67 -55
  61. package/dist/lp.js.map +1 -1
  62. package/dist/portfolio.d.ts.map +1 -1
  63. package/dist/portfolio.js +3 -8
  64. package/dist/portfolio.js.map +1 -1
  65. package/dist/reclaim.d.ts +4 -2
  66. package/dist/reclaim.d.ts.map +1 -1
  67. package/dist/reclaim.js +16 -16
  68. package/dist/reclaim.js.map +1 -1
  69. package/dist/risk.js +2 -2
  70. package/dist/risk.js.map +1 -1
  71. package/dist/rpc.d.ts +30 -7
  72. package/dist/rpc.d.ts.map +1 -1
  73. package/dist/rpc.js +400 -109
  74. package/dist/rpc.js.map +1 -1
  75. package/dist/simulate.d.ts.map +1 -1
  76. package/dist/simulate.js +3 -26
  77. package/dist/simulate.js.map +1 -1
  78. package/dist/source.d.ts +8 -4
  79. package/dist/source.d.ts.map +1 -1
  80. package/dist/source.js +11 -4
  81. package/dist/source.js.map +1 -1
  82. package/dist/subpaths/approvals.d.ts +3 -3
  83. package/dist/subpaths/approvals.d.ts.map +1 -1
  84. package/dist/subpaths/approvals.js.map +1 -1
  85. package/dist/subpaths/claim.d.ts +3 -3
  86. package/dist/subpaths/claim.d.ts.map +1 -1
  87. package/dist/subpaths/claim.js.map +1 -1
  88. package/dist/subpaths/reclaim.d.ts +3 -3
  89. package/dist/subpaths/reclaim.d.ts.map +1 -1
  90. package/dist/subpaths/reclaim.js.map +1 -1
  91. package/dist/swap/index.js +3 -3
  92. package/dist/swap/index.js.map +1 -1
  93. package/dist/tokenDetails.d.ts +12 -0
  94. package/dist/tokenDetails.d.ts.map +1 -0
  95. package/dist/tokenDetails.js +196 -0
  96. package/dist/tokenDetails.js.map +1 -0
  97. package/dist/tokenMeta.d.ts +63 -0
  98. package/dist/tokenMeta.d.ts.map +1 -0
  99. package/dist/tokenMeta.js +226 -0
  100. package/dist/tokenMeta.js.map +1 -0
  101. package/dist/tradeCheck.js +3 -3
  102. package/dist/tradeCheck.js.map +1 -1
  103. package/dist/util.d.ts +22 -0
  104. package/dist/util.d.ts.map +1 -0
  105. package/dist/util.js +107 -0
  106. package/dist/util.js.map +1 -0
  107. package/dist/write/chain.d.ts +51 -0
  108. package/dist/write/chain.d.ts.map +1 -0
  109. package/dist/write/chain.js +184 -0
  110. package/dist/write/chain.js.map +1 -0
  111. package/dist/write/collect.js +2 -2
  112. package/dist/write/collect.js.map +1 -1
  113. package/dist/write/errors.d.ts +10 -0
  114. package/dist/write/errors.d.ts.map +1 -0
  115. package/dist/write/errors.js +77 -0
  116. package/dist/write/errors.js.map +1 -0
  117. package/dist/write/pack.d.ts +28 -0
  118. package/dist/write/pack.d.ts.map +1 -0
  119. package/dist/write/pack.js +125 -0
  120. package/dist/write/pack.js.map +1 -0
  121. package/dist/write/send.d.ts +23 -9
  122. package/dist/write/send.d.ts.map +1 -1
  123. package/dist/write/send.js +53 -351
  124. package/dist/write/send.js.map +1 -1
  125. package/dist/write/tracker.d.ts +24 -0
  126. package/dist/write/tracker.d.ts.map +1 -0
  127. package/dist/write/tracker.js +129 -0
  128. package/dist/write/tracker.js.map +1 -0
  129. package/package.json +1 -1
  130. package/src/accountReader.ts +187 -0
  131. package/src/address.ts +41 -6
  132. package/src/approvals.ts +36 -44
  133. package/src/balances.ts +678 -0
  134. package/src/batchPlan.ts +6 -26
  135. package/src/bytes.ts +89 -0
  136. package/src/claim.ts +15 -11
  137. package/src/client.ts +59 -1239
  138. package/src/clientConfig.ts +12 -0
  139. package/src/decode.ts +4 -30
  140. package/src/fees.ts +2 -9
  141. package/src/history.ts +4 -15
  142. package/src/index.ts +13 -1
  143. package/src/layout.ts +63 -82
  144. package/src/liquidity/common.ts +2 -2
  145. package/src/lp.ts +86 -60
  146. package/src/portfolio.ts +8 -11
  147. package/src/reclaim.ts +20 -17
  148. package/src/risk.ts +2 -2
  149. package/src/rpc.ts +472 -116
  150. package/src/simulate.ts +3 -21
  151. package/src/source.ts +14 -5
  152. package/src/subpaths/approvals.ts +3 -2
  153. package/src/subpaths/claim.ts +3 -2
  154. package/src/subpaths/reclaim.ts +3 -2
  155. package/src/swap/index.ts +3 -3
  156. package/src/tokenDetails.ts +240 -0
  157. package/src/tokenMeta.ts +270 -0
  158. package/src/tradeCheck.ts +3 -3
  159. package/src/util.ts +110 -0
  160. package/src/write/chain.ts +218 -0
  161. package/src/write/collect.ts +2 -2
  162. package/src/write/errors.ts +82 -0
  163. package/src/write/pack.ts +142 -0
  164. package/src/write/send.ts +74 -407
  165. package/src/write/tracker.ts +152 -0
@@ -0,0 +1,270 @@
1
+ // 代币信息缓存(decimals / 所属程序 / name / symbol)和“数据为空的钱包账户”记录:余额、代币详情共用
2
+
3
+ import { isAddress } from './address.js'
4
+ import { SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from './constants.js'
5
+ import { parseMetaplexMetadata, parseMint, type AccountInfo, type MetaplexMetadata, type MintInfo } from './layout.js'
6
+
7
+ export interface TokenMeta {
8
+ decimals?: number
9
+ tokenProgram?: string
10
+ /** tokenProgram 来自导入的缓存、还没从链上确认过:ATA 模式第一次用时按程序未知处理(推导两个程序的 ATA、读 mint 确认) */
11
+ programUnverified?: boolean
12
+ /** null:确认没有元数据 */
13
+ name?: string | null
14
+ symbol?: string | null
15
+ }
16
+
17
+ interface CachedMeta extends TokenMeta {
18
+ /** name / symbol 的写入时间 */
19
+ namesAt?: number
20
+ }
21
+
22
+ // 代币信息缓存(按 网络 + mint):decimals / 所属程序不会变,永久缓存;
23
+ // name / symbol 来自可修改的元数据,缓存 1 小时。条目数有上限,超出时淘汰最早写入的
24
+ const NAME_TTL = 60 * 60 * 1000
25
+ const MAX_CACHED_TOKENS = 50_000
26
+ const tokenMetaCache = new Map<string, CachedMeta>()
27
+
28
+ // 已知数据为空的钱包账户(系统账户或不存在):ATA 模式下和代币账户放在同一个请求里读,不必单独带 dataSlice 多发一个请求。
29
+ // 按 scope + 地址记,最多 1 万个,超出淘汰最早的;读到不是系统账户(如程序账户)时移除
30
+ const MAX_SMALL_OWNERS = 10_000
31
+ const smallOwners = new Set<string>()
32
+
33
+ export function isSmallOwner(scope: string, owner: string): boolean {
34
+ return smallOwners.has(`${scope}:${owner}`)
35
+ }
36
+
37
+ /** 根据只读 lamports 的结果判断钱包账户是否“数据为空”(dataSlice 读不到数据长度,按所属程序判断:系统账户的数据为空) */
38
+ export function learnOwner(scope: string, owner: string, account: AccountInfo | null): void {
39
+ const key = `${scope}:${owner}`
40
+ if (account === null || account.owner === SYSTEM_PROGRAM_ID) {
41
+ smallOwners.delete(key)
42
+ smallOwners.add(key)
43
+ if (smallOwners.size > MAX_SMALL_OWNERS) {
44
+ smallOwners.delete(smallOwners.values().next().value as string)
45
+ }
46
+ } else {
47
+ smallOwners.delete(key)
48
+ }
49
+ }
50
+
51
+ /** 缓存写入监听(persistTokenMetaCache 用) */
52
+ const cacheWriteListeners = new Set<() => void>()
53
+
54
+ /** 测试用:清空代币信息缓存 */
55
+ export function resetTokenMetaCache(): void {
56
+ smallOwners.clear()
57
+ tokenMetaCache.clear()
58
+ }
59
+
60
+ /** exportTokenMetaCache() 的结果,可以 JSON 序列化 */
61
+ export interface TokenMetaSnapshot {
62
+ version: 1
63
+ /** [网络:mint, 信息];namesAt 是 name / symbol 的写入时间(毫秒时间戳),导入后继续按 1 小时过期 */
64
+ entries: Array<[string, { decimals?: number; tokenProgram?: string; name?: string | null; symbol?: string | null; namesAt?: number }]>
65
+ }
66
+
67
+ const SHARED_SCOPES = new Set<string>(['mainnet', 'devnet', 'testnet'])
68
+
69
+ /**
70
+ * 导出代币信息缓存(decimals / 所属程序 / name / symbol),用于持久化:刷新页面后导入,不必重查 decimals。
71
+ * 只导出按网络共享的条目(自定义节点且没指定 cluster 的客户端,缓存只在该客户端内,不导出)。
72
+ * maxEntries:只导出最近写入的这么多条
73
+ */
74
+ export function exportTokenMetaCache(maxEntries = Number.POSITIVE_INFINITY): TokenMetaSnapshot {
75
+ const entries: TokenMetaSnapshot['entries'] = []
76
+ for (const [key, meta] of tokenMetaCache) {
77
+ if (SHARED_SCOPES.has(key.slice(0, key.indexOf(':')))) {
78
+ const { programUnverified: _, ...exported } = meta
79
+ entries.push([key, exported])
80
+ }
81
+ }
82
+ return { version: 1, entries: entries.slice(Math.max(0, entries.length - maxEntries)) }
83
+ }
84
+
85
+ /** 导入 exportTokenMetaCache() 的结果;格式不对的条目跳过,内存里已有的条目不覆盖 */
86
+ export function importTokenMetaCache(snapshot: unknown): void {
87
+ const entries = (snapshot as Partial<TokenMetaSnapshot> | null)?.entries
88
+ if ((snapshot as Partial<TokenMetaSnapshot> | null)?.version !== 1 || !Array.isArray(entries)) {
89
+ return
90
+ }
91
+ for (const entry of entries) {
92
+ const meta = validMeta(entry)
93
+ if (meta && !tokenMetaCache.has(entry[0])) {
94
+ tokenMetaCache.set(entry[0], meta)
95
+ if (tokenMetaCache.size > MAX_CACHED_TOKENS) {
96
+ tokenMetaCache.delete(tokenMetaCache.keys().next().value as string)
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ function validMeta(entry: unknown): CachedMeta | null {
103
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string') {
104
+ return null
105
+ }
106
+ const [key, raw] = entry as [string, Record<string, unknown>]
107
+ const colon = key.indexOf(':')
108
+ if (!SHARED_SCOPES.has(key.slice(0, colon)) || !isAddress(key.slice(colon + 1)) || !raw || typeof raw !== 'object') {
109
+ return null
110
+ }
111
+ const meta: CachedMeta = {}
112
+ if (typeof raw.decimals === 'number' && Number.isInteger(raw.decimals) && raw.decimals >= 0 && raw.decimals <= 255) meta.decimals = raw.decimals
113
+ if (raw.tokenProgram === TOKEN_PROGRAM_ID || raw.tokenProgram === TOKEN_2022_PROGRAM_ID) {
114
+ meta.tokenProgram = raw.tokenProgram
115
+ meta.programUnverified = true
116
+ }
117
+ // 写入时间只接受有限值、且不晚于现在(留 1 分钟时钟误差):未来的时间会让 name / symbol 永不过期,截到现在
118
+ if (
119
+ typeof raw.namesAt === 'number' &&
120
+ Number.isFinite(raw.namesAt) &&
121
+ (typeof raw.name === 'string' || raw.name === null) &&
122
+ (typeof raw.symbol === 'string' || raw.symbol === null)
123
+ ) {
124
+ meta.name = raw.name
125
+ meta.symbol = raw.symbol
126
+ meta.namesAt = Math.min(raw.namesAt, Date.now())
127
+ }
128
+ return Object.keys(meta).length ? meta : null
129
+ }
130
+
131
+ export interface PersistTokenMetaOptions {
132
+ /** 存储的键,默认 'w3lib:solana-token-meta' */
133
+ key?: string
134
+ /** 最多保存多少条(最近写入的优先),默认 5000 */
135
+ maxEntries?: number
136
+ }
137
+
138
+ /**
139
+ * 把代币信息缓存持久化到同步的键值存储(如 localStorage):调用时读入,之后缓存有写入就防抖(约 1 秒)整体写回。
140
+ * 返回停止函数(会立即写回还没写的变化)。读到格式不对的数据会丢弃;写入失败(如存储已满)忽略。
141
+ *
142
+ * ```ts
143
+ * persistTokenMetaCache(localStorage) // 刷新页面后 decimals 不必重查
144
+ * ```
145
+ */
146
+ export function persistTokenMetaCache(
147
+ store: { getItem(key: string): string | null; setItem(key: string, value: string): void },
148
+ options: PersistTokenMetaOptions = {},
149
+ ): () => void {
150
+ const key = options.key ?? 'w3lib:solana-token-meta'
151
+ const maxEntries = options.maxEntries ?? 5000
152
+ try {
153
+ const raw = store.getItem(key)
154
+ if (raw) {
155
+ importTokenMetaCache(JSON.parse(raw))
156
+ }
157
+ } catch {
158
+ // 格式不对:丢弃
159
+ }
160
+ let timer: ReturnType<typeof setTimeout> | null = null
161
+ const write = () => {
162
+ timer = null
163
+ try {
164
+ store.setItem(key, JSON.stringify(exportTokenMetaCache(maxEntries)))
165
+ } catch {
166
+ // 存储已满等:忽略
167
+ }
168
+ }
169
+ const onWrite = () => {
170
+ if (!timer) {
171
+ timer = setTimeout(write, 1000)
172
+ ;(timer as { unref?: () => void }).unref?.()
173
+ }
174
+ }
175
+ cacheWriteListeners.add(onWrite)
176
+ return () => {
177
+ cacheWriteListeners.delete(onWrite)
178
+ if (timer) {
179
+ clearTimeout(timer)
180
+ write()
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * 从 mint 账户 / 元数据账户里学到的信息写入缓存,返回合并后的结果。
187
+ * lookedForNames:本次同时读了 mint 和元数据账户,都没有 name / symbol 时记为“确认没有”(null),之后不再重查
188
+ */
189
+ export function learnMeta(scope: string, mint: string, mintAccount: AccountInfo | null, metadataAccount: AccountInfo | null, lookedForNames = false): TokenMeta {
190
+ const update: TokenMeta = {}
191
+ const info = mintAccount ? safeParseMint(mintAccount) : null
192
+ if (info && mintAccount) {
193
+ update.decimals = info.decimals
194
+ update.tokenProgram = mintAccount.owner
195
+ if (info.metadata) {
196
+ update.name = info.metadata.name
197
+ update.symbol = info.metadata.symbol
198
+ }
199
+ }
200
+ if (metadataAccount && update.name === undefined) {
201
+ const metaplex = safeParseMetaplex(metadataAccount)
202
+ if (metaplex) {
203
+ update.name = metaplex.name
204
+ update.symbol = metaplex.symbol
205
+ }
206
+ }
207
+ if (lookedForNames && info && update.name === undefined) {
208
+ update.name = null
209
+ update.symbol = null
210
+ }
211
+ setMeta(scope, mint, update)
212
+ return getMeta(scope, mint)
213
+ }
214
+
215
+ export function safeParseMint(account: AccountInfo): MintInfo | null {
216
+ if (account.owner !== TOKEN_PROGRAM_ID && account.owner !== TOKEN_2022_PROGRAM_ID) {
217
+ return null
218
+ }
219
+ try {
220
+ const mint = parseMint(account.data)
221
+ // 未初始化的 mint(创建和初始化分在两笔交易里,恰好在中间读到):decimals 还是 0,不能当真、更不能永久缓存
222
+ return mint.isInitialized ? mint : null
223
+ } catch {
224
+ return null
225
+ }
226
+ }
227
+
228
+ export function safeParseMetaplex(account: AccountInfo): MetaplexMetadata | null {
229
+ try {
230
+ return parseMetaplexMetadata(account.data)
231
+ } catch {
232
+ return null
233
+ }
234
+ }
235
+
236
+ export function getMeta(scope: string, mint: string): TokenMeta {
237
+ const cached = tokenMetaCache.get(`${scope}:${mint}`)
238
+ if (!cached) {
239
+ return {}
240
+ }
241
+ const { namesAt, ...meta } = cached
242
+ if (namesAt === undefined || Date.now() - namesAt > NAME_TTL) {
243
+ delete meta.name
244
+ delete meta.symbol
245
+ }
246
+ return meta
247
+ }
248
+
249
+ export function setMeta(scope: string, mint: string, meta: TokenMeta): void {
250
+ const defined: CachedMeta = Object.fromEntries(Object.entries(meta).filter(([, v]) => v !== undefined))
251
+ if (Object.keys(defined).length === 0) {
252
+ return
253
+ }
254
+ if ('name' in defined || 'symbol' in defined) {
255
+ defined.namesAt = Date.now()
256
+ }
257
+ const key = `${scope}:${mint}`
258
+ const merged = { ...tokenMetaCache.get(key), ...defined }
259
+ if ('tokenProgram' in defined && !defined.programUnverified) {
260
+ delete merged.programUnverified // 从链上学到了所属程序
261
+ }
262
+ tokenMetaCache.delete(key) // 重新插入,保持“最近写入在后”的顺序
263
+ tokenMetaCache.set(key, merged)
264
+ if (tokenMetaCache.size > MAX_CACHED_TOKENS) {
265
+ tokenMetaCache.delete(tokenMetaCache.keys().next().value as string)
266
+ }
267
+ for (const listener of cacheWriteListeners) {
268
+ listener()
269
+ }
270
+ }
package/src/tradeCheck.ts CHANGED
@@ -4,13 +4,13 @@
4
4
  */
5
5
  import { base64 } from '@scure/base'
6
6
 
7
- import { getAssociatedTokenAddress, isAddress } from './address.js'
7
+ import { getAssociatedTokenAddress, InvalidAddressError, isAddress } from './address.js'
8
8
  import type { RequestOptions, SolanaClient } from './client.js'
9
9
  import { NATIVE_MINT, TOKEN_PROGRAM_ID } from './constants.js'
10
10
  import { parseTokenAccount, parseUnits } from './layout.js'
11
11
  import { buildSwap, type SolanaSwapOptions, type SolanaSwapPlan } from './swap/index.js'
12
12
  import { MAX_COMPUTE_UNITS, setComputeUnitLimit } from './write/instructions.js'
13
- import { transactionErrorText } from './write/send.js'
13
+ import { transactionErrorText } from './write/errors.js'
14
14
  import { MAX_ACCOUNT_LOCKS, PACKET_DATA_SIZE, compileMessage, serializeTransaction, transactionSize, type Instruction } from './write/transaction.js'
15
15
 
16
16
  export type TradeStage = 'ok' | 'no-pool' | 'buy-failed' | 'sell-failed'
@@ -55,7 +55,7 @@ export interface TradeCheckOptions extends RequestOptions, Pick<SolanaSwapOption
55
55
  * ```
56
56
  */
57
57
  export async function tradeChecks(client: SolanaClient, mints: readonly string[], options: TradeCheckOptions): Promise<SolanaTradeCheck[]> {
58
- if (!isAddress(options.owner)) throw new Error(`Invalid owner address: ${options.owner}`)
58
+ if (!isAddress(options.owner)) throw new InvalidAddressError(`Invalid owner address: ${options.owner}`, options.owner)
59
59
  if (options.amount !== undefined && options.amountFormatted !== undefined) throw new Error('Pass either amount or amountFormatted, not both')
60
60
  const spent = options.amountFormatted !== undefined ? parseUnits(options.amountFormatted, 9) : options.amount !== undefined ? BigInt(options.amount) : 50_000_000n
61
61
  if (spent <= 0n) throw new RangeError('amount must be greater than 0')
package/src/util.ts ADDED
@@ -0,0 +1,110 @@
1
+ /** 内部公共工具:并发池、可取消的等待、多个调用方共用一次操作时的取消 */
2
+
3
+ /** 并发数为 limit 的 map,结果按输入顺序。某一项出错时整体 reject,其余 worker 不再取后面的项(已开始的照常结束) */
4
+ export async function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
5
+ const results = new Array<R>(items.length)
6
+ let next = 0
7
+ let failed = false
8
+ const worker = async () => {
9
+ while (!failed && next < items.length) {
10
+ const i = next++
11
+ try {
12
+ results[i] = await fn(items[i] as T, i)
13
+ } catch (err) {
14
+ failed = true
15
+ throw err
16
+ }
17
+ }
18
+ }
19
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker))
20
+ return results
21
+ }
22
+
23
+ /**
24
+ * 等待 ms 毫秒。signal 取消时:rejectOnAbort 为 true(默认)时立即 reject(reason 为 signal.reason),
25
+ * 为 false 时提前 resolve(用于“取消后不再等,由调用方自己检查 signal”的场景)
26
+ */
27
+ export function sleep(ms: number, signal?: AbortSignal, rejectOnAbort = true): Promise<void> {
28
+ return new Promise((resolve, reject) => {
29
+ if (signal?.aborted) {
30
+ if (rejectOnAbort) {
31
+ reject(signal.reason)
32
+ } else {
33
+ resolve()
34
+ }
35
+ return
36
+ }
37
+ const timer = setTimeout(() => {
38
+ signal?.removeEventListener('abort', onAbort)
39
+ resolve()
40
+ }, ms)
41
+ const onAbort = () => {
42
+ clearTimeout(timer)
43
+ if (rejectOnAbort) {
44
+ reject(signal?.reason)
45
+ } else {
46
+ resolve()
47
+ }
48
+ }
49
+ signal?.addEventListener('abort', onAbort, { once: true })
50
+ })
51
+ }
52
+
53
+ /**
54
+ * 多个调用方共用一次操作(在途去重)时的取消:所有加入的调用方都取消后才中止;
55
+ * 有一个调用方没传 signal 就永远不会中止。操作结束后调用 done() 移除监听
56
+ */
57
+ export class SharedAbort {
58
+ readonly #controller = new AbortController()
59
+ /** 还没取消的调用方数量(只算传了 signal 的) */
60
+ #active = 0
61
+ /** 有调用方没传 signal */
62
+ #forever = false
63
+ #cleanups: Array<() => void> = []
64
+ #done = false
65
+
66
+ get signal(): AbortSignal {
67
+ return this.#controller.signal
68
+ }
69
+
70
+ /** 加入;已经中止或已结束时返回 false(调用方应另起一次操作) */
71
+ join(signal: AbortSignal | undefined): boolean {
72
+ if (this.#controller.signal.aborted || this.#done) {
73
+ return false
74
+ }
75
+ if (!signal) {
76
+ this.#forever = true
77
+ return true
78
+ }
79
+ if (signal.aborted) {
80
+ // 调用方已经取消(外层会直接 reject):不计入
81
+ return true
82
+ }
83
+ this.#active++
84
+ const onAbort = () => {
85
+ if (--this.#active === 0 && !this.#forever && !this.#done) {
86
+ this.#controller.abort(signal.reason)
87
+ }
88
+ }
89
+ signal.addEventListener('abort', onAbort, { once: true })
90
+ this.#cleanups.push(() => signal.removeEventListener('abort', onAbort))
91
+ return true
92
+ }
93
+
94
+ done(): void {
95
+ this.#done = true
96
+ for (const cleanup of this.#cleanups) {
97
+ cleanup()
98
+ }
99
+ this.#cleanups = []
100
+ }
101
+ }
102
+
103
+ /** 往有条目上限的 Map 里写入(先删再插保持“最近写入在后”),超出时淘汰最早写入的 */
104
+ export function boundedSet<K, V>(map: Map<K, V>, key: K, value: V, max: number): void {
105
+ map.delete(key)
106
+ map.set(key, value)
107
+ while (map.size > max) {
108
+ map.delete(map.keys().next().value as K)
109
+ }
110
+ }
@@ -0,0 +1,218 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js'
2
+ import { base64 } from '@scure/base'
3
+
4
+ import type { SolanaClient } from '../client.js'
5
+ import { FallbackRpc, RpcError, isNodeFault, type RpcTransport } from '../rpc.js'
6
+ import { clientSourceConfigs } from '../clientConfig.js'
7
+ import { sleep } from '../util.js'
8
+ import { errorText } from './errors.js'
9
+ import type { SendOptions } from './send.js'
10
+ import { serializeTransaction, type CompiledMessage } from './transaction.js'
11
+
12
+ /**
13
+ * 最近发出过的交易消息(sha256):同样的消息签名也相同,再发一次只会被节点当成同一笔。
14
+ * 在签名之前检查(钱包签名的交易也适用),重复时调整计算单元上限让消息不同
15
+ */
16
+ const recentMessages = new Set<string>()
17
+
18
+ export function messageKey(message: Uint8Array): string {
19
+ return base64.encode(sha256(message))
20
+ }
21
+
22
+ export function isRecentMessage(message: Uint8Array): boolean {
23
+ return recentMessages.has(messageKey(message))
24
+ }
25
+
26
+ export function remember(message: Uint8Array): void {
27
+ recentMessages.add(messageKey(message))
28
+ if (recentMessages.size > 10_000) {
29
+ recentMessages.delete(recentMessages.values().next().value as string)
30
+ }
31
+ }
32
+
33
+ export interface Blockhash {
34
+ blockhash: string
35
+ lastValidBlockHeight: number
36
+ }
37
+
38
+ export interface SimulationValue {
39
+ err: unknown
40
+ logs?: string[] | null
41
+ unitsConsumed?: number
42
+ /** 请求了 accounts 时:模拟执行后这些账户的状态(已扣手续费);账户不存在时为 null */
43
+ accounts?: Array<{ lamports: number | string } | null> | null
44
+ }
45
+
46
+ /**
47
+ * 'auto' 优先费用:最近费用里去掉 0 再取 75 分位。
48
+ * 不活跃的账户大多数 slot 没有相关交易,返回 0(2026-10 主网实测 USDC mint 150 个 slot 里 135 个是 0),
49
+ * 带上 0 取分位数几乎总是 0,等于不加优先费用
50
+ */
51
+ export function autoPriorityFee(fees: readonly number[], cap: bigint): bigint {
52
+ const paid = fees
53
+ .filter((fee) => fee > 0)
54
+ .map((fee) => BigInt(fee))
55
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
56
+ const p75 = paid.length ? (paid[Math.min(paid.length - 1, Math.floor(paid.length * 0.75))] as bigint) : 0n
57
+ return p75 < cap ? p75 : cap
58
+ }
59
+
60
+ /** 一批发送共用的节点访问:blockhash(缓存 10 秒)、优先费用(按账户缓存 10 秒)、模拟、广播 */
61
+ export class Chain {
62
+ #blockhash: { at: number; value: Promise<Blockhash> } | null = null
63
+ readonly #fees = new Map<string, { at: number; value: Promise<bigint> }>()
64
+ #broadcastTargets: Promise<RpcTransport[]> | null = null
65
+
66
+ constructor(
67
+ readonly client: SolanaClient,
68
+ readonly options: SendOptions,
69
+ ) {}
70
+
71
+ get commitment(): 'confirmed' | 'finalized' {
72
+ return this.options.commitment ?? 'confirmed'
73
+ }
74
+
75
+ #walletChain: Promise<string | undefined> | null = null
76
+
77
+ /** Wallet Standard 的链标识('solana:mainnet' 等);网络未知时为 undefined */
78
+ walletChain(): Promise<string | undefined> {
79
+ this.#walletChain ??= this.client.getCluster().then(
80
+ (cluster) => (cluster ? `solana:${cluster}` : undefined),
81
+ () => undefined,
82
+ )
83
+ return this.#walletChain
84
+ }
85
+
86
+ blockhash(fresh = false): Promise<Blockhash> {
87
+ if (fresh || !this.#blockhash || Date.now() - this.#blockhash.at > 10_000) {
88
+ const value = this.client
89
+ .request<{ value: Blockhash }>('getLatestBlockhash', [{ commitment: 'confirmed' }])
90
+ .then((res) => res.value)
91
+ value.catch(() => {
92
+ if (this.#blockhash?.value === value) this.#blockhash = null
93
+ })
94
+ this.#blockhash = { at: Date.now(), value }
95
+ }
96
+ return this.#blockhash.value
97
+ }
98
+
99
+ async priorityFee(writable: readonly string[]): Promise<bigint> {
100
+ const option = this.options.priorityFee ?? 'auto'
101
+ if (option !== 'auto') {
102
+ return BigInt(option)
103
+ }
104
+ const accounts = [...writable].sort().slice(0, 128)
105
+ const key = accounts.join(',')
106
+ let cached = this.#fees.get(key)
107
+ if (!cached || Date.now() - cached.at > 10_000) {
108
+ const cap = BigInt(this.options.maxPriorityFee ?? 1_000_000)
109
+ const value = this.client
110
+ .request<Array<{ prioritizationFee: number }>>('getRecentPrioritizationFees', [accounts])
111
+ .then((list) => autoPriorityFee(list.map((f) => f.prioritizationFee), cap))
112
+ .catch(() => 0n) // 节点不支持时不加优先费用
113
+ cached = { at: Date.now(), value }
114
+ this.#fees.set(key, cached)
115
+ }
116
+ return cached.value
117
+ }
118
+
119
+ /** 模拟;watch 是要返回模拟后状态的账户(如手续费支付者,用来判断付不付得起优先费用) */
120
+ async simulate(message: CompiledMessage, minContextSlot: number | undefined, watch?: readonly string[]): Promise<SimulationValue> {
121
+ const raw = base64.encode(serializeTransaction(message, message.signers.map(() => null)))
122
+ const config: Record<string, unknown> = { encoding: 'base64', sigVerify: false, replaceRecentBlockhash: true, commitment: 'confirmed' }
123
+ if (minContextSlot !== undefined) {
124
+ config.minContextSlot = minContextSlot
125
+ }
126
+ if (watch?.length) {
127
+ config.accounts = { encoding: 'base64', addresses: watch }
128
+ }
129
+ // 节点暂时的问题(限频、高度还没到 minContextSlot 等)重试几次,不当成调用失败
130
+ for (let attempt = 0; ; attempt++) {
131
+ try {
132
+ const res = await this.client.request<{ value: SimulationValue }>('simulateTransaction', [raw, config])
133
+ return res.value
134
+ } catch (err) {
135
+ if (attempt >= 3 || (err instanceof RpcError && !isNodeFault(err))) throw err
136
+ await sleep(500 * (attempt + 1), this.options.signal, false)
137
+ }
138
+ }
139
+ }
140
+
141
+ /** 账户当前的 lamports;读不到时为 null(调用方按“不知道”处理) */
142
+ async balance(address: string, minContextSlot: number | undefined): Promise<bigint | null> {
143
+ try {
144
+ const config: Record<string, unknown> = { commitment: 'confirmed' }
145
+ if (minContextSlot !== undefined) config.minContextSlot = minContextSlot
146
+ const res = await this.client.request<{ value: number | string }>('getBalance', [address, config])
147
+ return BigInt(res.value)
148
+ } catch {
149
+ return null
150
+ }
151
+ }
152
+
153
+ /**
154
+ * 广播目标:sendRpc(不传时用 client 的节点)。sendRpc 按 client 的网络和节点选项(headers / fetch / timeout 等)解析,
155
+ * 并校验和 client 在同一个网络(不一致时报 NetworkMismatchError)。
156
+ * broadcast 为 'all' 时拆成各个节点(每个单独包一层 FallbackRpc,照常触发 onRequest、记录健康状态),否则整体按故障切换顺序只发给第一个可用的节点。
157
+ * 解析出错时抛出、不缓存(下次重试)。source.ts 只在传了 sendRpc 时按需加载(/write 的入口本身不静态依赖它)
158
+ */
159
+ #targets(): Promise<RpcTransport[]> {
160
+ this.#broadcastTargets ??= this.#resolveTargets().catch((err: unknown) => {
161
+ this.#broadcastTargets = null
162
+ throw err
163
+ })
164
+ return this.#broadcastTargets
165
+ }
166
+
167
+ async #resolveTargets(): Promise<RpcTransport[]> {
168
+ let sender = this.client.transport
169
+ if (this.options.sendRpc !== undefined) {
170
+ const { resolveSource } = await import('../source.js')
171
+ const config = clientSourceConfigs.get(this.client)
172
+ sender = resolveSource(this.options.sendRpc, config?.cluster, config?.options, config?.genesis).transport
173
+ }
174
+ const nodes = sender instanceof FallbackRpc ? sender.nodes : [sender]
175
+ return this.options.broadcast === 'all' && nodes.length > 1 ? nodes.map((node) => new FallbackRpc([node])) : [sender]
176
+ }
177
+
178
+ /**
179
+ * 广播:'accepted'(第一个接受的节点返回就结束,其余节点在后台继续);没有节点接受时,有节点明确拒绝(或节点不在同一个网络)则抛出,
180
+ * 否则返回错误说明(节点都连不上,交易可能已经发出)
181
+ */
182
+ async broadcast(raw: string): Promise<'accepted' | { uncertain: string }> {
183
+ const params = [raw, { encoding: 'base64', skipPreflight: true, maxRetries: 0 }]
184
+ const attempts = (await this.#targets()).map((target) =>
185
+ target.request('sendTransaction', params).then(
186
+ () => 'accepted' as const,
187
+ (err: unknown) => {
188
+ if (/already (been )?processed/i.test(errorText(err))) {
189
+ return 'accepted' as const
190
+ }
191
+ throw err
192
+ },
193
+ ),
194
+ )
195
+ // 返回后还没结束的节点照常在后台完成:吞掉它们的拒绝,避免未处理的 rejection
196
+ attempts.forEach((attempt) => attempt.catch(() => {}))
197
+ try {
198
+ return await Promise.any(attempts)
199
+ } catch (err) {
200
+ const errors: unknown[] = err instanceof AggregateError ? err.errors : [err]
201
+ const rejected = errors.find((e) => (e instanceof RpcError && !isNodeFault(e)) || isNetworkMismatch(e))
202
+ if (rejected) {
203
+ throw rejected
204
+ }
205
+ return { uncertain: `broadcast not acknowledged: ${errors.map(errorText).join(' | ')}` }
206
+ }
207
+ }
208
+ }
209
+
210
+ /** 按 SendOptions 取优先费用单价(micro-lamports / 计算单元);writable 是交易里的可写账户 */
211
+ export function priorityFeeFor(client: SolanaClient, options: SendOptions, writable: readonly string[]): Promise<bigint> {
212
+ return new Chain(client, options).priorityFee(writable)
213
+ }
214
+
215
+ /** 节点不在同一个网络(source.ts 的 NetworkMismatchError):按 name 判断,免得为 instanceof 静态带上 source.ts */
216
+ function isNetworkMismatch(err: unknown): boolean {
217
+ return err instanceof Error && err.name === 'NetworkMismatchError'
218
+ }
@@ -1,4 +1,4 @@
1
- import { getAssociatedTokenAddress, isAddress } from '../address.js'
1
+ import { getAssociatedTokenAddress, InvalidAddressError, isAddress } from '../address.js'
2
2
  import type { SolanaClient } from '../client.js'
3
3
  import { formatUnits } from '../layout.js'
4
4
  import type { TokenBalance } from '../types.js'
@@ -71,7 +71,7 @@ interface Item {
71
71
  */
72
72
  export async function batchCollect(client: SolanaClient, wallets: readonly SignerInput[], to: string, options: BatchCollectOptions): Promise<CollectResult> {
73
73
  if (!isAddress(to)) {
74
- throw new Error(`Invalid collect address: ${to}`)
74
+ throw new InvalidAddressError(`Invalid collect address: ${to}`, to)
75
75
  }
76
76
  const cache = new Map()
77
77
  const signers = await Promise.all(wallets.map((w) => resolveSigner(w, cache)))