@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/CHANGELOG.md +11 -0
- package/README.md +91 -0
- package/dist/aggregate.d.ts +5 -2
- package/dist/aggregate.d.ts.map +1 -1
- package/dist/aggregate.js.map +1 -1
- package/dist/fallback.d.ts +23 -0
- package/dist/fallback.d.ts.map +1 -1
- package/dist/fallback.js +117 -3
- package/dist/fallback.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/owner.d.ts +136 -0
- package/dist/owner.d.ts.map +1 -0
- package/dist/owner.js +501 -0
- package/dist/owner.js.map +1 -0
- package/dist/provider.d.ts +19 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +37 -0
- package/dist/provider.js.map +1 -1
- package/dist/scan.d.ts +59 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +245 -0
- package/dist/scan.js.map +1 -0
- package/dist/shortcuts.d.ts +18 -0
- package/dist/shortcuts.d.ts.map +1 -1
- package/dist/shortcuts.js +18 -0
- package/dist/shortcuts.js.map +1 -1
- package/dist/source.d.ts +2 -0
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +14 -0
- package/dist/source.js.map +1 -1
- package/package.json +1 -1
- package/src/aggregate.ts +5 -2
- package/src/fallback.ts +120 -3
- package/src/index.ts +35 -0
- package/src/owner.ts +651 -0
- package/src/provider.ts +42 -0
- package/src/scan.ts +293 -0
- package/src/shortcuts.ts +22 -0
- package/src/source.ts +16 -0
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
|
+
}
|