@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/provider.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  import { CallFailedError, isExecutionError } from './errors.js'
25
25
  import { MULTICALL3_ADDRESS, getMulticall3, type Multicall } from './multicall.js'
26
26
  import { getNativeCurrency } from './chains.js'
27
+ import { ownerTokens, type OwnedToken, type OwnerTokensOptions } from './owner.js'
27
28
  import {
28
29
  erc1155Balances,
29
30
  nftBalances,
@@ -364,6 +365,19 @@ export class Provider implements ContractRunner {
364
365
  })
365
366
  }
366
367
 
368
+ /**
369
+ * 列出持有人拥有的代币(资产列表):从代币来源拿候选代币,再用 multicall 在链上核对余额。
370
+ * 默认来源是公开代币列表,**只能发现列表里的代币**,局限性见 README「资产列表」一节。
371
+ *
372
+ * @deprecated 不推荐使用:EVM 链上无法只靠节点可靠地列出地址持有的全部代币,这个方法只是尽力而为——
373
+ * 默认来源只能发现公开列表里的代币,依赖第三方免费服务(可能限流、改格式或停止服务),要查几千个代币的余额、耗时数秒,
374
+ * 价格也不一定可信;传 `alchemy` / `nodereal` 来源能查全,但同样受第三方服务的额度和可用性约束。
375
+ * 已知要查哪些代币时请用 `getBalances`;需要可靠的完整持仓请直接接入索引服务的接口或自己的索引。局限性详见 README「资产列表」。
376
+ */
377
+ ownerTokens(owner: string, options?: OwnerTokensOptions): Promise<OwnedToken[]> {
378
+ return ownerTokens(this, owner, options)
379
+ }
380
+
367
381
  /** 批量查 NFT 集合信息(标准 / name / symbol / totalSupply),字段可选 */
368
382
  nftCollections<const F extends NftCollectionField = DefaultNftCollectionField>(
369
383
  collections: readonly string[],
@@ -648,6 +662,34 @@ export class Provider implements ContractRunner {
648
662
  }
649
663
  }
650
664
 
665
+ /** 当前区块号(底层节点需支持 getBlockNumber) */
666
+ async getBlockNumber(): Promise<number> {
667
+ const { provider } = await this.#ensureReady()
668
+ if (!provider.getBlockNumber) {
669
+ throw new Error('getBlockNumber is not supported by this provider')
670
+ }
671
+ return provider.getBlockNumber()
672
+ }
673
+
674
+ /** eth_getLogs(底层节点需支持 getLogs)。多节点时依次尝试,全部失败抛 GetLogsError */
675
+ async getLogs(filter: Parameters<NonNullable<EthersLikeProvider['getLogs']>>[0]): Promise<Awaited<ReturnType<NonNullable<EthersLikeProvider['getLogs']>>>> {
676
+ const { provider } = await this.#ensureReady()
677
+ if (!provider.getLogs) {
678
+ throw new Error('getLogs is not supported by this provider')
679
+ }
680
+ return provider.getLogs(filter)
681
+ }
682
+
683
+ /** 是否是按主币处理的地址(config.nativeTokens,默认 0xeeee…eeee 和零地址) */
684
+ isNativeToken(address: string): boolean {
685
+ return this.#nativeTokens.has(address.toLowerCase())
686
+ }
687
+
688
+ /** 按主币处理的地址(小写),顺序同 config.nativeTokens */
689
+ get nativeTokens(): readonly string[] {
690
+ return [...this.#nativeTokens]
691
+ }
692
+
651
693
  /** 底层连接(多节点时是 FallbackRpc)。只传节点构造时,需在 ready() 之后读取 */
652
694
  get rpc(): EthersLikeProvider {
653
695
  if (!this.#ctx) {
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
+
package/src/shortcuts.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { CallOverrides } from './aggregate.js'
2
+ import type { OwnedToken, OwnerTokensOptions } from './owner.js'
2
3
  import type { BalanceToken, DefaultTokenField, TokenAllowance, TokenBalance, TokenDetails, TokenField } from './erc20.js'
3
4
  import type {
4
5
  DefaultNftCollectionField,
@@ -245,6 +246,27 @@ export function getErc1155Balances(owner: string, items: readonly NftItem[], opt
245
246
  return provider.erc1155Balances(owner, items, overrides)
246
247
  }
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
+
248
270
  /** 测试用:按参数取(或创建)Provider,用于验证缓存复用(不在包的公开导出里) */
249
271
  export function resolveProviderForTest(options: ShortcutOptions): Provider {
250
272
  return resolve(options).provider
package/src/source.ts CHANGED
@@ -120,6 +120,22 @@ export class ChainCheckedProvider implements EthersLikeProvider {
120
120
  return this.#run(() => this.inner.getBalance(...args))
121
121
  }
122
122
 
123
+ getBlockNumber(): Promise<number> {
124
+ const inner = this.inner
125
+ if (!inner.getBlockNumber) {
126
+ return Promise.reject(new Error('getBlockNumber is not supported by this node'))
127
+ }
128
+ return this.#run(() => (inner.getBlockNumber as () => Promise<number>)())
129
+ }
130
+
131
+ getLogs(filter: Parameters<NonNullable<EthersLikeProvider['getLogs']>>[0]): Promise<Awaited<ReturnType<NonNullable<EthersLikeProvider['getLogs']>>>> {
132
+ const inner = this.inner
133
+ if (!inner.getLogs) {
134
+ return Promise.reject(new Error('getLogs is not supported by this node'))
135
+ }
136
+ return this.#run(() => (inner.getLogs as NonNullable<EthersLikeProvider['getLogs']>)(filter))
137
+ }
138
+
123
139
  async getChainId(): Promise<number> {
124
140
  return this.chainId
125
141
  }