@w3lib/evm-batch-call 0.4.0 → 0.4.2

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/fallback.ts CHANGED
@@ -25,6 +25,42 @@ 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
+ * 检查节点返回的错误:可以抛出别的错误替换它(如 deployless 结果过大时通过 revert 带回的结果,
42
+ * 校验出节点落后时抛 NodeBehindError,这样也能换节点,而不是当成确定性的合约错误直接返回)
43
+ */
44
+ inspectError?: (err: unknown) => void
45
+ }
46
+
47
+ /** 节点的结果早于要求的区块(minBlock):节点落后,换下一个节点,不冷却(节点是健康的,只是慢几个块) */
48
+ export class NodeBehindError extends Error {
49
+ constructor(
50
+ readonly minBlock: bigint,
51
+ readonly block: bigint,
52
+ ) {
53
+ super(`Node is behind: block ${block} < minBlock ${minBlock}`)
54
+ this.name = 'NodeBehindError'
55
+ }
56
+ }
57
+
58
+ /** 节点返回的结果没通过校验(如 eth_call 返回 0x / 无法解码):换节点,不冷却(可能是合约确实不存在,所有节点都会这样) */
59
+ export class InvalidResultError extends Error {
60
+ constructor(readonly result: string) {
61
+ super(`Unusable eth_call result: ${result.length > 66 ? `${result.slice(0, 66)}…` : result}`)
62
+ this.name = 'InvalidResultError'
63
+ }
28
64
  }
29
65
 
30
66
  /** 节点健康状态,按节点(URL / 对象)全局共享:Provider 重建后刚失败过的节点仍排在后面 */
@@ -32,19 +68,27 @@ interface Health {
32
68
  failedAt: number
33
69
  /** 连续超时次数 */
34
70
  timeouts: number
71
+ /** 开了 stallTimeout 时,在并发竞争中输给后发节点的时间(慢但没出错):冷却期内排在健康节点之后 */
72
+ slowAt: number
73
+ /** minBlock 校验发现节点落后的时间:短时间内(BEHIND_DEMOTE)排在健康节点之后——出块只要几秒,不必像故障那样冷却 30 秒 */
74
+ behindAt: number
35
75
  }
36
76
 
77
+ /** 落后的节点排到后面的时长:通常几秒内就会跟上,不长期影响其他查询的节点顺序 */
78
+ const BEHIND_DEMOTE = 5_000
79
+
37
80
  /** 连续超时多少次后才冷却(偶尔慢不算坏节点) */
38
81
  const TIMEOUTS_BEFORE_COOLDOWN = 3
39
82
  const MAX_TRACKED_URLS = 1000
40
83
  const healthByUrl = new Map<string, Health>()
41
- const healthByObject = new WeakMap<object, Health>()
84
+ /** 对象节点(钱包、ethers Provider)按链分开记:钱包在一条链上出错不影响它在其他链上的排序 */
85
+ const healthByObject = new WeakMap<object, Map<number | undefined, Health>>()
42
86
 
43
- function healthOf(key: string | object): Health {
87
+ function healthOf(key: string | object, scope?: number): Health {
44
88
  if (typeof key === 'string') {
45
89
  let health = healthByUrl.get(key)
46
90
  if (!health) {
47
- health = { failedAt: 0, timeouts: 0 }
91
+ health = { failedAt: 0, timeouts: 0, slowAt: 0, behindAt: 0 }
48
92
  healthByUrl.set(key, health)
49
93
  if (healthByUrl.size > MAX_TRACKED_URLS) {
50
94
  healthByUrl.delete(healthByUrl.keys().next().value as string)
@@ -52,10 +96,15 @@ function healthOf(key: string | object): Health {
52
96
  }
53
97
  return health
54
98
  }
55
- let health = healthByObject.get(key)
99
+ let byScope = healthByObject.get(key)
100
+ if (!byScope) {
101
+ byScope = new Map()
102
+ healthByObject.set(key, byScope)
103
+ }
104
+ let health = byScope.get(scope)
56
105
  if (!health) {
57
- health = { failedAt: 0, timeouts: 0 }
58
- healthByObject.set(key, health)
106
+ health = { failedAt: 0, timeouts: 0, slowAt: 0, behindAt: 0 }
107
+ byScope.set(scope, health)
59
108
  }
60
109
  return health
61
110
  }
@@ -90,6 +139,7 @@ export class FallbackRpc implements EthersLikeProvider {
90
139
  readonly #labels: string[]
91
140
  /** 健康状态的 key;每次用时再从全局表里取(表淘汰条目后,各实例仍取到同一个对象) */
92
141
  readonly #healthKeys: Array<string | object>
142
+ readonly #healthScopes: Array<number | undefined>
93
143
  /** 上次 getLogs 成功的节点 */
94
144
  #logsNode = -1
95
145
  /** 上次成功的节点连续被限频的次数 */
@@ -107,6 +157,7 @@ export class FallbackRpc implements EthersLikeProvider {
107
157
  this.#cooldown = options.cooldown ?? 30_000
108
158
  this.#labels = nodes.map((_, i) => info?.[i]?.label ?? `node${i}`)
109
159
  this.#healthKeys = nodes.map((node, i) => info?.[i]?.key ?? node)
160
+ this.#healthScopes = nodes.map((_, i) => info?.[i]?.chainId)
110
161
  this.#logsFailedAt = nodes.map(() => 0)
111
162
  }
112
163
 
@@ -114,8 +165,30 @@ export class FallbackRpc implements EthersLikeProvider {
114
165
  return this.#nodes
115
166
  }
116
167
 
117
- call(tx: TransactionRequest): Promise<string> {
118
- return this.#run('call', (node) => node.call(tx))
168
+ /** eth_call;validation.accept 返回 false 的结果视为这个节点不可用,换下一个节点(不冷却) */
169
+ call(tx: TransactionRequest, validation?: CallValidation): Promise<string> {
170
+ const accept = validation?.accept
171
+ return this.#run(
172
+ 'call',
173
+ (node) =>
174
+ node.call(tx).then(
175
+ (result) => {
176
+ if (accept && !accept(result)) {
177
+ throw new InvalidResultError(result)
178
+ }
179
+ return result
180
+ },
181
+ (err: unknown) => {
182
+ validation?.inspectError?.(err)
183
+ throw err
184
+ },
185
+ ),
186
+ accept ? (validation?.maxInvalid ?? 2) : Number.POSITIVE_INFINITY,
187
+ )
188
+ }
189
+
190
+ #health(index: number): Health {
191
+ return healthOf(this.#healthKeys[index] as string | object, this.#healthScopes[index])
119
192
  }
120
193
 
121
194
  getBalance(address: Parameters<EthersLikeProvider['getBalance']>[0], blockTag?: Parameters<EthersLikeProvider['getBalance']>[1]): Promise<bigint> {
@@ -238,12 +311,16 @@ export class FallbackRpc implements EthersLikeProvider {
238
311
  * 节点还没有请求的区块(minBlock 重查时落后的节点)也只换节点、不冷却。
239
312
  * 确定性错误(revert、参数错误等)直接抛出;所有节点都失败时抛 AllNodesFailedError(单节点时抛原始错误)。
240
313
  */
241
- #run<T>(method: string, fn: (node: EthersLikeProvider) => Promise<T>): Promise<T> {
314
+ #run<T>(method: string, fn: (node: EthersLikeProvider) => Promise<T>, maxInvalid = Number.POSITIVE_INFINITY): Promise<T> {
242
315
  const order = this.#order()
316
+ let invalid = 0
243
317
  return new Promise<T>((resolve, reject) => {
244
318
  let next = 0
245
319
  let running = 0
246
320
  let settled = false
321
+ /** 正在进行的请求(按发出顺序):有节点胜出时,比它先发出、还没返回的节点记一次“慢” */
322
+ const inFlight: Array<{ index: number; attempt: number }> = []
323
+ const done = (index: number) => inFlight.splice(inFlight.findIndex((f) => f.index === index), 1)
247
324
  const errors: Array<{ node: string; error: unknown }> = []
248
325
  let stallTimer: ReturnType<typeof setTimeout> | undefined
249
326
  const finish = (done: () => void) => {
@@ -261,8 +338,9 @@ export class FallbackRpc implements EthersLikeProvider {
261
338
  }
262
339
  const attempt = next++
263
340
  const index = order[attempt] as number
264
- const health = healthOf(this.#healthKeys[index] as string | object)
341
+ const health = this.#health(index)
265
342
  running++
343
+ inFlight.push({ index, attempt })
266
344
  clearTimeout(stallTimer)
267
345
  if (this.#stallTimeout > 0 && next < order.length) {
268
346
  stallTimer = setTimeout(launch, this.#stallTimeout)
@@ -270,21 +348,31 @@ export class FallbackRpc implements EthersLikeProvider {
270
348
  this.#attempt(method, index, attempt, fn).then(
271
349
  (result) => {
272
350
  running--
351
+ done(index)
273
352
  health.failedAt = 0
274
353
  health.timeouts = 0
354
+ health.behindAt = 0 // 已经能给出结果:不再因为之前落后而排到后面
275
355
  if (!settled) {
356
+ health.slowAt = 0
357
+ // 先发出却还没返回的节点输掉了竞争:记一次慢,冷却期内排在健康节点之后(之后返回成功也不清除)
358
+ inFlight.filter((f) => f.attempt < attempt).forEach((f) => (this.#health(f.index).slowAt = Date.now()))
276
359
  finish(() => resolve(result))
277
360
  }
278
361
  },
279
362
  (err: unknown) => {
280
363
  running--
364
+ done(index)
281
365
  const deterministic = isDeterministic(err)
282
366
  if (err instanceof TimeoutError) {
283
367
  if (++health.timeouts >= TIMEOUTS_BEFORE_COOLDOWN) {
284
368
  health.failedAt = Date.now()
285
369
  health.timeouts = 0
286
370
  }
287
- } else if (!deterministic && !isBehind(err)) {
371
+ } else if (err instanceof NodeBehindError || isBehind(err)) {
372
+ // 落后(结果早于 minBlock,或对按区块号的查询报“区块不存在”)不算故障、不冷却,
373
+ // 只在几秒内排到健康节点之后:紧接着的查询先问别的节点
374
+ health.behindAt = Date.now()
375
+ } else if (!deterministic && !(err instanceof InvalidResultError) && !isNullResult(err)) {
288
376
  health.failedAt = Date.now()
289
377
  }
290
378
  if (settled) {
@@ -295,7 +383,9 @@ export class FallbackRpc implements EthersLikeProvider {
295
383
  return
296
384
  }
297
385
  errors.push({ node: this.#labels[index] as string, error: err })
298
- if (!launch() && running === 0) {
386
+ // 结果没通过校验的节点够数了(已能确认是合约本身的问题):不再换节点,等进行中的请求结束
387
+ const enough = err instanceof InvalidResultError && ++invalid >= maxInvalid
388
+ if ((enough || !launch()) && running === 0) {
299
389
  fail()
300
390
  }
301
391
  },
@@ -306,14 +396,32 @@ export class FallbackRpc implements EthersLikeProvider {
306
396
  })
307
397
  }
308
398
 
309
- /** 健康的节点按原顺序在前,冷却中的按出错时间先后排在后面(全挂时也都会再试一次) */
310
- #order(failedAt: readonly number[] = this.#healthKeys.map((key) => healthOf(key).failedAt)): number[] {
399
+ /**
400
+ * 健康的节点按原顺序在前;最近在竞争中输过的慢节点其次;冷却中的按出错时间先后排在最后(全挂时也都会再试一次)。
401
+ * 传入 failedAt 时(getLogs 自己的冷却表)不考虑慢节点
402
+ */
403
+ #order(failedAt?: readonly number[]): number[] {
311
404
  const now = Date.now()
405
+ const health = this.#nodes.map((_, i) => this.#health(i))
406
+ const failed = failedAt ?? health.map((h) => h.failedAt)
312
407
  const healthy: number[] = []
408
+ const slow: number[] = []
313
409
  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]
410
+ failed.forEach((at, i) => {
411
+ if (at && now - at < this.#cooldown) {
412
+ cooling.push(i)
413
+ } else if (
414
+ !failedAt &&
415
+ (((health[i] as Health).slowAt && now - (health[i] as Health).slowAt < this.#cooldown) ||
416
+ ((health[i] as Health).behindAt && now - (health[i] as Health).behindAt < BEHIND_DEMOTE))
417
+ ) {
418
+ slow.push(i)
419
+ } else {
420
+ healthy.push(i)
421
+ }
422
+ })
423
+ cooling.sort((a, b) => (failed[a] as number) - (failed[b] as number))
424
+ return [...healthy, ...slow, ...cooling]
317
425
  }
318
426
 
319
427
  /** 上次 getLogs 成功的节点(不在 getLogs 冷却中时)排到最前 */
@@ -361,16 +469,26 @@ function isRateLimited(err: unknown): boolean {
361
469
  /**
362
470
  * 节点还没有请求的区块(minBlock 按指定区块重查时,落后的节点会这样报错):只换节点、不冷却——节点本身是健康的,只是慢一两个块
363
471
  */
364
- 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))
472
+ export function isBehind(err: unknown): boolean {
473
+ return BEHIND_RE.test(errorText(err))
474
+ }
475
+
476
+ // 各节点对“还没有这个区块”的报错(2026-10 对内置节点实测):geth / erigon、Arbitrum 系、X Layer、Mantle、zkSync、Winchain 等
477
+ const BEHIND_RE =
478
+ /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
479
+
480
+ /**
481
+ * 节点返回 result: null(ethers 解析响应时报 INVALID_ARGUMENT,值为 null):换节点、不冷却。
482
+ * 只认 null——调用方传入 undefined 等非法参数时值是 undefined,仍是确定性错误,不能拿去每个节点重试
483
+ */
484
+ function isNullResult(err: unknown): boolean {
485
+ return isError(err, 'INVALID_ARGUMENT') && (err as { value?: unknown }).value === null
366
486
  }
367
487
 
368
- /** 换节点也不会变的错误:合约执行结果、参数错误、用户拒绝等 */
488
+ /** 换节点也不会变的错误:合约执行结果、参数错误、用户拒绝等(节点返回 null 除外,见 isNullResult) */
369
489
  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
- )
490
+ if (isError(err, 'INVALID_ARGUMENT')) {
491
+ return !isNullResult(err)
492
+ }
493
+ return isExecutionError(err) || isError(err, 'ACTION_REJECTED') || isError(err, 'NUMERIC_FAULT')
376
494
  }
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
  }