@w3lib/evm-batch-call 0.2.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.
package/src/owner.ts ADDED
@@ -0,0 +1,651 @@
1
+ import type { CallOverrides } from './aggregate.js'
2
+ import { NATIVE_TOKEN, formatAmount, type BalanceToken } from './erc20.js'
3
+ import type { Provider } from './provider.js'
4
+ import { transferScan, type TransferScanOptions } from './scan.js'
5
+ import { isTronAddress } from './tron.js'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // 代币来源:负责“这个地址可能持有哪些代币”。余额和精度一律由本库用 multicall 在链上核对。
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export interface DiscoveredToken {
12
+ address: string
13
+ symbol?: string | null
14
+ name?: string | null
15
+ decimals?: number | null
16
+ logo?: string | null
17
+ }
18
+
19
+ export interface TokenSourceContext {
20
+ chainId: number
21
+ owner: string
22
+ fetch: typeof fetch
23
+ /** 查询用的 Provider(扫描链上事件的来源会用到,如 transferScan) */
24
+ provider?: Provider
25
+ }
26
+
27
+ export interface TokenSource {
28
+ /** 名称,出现在结果的 source 字段 */
29
+ readonly name: string
30
+ /** 返回候选代币;不支持这条链时返回 null(firstAvailable 会接着试下一个来源) */
31
+ discover(ctx: TokenSourceContext): Promise<DiscoveredToken[] | null>
32
+ }
33
+
34
+ const REQUEST_TIMEOUT = 20_000
35
+ const LIST_TTL = 60 * 60 * 1000
36
+ const MAX_CACHED_LISTS = 16
37
+ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
38
+
39
+ export class HttpStatusError extends Error {
40
+ constructor(
41
+ readonly status: number,
42
+ target: string,
43
+ ) {
44
+ super(`HTTP ${status} from ${target}`)
45
+ this.name = 'HttpStatusError'
46
+ }
47
+ }
48
+
49
+ /**
50
+ * @param secret 请求里带的 Key:出错时错误信息只写域名,不写完整地址,避免 Key 出现在日志里
51
+ */
52
+ function fetchJson<T>(fetchFn: typeof fetch, url: string, init?: RequestInit, secret?: string): Promise<T> {
53
+ return fetchFn(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT) }).then(async (res) => {
54
+ if (!res.ok) {
55
+ throw new HttpStatusError(res.status, secret ? safeHost(url) : url)
56
+ }
57
+ return (await res.json()) as T
58
+ })
59
+ }
60
+
61
+ function safeHost(url: string): string {
62
+ try {
63
+ return new URL(url).host
64
+ } catch {
65
+ return 'request'
66
+ }
67
+ }
68
+
69
+ // 公开代币列表:缓存“按链过滤、转换后”的结果(不缓存原始 JSON),1 小时;缓存 Promise,同时发起的请求共用,失败不缓存。
70
+ // 公开列表与使用哪个 fetch 无关,所以缓存键只看列表和链
71
+ const listCache = new Map<string, { at: number; promise: Promise<DiscoveredToken[] | null> }>()
72
+
73
+ /** 清空代币列表缓存 */
74
+ export function clearTokenListCache(): void {
75
+ listCache.clear()
76
+ }
77
+
78
+ function cachedList(key: string, load: () => Promise<DiscoveredToken[] | null>): Promise<DiscoveredToken[] | null> {
79
+ const hit = listCache.get(key)
80
+ if (hit && Date.now() - hit.at < LIST_TTL) {
81
+ return hit.promise
82
+ }
83
+ const promise = load()
84
+ listCache.set(key, { at: Date.now(), promise })
85
+ if (listCache.size > MAX_CACHED_LISTS) {
86
+ listCache.delete(listCache.keys().next().value as string)
87
+ }
88
+ promise.catch(() => {
89
+ if (listCache.get(key)?.promise === promise) listCache.delete(key)
90
+ })
91
+ return promise
92
+ }
93
+
94
+ /** 来源给的 decimals 只在是合法整数时使用(否则当作未知,由链上查询) */
95
+ function validDecimals(value: unknown): number | null {
96
+ const n = typeof value === 'string' ? Number(value) : value
97
+ return typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= 255 ? n : null
98
+ }
99
+
100
+ /**
101
+ * MetaMask 钱包用来自动发现代币的列表(汇总多家来源,Ethereum 约 9500 个、BSC 约 2 万个)。
102
+ * minOccurrences:至少被几家来源收录(MetaMask 自己用 3),用来过滤垃圾币。
103
+ *
104
+ * 注意:这是 MetaMask 自己在用的接口,并非对外承诺的公开 API,可能随时限流或变更,建议与其他来源组合使用。
105
+ */
106
+ export function metamaskTokenList(options: { minOccurrences?: number } = {}): TokenSource {
107
+ const min = options.minOccurrences ?? 3
108
+ return {
109
+ name: 'metamask',
110
+ discover({ chainId, fetch: fetchFn }) {
111
+ const url = `https://token.api.cx.metamask.io/tokens/${chainId}`
112
+ return cachedList(`metamask:${chainId}:${min}`, async () => {
113
+ let list: Array<{ address: string; symbol?: string; name?: string; decimals?: number; iconUrl?: string; occurrences?: number }>
114
+ try {
115
+ list = await fetchJson(fetchFn, url)
116
+ } catch (err) {
117
+ if (err instanceof HttpStatusError && (err.status === 400 || err.status === 404)) {
118
+ return null // 不支持这条链
119
+ }
120
+ throw err
121
+ }
122
+ const tokens = (Array.isArray(list) ? list : [])
123
+ .filter((t) => t.address && t.address !== ZERO_ADDRESS && (t.occurrences ?? 0) >= min)
124
+ .map((t) => ({ address: t.address, symbol: t.symbol ?? null, name: t.name ?? null, decimals: validDecimals(t.decimals), logo: t.iconUrl ?? null }))
125
+ return tokens.length ? tokens : null
126
+ })
127
+ },
128
+ }
129
+ }
130
+
131
+ /** CoinGecko 按链维护的代币列表:chainId → 平台 id(2026-10 实测可用) */
132
+ export const COINGECKO_PLATFORMS: Readonly<Record<number, string>> = {
133
+ 1: 'ethereum',
134
+ 10: 'optimistic-ethereum',
135
+ 56: 'binance-smart-chain',
136
+ 130: 'unichain',
137
+ 137: 'polygon-pos',
138
+ 143: 'monad',
139
+ 173: 'eni',
140
+ 196: 'x-layer',
141
+ 204: 'opbnb',
142
+ 324: 'zksync',
143
+ 988: 'stable',
144
+ 999: 'hyperevm',
145
+ 1116: 'core',
146
+ 2818: 'morph-l2',
147
+ 4663: 'robinhood',
148
+ 5000: 'mantle',
149
+ 8453: 'base',
150
+ 9745: 'plasma',
151
+ 34443: 'mode',
152
+ 42161: 'arbitrum-one',
153
+ 43114: 'avalanche',
154
+ 57073: 'ink',
155
+ 59144: 'linea',
156
+ 80094: 'berachain',
157
+ 81457: 'blast',
158
+ 534352: 'scroll',
159
+ }
160
+
161
+ interface UniswapListToken {
162
+ chainId?: number
163
+ address: string
164
+ symbol?: string
165
+ name?: string
166
+ decimals?: number
167
+ logoURI?: string
168
+ }
169
+
170
+ function fromUniswapList(tokens: readonly UniswapListToken[], chainId: number): DiscoveredToken[] | null {
171
+ const out = tokens
172
+ .filter((t) => (t.chainId === undefined || t.chainId === chainId) && t.address)
173
+ .map((t) => ({ address: t.address, symbol: t.symbol ?? null, name: t.name ?? null, decimals: validDecimals(t.decimals), logo: t.logoURI ?? null }))
174
+ return out.length ? out : null
175
+ }
176
+
177
+ /** CoinGecko 代币列表(CoinGecko 收录的代币,Ethereum 约 6000 个、BSC 约 4500 个) */
178
+ export function coingeckoTokenList(): TokenSource {
179
+ return {
180
+ name: 'coingecko',
181
+ async discover({ chainId, fetch: fetchFn }) {
182
+ const platform = COINGECKO_PLATFORMS[chainId]
183
+ if (!platform) {
184
+ return null
185
+ }
186
+ const url = `https://tokens.coingecko.com/${platform}/all.json`
187
+ return cachedList(`coingecko:${chainId}`, async () => fromUniswapList((await fetchJson<{ tokens?: UniswapListToken[] }>(fetchFn, url)).tokens ?? [], chainId))
188
+ },
189
+ }
190
+ }
191
+
192
+ /** 任意 Uniswap Token List 格式的列表(如 https://tokens.uniswap.org、PancakeSwap 列表、自己维护的列表) */
193
+ export function tokenList(url: string, options: { name?: string } = {}): TokenSource {
194
+ return {
195
+ name: options.name ?? `list:${url}`,
196
+ discover({ chainId, fetch: fetchFn }) {
197
+ return cachedList(`list:${url}:${chainId}`, async () => fromUniswapList((await fetchJson<{ tokens?: UniswapListToken[] }>(fetchFn, url)).tokens ?? [], chainId))
198
+ },
199
+ }
200
+ }
201
+
202
+ /** 固定的代币列表(如之前发现过、存下来的代币) */
203
+ export function staticTokens(tokens: ReadonlyArray<string | DiscoveredToken>, options: { name?: string } = {}): TokenSource {
204
+ const list = tokens.map((t) => (typeof t === 'string' ? { address: t } : { ...t, decimals: validDecimals(t.decimals) }))
205
+ return {
206
+ name: options.name ?? 'static',
207
+ async discover() {
208
+ return list.length ? list : null
209
+ },
210
+ }
211
+ }
212
+
213
+ /** Alchemy 各链的子域名(2026-10 实测存在) */
214
+ export const ALCHEMY_NETWORKS: Readonly<Record<number, string>> = {
215
+ 1: 'eth-mainnet',
216
+ 10: 'opt-mainnet',
217
+ 56: 'bnb-mainnet',
218
+ 130: 'unichain-mainnet',
219
+ 137: 'polygon-mainnet',
220
+ 143: 'monad-mainnet',
221
+ 196: 'xlayer-mainnet',
222
+ 204: 'opbnb-mainnet',
223
+ 324: 'zksync-mainnet',
224
+ 999: 'hyperliquid-mainnet',
225
+ 5000: 'mantle-mainnet',
226
+ 8453: 'base-mainnet',
227
+ 42161: 'arb-mainnet',
228
+ 43114: 'avax-mainnet',
229
+ 57073: 'ink-mainnet',
230
+ 59144: 'linea-mainnet',
231
+ 80094: 'berachain-mainnet',
232
+ 81457: 'blast-mainnet',
233
+ 534352: 'scroll-mainnet',
234
+ }
235
+
236
+ export interface IndexerSourceOptions {
237
+ /** API Key(必填;库里没有内置 Key) */
238
+ apiKey: string
239
+ /** 按链覆盖请求地址(如代理或表里没有的链):{ [chainId]: url };其他链仍用默认地址 */
240
+ urls?: Readonly<Record<number, string>>
241
+ /** 最多翻几页(每页 100 个代币),默认 20 */
242
+ maxPages?: number
243
+ }
244
+
245
+ /**
246
+ * Alchemy `alchemy_getTokenBalances`:查到地址的全部历史持仓(不限于公开列表)。需要 Alchemy 的 API Key(有免费额度)。
247
+ */
248
+ export function alchemy(options: IndexerSourceOptions): TokenSource {
249
+ if (!options?.apiKey) {
250
+ throw new Error('alchemy() requires an apiKey (https://dashboard.alchemy.com)')
251
+ }
252
+ const maxPages = options.maxPages ?? 20
253
+ const secret = options.apiKey
254
+ return {
255
+ name: 'alchemy',
256
+ async discover({ chainId, owner, fetch: fetchFn }) {
257
+ const network = ALCHEMY_NETWORKS[chainId]
258
+ const url = options.urls?.[chainId] ?? (network ? `https://${network}.g.alchemy.com/v2/${secret}` : null)
259
+ if (!url) {
260
+ return null
261
+ }
262
+ const out: DiscoveredToken[] = []
263
+ let pageKey: string | undefined
264
+ for (let page = 0; page < maxPages; page++) {
265
+ const res = await fetchJson<{ result?: { tokenBalances?: Array<{ contractAddress: string }>; pageKey?: string }; error?: { message?: string } }>(
266
+ fetchFn,
267
+ url,
268
+ {
269
+ method: 'POST',
270
+ headers: { 'content-type': 'application/json' },
271
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'alchemy_getTokenBalances', params: [owner, 'erc20', { maxCount: 100, ...(pageKey ? { pageKey } : {}) }] }),
272
+ },
273
+ secret,
274
+ )
275
+ if (res.error) {
276
+ throw new Error(`alchemy_getTokenBalances failed: ${res.error.message ?? 'unknown error'}`)
277
+ }
278
+ for (const t of res.result?.tokenBalances ?? []) {
279
+ out.push({ address: t.contractAddress })
280
+ }
281
+ pageKey = res.result?.pageKey
282
+ if (!pageKey) {
283
+ break
284
+ }
285
+ }
286
+ return out
287
+ },
288
+ }
289
+ }
290
+
291
+ const NODEREAL_NETWORKS: Readonly<Record<number, string>> = { 1: 'eth-mainnet', 56: 'bsc-mainnet' }
292
+
293
+ /**
294
+ * NodeReal `nr_getTokenHoldings`:查到地址的全部历史持仓(仅 BSC、Ethereum)。需要 NodeReal 的 API Key(有免费额度)。
295
+ */
296
+ export function nodereal(options: IndexerSourceOptions): TokenSource {
297
+ if (!options?.apiKey) {
298
+ throw new Error('nodereal() requires an apiKey (https://nodereal.io)')
299
+ }
300
+ const maxPages = options.maxPages ?? 20
301
+ const secret = options.apiKey
302
+ return {
303
+ name: 'nodereal',
304
+ async discover({ chainId, owner, fetch: fetchFn }) {
305
+ const network = NODEREAL_NETWORKS[chainId]
306
+ const url = options.urls?.[chainId] ?? (network ? `https://${network}.nodereal.io/v1/${secret}` : null)
307
+ if (!url) {
308
+ return null
309
+ }
310
+ type Detail = { tokenAddress: string; tokenName?: string; tokenSymbol?: string; tokenDecimals?: string; tokenDecimails?: string }
311
+ const out: DiscoveredToken[] = []
312
+ for (let page = 1; page <= maxPages; page++) {
313
+ const res = await fetchJson<{ result?: { totalCount?: string; details?: Detail[] }; error?: { message?: string } }>(
314
+ fetchFn,
315
+ url,
316
+ {
317
+ method: 'POST',
318
+ headers: { 'content-type': 'application/json' },
319
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'nr_getTokenHoldings', params: [owner, `0x${page.toString(16)}`, '0x64'] }),
320
+ },
321
+ secret,
322
+ )
323
+ if (res.error) {
324
+ throw new Error(`nr_getTokenHoldings failed: ${res.error.message ?? 'unknown error'}`)
325
+ }
326
+ const details = res.result?.details ?? []
327
+ for (const d of details) {
328
+ // 文档里字段名就是 tokenDecimails(拼写如此),两种都兼容
329
+ out.push({ address: d.tokenAddress, symbol: d.tokenSymbol ?? null, name: d.tokenName ?? null, decimals: validDecimals(d.tokenDecimals ?? d.tokenDecimails) })
330
+ }
331
+ const total = Number(res.result?.totalCount ?? 0)
332
+ if (!details.length || out.length >= total) {
333
+ break
334
+ }
335
+ }
336
+ return out
337
+ },
338
+ }
339
+ }
340
+
341
+ const SOURCE = Symbol('source')
342
+ type Tagged = DiscoveredToken & { [SOURCE]?: string }
343
+
344
+ function tag(tokens: DiscoveredToken[], name: string): Tagged[] {
345
+ return tokens.map((t) => ({ ...t, [SOURCE]: (t as Tagged)[SOURCE] ?? name }))
346
+ }
347
+
348
+ /** 按顺序尝试:前一个不支持这条链(返回 null)或出错时用下一个;全部出错时抛出最后一个错误 */
349
+ export function firstAvailable(...sources: TokenSource[]): TokenSource {
350
+ return {
351
+ name: sources.map((s) => s.name).join('|'),
352
+ async discover(ctx) {
353
+ let lastError: unknown
354
+ for (const source of sources) {
355
+ try {
356
+ const tokens = await source.discover(ctx)
357
+ if (tokens) {
358
+ return tag(tokens, source.name)
359
+ }
360
+ } catch (err) {
361
+ lastError = err
362
+ }
363
+ }
364
+ if (lastError) {
365
+ throw lastError
366
+ }
367
+ return null
368
+ },
369
+ }
370
+ }
371
+
372
+ /**
373
+ * 合并多个来源(去重,先出现的元数据优先),如“公开列表 + 自己存下来的代币”。
374
+ * 单个来源出错不影响其他来源;全部出错时抛出第一个错误。
375
+ */
376
+ export function combine(...sources: TokenSource[]): TokenSource {
377
+ return {
378
+ name: sources.map((s) => s.name).join('+'),
379
+ async discover(ctx) {
380
+ const settled = await Promise.allSettled(sources.map((s) => s.discover(ctx)))
381
+ const ok = settled.flatMap((r, i) => (r.status === 'fulfilled' && r.value ? [tag(r.value, (sources[i] as TokenSource).name)] : []))
382
+ if (!ok.length) {
383
+ const failed = settled.find((r) => r.status === 'rejected')
384
+ if (failed) {
385
+ throw (failed as PromiseRejectedResult).reason
386
+ }
387
+ return null
388
+ }
389
+ return dedupe(ok.flat())
390
+ },
391
+ }
392
+ }
393
+
394
+ /** 默认来源:MetaMask 列表(至少 3 家收录),不支持或不可用时用 CoinGecko 列表。都免费、不需要 Key */
395
+ export function defaultTokenSource(): TokenSource {
396
+ return firstAvailable(metamaskTokenList(), coingeckoTokenList())
397
+ }
398
+
399
+ /** 去重键:0x 地址不区分大小写;Tron 的 base58 地址区分大小写,原样比较 */
400
+ function addressKey(address: string): string {
401
+ return isTronAddress(address) ? address : address.toLowerCase()
402
+ }
403
+
404
+ function dedupe<T extends DiscoveredToken>(tokens: T[]): T[] {
405
+ const seen = new Map<string, T>()
406
+ for (const t of tokens) {
407
+ const key = addressKey(t.address)
408
+ if (!seen.has(key)) seen.set(key, t)
409
+ }
410
+ return [...seen.values()]
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // 价格来源
415
+ // ---------------------------------------------------------------------------
416
+
417
+ export interface PriceSourceContext {
418
+ chainId: number
419
+ /** 代币地址(不含主币) */
420
+ tokens: readonly string[]
421
+ /** 主币 symbol(需要主币价格时);不需要时为 null */
422
+ nativeSymbol: string | null
423
+ fetch: typeof fetch
424
+ }
425
+
426
+ export interface PriceSource {
427
+ readonly name: string
428
+ /** 返回 去重键(0x 地址小写)→ 美元价格;主币用键 'native'。没有价格的不返回 */
429
+ prices(ctx: PriceSourceContext): Promise<Map<string, number>>
430
+ }
431
+
432
+ /** DefiLlama 的链标识:chainId → slug(2026-10 实测可用) */
433
+ export const DEFILLAMA_CHAINS: Readonly<Record<number, string>> = {
434
+ 1: 'ethereum',
435
+ 10: 'optimism',
436
+ 56: 'bsc',
437
+ 130: 'unichain',
438
+ 137: 'polygon',
439
+ 143: 'monad',
440
+ 196: 'xlayer',
441
+ 204: 'op_bnb',
442
+ 324: 'era',
443
+ 999: 'hyperliquid',
444
+ 1116: 'core',
445
+ 2818: 'morph',
446
+ 5000: 'mantle',
447
+ 8453: 'base',
448
+ 9745: 'plasma',
449
+ 34443: 'mode',
450
+ 42161: 'arbitrum',
451
+ 43114: 'avax',
452
+ 57073: 'ink',
453
+ 59144: 'linea',
454
+ 80094: 'berachain',
455
+ 81457: 'blast',
456
+ 534352: 'scroll',
457
+ }
458
+
459
+ /** 主币价格:按主币 symbol 对应 CoinGecko 的币种 id(DefiLlama 用 coingecko:<id> 查) */
460
+ const NATIVE_PRICE_IDS: Readonly<Record<string, string>> = {
461
+ ETH: 'ethereum',
462
+ BNB: 'binancecoin',
463
+ POL: 'polygon-ecosystem-token',
464
+ AVAX: 'avalanche-2',
465
+ MNT: 'mantle',
466
+ OKB: 'okb',
467
+ HYPE: 'hyperliquid',
468
+ BERA: 'berachain-bera',
469
+ CORE: 'coredaoorg',
470
+ MON: 'monad',
471
+ XPL: 'plasma',
472
+ TRX: 'tron',
473
+ }
474
+
475
+ /**
476
+ * DefiLlama 价格(免费、不需要 Key)。minConfidence:DefiLlama 给出的置信度下限(0~1),低于它视为没有价格,默认 0.9。
477
+ * 注意:流动性很差的代币价格可能虚高,展示总资产时建议配合 minUsd 或自己的价格源。
478
+ */
479
+ export function defillamaPrices(options: { minConfidence?: number } = {}): PriceSource {
480
+ const minConfidence = options.minConfidence ?? 0.9
481
+ return {
482
+ name: 'defillama',
483
+ async prices({ chainId, tokens, nativeSymbol, fetch: fetchFn }) {
484
+ const result = new Map<string, number>()
485
+ const slug = DEFILLAMA_CHAINS[chainId]
486
+ const nativeId = nativeSymbol ? NATIVE_PRICE_IDS[nativeSymbol.toUpperCase()] : undefined
487
+ const keys = [...(nativeId ? [`coingecko:${nativeId}`] : []), ...(slug ? tokens.map((t) => `${slug}:${t.toLowerCase()}`) : [])]
488
+ const batches: string[][] = []
489
+ for (let i = 0; i < keys.length; i += 100) {
490
+ batches.push(keys.slice(i, i + 100))
491
+ }
492
+ const responses = await Promise.all(
493
+ batches.map((batch) => fetchJson<{ coins?: Record<string, { price?: number; confidence?: number }> }>(fetchFn, `https://coins.llama.fi/prices/current/${batch.join(',')}`)),
494
+ )
495
+ for (const res of responses) {
496
+ for (const [key, coin] of Object.entries(res.coins ?? {})) {
497
+ if (typeof coin.price !== 'number' || !Number.isFinite(coin.price) || (coin.confidence !== undefined && coin.confidence < minConfidence)) {
498
+ continue
499
+ }
500
+ result.set(key.startsWith('coingecko:') ? 'native' : key.slice(key.indexOf(':') + 1).toLowerCase(), coin.price)
501
+ }
502
+ }
503
+ return result
504
+ },
505
+ }
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // ownerTokens
510
+ // ---------------------------------------------------------------------------
511
+
512
+ /**
513
+ * 主币在部分链上的 ERC20 映射地址:balanceOf 返回的就是主币余额,列表里出现时要排除,否则主币会被算两次。
514
+ * (Polygon 的 POL、zkSync 的 ETH 系统合约)
515
+ */
516
+ const NATIVE_ALIASES: Readonly<Record<number, readonly string[]>> = {
517
+ 137: ['0x0000000000000000000000000000000000001010'],
518
+ 324: ['0x000000000000000000000000000000000000800a'],
519
+ }
520
+
521
+ export interface OwnerTokensOptions extends CallOverrides {
522
+ /** 代币来源,默认 defaultTokenSource()(MetaMask 列表 → CoinGecko 列表,免费免 Key) */
523
+ source?: TokenSource
524
+ /** 查美元价格:true 用 DefiLlama,也可以传自己的 PriceSource。传了 minUsd 而没设置 prices 时自动用 DefiLlama */
525
+ prices?: boolean | PriceSource
526
+ /** 只保留价值 ≥ minUsd 的代币(没有价格的代币也会被去掉;主币不受影响)。与 prices: false 同时使用会报错 */
527
+ minUsd?: number
528
+ /** 第一项返回主币(用 Provider 的 nativeTokens 配置里的第一个地址查,默认 0xeeee…eeee;配置成 [] 时不返回)。默认 true */
529
+ includeNative?: boolean
530
+ /** 自定义 fetch(代理等场景);默认全局 fetch */
531
+ fetch?: typeof fetch
532
+ /**
533
+ * 增量扫描开关(默认关闭):开启后,额外合并“从第一次调用开始,该地址收到过的 ERC20 代币”(扫描 Transfer 事件),
534
+ * 用来补充代币列表里没有的新代币。传对象可以配置进度存储、回扫区块数等,见 TransferScanOptions
535
+ */
536
+ scanTransfers?: boolean | TransferScanOptions
537
+ }
538
+
539
+ export interface OwnedToken {
540
+ token: string
541
+ native: boolean
542
+ /** 余额(最小单位的十进制字符串),由 multicall 在链上核对 */
543
+ balance: string
544
+ /** 精度,由 multicall 在链上核对(不使用来源给的值) */
545
+ decimals: number
546
+ formatted: string
547
+ symbol: string | null
548
+ name: string | null
549
+ logo: string | null
550
+ /** 美元单价;未开启价格或没有价格时为 null */
551
+ price: number | null
552
+ /** 美元价值(formatted × price) */
553
+ value: number | null
554
+ /** 发现这个代币的来源(如 metamask / coingecko / alchemy);主币为 'native' */
555
+ source: string
556
+ }
557
+
558
+ export async function ownerTokens(provider: Provider, owner: string, options: OwnerTokensOptions = {}): Promise<OwnedToken[]> {
559
+ const { source: baseSource = defaultTokenSource(), prices, minUsd, includeNative = true, fetch: fetchOption, scanTransfers, ...overrides } = options
560
+ const source = scanTransfers ? combine(baseSource, transferScan(scanTransfers === true ? {} : scanTransfers)) : baseSource
561
+ if (minUsd !== undefined && prices === false) {
562
+ throw new Error('minUsd requires prices; remove prices: false or pass a PriceSource')
563
+ }
564
+ const priceSource = prices === true || (prices === undefined && minUsd !== undefined) ? defillamaPrices() : prices || null
565
+ const fetchFn = fetchOption ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
566
+ const chainId = await provider.getChainId()
567
+
568
+ const discovered = await source.discover({ chainId, owner, fetch: fetchFn, provider })
569
+ if (!discovered) {
570
+ throw new Error(`No token source supports chain ${chainId}; pass options.source (e.g. tokenList(url), alchemy({ apiKey }))`)
571
+ }
572
+ const aliases = new Set(NATIVE_ALIASES[chainId] ?? [])
573
+ const candidates = dedupe(discovered).filter(
574
+ (t) =>
575
+ (/^0x[0-9a-fA-F]{40}$/.test(t.address) || isTronAddress(t.address)) &&
576
+ t.address !== ZERO_ADDRESS &&
577
+ !provider.isNativeToken(t.address) &&
578
+ !aliases.has(t.address.toLowerCase()),
579
+ )
580
+
581
+ // 主币用 Provider 配置里的主币地址查(默认 0xeeee…eeee);配置成 [] 时不返回主币
582
+ const nativeAddress = includeNative ? (provider.nativeTokens[0] ?? null) : null
583
+ // 余额在链上核对;来源给的 decimals 先用于这一步(省一次查询),有余额的代币之后再核对精度
584
+ const inputs: BalanceToken[] = candidates.map((t) => (typeof t.decimals === 'number' ? { address: t.address, decimals: t.decimals } : t.address))
585
+ const balances = await provider.balances(owner, nativeAddress ? [nativeAddress, ...inputs] : inputs, overrides)
586
+ const nativeBalance = nativeAddress ? balances[0] : undefined
587
+ const tokenBalances = nativeAddress ? balances.slice(1) : balances
588
+ const held = candidates
589
+ .map((meta, i) => ({ meta, balance: tokenBalances[i] }))
590
+ .filter((x): x is { meta: Tagged; balance: NonNullable<typeof x.balance> } => !!x.balance?.success && x.balance.balance !== '0')
591
+
592
+ // 并行:有余额的代币在链上核对精度(并补齐来源没给的 name / symbol)、主币信息(含 nativeSymbol 等配置)、价格
593
+ const [onchain, [nativeInfo]] = await Promise.all([
594
+ held.length ? provider.tokens(held.map((x) => x.meta.address), { ...overrides, fields: ['decimals', 'name', 'symbol'] }) : Promise.resolve([]),
595
+ nativeAddress ? provider.tokens([nativeAddress], { fields: ['name', 'symbol'] }) : Promise.resolve([undefined]),
596
+ ])
597
+ const priceMap = priceSource
598
+ ? await priceSource.prices({ chainId, tokens: held.map((x) => x.meta.address), nativeSymbol: nativeAddress ? (nativeInfo?.symbol ?? null) : null, fetch: fetchFn })
599
+ : new Map<string, number>()
600
+ const valueOf = (formatted: string, price: number | undefined) => (price === undefined ? null : Number(formatted) * price)
601
+
602
+ let list: OwnedToken[] = held.flatMap(({ meta, balance }, i) => {
603
+ const chain = onchain[i]
604
+ // 链上读不到精度的不是正常 ERC20,去掉
605
+ if (!chain || chain.decimals === null) {
606
+ return []
607
+ }
608
+ const decimals = chain.decimals
609
+ const formatted = decimals === balance.decimals ? balance.formatted : formatAmount(BigInt(balance.balance), decimals)
610
+ const price = priceMap.get(addressKey(meta.address))
611
+ return [
612
+ {
613
+ token: meta.address,
614
+ native: false,
615
+ balance: balance.balance,
616
+ decimals,
617
+ formatted,
618
+ symbol: meta.symbol || chain.symbol || null,
619
+ name: meta.name || chain.name || null,
620
+ logo: meta.logo ?? null,
621
+ price: price ?? null,
622
+ value: valueOf(formatted, price),
623
+ source: meta[SOURCE] ?? source.name,
624
+ },
625
+ ]
626
+ })
627
+ if (minUsd !== undefined && priceSource) {
628
+ list = list.filter((t) => t.value !== null && t.value >= minUsd)
629
+ }
630
+ // 有价格时按价值从高到低,没有价格的排在后面(保持来源里的顺序)
631
+ if (priceSource) {
632
+ list.sort((a, b) => (b.value ?? -1) - (a.value ?? -1))
633
+ }
634
+ if (nativeAddress && nativeBalance?.success) {
635
+ const price = priceMap.get('native')
636
+ list.unshift({
637
+ token: nativeAddress === NATIVE_TOKEN.toLowerCase() ? NATIVE_TOKEN : nativeAddress,
638
+ native: true,
639
+ balance: nativeBalance.balance,
640
+ decimals: nativeBalance.decimals,
641
+ formatted: nativeBalance.formatted,
642
+ symbol: nativeInfo?.symbol ?? null,
643
+ name: nativeInfo?.name ?? null,
644
+ logo: null,
645
+ price: price ?? null,
646
+ value: valueOf(nativeBalance.formatted, price),
647
+ source: 'native',
648
+ })
649
+ }
650
+ return list
651
+ }