@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/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/dist/aggregate.d.ts.map +1 -1
- package/dist/aggregate.js +221 -56
- package/dist/aggregate.js.map +1 -1
- package/dist/batcher.d.ts.map +1 -1
- package/dist/batcher.js +9 -4
- package/dist/batcher.js.map +1 -1
- package/dist/erc20.d.ts.map +1 -1
- package/dist/erc20.js +16 -4
- package/dist/erc20.js.map +1 -1
- package/dist/fallback.d.ts +33 -1
- package/dist/fallback.d.ts.map +1 -1
- package/dist/fallback.js +111 -23
- package/dist/fallback.js.map +1 -1
- package/dist/source.js +1 -1
- package/dist/source.js.map +1 -1
- package/dist/watch.d.ts.map +1 -1
- package/dist/watch.js +4 -3
- package/dist/watch.js.map +1 -1
- package/package.json +1 -1
- package/src/aggregate.ts +238 -52
- package/src/batcher.ts +9 -5
- package/src/erc20.ts +17 -4
- package/src/fallback.ts +144 -26
- package/src/source.ts +1 -1
- package/src/watch.ts +6 -3
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
106
|
-
current.timer = setTimeout(poll,
|
|
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
|
}
|