@w3lib/evm-batch-call 0.1.0 → 0.3.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 (77) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +194 -12
  3. package/dist/aggregate.d.ts +5 -2
  4. package/dist/aggregate.d.ts.map +1 -1
  5. package/dist/aggregate.js.map +1 -1
  6. package/dist/call.d.ts +8 -2
  7. package/dist/call.d.ts.map +1 -1
  8. package/dist/call.js +25 -1
  9. package/dist/call.js.map +1 -1
  10. package/dist/chains.d.ts +13 -0
  11. package/dist/chains.d.ts.map +1 -0
  12. package/dist/chains.js +35 -0
  13. package/dist/chains.js.map +1 -0
  14. package/dist/detect.d.ts +23 -0
  15. package/dist/detect.d.ts.map +1 -0
  16. package/dist/detect.js +178 -0
  17. package/dist/detect.js.map +1 -0
  18. package/dist/erc20.d.ts +83 -7
  19. package/dist/erc20.d.ts.map +1 -1
  20. package/dist/erc20.js +36 -5
  21. package/dist/erc20.js.map +1 -1
  22. package/dist/fallback.d.ts +25 -0
  23. package/dist/fallback.d.ts.map +1 -1
  24. package/dist/fallback.js +123 -21
  25. package/dist/fallback.js.map +1 -1
  26. package/dist/index.d.ts +10 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +9 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/nft.d.ts +71 -0
  31. package/dist/nft.d.ts.map +1 -0
  32. package/dist/nft.js +194 -0
  33. package/dist/nft.js.map +1 -0
  34. package/dist/owner.d.ts +136 -0
  35. package/dist/owner.d.ts.map +1 -0
  36. package/dist/owner.js +501 -0
  37. package/dist/owner.js.map +1 -0
  38. package/dist/provider.d.ts +112 -12
  39. package/dist/provider.d.ts.map +1 -1
  40. package/dist/provider.js +343 -48
  41. package/dist/provider.js.map +1 -1
  42. package/dist/scan.d.ts +59 -0
  43. package/dist/scan.d.ts.map +1 -0
  44. package/dist/scan.js +245 -0
  45. package/dist/scan.js.map +1 -0
  46. package/dist/shortcuts.d.ts +113 -0
  47. package/dist/shortcuts.d.ts.map +1 -0
  48. package/dist/shortcuts.js +193 -0
  49. package/dist/shortcuts.js.map +1 -0
  50. package/dist/source.d.ts +23 -1
  51. package/dist/source.d.ts.map +1 -1
  52. package/dist/source.js +110 -3
  53. package/dist/source.js.map +1 -1
  54. package/dist/tron.d.ts +6 -0
  55. package/dist/tron.d.ts.map +1 -1
  56. package/dist/tron.js +16 -1
  57. package/dist/tron.js.map +1 -1
  58. package/dist/util.d.ts +6 -0
  59. package/dist/util.d.ts.map +1 -0
  60. package/dist/util.js +20 -0
  61. package/dist/util.js.map +1 -0
  62. package/package.json +3 -2
  63. package/src/aggregate.ts +5 -2
  64. package/src/call.ts +32 -3
  65. package/src/chains.ts +46 -0
  66. package/src/detect.ts +193 -0
  67. package/src/erc20.ts +111 -9
  68. package/src/fallback.ts +127 -23
  69. package/src/index.ts +90 -4
  70. package/src/nft.ts +298 -0
  71. package/src/owner.ts +651 -0
  72. package/src/provider.ts +458 -54
  73. package/src/scan.ts +293 -0
  74. package/src/shortcuts.ts +278 -0
  75. package/src/source.ts +118 -3
  76. package/src/tron.ts +21 -2
  77. package/src/util.ts +20 -0
package/src/scan.ts ADDED
@@ -0,0 +1,293 @@
1
+ import { errorText, GetLogsError } from './fallback.js'
2
+ import type { DiscoveredToken, TokenSource } from './owner.js'
3
+ import { isTronChain } from './source.js'
4
+
5
+ /** ERC20 / ERC721 的 Transfer(address,address,uint256) 事件 */
6
+ const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
7
+
8
+ /** 增量扫描的进度,按 chainId + 地址保存 */
9
+ export interface TransferScanState {
10
+ /** 第一个扫描的区块(之前的历史不扫描) */
11
+ startBlock: number
12
+ /** 已扫描到的区块号(含);第一次调用且 lookbackBlocks 为 0 时等于 startBlock - 1 */
13
+ cursor: number
14
+ /** 扫描中发现的代币合约地址(小写) */
15
+ tokens: string[]
16
+ /** 当前使用的单次查询区块范围:出错时缩小,连续成功后翻倍,直到 rangeLimit / blockRange 选项 */
17
+ blockRange: number
18
+ /** 节点的区块范围上限(报错里给出的,或减半试出来的),记录 1 天后失效,之后会重新试更大的范围 */
19
+ rangeLimit?: number
20
+ /** rangeLimit 的记录时间(毫秒时间戳) */
21
+ rangeLimitAt?: number
22
+ /** 节点不开放 getLogs 时,在这个时间之前不再尝试(毫秒时间戳) */
23
+ pausedUntil?: number
24
+ }
25
+
26
+ /** 进度存储:默认存在内存里;传自定义存储可以跨页面刷新 / 重启接着扫(如 localStorage、数据库) */
27
+ export interface TransferScanStorage {
28
+ get(key: string): TransferScanState | undefined | Promise<TransferScanState | undefined>
29
+ set(key: string, state: TransferScanState): void | Promise<void>
30
+ }
31
+
32
+ /** 内存存储(默认):最多保存 1000 个地址,超出时淘汰最早的 */
33
+ export function memoryScanStorage(limit = 1000): TransferScanStorage {
34
+ const map = new Map<string, TransferScanState>()
35
+ return {
36
+ get: (key) => map.get(key),
37
+ set: (key, state) => {
38
+ map.delete(key)
39
+ map.set(key, state)
40
+ if (map.size > limit) {
41
+ map.delete(map.keys().next().value as string)
42
+ }
43
+ },
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 基于 getItem / setItem 的存储(如浏览器的 localStorage):
49
+ * `transferScan({ storage: keyValueScanStorage(localStorage) })`
50
+ */
51
+ export function keyValueScanStorage(store: { getItem(key: string): string | null; setItem(key: string, value: string): void }, prefix = 'w3lib:transfer-scan:'): TransferScanStorage {
52
+ return {
53
+ get: (key) => {
54
+ try {
55
+ const raw = store.getItem(prefix + key)
56
+ return raw ? (JSON.parse(raw) as TransferScanState) : undefined // 格式在读取后校验
57
+ } catch {
58
+ return undefined
59
+ }
60
+ },
61
+ set: (key, state) => {
62
+ try {
63
+ store.setItem(prefix + key, JSON.stringify(state))
64
+ } catch {
65
+ // 存储满或不可用:只影响下次能否接着扫
66
+ }
67
+ },
68
+ }
69
+ }
70
+
71
+ export interface TransferScanOptions {
72
+ /** 进度存储,默认内存(进程 / 页面结束就丢失) */
73
+ storage?: TransferScanStorage
74
+ /** 第一次调用时往回扫多少个区块,默认 0(只从现在开始往后扫) */
75
+ lookbackBlocks?: number
76
+ /** 单次 getLogs 的最大区块范围,默认 1000;节点有上限时自动缩小 */
77
+ blockRange?: number
78
+ /** 每次调用最多发多少个 getLogs 请求,默认 20;落后很多时分多次追上 */
79
+ maxRequests?: number
80
+ /** 每次调用的扫描时间预算(毫秒),默认 5000:超过后不再发新请求,留到下次调用 */
81
+ timeBudget?: number
82
+ /** 只扫到最新区块往前这么多个区块,默认 5:避开链重组,也避免节点之间高度不一致时漏扫 */
83
+ confirmations?: number
84
+ }
85
+
86
+ const defaultStorage = memoryScanStorage()
87
+
88
+ /** 节点不开放 getLogs 时,暂停多久再试 */
89
+ const PAUSE_ON_UNSUPPORTED = 10 * 60 * 1000
90
+ /** 记下的区块范围上限多久后失效(节点可能换了 / 调整了限制) */
91
+ const RANGE_LIMIT_TTL = 24 * 60 * 60 * 1000
92
+ /** 连续成功多少次后把范围翻倍 */
93
+ const GROW_AFTER = 2
94
+
95
+ type ErrorKind = 'transient' | 'range' | 'unsupported' | 'other'
96
+
97
+ /** 按节点返回的错误信息分类 */
98
+ function kindOf(text: string): ErrorKind {
99
+ if (/too many|rate.?limit|429|temporarily|unavailable|busy|timed? ?out|timeout|network|ECONN|ENOTFOUND|socket|fetch failed|missing response|bad response|50[234]|quota|credits|compute units/i.test(text)) {
100
+ return 'transient' // 限频 / 超时 / 连不上:换个时间重试即可
101
+ }
102
+ if (/range|too (?:large|wide|big)|exceed|query returned more than|more than \d+ (?:results|logs)|response size|block limit/i.test(text)) {
103
+ return 'range'
104
+ }
105
+ if (/method not found|-32601|not supported|unsupported|not available|does not exist|not (?:allowed|enabled|whitelisted)|disabled/i.test(text)) {
106
+ return 'unsupported'
107
+ }
108
+ return 'other'
109
+ }
110
+
111
+ /** 每个节点的错误;GetLogsError 里没有错误表示没有节点支持 getLogs */
112
+ function errorTexts(err: unknown): string[] {
113
+ if (err instanceof GetLogsError) {
114
+ return err.errors.length ? err.errors.map(errorText) : ['getLogs is not supported']
115
+ }
116
+ return [errorText(err)]
117
+ }
118
+
119
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
120
+
121
+ /** 从错误信息里找区块范围上限,如 “log query range must not exceed 25 blocks”“maximum block range: 5000” */
122
+ function parseRangeLimit(texts: string[]): number | null {
123
+ let best: number | null = null
124
+ for (const raw of texts) {
125
+ const text = raw.replace(/(\d),(?=\d{3}\b)/g, '$1') // 10,000 → 10000
126
+ const match =
127
+ /(?:exceed|maximum|max|limit(?:ed)? (?:of|to)?|up to|range)[^\d]{0,40}?(\d{1,7})\s*blocks?/i.exec(text) ??
128
+ /(\d{1,7})\s*blocks?[^.]{0,20}(?:range|limit|maximum)/i.exec(text) ??
129
+ /block range[^\d]{0,20}(\d{1,7})/i.exec(text)
130
+ const n = match ? Number(match[1]) : NaN
131
+ if (Number.isInteger(n) && n > 0 && (best === null || n > best)) {
132
+ best = n // 多个节点给了不同上限时取最大的(能用的节点里最宽松的那个)
133
+ }
134
+ }
135
+ return best
136
+ }
137
+
138
+ const isInt = (value: unknown, min: number): value is number => Number.isSafeInteger(value) && (value as number) >= min
139
+ const isTime = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
140
+
141
+ /** 校验存储里读出的进度:格式不对(旧版本 / 被改坏)时丢弃,重新开始 */
142
+ function parseState(raw: unknown): TransferScanState | undefined {
143
+ const s = raw as Partial<Record<keyof TransferScanState, unknown>> | null
144
+ if (!s || typeof s !== 'object' || !isInt(s.startBlock, 0) || !isInt(s.cursor, -1) || !isInt(s.blockRange, 1) || !Array.isArray(s.tokens)) {
145
+ return undefined
146
+ }
147
+ if (!s.tokens.every((t) => typeof t === 'string' && /^0x[0-9a-fA-F]{40}$/.test(t))) {
148
+ return undefined
149
+ }
150
+ return {
151
+ startBlock: s.startBlock,
152
+ cursor: s.cursor,
153
+ tokens: (s.tokens as string[]).map((t) => t.toLowerCase()),
154
+ blockRange: s.blockRange,
155
+ ...(isInt(s.rangeLimit, 1) && isTime(s.rangeLimitAt) ? { rangeLimit: s.rangeLimit, rangeLimitAt: s.rangeLimitAt } : {}),
156
+ ...(isTime(s.pausedUntil) ? { pausedUntil: s.pausedUntil } : {}),
157
+ }
158
+ }
159
+
160
+ async function loadState(storage: TransferScanStorage, key: string): Promise<TransferScanState | undefined> {
161
+ try {
162
+ return parseState(await storage.get(key))
163
+ } catch {
164
+ return undefined
165
+ }
166
+ }
167
+
168
+ /**
169
+ * 增量扫描地址收到的 ERC20 代币(Transfer 事件,to = 该地址):第一次调用记下当前区块,之后每次从上次的位置往后扫。
170
+ * 用来补充代币列表发现不了的新代币。作为代币来源使用:`combine(defaultTokenSource(), transferScan())`,
171
+ * 或在 getOwnerTokens 里用 `scanTransfers: true`。
172
+ *
173
+ * 局限:只能发现开始扫描之后收到的代币(除非设置 lookbackBlocks);需要节点支持 eth_getLogs,免费公共节点限制较多
174
+ *(BSC 上只有 blockrazor 可用且每次最多 25 个区块);Tron 不支持。
175
+ */
176
+ export function transferScan(options: TransferScanOptions = {}): TokenSource {
177
+ const storage = options.storage ?? defaultStorage
178
+ const lookback = Math.max(0, Math.floor(options.lookbackBlocks ?? 0))
179
+ const maxRange = Math.max(1, Math.floor(options.blockRange ?? 1000))
180
+ const maxRequests = Math.max(1, options.maxRequests ?? 20)
181
+ const timeBudget = options.timeBudget ?? 5000
182
+ const confirmations = Math.max(0, Math.floor(options.confirmations ?? 5))
183
+ return {
184
+ name: 'transfers',
185
+ async discover({ chainId, owner, provider }) {
186
+ if (!provider || isTronChain(chainId) || !/^0x[0-9a-fA-F]{40}$/.test(owner)) {
187
+ return null
188
+ }
189
+ const key = `${chainId}:${owner.toLowerCase()}`
190
+ const saved = await loadState(storage, key)
191
+ const toResult = (state: TransferScanState | undefined): DiscoveredToken[] => (state?.tokens ?? []).map((address) => ({ address }))
192
+ if (saved?.pausedUntil && Date.now() < saved.pausedUntil) {
193
+ return toResult(saved)
194
+ }
195
+
196
+ const startedAt = Date.now()
197
+ let target: number
198
+ try {
199
+ target = (await provider.getBlockNumber()) - confirmations
200
+ } catch {
201
+ return saved ? toResult(saved) : null // 节点不支持 / 暂时不可用:返回已发现的代币
202
+ }
203
+ const now = Date.now()
204
+ let state: TransferScanState
205
+ if (saved) {
206
+ state = { ...saved, tokens: [...saved.tokens] }
207
+ delete state.pausedUntil
208
+ if (state.rangeLimitAt !== undefined && now - state.rangeLimitAt > RANGE_LIMIT_TTL) {
209
+ delete state.rangeLimit
210
+ delete state.rangeLimitAt
211
+ }
212
+ } else {
213
+ const cursor = Math.max(-1, target - lookback)
214
+ state = { startBlock: cursor + 1, cursor, tokens: [], blockRange: maxRange }
215
+ }
216
+ const tokens = new Set(state.tokens)
217
+ const ownerTopic = `0x${owner.slice(2).toLowerCase().padStart(64, '0')}`
218
+ const cap = () => Math.min(maxRange, state.rangeLimit ?? maxRange)
219
+ const setLimit = (limit: number) => {
220
+ state.rangeLimit = limit
221
+ state.rangeLimitAt = Date.now()
222
+ }
223
+ let range = Math.min(state.blockRange, cap())
224
+ let successes = 0
225
+ let transient = 0
226
+
227
+ for (let requests = 0; requests < maxRequests && state.cursor < target; requests++) {
228
+ if (timeBudget > 0 && Date.now() - startedAt >= timeBudget) {
229
+ break // 超出时间预算,留到下次调用
230
+ }
231
+ const fromBlock = state.cursor + 1
232
+ const toBlock = Math.min(target, fromBlock + range - 1)
233
+ try {
234
+ const logs = await provider.getLogs({ fromBlock, toBlock, topics: [TRANSFER_TOPIC, null, ownerTopic] })
235
+ for (const log of logs) {
236
+ // ERC20 的 Transfer 有 3 个 topic;ERC721 的 tokenId 也是 indexed,有 4 个,排除
237
+ if (log.topics.length === 3) {
238
+ tokens.add(log.address.toLowerCase())
239
+ }
240
+ }
241
+ state.cursor = toBlock
242
+ transient = 0
243
+ if (++successes >= GROW_AFTER && range < cap()) {
244
+ range = Math.min(cap(), range * 2) // 之前缩小过(偶发错误 / 换了节点):逐步放大回去
245
+ successes = 0
246
+ }
247
+ } catch (err) {
248
+ successes = 0
249
+ const texts = errorTexts(err)
250
+ const kinds = texts.map(kindOf)
251
+ // 上次成功的节点这次的错误最能说明问题(其他节点可能一直就不能用,它们的报错不代表当前范围有问题)
252
+ const preferred = err instanceof GetLogsError && err.preferred !== undefined ? kindOf(errorText(err.preferred)) : undefined
253
+ const limit = parseRangeLimit(texts)
254
+ if (limit !== null && limit < range) {
255
+ range = limit // 节点明确给了范围上限
256
+ setLimit(limit)
257
+ } else if (kinds.every((k) => k === 'unsupported')) {
258
+ // 所有节点都不开放 getLogs:暂停一段时间,避免每次调用都白发请求
259
+ state.pausedUntil = Date.now() + PAUSE_ON_UNSUPPORTED
260
+ break
261
+ } else if (preferred === 'transient' || (preferred === undefined && kinds.every((k) => k === 'transient' || k === 'unsupported'))) {
262
+ // 在用的节点只是限频 / 超时:不缩小范围,退避后重试;连续 3 次就留到下次调用
263
+ const remaining = timeBudget > 0 ? timeBudget - (Date.now() - startedAt) : Infinity
264
+ if (++transient >= 3 || remaining <= 0) {
265
+ break
266
+ }
267
+ await sleep(Math.min(500 * transient, remaining))
268
+ } else if (range > 1) {
269
+ range = Math.max(1, Math.floor(range / 2)) // 范围超限但没说上限:减半再试
270
+ setLimit(range)
271
+ } else {
272
+ state.pausedUntil = Date.now() + PAUSE_ON_UNSUPPORTED // 连 1 个区块都查不了
273
+ break
274
+ }
275
+ }
276
+ }
277
+ state.blockRange = range
278
+ state.tokens = [...tokens]
279
+ try {
280
+ await storage.set(key, state)
281
+ } catch {
282
+ // 存储不可用:只影响下次能否接着扫
283
+ }
284
+ return toResult(state)
285
+ },
286
+ }
287
+ }
288
+
289
+ /** 读取某地址的扫描进度(不存在时为 undefined) */
290
+ export async function getTransferScanState(chainId: number, owner: string, storage: TransferScanStorage = defaultStorage): Promise<TransferScanState | undefined> {
291
+ return loadState(storage, `${chainId}:${owner.toLowerCase()}`)
292
+ }
293
+
@@ -0,0 +1,278 @@
1
+ import type { CallOverrides } from './aggregate.js'
2
+ import type { OwnedToken, OwnerTokensOptions } from './owner.js'
3
+ import type { BalanceToken, DefaultTokenField, TokenAllowance, TokenBalance, TokenDetails, TokenField } from './erc20.js'
4
+ import type {
5
+ DefaultNftCollectionField,
6
+ Erc1155Balance,
7
+ NftBalance,
8
+ NftCollection,
9
+ NftCollectionField,
10
+ NftCollectionsOptions,
11
+ NftItem,
12
+ NftOwner,
13
+ NftTokenUri,
14
+ } from './nft.js'
15
+ import { Provider, type BalancesOptions, type ProviderConfig, type TokensOptions } from './provider.js'
16
+ import type { ProviderSource } from './source.js'
17
+
18
+ /** getBalances / getTokens 共用的节点参数 */
19
+ export interface ShortcutProviderOptions extends ProviderConfig {
20
+ /** 链 ID。传了 provider 时可省略(从节点识别,结果会缓存);不传 provider 时必填(用于选择内置公共节点) */
21
+ chainId?: number | string
22
+ /**
23
+ * 节点,与 `new Provider(...)` 相同,不传则使用内置公共节点:
24
+ * - RPC URL,或 URL 数组(主节点 + 备用节点)
25
+ * - ethers Provider
26
+ * - 浏览器插件钱包:`window.ethereum`(EIP-1193)、`window.tronWeb`
27
+ * - 混合数组,如 `[window.ethereum, 'https://…']`:钱包出错或不在这条链上时自动用后面的节点
28
+ */
29
+ provider?: ProviderSource | readonly ProviderSource[]
30
+ }
31
+
32
+ export interface GetBalancesOptions extends BalancesOptions, ShortcutProviderOptions {}
33
+
34
+ export interface GetTokensOptions<F extends TokenField = DefaultTokenField> extends TokensOptions<F>, ShortcutProviderOptions {}
35
+
36
+ // 复用 Provider:多次调用共享连接、multicall 地址判定和自动合并队列。
37
+ // 按 “chainId + 配置内容” 分组,组内 URL 按字符串、钱包 / Provider 对象按引用(WeakMap,不阻止回收)。
38
+ // 不缓存的情况:配置里有函数等无法比较内容的值;含对象的节点数组;没传 chainId 且节点是对象
39
+ // (钱包可能切链,每次按当前链新建;chainId 识别本身有缓存,切链时自动失效,不会每次都发请求)
40
+ interface ProviderGroup {
41
+ byString: Map<string, Provider>
42
+ byObject: WeakMap<object, Provider>
43
+ }
44
+
45
+ const providerCache = new Map<string, ProviderGroup>()
46
+
47
+ /** 缓存上限(分组数、每组 URL 数),超出时淘汰最早加入的;对象来源用 WeakMap,随对象回收 */
48
+ const MAX_CACHED = 32
49
+
50
+ function setBounded<K, V>(map: Map<K, V>, key: K, value: V): void {
51
+ map.set(key, value)
52
+ if (map.size > MAX_CACHED) {
53
+ map.delete(map.keys().next().value as K)
54
+ }
55
+ }
56
+
57
+ function configKey(config: ProviderConfig): string | null {
58
+ try {
59
+ return JSON.stringify(config, (_key, value: unknown) => {
60
+ if (typeof value === 'function') {
61
+ throw new Error('not comparable')
62
+ }
63
+ return typeof value === 'bigint' ? value.toString() : value
64
+ })
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ function sourceKey(chainId: number | undefined, source: ShortcutProviderOptions['provider']): string | object | null {
71
+ if (source === undefined) {
72
+ return 'default'
73
+ }
74
+ if (typeof source === 'string') {
75
+ return `url:${source}`
76
+ }
77
+ if (Array.isArray(source)) {
78
+ // URL 数组每次调用都是新数组,按内容缓存
79
+ return source.every((item) => typeof item === 'string') ? `urls:${source.join('\n')}` : null
80
+ }
81
+ return chainId === undefined ? null : (source as object)
82
+ }
83
+
84
+ function getProvider(chainId: number | undefined, source: ShortcutProviderOptions['provider'], config: ProviderConfig): Provider {
85
+ if (chainId === undefined && source === undefined) {
86
+ throw new Error('getBalances / getTokens requires chainId or provider')
87
+ }
88
+ const create = () => (chainId === undefined ? new Provider(source as ProviderSource, config) : new Provider(chainId, source, config))
89
+ const cfg = configKey(config)
90
+ const key = sourceKey(chainId, source)
91
+ if (cfg === null || key === null) {
92
+ return create()
93
+ }
94
+ const groupKey = `${chainId ?? 'auto'}|${cfg}`
95
+ let group = providerCache.get(groupKey)
96
+ if (!group) {
97
+ group = { byString: new Map(), byObject: new WeakMap() }
98
+ setBounded(providerCache, groupKey, group)
99
+ }
100
+ let provider = typeof key === 'string' ? group.byString.get(key) : group.byObject.get(key)
101
+ if (!provider) {
102
+ provider = create()
103
+ if (typeof key === 'string') {
104
+ setBounded(group.byString, key, provider)
105
+ } else {
106
+ group.byObject.set(key, provider)
107
+ }
108
+ }
109
+ return provider
110
+ }
111
+
112
+ // ---- 各查询函数:节点参数都相同 ----
113
+
114
+ export interface ShortcutOptions extends CallOverrides, ShortcutProviderOptions {}
115
+
116
+ /** 拆出节点参数、调用参数和函数自己的参数(ownKeys),其余作为 Provider 配置;值为 undefined 的参数忽略 */
117
+ function resolve<T extends ShortcutOptions, K extends keyof T & string = never>(
118
+ options: T,
119
+ ownKeys: readonly K[] = [],
120
+ ): { provider: Provider; overrides: CallOverrides; own: Pick<T, K> } {
121
+ const { chainId, provider, blockTag, from, ...others } = options
122
+ const own: Record<string, unknown> = {}
123
+ const config: Record<string, unknown> = {}
124
+ for (const [key, value] of Object.entries(others)) {
125
+ if (value !== undefined) {
126
+ ;((ownKeys as readonly string[]).includes(key) ? own : config)[key] = value
127
+ }
128
+ }
129
+ return {
130
+ // null / 空字符串(如状态里还没有 chainId)按“未传”处理,走自动识别,而不是变成 chainId 0
131
+ provider: getProvider(chainId === undefined || chainId === null || chainId === '' ? undefined : Number(chainId), provider, config as ProviderConfig),
132
+ overrides: { blockTag, from },
133
+ own: own as Pick<T, K>,
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 批量查余额(主币 + 代币,一次请求),返回原始余额、decimals 和换算后的数值。
139
+ *
140
+ * ```ts
141
+ * import { NATIVE_TOKEN, getBalances } from '@w3lib/evm-batch-call'
142
+ *
143
+ * await getBalances(user, [NATIVE_TOKEN, USDT], { chainId: 56 }) // 内置公共节点
144
+ * await getBalances(user, tokens, { provider: window.ethereum }) // 钱包,chainId 自动识别
145
+ * await getBalances(user, tokens, { provider: 'https://bsc-dataseed.bnbchain.org' })
146
+ * await getBalances(user, tokens, { chainId: 56, provider: [window.ethereum, 'https://…'] }) // 钱包优先,失败用公共节点
147
+ * await getBalances(user, tokens, { chainId: 56, symbol: true }) // 同时返回 symbol
148
+ * // [{ token: NATIVE_TOKEN, native: true, balance: '1500000000000000000', decimals: 18, formatted: '1.5', success: true }, ...]
149
+ * ```
150
+ */
151
+ export function getBalances(owner: string, tokens: readonly BalanceToken[], options: GetBalancesOptions = {}): Promise<TokenBalance[]> {
152
+ const { provider, overrides, own } = resolve(options, ['symbol'])
153
+ return provider.balances(owner, tokens, { ...overrides, symbol: own.symbol })
154
+ }
155
+
156
+ /**
157
+ * 批量查 ERC20 代币详情(一次请求),可以选择返回哪些字段。节点参数与 getBalances 相同。
158
+ *
159
+ * ```ts
160
+ * import { NATIVE_TOKEN, getTokens } from '@w3lib/evm-batch-call'
161
+ *
162
+ * await getTokens([USDT, NATIVE_TOKEN], { chainId: 56 }) // 默认 name / symbol / decimals
163
+ * await getTokens([USDT], { provider: window.ethereum, fields: ['symbol', 'decimals', 'totalSupply'] })
164
+ * // [{ address: USDT, native: false, symbol: 'USDT', decimals: 18, totalSupply: '…', totalSupplyFormatted: '…', success: true }]
165
+ * ```
166
+ */
167
+ export function getTokens<const F extends TokenField = DefaultTokenField>(
168
+ tokens: readonly string[],
169
+ options: GetTokensOptions<F> = {},
170
+ ): Promise<TokenDetails<F>[]> {
171
+ const { provider, overrides, own } = resolve(options, ['fields'])
172
+ return provider.tokens<F>(tokens, { ...overrides, fields: own.fields })
173
+ }
174
+
175
+ /**
176
+ * 批量查 ERC20 授权额度(一次请求)。
177
+ *
178
+ * ```ts
179
+ * const [usdt] = await getAllowances(user, router, [USDT], { chainId: 56 })
180
+ * if (!usdt.unlimited && BigInt(usdt.allowance) < amount) { // 需要 approve
181
+ * ```
182
+ */
183
+ export function getAllowances(
184
+ owner: string,
185
+ spender: string,
186
+ tokens: readonly BalanceToken[],
187
+ options: ShortcutOptions = {},
188
+ ): Promise<TokenAllowance[]> {
189
+ const { provider, overrides } = resolve(options)
190
+ return provider.allowances(owner, spender, tokens, overrides)
191
+ }
192
+
193
+ export interface GetNftCollectionsOptions<F extends NftCollectionField = DefaultNftCollectionField>
194
+ extends NftCollectionsOptions<F>,
195
+ ShortcutProviderOptions {}
196
+
197
+ /**
198
+ * 批量查 NFT 集合信息,字段可选(默认 standard / name / symbol)。
199
+ *
200
+ * ```ts
201
+ * await getNftCollections([BAYC, MAYC], { chainId: 1, fields: ['standard', 'name', 'totalSupply'] })
202
+ * // [{ address: BAYC, standard: 'ERC721', name: 'BoredApeYachtClub', totalSupply: '10000', success: true }, ...]
203
+ * ```
204
+ */
205
+ export function getNftCollections<const F extends NftCollectionField = DefaultNftCollectionField>(
206
+ collections: readonly string[],
207
+ options: GetNftCollectionsOptions<F> = {},
208
+ ): Promise<NftCollection<F>[]> {
209
+ const { provider, overrides, own } = resolve(options, ['fields'])
210
+ return provider.nftCollections<F>(collections, { ...overrides, fields: own.fields })
211
+ }
212
+
213
+ /** 批量查 ERC721 持有数量:`await getNftBalances(user, [BAYC, MAYC], { chainId: 1 })` */
214
+ export function getNftBalances(owner: string, collections: readonly string[], options: ShortcutOptions = {}): Promise<NftBalance[]> {
215
+ const { provider, overrides } = resolve(options)
216
+ return provider.nftBalances(owner, collections, overrides)
217
+ }
218
+
219
+ /** 批量查 ERC721 持有人:`await getNftOwners([{ contract: BAYC, tokenId: 1 }], { chainId: 1 })` */
220
+ export function getNftOwners(items: readonly NftItem[], options: ShortcutOptions = {}): Promise<NftOwner[]> {
221
+ const { provider, overrides } = resolve(options)
222
+ return provider.nftOwners(items, overrides)
223
+ }
224
+
225
+ export interface GetNftTokenUrisOptions extends ShortcutOptions {
226
+ /** 把 ipfs://xxx 转成 `${ipfsGateway}xxx`,如 'https://ipfs.io/ipfs/' */
227
+ ipfsGateway?: string
228
+ }
229
+
230
+ /**
231
+ * 批量查 NFT 元数据地址(ERC721 tokenURI / ERC1155 uri 自动兼容)。
232
+ *
233
+ * ```ts
234
+ * await getNftTokenUris([{ contract: BAYC, tokenId: 1 }], { chainId: 1, ipfsGateway: 'https://ipfs.io/ipfs/' })
235
+ * // [{ contract: BAYC, tokenId: '1', uri: 'https://ipfs.io/ipfs/Qm…/1', success: true }]
236
+ * ```
237
+ */
238
+ export function getNftTokenUris(items: readonly NftItem[], options: GetNftTokenUrisOptions = {}): Promise<NftTokenUri[]> {
239
+ const { provider, overrides, own } = resolve(options, ['ipfsGateway'])
240
+ return provider.nftTokenUris(items, { ...overrides, ipfsGateway: own.ipfsGateway })
241
+ }
242
+
243
+ /** 批量查 ERC1155 余额:`await getErc1155Balances(user, [{ contract, tokenId: 1 }], { chainId: 137 })` */
244
+ export function getErc1155Balances(owner: string, items: readonly NftItem[], options: ShortcutOptions = {}): Promise<Erc1155Balance[]> {
245
+ const { provider, overrides } = resolve(options)
246
+ return provider.erc1155Balances(owner, items, overrides)
247
+ }
248
+
249
+ export interface GetOwnerTokensOptions extends OwnerTokensOptions, ShortcutProviderOptions {}
250
+
251
+ /**
252
+ * 列出持有人拥有的代币(资产列表)。默认用免费公开代币列表发现代币,余额用 multicall 在链上核对。
253
+ *
254
+ * ```ts
255
+ * await getOwnerTokens(user, { chainId: 56 }) // 免费,只能发现公开列表里的代币
256
+ * await getOwnerTokens(user, { chainId: 1, prices: true, minUsd: 1 }) // 带美元价值,过滤零头和垃圾币
257
+ * await getOwnerTokens(user, { chainId: 56, source: alchemy({ apiKey }) }) // 用 Alchemy 查全部历史持仓
258
+ * ```
259
+ *
260
+ * @deprecated 不推荐使用:EVM 链上无法只靠节点可靠地列出地址持有的全部代币,这个方法只是尽力而为——
261
+ * 默认来源只能发现公开列表里的代币,依赖第三方免费服务(可能限流、改格式或停止服务),要查几千个代币的余额、耗时数秒,
262
+ * 价格也不一定可信;传 `alchemy` / `nodereal` 来源能查全,但同样受第三方服务的额度和可用性约束。
263
+ * 已知要查哪些代币时请用 `getBalances`;需要可靠的完整持仓请直接接入索引服务的接口或自己的索引。局限性详见 README「资产列表」。
264
+ */
265
+ export function getOwnerTokens(owner: string, options: GetOwnerTokensOptions = {}): Promise<OwnedToken[]> {
266
+ const { provider, overrides, own } = resolve(options, ['source', 'prices', 'minUsd', 'includeNative', 'fetch', 'scanTransfers'])
267
+ return provider.ownerTokens(owner, { ...overrides, ...own })
268
+ }
269
+
270
+ /** 测试用:按参数取(或创建)Provider,用于验证缓存复用(不在包的公开导出里) */
271
+ export function resolveProviderForTest(options: ShortcutOptions): Provider {
272
+ return resolve(options).provider
273
+ }
274
+
275
+ /** 测试用:清空 getBalances / getTokens 的 Provider 缓存 */
276
+ export function resetBalancesProviderCache(): void {
277
+ providerCache.clear()
278
+ }