@w3lib/evm-batch-call 0.4.0 → 0.4.1

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/erc20.ts CHANGED
@@ -243,13 +243,15 @@ export function importTokenMetaCache(snapshot: unknown): void {
243
243
  const key = `${rawKey.slice(0, separator)}:${rawKey.slice(separator + 1).toLowerCase()}`
244
244
  const meta: CachedMeta = {}
245
245
  if (Number.isInteger(value.decimals) && (value.decimals as number) >= 0 && (value.decimals as number) <= 255) meta.decimals = value.decimals
246
- if (typeof value.name === 'string' && typeof value.nameAt === 'number') {
246
+ const nameAt = validTimestamp(value.nameAt)
247
+ if (typeof value.name === 'string' && nameAt !== null) {
247
248
  meta.name = value.name
248
- meta.nameAt = value.nameAt
249
+ meta.nameAt = nameAt
249
250
  }
250
- if (typeof value.symbol === 'string' && typeof value.symbolAt === 'number') {
251
+ const symbolAt = validTimestamp(value.symbolAt)
252
+ if (typeof value.symbol === 'string' && symbolAt !== null) {
251
253
  meta.symbol = value.symbol
252
- meta.symbolAt = value.symbolAt
254
+ meta.symbolAt = symbolAt
253
255
  }
254
256
  if (value.standard === 'ERC721' || value.standard === 'ERC1155' || value.standard === null) meta.standard = value.standard
255
257
  if (Object.keys(meta).length && !tokenMetaCache.has(key)) {
@@ -261,6 +263,17 @@ export function importTokenMetaCache(snapshot: unknown): void {
261
263
  }
262
264
  }
263
265
 
266
+ /**
267
+ * 导入的写入时间:只接受有限的数值;晚于当前时间的(时钟不准、数据被改过)截到当前时间——否则永不过期。
268
+ * 不合格时为 null(丢弃 name / symbol,之后重新查)
269
+ */
270
+ function validTimestamp(value: unknown): number | null {
271
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
272
+ return null
273
+ }
274
+ return Math.min(value, Date.now())
275
+ }
276
+
264
277
  /** persistTokenMetaCache 的存储:localStorage / sessionStorage 可以直接传,也可以自己实现 */
265
278
  export interface TokenMetaStore {
266
279
  getItem(key: string): string | null
package/src/fallback.ts CHANGED
@@ -25,6 +25,26 @@ export interface FallbackNodeInfo {
25
25
  label: string
26
26
  /** 健康状态的 key:URL 字符串或节点对象。相同 key 的节点在所有 FallbackRpc 之间共享冷却状态 */
27
27
  key: string | object
28
+ /** 对象节点按链分开记健康状态(钱包可以切链) */
29
+ chainId?: number
30
+ }
31
+
32
+ /** call 的结果校验:返回 false 时视为这个节点的结果不可用(如返回空数据),换下一个节点、不冷却 */
33
+ export interface CallValidation {
34
+ accept?: (result: string) => boolean
35
+ /**
36
+ * 最多几个节点的结果没通过校验就不再换节点(默认 2:两个节点结果一致,足以确认是合约本身的问题,
37
+ * 不必把所有节点挨个试一遍——链上确实没有合约时,每个节点都会这样)
38
+ */
39
+ maxInvalid?: number
40
+ }
41
+
42
+ /** 节点返回的结果没通过校验(如 eth_call 返回 0x / 无法解码):换节点,不冷却(可能是合约确实不存在,所有节点都会这样) */
43
+ export class InvalidResultError extends Error {
44
+ constructor(readonly result: string) {
45
+ super(`Unusable eth_call result: ${result.length > 66 ? `${result.slice(0, 66)}…` : result}`)
46
+ this.name = 'InvalidResultError'
47
+ }
28
48
  }
29
49
 
30
50
  /** 节点健康状态,按节点(URL / 对象)全局共享:Provider 重建后刚失败过的节点仍排在后面 */
@@ -32,19 +52,22 @@ interface Health {
32
52
  failedAt: number
33
53
  /** 连续超时次数 */
34
54
  timeouts: number
55
+ /** 开了 stallTimeout 时,在并发竞争中输给后发节点的时间(慢但没出错):冷却期内排在健康节点之后 */
56
+ slowAt: number
35
57
  }
36
58
 
37
59
  /** 连续超时多少次后才冷却(偶尔慢不算坏节点) */
38
60
  const TIMEOUTS_BEFORE_COOLDOWN = 3
39
61
  const MAX_TRACKED_URLS = 1000
40
62
  const healthByUrl = new Map<string, Health>()
41
- const healthByObject = new WeakMap<object, Health>()
63
+ /** 对象节点(钱包、ethers Provider)按链分开记:钱包在一条链上出错不影响它在其他链上的排序 */
64
+ const healthByObject = new WeakMap<object, Map<number | undefined, Health>>()
42
65
 
43
- function healthOf(key: string | object): Health {
66
+ function healthOf(key: string | object, scope?: number): Health {
44
67
  if (typeof key === 'string') {
45
68
  let health = healthByUrl.get(key)
46
69
  if (!health) {
47
- health = { failedAt: 0, timeouts: 0 }
70
+ health = { failedAt: 0, timeouts: 0, slowAt: 0 }
48
71
  healthByUrl.set(key, health)
49
72
  if (healthByUrl.size > MAX_TRACKED_URLS) {
50
73
  healthByUrl.delete(healthByUrl.keys().next().value as string)
@@ -52,10 +75,15 @@ function healthOf(key: string | object): Health {
52
75
  }
53
76
  return health
54
77
  }
55
- let health = healthByObject.get(key)
78
+ let byScope = healthByObject.get(key)
79
+ if (!byScope) {
80
+ byScope = new Map()
81
+ healthByObject.set(key, byScope)
82
+ }
83
+ let health = byScope.get(scope)
56
84
  if (!health) {
57
- health = { failedAt: 0, timeouts: 0 }
58
- healthByObject.set(key, health)
85
+ health = { failedAt: 0, timeouts: 0, slowAt: 0 }
86
+ byScope.set(scope, health)
59
87
  }
60
88
  return health
61
89
  }
@@ -90,6 +118,7 @@ export class FallbackRpc implements EthersLikeProvider {
90
118
  readonly #labels: string[]
91
119
  /** 健康状态的 key;每次用时再从全局表里取(表淘汰条目后,各实例仍取到同一个对象) */
92
120
  readonly #healthKeys: Array<string | object>
121
+ readonly #healthScopes: Array<number | undefined>
93
122
  /** 上次 getLogs 成功的节点 */
94
123
  #logsNode = -1
95
124
  /** 上次成功的节点连续被限频的次数 */
@@ -107,6 +136,7 @@ export class FallbackRpc implements EthersLikeProvider {
107
136
  this.#cooldown = options.cooldown ?? 30_000
108
137
  this.#labels = nodes.map((_, i) => info?.[i]?.label ?? `node${i}`)
109
138
  this.#healthKeys = nodes.map((node, i) => info?.[i]?.key ?? node)
139
+ this.#healthScopes = nodes.map((_, i) => info?.[i]?.chainId)
110
140
  this.#logsFailedAt = nodes.map(() => 0)
111
141
  }
112
142
 
@@ -114,8 +144,24 @@ export class FallbackRpc implements EthersLikeProvider {
114
144
  return this.#nodes
115
145
  }
116
146
 
117
- call(tx: TransactionRequest): Promise<string> {
118
- return this.#run('call', (node) => node.call(tx))
147
+ /** eth_call;validation.accept 返回 false 的结果视为这个节点不可用,换下一个节点(不冷却) */
148
+ call(tx: TransactionRequest, validation?: CallValidation): Promise<string> {
149
+ const accept = validation?.accept
150
+ return this.#run(
151
+ 'call',
152
+ (node) =>
153
+ node.call(tx).then((result) => {
154
+ if (accept && !accept(result)) {
155
+ throw new InvalidResultError(result)
156
+ }
157
+ return result
158
+ }),
159
+ accept ? (validation?.maxInvalid ?? 2) : Number.POSITIVE_INFINITY,
160
+ )
161
+ }
162
+
163
+ #health(index: number): Health {
164
+ return healthOf(this.#healthKeys[index] as string | object, this.#healthScopes[index])
119
165
  }
120
166
 
121
167
  getBalance(address: Parameters<EthersLikeProvider['getBalance']>[0], blockTag?: Parameters<EthersLikeProvider['getBalance']>[1]): Promise<bigint> {
@@ -238,12 +284,16 @@ export class FallbackRpc implements EthersLikeProvider {
238
284
  * 节点还没有请求的区块(minBlock 重查时落后的节点)也只换节点、不冷却。
239
285
  * 确定性错误(revert、参数错误等)直接抛出;所有节点都失败时抛 AllNodesFailedError(单节点时抛原始错误)。
240
286
  */
241
- #run<T>(method: string, fn: (node: EthersLikeProvider) => Promise<T>): Promise<T> {
287
+ #run<T>(method: string, fn: (node: EthersLikeProvider) => Promise<T>, maxInvalid = Number.POSITIVE_INFINITY): Promise<T> {
242
288
  const order = this.#order()
289
+ let invalid = 0
243
290
  return new Promise<T>((resolve, reject) => {
244
291
  let next = 0
245
292
  let running = 0
246
293
  let settled = false
294
+ /** 正在进行的请求(按发出顺序):有节点胜出时,比它先发出、还没返回的节点记一次“慢” */
295
+ const inFlight: Array<{ index: number; attempt: number }> = []
296
+ const done = (index: number) => inFlight.splice(inFlight.findIndex((f) => f.index === index), 1)
247
297
  const errors: Array<{ node: string; error: unknown }> = []
248
298
  let stallTimer: ReturnType<typeof setTimeout> | undefined
249
299
  const finish = (done: () => void) => {
@@ -261,8 +311,9 @@ export class FallbackRpc implements EthersLikeProvider {
261
311
  }
262
312
  const attempt = next++
263
313
  const index = order[attempt] as number
264
- const health = healthOf(this.#healthKeys[index] as string | object)
314
+ const health = this.#health(index)
265
315
  running++
316
+ inFlight.push({ index, attempt })
266
317
  clearTimeout(stallTimer)
267
318
  if (this.#stallTimeout > 0 && next < order.length) {
268
319
  stallTimer = setTimeout(launch, this.#stallTimeout)
@@ -270,21 +321,26 @@ export class FallbackRpc implements EthersLikeProvider {
270
321
  this.#attempt(method, index, attempt, fn).then(
271
322
  (result) => {
272
323
  running--
324
+ done(index)
273
325
  health.failedAt = 0
274
326
  health.timeouts = 0
275
327
  if (!settled) {
328
+ health.slowAt = 0
329
+ // 先发出却还没返回的节点输掉了竞争:记一次慢,冷却期内排在健康节点之后(之后返回成功也不清除)
330
+ inFlight.filter((f) => f.attempt < attempt).forEach((f) => (this.#health(f.index).slowAt = Date.now()))
276
331
  finish(() => resolve(result))
277
332
  }
278
333
  },
279
334
  (err: unknown) => {
280
335
  running--
336
+ done(index)
281
337
  const deterministic = isDeterministic(err)
282
338
  if (err instanceof TimeoutError) {
283
339
  if (++health.timeouts >= TIMEOUTS_BEFORE_COOLDOWN) {
284
340
  health.failedAt = Date.now()
285
341
  health.timeouts = 0
286
342
  }
287
- } else if (!deterministic && !isBehind(err)) {
343
+ } else if (!deterministic && !isBehind(err) && !(err instanceof InvalidResultError) && !isNullResult(err)) {
288
344
  health.failedAt = Date.now()
289
345
  }
290
346
  if (settled) {
@@ -295,7 +351,9 @@ export class FallbackRpc implements EthersLikeProvider {
295
351
  return
296
352
  }
297
353
  errors.push({ node: this.#labels[index] as string, error: err })
298
- if (!launch() && running === 0) {
354
+ // 结果没通过校验的节点够数了(已能确认是合约本身的问题):不再换节点,等进行中的请求结束
355
+ const enough = err instanceof InvalidResultError && ++invalid >= maxInvalid
356
+ if ((enough || !launch()) && running === 0) {
299
357
  fail()
300
358
  }
301
359
  },
@@ -306,14 +364,28 @@ export class FallbackRpc implements EthersLikeProvider {
306
364
  })
307
365
  }
308
366
 
309
- /** 健康的节点按原顺序在前,冷却中的按出错时间先后排在后面(全挂时也都会再试一次) */
310
- #order(failedAt: readonly number[] = this.#healthKeys.map((key) => healthOf(key).failedAt)): number[] {
367
+ /**
368
+ * 健康的节点按原顺序在前;最近在竞争中输过的慢节点其次;冷却中的按出错时间先后排在最后(全挂时也都会再试一次)。
369
+ * 传入 failedAt 时(getLogs 自己的冷却表)不考虑慢节点
370
+ */
371
+ #order(failedAt?: readonly number[]): number[] {
311
372
  const now = Date.now()
373
+ const health = this.#nodes.map((_, i) => this.#health(i))
374
+ const failed = failedAt ?? health.map((h) => h.failedAt)
312
375
  const healthy: number[] = []
376
+ const slow: number[] = []
313
377
  const cooling: number[] = []
314
- failedAt.forEach((at, i) => (at && now - at < this.#cooldown ? cooling : healthy).push(i))
315
- cooling.sort((a, b) => (failedAt[a] as number) - (failedAt[b] as number))
316
- return [...healthy, ...cooling]
378
+ failed.forEach((at, i) => {
379
+ if (at && now - at < this.#cooldown) {
380
+ cooling.push(i)
381
+ } else if (!failedAt && (health[i] as Health).slowAt && now - (health[i] as Health).slowAt < this.#cooldown) {
382
+ slow.push(i)
383
+ } else {
384
+ healthy.push(i)
385
+ }
386
+ })
387
+ cooling.sort((a, b) => (failed[a] as number) - (failed[b] as number))
388
+ return [...healthy, ...slow, ...cooling]
317
389
  }
318
390
 
319
391
  /** 上次 getLogs 成功的节点(不在 getLogs 冷却中时)排到最前 */
@@ -362,15 +434,25 @@ function isRateLimited(err: unknown): boolean {
362
434
  * 节点还没有请求的区块(minBlock 按指定区块重查时,落后的节点会这样报错):只换节点、不冷却——节点本身是健康的,只是慢一两个块
363
435
  */
364
436
  function isBehind(err: unknown): boolean {
365
- return /header not found|unknown block|block not found|block .*not (yet )?(available|found)|requested block .*(ahead|future)|after last accepted block/i.test(errorText(err))
437
+ return BEHIND_RE.test(errorText(err))
438
+ }
439
+
440
+ // 各节点对“还没有这个区块”的报错(2026-10 对内置节点实测):geth / erigon、Arbitrum 系、X Layer、Mantle、zkSync、Winchain 等
441
+ const BEHIND_RE =
442
+ /header not found|unknown block|block not found|block .*not (yet )?(available|found)|requested block .*(ahead|future|above)|after last accepted block|unsupported block number|block is out of range|above the latest|height must be less than or equal to the head|doesn't exist yet|block number .*(exceeds|greater than|higher than)|future block/i
443
+
444
+ /**
445
+ * 节点返回 result: null(ethers 解析响应时报 INVALID_ARGUMENT,值为 null):换节点、不冷却。
446
+ * 只认 null——调用方传入 undefined 等非法参数时值是 undefined,仍是确定性错误,不能拿去每个节点重试
447
+ */
448
+ function isNullResult(err: unknown): boolean {
449
+ return isError(err, 'INVALID_ARGUMENT') && (err as { value?: unknown }).value === null
366
450
  }
367
451
 
368
- /** 换节点也不会变的错误:合约执行结果、参数错误、用户拒绝等 */
452
+ /** 换节点也不会变的错误:合约执行结果、参数错误、用户拒绝等(节点返回 null 除外,见 isNullResult) */
369
453
  function isDeterministic(err: unknown): boolean {
370
- return (
371
- isExecutionError(err) ||
372
- isError(err, 'INVALID_ARGUMENT') ||
373
- isError(err, 'ACTION_REJECTED') ||
374
- isError(err, 'NUMERIC_FAULT')
375
- )
454
+ if (isError(err, 'INVALID_ARGUMENT')) {
455
+ return !isNullResult(err)
456
+ }
457
+ return isExecutionError(err) || isError(err, 'ACTION_REJECTED') || isError(err, 'NUMERIC_FAULT')
376
458
  }
package/src/source.ts CHANGED
@@ -53,7 +53,7 @@ export function resolveSource(
53
53
  // 内置公共节点本来就是按 chainId 选出来的,不需要再校验
54
54
  return source === undefined ? node : withChainCheck(chainId, item, node, timeout)
55
55
  })
56
- const info: FallbackNodeInfo[] = list.map((item) => ({ label: nodeLabel(item), key: item as string | object }))
56
+ const info: FallbackNodeInfo[] = list.map((item) => ({ label: nodeLabel(item), key: item as string | object, chainId: Number.isFinite(chainId) ? chainId : undefined }))
57
57
  // 单节点也经过 FallbackRpc(统一触发 onRequest 事件、抛原始错误)。外层超时只对单个 EVM URL 节点加(FetchRequest 本来就有同样的超时);
58
58
  // 钱包等对象节点、Tron URL(TronProvider 有自己的超时和 429 退避、限流队列,外层超时会把排队 / 退避中的请求截断)不传 timeout 时不加,与原来一致
59
59
  const singleEvmUrl = typeof list[0] === 'string' && !isTronChain(chainId)
package/src/watch.ts CHANGED
@@ -28,6 +28,8 @@ interface Watch {
28
28
  running: boolean
29
29
  /** 最近一次的结果(按规范化的代币 key),新订阅者可以立即拿到 */
30
30
  latest: Map<string, AnyBalance> | null
31
+ /** 上一次轮询结束的时间:下一次 = 它 + 最小间隔(新订阅者间隔更短时提前,不会因重新计时而推迟) */
32
+ lastPollAt: number
31
33
  }
32
34
 
33
35
  // 按 Provider 实例分组(快捷函数对相同节点参数复用同一个实例),组内按 “钱包 + 代币集合 + 选项” 共用一份轮询
@@ -73,7 +75,7 @@ export function watchBalances(owner: string, tokens: readonly BalanceToken[], op
73
75
  let watch = group.get(key)
74
76
  const isNew = !watch
75
77
  if (!watch) {
76
- watch = { subscribers: new Set(), timer: null, running: false, latest: null }
78
+ watch = { subscribers: new Set(), timer: null, running: false, latest: null, lastPollAt: 0 }
77
79
  group.set(key, watch)
78
80
  }
79
81
  const subscriber: Subscriber = { tokens, interval, onChange: onChange as Subscriber['onChange'], onError, last: null, signature: null }
@@ -102,8 +104,8 @@ export function watchBalances(owner: string, tokens: readonly BalanceToken[], op
102
104
  if (!current.subscribers.size) {
103
105
  return
104
106
  }
105
- const wait = Math.min(...[...current.subscribers].map((sub) => sub.interval))
106
- current.timer = setTimeout(poll, wait)
107
+ const interval = Math.min(...[...current.subscribers].map((sub) => sub.interval))
108
+ current.timer = setTimeout(poll, Math.max(0, current.lastPollAt + interval - Date.now()))
107
109
  }
108
110
  const poll = async () => {
109
111
  current.timer = null
@@ -119,6 +121,7 @@ export function watchBalances(owner: string, tokens: readonly BalanceToken[], op
119
121
  current.subscribers.forEach((sub) => safely(() => sub.onError?.(err)))
120
122
  } finally {
121
123
  current.running = false
124
+ current.lastPollAt = Date.now()
122
125
  schedule()
123
126
  }
124
127
  }