@w3lib/evm-batch-call 0.3.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +98 -35
  3. package/contracts/DeploylessMulticall3.sol +5 -0
  4. package/dist/aggregate.d.ts +20 -1
  5. package/dist/aggregate.d.ts.map +1 -1
  6. package/dist/aggregate.js +222 -32
  7. package/dist/aggregate.js.map +1 -1
  8. package/dist/batcher.d.ts +6 -1
  9. package/dist/batcher.d.ts.map +1 -1
  10. package/dist/batcher.js +64 -9
  11. package/dist/batcher.js.map +1 -1
  12. package/dist/call.d.ts +27 -2
  13. package/dist/call.d.ts.map +1 -1
  14. package/dist/call.js +22 -0
  15. package/dist/call.js.map +1 -1
  16. package/dist/chains.d.ts +7 -0
  17. package/dist/chains.d.ts.map +1 -1
  18. package/dist/chains.js +11 -0
  19. package/dist/chains.js.map +1 -1
  20. package/dist/deployless.d.ts +1 -1
  21. package/dist/deployless.d.ts.map +1 -1
  22. package/dist/deployless.js +1 -1
  23. package/dist/deployless.js.map +1 -1
  24. package/dist/erc20.d.ts +70 -4
  25. package/dist/erc20.d.ts.map +1 -1
  26. package/dist/erc20.js +133 -5
  27. package/dist/erc20.js.map +1 -1
  28. package/dist/events.d.ts +25 -0
  29. package/dist/events.d.ts.map +1 -0
  30. package/dist/events.js +25 -0
  31. package/dist/events.js.map +1 -0
  32. package/dist/fallback.d.ts +49 -4
  33. package/dist/fallback.d.ts.map +1 -1
  34. package/dist/fallback.js +239 -37
  35. package/dist/fallback.js.map +1 -1
  36. package/dist/index.d.ts +9 -10
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +6 -7
  39. package/dist/index.js.map +1 -1
  40. package/dist/multicall.d.ts.map +1 -1
  41. package/dist/multicall.js +33 -2
  42. package/dist/multicall.js.map +1 -1
  43. package/dist/nft.d.ts +5 -0
  44. package/dist/nft.d.ts.map +1 -1
  45. package/dist/nft.js +5 -0
  46. package/dist/nft.js.map +1 -1
  47. package/dist/owner.d.ts +9 -0
  48. package/dist/owner.d.ts.map +1 -1
  49. package/dist/owner.js +11 -2
  50. package/dist/owner.js.map +1 -1
  51. package/dist/provider.d.ts +55 -29
  52. package/dist/provider.d.ts.map +1 -1
  53. package/dist/provider.js +140 -111
  54. package/dist/provider.js.map +1 -1
  55. package/dist/shortcuts.d.ts +34 -53
  56. package/dist/shortcuts.d.ts.map +1 -1
  57. package/dist/shortcuts.js +41 -91
  58. package/dist/shortcuts.js.map +1 -1
  59. package/dist/source.d.ts +4 -2
  60. package/dist/source.d.ts.map +1 -1
  61. package/dist/source.js +35 -6
  62. package/dist/source.js.map +1 -1
  63. package/dist/subpaths/nft.d.ts +46 -0
  64. package/dist/subpaths/nft.d.ts.map +1 -0
  65. package/dist/subpaths/nft.js +55 -0
  66. package/dist/subpaths/nft.js.map +1 -0
  67. package/dist/subpaths/owner.d.ts +33 -0
  68. package/dist/subpaths/owner.d.ts.map +1 -0
  69. package/dist/subpaths/owner.js +34 -0
  70. package/dist/subpaths/owner.js.map +1 -0
  71. package/dist/tron.d.ts +2 -0
  72. package/dist/tron.d.ts.map +1 -1
  73. package/dist/tron.js +5 -0
  74. package/dist/tron.js.map +1 -1
  75. package/dist/util.d.ts +5 -0
  76. package/dist/util.d.ts.map +1 -1
  77. package/dist/util.js +23 -0
  78. package/dist/util.js.map +1 -1
  79. package/dist/watch.d.ts +25 -0
  80. package/dist/watch.d.ts.map +1 -0
  81. package/dist/watch.js +106 -0
  82. package/dist/watch.js.map +1 -0
  83. package/package.json +19 -10
  84. package/src/aggregate.ts +253 -34
  85. package/src/batcher.ts +73 -9
  86. package/src/call.ts +42 -2
  87. package/src/chains.ts +15 -0
  88. package/src/deployless.ts +1 -1
  89. package/src/erc20.ts +184 -8
  90. package/src/events.ts +42 -0
  91. package/src/fallback.ts +287 -38
  92. package/src/index.ts +19 -61
  93. package/src/multicall.ts +34 -2
  94. package/src/nft.ts +5 -0
  95. package/src/owner.ts +11 -2
  96. package/src/provider.ts +225 -124
  97. package/src/shortcuts.ts +76 -110
  98. package/src/source.ts +35 -8
  99. package/src/subpaths/nft.ts +107 -0
  100. package/src/subpaths/owner.ts +69 -0
  101. package/src/tron.ts +7 -0
  102. package/src/util.ts +27 -0
  103. package/src/watch.ts +152 -0
package/src/fallback.ts CHANGED
@@ -3,17 +3,108 @@ import { isError, type TransactionRequest } from 'ethers'
3
3
  import type { EthersLikeProvider } from './aggregate.js'
4
4
  import { detectChainIdOf } from './detect.js'
5
5
  import { isExecutionError } from './errors.js'
6
- import { withTimeout } from './util.js'
6
+ import { emitRequest } from './events.js'
7
+ import { TimeoutError, withTimeout } from './util.js'
7
8
 
8
9
  export interface FallbackOptions {
9
10
  /** 单个节点单次请求超时(毫秒),超时视为该节点故障并换下一个。默认 10000;<= 0 表示不限制 */
10
11
  timeout?: number
11
- /** 节点出错后跳过它的时长(毫秒),期间优先用其他节点。默认 30000 */
12
+ /**
13
+ * 节点迟迟不返回时,过多久(毫秒)同时请求下一个节点:原来的请求不取消,谁先成功用谁(与 ethers FallbackProvider 的 stallTimeout 相同)。
14
+ * 适合正常但偶尔较慢的节点:不必把 timeout 设得很短(正常的慢请求会被判成超时)。
15
+ * 默认 0(不提前请求,只在出错 / 超时后换节点);只影响 call / getBalance,不影响 getLogs / getBlockNumber
16
+ */
17
+ stallTimeout?: number
18
+ /** 节点出错后跳过它的时长(毫秒),期间优先用其他节点。默认 30000。超时不算出错,同一节点连续 3 次超时才冷却 */
12
19
  cooldown?: number
13
20
  }
14
21
 
22
+ /** FallbackRpc 每个节点的标识(resolveSource 传入;直接 new FallbackRpc 时可省略) */
23
+ export interface FallbackNodeInfo {
24
+ /** 用于错误信息和 onRequest 事件的节点标识 */
25
+ label: string
26
+ /** 健康状态的 key:URL 字符串或节点对象。相同 key 的节点在所有 FallbackRpc 之间共享冷却状态 */
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
+ }
48
+ }
49
+
50
+ /** 节点健康状态,按节点(URL / 对象)全局共享:Provider 重建后刚失败过的节点仍排在后面 */
51
+ interface Health {
52
+ failedAt: number
53
+ /** 连续超时次数 */
54
+ timeouts: number
55
+ /** 开了 stallTimeout 时,在并发竞争中输给后发节点的时间(慢但没出错):冷却期内排在健康节点之后 */
56
+ slowAt: number
57
+ }
58
+
59
+ /** 连续超时多少次后才冷却(偶尔慢不算坏节点) */
60
+ const TIMEOUTS_BEFORE_COOLDOWN = 3
61
+ const MAX_TRACKED_URLS = 1000
62
+ const healthByUrl = new Map<string, Health>()
63
+ /** 对象节点(钱包、ethers Provider)按链分开记:钱包在一条链上出错不影响它在其他链上的排序 */
64
+ const healthByObject = new WeakMap<object, Map<number | undefined, Health>>()
65
+
66
+ function healthOf(key: string | object, scope?: number): Health {
67
+ if (typeof key === 'string') {
68
+ let health = healthByUrl.get(key)
69
+ if (!health) {
70
+ health = { failedAt: 0, timeouts: 0, slowAt: 0 }
71
+ healthByUrl.set(key, health)
72
+ if (healthByUrl.size > MAX_TRACKED_URLS) {
73
+ healthByUrl.delete(healthByUrl.keys().next().value as string)
74
+ }
75
+ }
76
+ return health
77
+ }
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)
84
+ if (!health) {
85
+ health = { failedAt: 0, timeouts: 0, slowAt: 0 }
86
+ byScope.set(scope, health)
87
+ }
88
+ return health
89
+ }
90
+
91
+ /** 测试用:清空全局的节点健康状态 */
92
+ export function resetNodeHealth(): void {
93
+ healthByUrl.clear()
94
+ }
95
+
96
+ /** 所有节点都失败(多节点时):errors 是每个节点的标识和失败原因,按尝试顺序 */
97
+ export class AllNodesFailedError extends Error {
98
+ constructor(readonly errors: ReadonlyArray<{ node: string; error: unknown }>) {
99
+ super(`All RPC nodes failed: ${errors.map((e) => `${e.node}: ${errorText(e.error)}`).join(' | ')}`, { cause: errors[errors.length - 1]?.error })
100
+ this.name = 'AllNodesFailedError'
101
+ }
102
+ }
103
+
15
104
  /**
16
- * 多节点故障切换:按顺序使用节点,出错或超时换下一个;出错的节点在 cooldown 内排到最后。
105
+ * 多节点故障切换:按顺序使用节点,出错或超时换下一个;出错的节点在 cooldown 内排到最后(超时只换节点,连续 3 次才冷却)。
106
+ * 设置了 stallTimeout 时,节点迟迟不返回就同时请求下一个,谁先成功用谁。所有节点都失败时抛 AllNodesFailedError(单节点时抛原始错误)。
107
+ * 冷却状态按节点全局共享,每次请求都会触发 onRequest 事件。
17
108
  *
18
109
  * 与 ethers FallbackProvider 相比:
19
110
  * - 没有首次请求前对所有节点的 getBlockNumber 同步,一个卡住的节点不会拖住第一次查询
@@ -22,8 +113,12 @@ export interface FallbackOptions {
22
113
  export class FallbackRpc implements EthersLikeProvider {
23
114
  readonly #nodes: EthersLikeProvider[]
24
115
  readonly #timeout: number
116
+ readonly #stallTimeout: number
25
117
  readonly #cooldown: number
26
- readonly #failedAt: number[]
118
+ readonly #labels: string[]
119
+ /** 健康状态的 key;每次用时再从全局表里取(表淘汰条目后,各实例仍取到同一个对象) */
120
+ readonly #healthKeys: Array<string | object>
121
+ readonly #healthScopes: Array<number | undefined>
27
122
  /** 上次 getLogs 成功的节点 */
28
123
  #logsNode = -1
29
124
  /** 上次成功的节点连续被限频的次数 */
@@ -31,14 +126,17 @@ export class FallbackRpc implements EthersLikeProvider {
31
126
  /** getLogs 超时 / 连不上的时间,只影响 getLogs 的节点顺序 */
32
127
  readonly #logsFailedAt: number[]
33
128
 
34
- constructor(nodes: EthersLikeProvider[], options: FallbackOptions = {}) {
129
+ constructor(nodes: EthersLikeProvider[], options: FallbackOptions = {}, info?: readonly FallbackNodeInfo[]) {
35
130
  if (!nodes.length) {
36
131
  throw new Error('FallbackRpc requires at least one node')
37
132
  }
38
133
  this.#nodes = nodes
39
134
  this.#timeout = options.timeout ?? 10_000
135
+ this.#stallTimeout = options.stallTimeout ?? 0
40
136
  this.#cooldown = options.cooldown ?? 30_000
41
- this.#failedAt = nodes.map(() => 0)
137
+ this.#labels = nodes.map((_, i) => info?.[i]?.label ?? `node${i}`)
138
+ this.#healthKeys = nodes.map((node, i) => info?.[i]?.key ?? node)
139
+ this.#healthScopes = nodes.map((_, i) => info?.[i]?.chainId)
42
140
  this.#logsFailedAt = nodes.map(() => 0)
43
141
  }
44
142
 
@@ -46,17 +144,51 @@ export class FallbackRpc implements EthersLikeProvider {
46
144
  return this.#nodes
47
145
  }
48
146
 
49
- call(tx: TransactionRequest): Promise<string> {
50
- return this.#run((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])
51
165
  }
52
166
 
53
167
  getBalance(address: Parameters<EthersLikeProvider['getBalance']>[0], blockTag?: Parameters<EthersLikeProvider['getBalance']>[1]): Promise<bigint> {
54
- return this.#run((node) => node.getBalance(address, blockTag))
168
+ return this.#run('getBalance', (node) => node.getBalance(address, blockTag))
55
169
  }
56
170
 
57
171
  /** 按节点顺序探测 chainId(节点需实现 getChainId,见 detect.ts) */
58
172
  getChainId(): Promise<number> {
59
- return this.#run((node) => detectChainIdOf(node))
173
+ return this.#run('getChainId', (node) => detectChainIdOf(node))
174
+ }
175
+
176
+ /** 对第 index 个节点发一次请求(带超时),并触发 onRequest 事件 */
177
+ async #attempt<T>(method: string, index: number, attempt: number, fn: (node: EthersLikeProvider) => Promise<T>, timeout = this.#timeout): Promise<T> {
178
+ const started = Date.now()
179
+ try {
180
+ const result = await withTimeout(Promise.resolve().then(() => fn(this.#nodes[index] as EthersLikeProvider)), timeout)
181
+ emitRequest({ node: this.#labels[index] as string, method, ms: Date.now() - started, ok: true, attempt })
182
+ return result
183
+ } catch (err) {
184
+ emitRequest({ node: this.#labels[index] as string, method, ms: Date.now() - started, ok: false, error: err, attempt })
185
+ throw err
186
+ }
187
+ }
188
+
189
+ /** 单节点:直接请求(getLogs / getBlockNumber 不走多节点的特殊逻辑),抛原始错误 */
190
+ #single<T>(method: string, fn: (node: EthersLikeProvider) => Promise<T>): Promise<T> {
191
+ return this.#attempt(method, 0, 0, fn)
60
192
  }
61
193
 
62
194
  /**
@@ -64,14 +196,22 @@ export class FallbackRpc implements EthersLikeProvider {
64
196
  * 不支持 getBlockNumber 的节点跳过,失败也不让节点进入冷却(不影响 call / getBalance)
65
197
  */
66
198
  async getBlockNumber(): Promise<number> {
199
+ if (this.#nodes.length === 1) {
200
+ const node = this.#nodes[0] as EthersLikeProvider
201
+ if (!node.getBlockNumber) {
202
+ throw new Error('getBlockNumber is not supported by this provider')
203
+ }
204
+ return this.#single('getBlockNumber', (n) => (n.getBlockNumber as () => Promise<number>)())
205
+ }
67
206
  let lastError: unknown = new Error('No node supports getBlockNumber')
207
+ let attempt = 0
68
208
  for (const index of this.#preferLogsNode(this.#order())) {
69
209
  const node = this.#nodes[index] as EthersLikeProvider
70
210
  if (!node.getBlockNumber) {
71
211
  continue
72
212
  }
73
213
  try {
74
- return await withTimeout(node.getBlockNumber(), this.#timeout)
214
+ return await this.#attempt('getBlockNumber', index, attempt++, (n) => (n.getBlockNumber as () => Promise<number>)())
75
215
  } catch (err) {
76
216
  lastError = err
77
217
  }
@@ -86,16 +226,25 @@ export class FallbackRpc implements EthersLikeProvider {
86
226
  * - 在用的节点被限频时直接抛出(连续 3 次后才换节点),其他情况全部失败时抛出;GetLogsError 带上每个节点的错误
87
227
  */
88
228
  async getLogs(filter: Parameters<NonNullable<EthersLikeProvider['getLogs']>>[0]): Promise<Awaited<ReturnType<NonNullable<EthersLikeProvider['getLogs']>>>> {
229
+ type GetLogs = NonNullable<EthersLikeProvider['getLogs']>
230
+ if (this.#nodes.length === 1) {
231
+ // 单节点:原样请求、抛原始错误(与不经过 FallbackRpc 时一致)
232
+ if (!this.#nodes[0]?.getLogs) {
233
+ throw new Error('getLogs is not supported by this provider')
234
+ }
235
+ return this.#single('getLogs', (n) => (n.getLogs as GetLogs)(filter))
236
+ }
89
237
  const errors: unknown[] = []
90
238
  let preferred: unknown
91
239
  const sticky = this.#logsNode
240
+ let attempt = 0
92
241
  for (const index of this.#preferLogsNode(this.#order(this.#logsFailedAt))) {
93
242
  const node = this.#nodes[index] as EthersLikeProvider
94
243
  if (!node.getLogs) {
95
244
  continue
96
245
  }
97
246
  try {
98
- const logs = await withTimeout(node.getLogs(filter), this.#timeout)
247
+ const logs = await this.#attempt('getLogs', index, attempt++, (n) => (n.getLogs as GetLogs)(filter))
99
248
  this.#logsNode = index
100
249
  this.#logsFailedAt[index] = 0
101
250
  this.#logsThrottled = 0
@@ -128,32 +277,115 @@ export class FallbackRpc implements EthersLikeProvider {
128
277
  throw new GetLogsError(errors, preferred)
129
278
  }
130
279
 
131
- async #run<T>(fn: (node: EthersLikeProvider) => Promise<T>): Promise<T> {
132
- let lastError: unknown
133
- for (const index of this.#order()) {
134
- try {
135
- const result = await withTimeout(fn(this.#nodes[index] as EthersLikeProvider), this.#timeout)
136
- this.#failedAt[index] = 0
137
- return result
138
- } catch (err) {
139
- if (isDeterministic(err)) {
140
- throw err
280
+ /**
281
+ * 按顺序请求节点:出错(非确定性错误)或超时立即换下一个;设置了 stallTimeout 时,
282
+ * 当前节点超过 stallTimeout 还没返回也会同时请求下一个(已发出的请求不取消),第一个成功的结果胜出。
283
+ * 出错的节点进入冷却;超时只换节点,同一节点连续 3 次超时才冷却(冷启动的大批量查询偶尔慢,不应把健康的节点踢到后面);
284
+ * 节点还没有请求的区块(minBlock 重查时落后的节点)也只换节点、不冷却。
285
+ * 确定性错误(revert、参数错误等)直接抛出;所有节点都失败时抛 AllNodesFailedError(单节点时抛原始错误)。
286
+ */
287
+ #run<T>(method: string, fn: (node: EthersLikeProvider) => Promise<T>, maxInvalid = Number.POSITIVE_INFINITY): Promise<T> {
288
+ const order = this.#order()
289
+ let invalid = 0
290
+ return new Promise<T>((resolve, reject) => {
291
+ let next = 0
292
+ let running = 0
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)
297
+ const errors: Array<{ node: string; error: unknown }> = []
298
+ let stallTimer: ReturnType<typeof setTimeout> | undefined
299
+ const finish = (done: () => void) => {
300
+ settled = true
301
+ clearTimeout(stallTimer)
302
+ done()
303
+ }
304
+ const fail = () => {
305
+ const only = errors.length === 1 && this.#nodes.length === 1
306
+ finish(() => reject(only ? errors[0]?.error : new AllNodesFailedError(errors)))
307
+ }
308
+ const launch = (): boolean => {
309
+ if (settled || next >= order.length) {
310
+ return false
141
311
  }
142
- this.#failedAt[index] = Date.now()
143
- lastError = err
312
+ const attempt = next++
313
+ const index = order[attempt] as number
314
+ const health = this.#health(index)
315
+ running++
316
+ inFlight.push({ index, attempt })
317
+ clearTimeout(stallTimer)
318
+ if (this.#stallTimeout > 0 && next < order.length) {
319
+ stallTimer = setTimeout(launch, this.#stallTimeout)
320
+ }
321
+ this.#attempt(method, index, attempt, fn).then(
322
+ (result) => {
323
+ running--
324
+ done(index)
325
+ health.failedAt = 0
326
+ health.timeouts = 0
327
+ if (!settled) {
328
+ health.slowAt = 0
329
+ // 先发出却还没返回的节点输掉了竞争:记一次慢,冷却期内排在健康节点之后(之后返回成功也不清除)
330
+ inFlight.filter((f) => f.attempt < attempt).forEach((f) => (this.#health(f.index).slowAt = Date.now()))
331
+ finish(() => resolve(result))
332
+ }
333
+ },
334
+ (err: unknown) => {
335
+ running--
336
+ done(index)
337
+ const deterministic = isDeterministic(err)
338
+ if (err instanceof TimeoutError) {
339
+ if (++health.timeouts >= TIMEOUTS_BEFORE_COOLDOWN) {
340
+ health.failedAt = Date.now()
341
+ health.timeouts = 0
342
+ }
343
+ } else if (!deterministic && !isBehind(err) && !(err instanceof InvalidResultError) && !isNullResult(err)) {
344
+ health.failedAt = Date.now()
345
+ }
346
+ if (settled) {
347
+ return
348
+ }
349
+ if (deterministic) {
350
+ finish(() => reject(err))
351
+ return
352
+ }
353
+ errors.push({ node: this.#labels[index] as string, error: err })
354
+ // 结果没通过校验的节点够数了(已能确认是合约本身的问题):不再换节点,等进行中的请求结束
355
+ const enough = err instanceof InvalidResultError && ++invalid >= maxInvalid
356
+ if ((enough || !launch()) && running === 0) {
357
+ fail()
358
+ }
359
+ },
360
+ )
361
+ return true
144
362
  }
145
- }
146
- throw lastError
363
+ launch()
364
+ })
147
365
  }
148
366
 
149
- /** 健康的节点按原顺序在前,冷却中的按出错时间先后排在后面(全挂时也都会再试一次) */
150
- #order(failedAt: readonly number[] = this.#failedAt): number[] {
367
+ /**
368
+ * 健康的节点按原顺序在前;最近在竞争中输过的慢节点其次;冷却中的按出错时间先后排在最后(全挂时也都会再试一次)。
369
+ * 传入 failedAt 时(getLogs 自己的冷却表)不考虑慢节点
370
+ */
371
+ #order(failedAt?: readonly number[]): number[] {
151
372
  const now = Date.now()
373
+ const health = this.#nodes.map((_, i) => this.#health(i))
374
+ const failed = failedAt ?? health.map((h) => h.failedAt)
152
375
  const healthy: number[] = []
376
+ const slow: number[] = []
153
377
  const cooling: number[] = []
154
- failedAt.forEach((at, i) => (at && now - at < this.#cooldown ? cooling : healthy).push(i))
155
- cooling.sort((a, b) => (failedAt[a] as number) - (failedAt[b] as number))
156
- 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]
157
389
  }
158
390
 
159
391
  /** 上次 getLogs 成功的节点(不在 getLogs 冷却中时)排到最前 */
@@ -198,12 +430,29 @@ function isRateLimited(err: unknown): boolean {
198
430
  return /too many requests|rate.?limit|\b429\b/i.test(errorText(err))
199
431
  }
200
432
 
201
- /** 换节点也不会变的错误:合约执行结果、参数错误、用户拒绝等 */
433
+ /**
434
+ * 节点还没有请求的区块(minBlock 按指定区块重查时,落后的节点会这样报错):只换节点、不冷却——节点本身是健康的,只是慢一两个块
435
+ */
436
+ function isBehind(err: unknown): boolean {
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
450
+ }
451
+
452
+ /** 换节点也不会变的错误:合约执行结果、参数错误、用户拒绝等(节点返回 null 除外,见 isNullResult) */
202
453
  function isDeterministic(err: unknown): boolean {
203
- return (
204
- isExecutionError(err) ||
205
- isError(err, 'INVALID_ARGUMENT') ||
206
- isError(err, 'ACTION_REJECTED') ||
207
- isError(err, 'NUMERIC_FAULT')
208
- )
454
+ if (isError(err, 'INVALID_ARGUMENT')) {
455
+ return !isNullResult(err)
456
+ }
457
+ return isExecutionError(err) || isError(err, 'ACTION_REJECTED') || isError(err, 'NUMERIC_FAULT')
209
458
  }
package/src/index.ts CHANGED
@@ -1,80 +1,33 @@
1
1
  export { Contract, bindCall, type BoundMethod, type ContractAbi, type ContractRunner } from './contract.js'
2
- export {
3
- getTransferScanState,
4
- keyValueScanStorage,
5
- memoryScanStorage,
6
- transferScan,
7
- type TransferScanOptions,
8
- type TransferScanState,
9
- type TransferScanStorage,
10
- } from './scan.js'
11
2
  export { GetLogsError } from './fallback.js'
12
- export {
13
- ALCHEMY_NETWORKS,
14
- COINGECKO_PLATFORMS,
15
- DEFILLAMA_CHAINS,
16
- alchemy,
17
- clearTokenListCache,
18
- coingeckoTokenList,
19
- combine,
20
- defaultTokenSource,
21
- defillamaPrices,
22
- firstAvailable,
23
- metamaskTokenList,
24
- nodereal,
25
- staticTokens,
26
- tokenList,
27
- type DiscoveredToken,
28
- type OwnedToken,
29
- type OwnerTokensOptions,
30
- type PriceSource,
31
- type PriceSourceContext,
32
- type TokenSource,
33
- type TokenSourceContext,
34
- } from './owner.js'
35
3
  export {
36
4
  getAllowances,
37
5
  getBalances,
38
- getErc1155Balances,
39
- getNftBalances,
40
- getNftCollections,
41
- getNftOwners,
42
- getNftTokenUris,
43
- getOwnerTokens,
6
+ getMultiBalances,
44
7
  getTokens,
8
+ type GetAllowancesOptions,
45
9
  type GetBalancesOptions,
46
- type GetNftCollectionsOptions,
47
- type GetNftTokenUrisOptions,
48
- type GetOwnerTokensOptions,
49
10
  type GetTokensOptions,
50
11
  type ShortcutOptions,
51
12
  type ShortcutProviderOptions,
52
13
  } from './shortcuts.js'
53
- export {
54
- DEFAULT_NFT_COLLECTION_FIELDS,
55
- ERC1155_ABI,
56
- ERC721_ABI,
57
- type DefaultNftCollectionField,
58
- type Erc1155Balance,
59
- type NftBalance,
60
- type NftCollection,
61
- type NftCollectionField,
62
- type NftCollectionsOptions,
63
- type NftItem,
64
- type NftOwner,
65
- type NftStandard,
66
- type NftTokenUri,
67
- type NftTokenUriOptions,
68
- } from './nft.js'
69
- export { NATIVE_CURRENCIES, getNativeCurrency, type NativeCurrency } from './chains.js'
14
+ export { NATIVE_BALANCE_MODES, NATIVE_CURRENCIES, getNativeBalanceMode, getNativeCurrency, type NativeCurrency } from './chains.js'
70
15
  export { clearChainIdCache, detectChainId } from './detect.js'
71
16
  export {
72
17
  DEFAULT_NATIVE_TOKENS,
73
18
  ERC20_ABI,
74
19
  NATIVE_TOKEN,
20
+ exportTokenMetaCache,
75
21
  formatAmount,
22
+ formatUnits,
23
+ importTokenMetaCache,
24
+ persistTokenMetaCache,
25
+ type TokenMetaSnapshot,
26
+ type TokenMetaStore,
76
27
  type BalanceToken,
77
28
  type Erc20Contract,
29
+ type RawTokenAllowance,
30
+ type RawTokenBalance,
78
31
  type TokenBalance,
79
32
  type TokenAllowance,
80
33
  MAX_UINT256,
@@ -86,7 +39,10 @@ export {
86
39
  } from './erc20.js'
87
40
  export {
88
41
  Provider,
42
+ type AllowancesOptions,
89
43
  type BalancesOptions,
44
+ type BalancesQuery,
45
+ type BlockOptions,
90
46
  type CallInput,
91
47
  type CallResults,
92
48
  type ProviderConfig,
@@ -108,10 +64,12 @@ export {
108
64
  type TronWebLike,
109
65
  } from './tron.js'
110
66
  export { CallFailedError, decodeRevertReason, isExecutionError } from './errors.js'
111
- export { FallbackRpc, type FallbackOptions } from './fallback.js'
67
+ export { AllNodesFailedError, FallbackRpc, type FallbackNodeInfo, type FallbackOptions } from './fallback.js'
68
+ export { onRequest, type RequestEvent, type RequestListener } from './events.js'
69
+ export { watchBalances, type WatchBalancesOptions } from './watch.js'
112
70
  export { ChainCheckedProvider, getDefaultRpcUrls, isTronChain, type SourceOptions } from './source.js'
113
71
  export { DEFAULT_RPC_URLS, DEFAULT_TRON_HOSTS } from './rpcNodes.js'
114
72
  export { MULTICALL3_ADDRESS, getMulticall3, type Multicall } from './multicall.js'
115
- export type { BlockTag, CallOverrides, EthersLikeProvider } from './aggregate.js'
73
+ export type { BlockTag, CallOverrides, EthersLikeProvider, NativeBalanceMode } from './aggregate.js'
116
74
  export type { BatchOptions } from './batcher.js'
117
- export type { BoundCall, Call, FailableCall, Params, RawResult } from './call.js'
75
+ export type { BoundCall, Call, FailableCall, FailureReason, Params, RawResult, Settled } from './call.js'
package/src/multicall.ts CHANGED
@@ -47,12 +47,44 @@ const MULTICALL3: Record<number, Multicall> = {
47
47
  43114: { address: CANONICAL, block: 11907934 },
48
48
  80001: { address: CANONICAL, block: 25444704 },
49
49
  421611: { address: CANONICAL, block: 10228837 },
50
- 5000: { address: '0xd77b59d4cb13bea71f3cc093e401720867355d6e', block: 18877383 },
50
+ // Mantle:标准地址上的合约字节码与其他链不同,但 aggregate3 / getBlockNumber / getEthBalance 行为一致(2026-10 实测);
51
+ // ethcall 表里的 0xd77b…5d6e 调 aggregate3 会 revert
52
+ 5000: { address: CANONICAL, block: 304717 },
51
53
  1666600000: { address: CANONICAL, block: 24185753 },
52
54
  73927: { address: '0x138A85647768815078DF1dD85C6121e611381A0b', block: 14080843 },
53
55
  // Tron 主网 TEazPvZwDjDtFeJupyo7QunvnrnUjPH8ED(链上合约名 Multicall3,2026-10 实测)
54
56
  728126428: { address: '0x32a4f47a74a6810bd0bf861cabab99656a75de9e', block: 0 },
55
- 81457: { address: '0x23928c6f823e0d78eadc9d2f69ace652ff3cdcb1', block: 368182 },
57
+ // Blast:ethcall 表里的 0x2392…cdcb1 调 aggregate3 会 revert,标准地址可用(2026-10 实测)
58
+ 81457: { address: CANONICAL, block: 88189 },
59
+
60
+ // ---- 2026-10 实测补充:标准地址上 aggregate3 可用、区块号与节点一致 ----
61
+ // 部署区块查到的按实际填写;节点不提供历史状态(或忽略区块参数)查不到的,保守地填核实时的区块:
62
+ // 更早的历史查询走 deployless,最新状态照常用合约
63
+ 25: { address: CANONICAL, block: 98990812 }, // Cronos,部署区块未查到
64
+ 106: { address: CANONICAL, block: 70377223 }, // Velas,部署区块未查到
65
+ 130: { address: CANONICAL, block: 60854543 }, // Unichain,部署区块未查到
66
+ 143: { address: CANONICAL, block: 9248132 }, // Monad
67
+ 204: { address: CANONICAL, block: 193943883 }, // opBNB,部署区块未查到
68
+ 288: { address: CANONICAL, block: 40299030 }, // Boba,部署区块未查到
69
+ 321: { address: CANONICAL, block: 56352410 }, // KCC,部署区块未查到
70
+ 592: { address: CANONICAL, block: 761794 }, // Astar
71
+ 988: { address: CANONICAL, block: 42479473 }, // Stable,部署区块未查到
72
+ 999: { address: CANONICAL, block: 48135400 }, // HyperEVM,部署区块未查到
73
+ 1116: { address: CANONICAL, block: 6988931 }, // Core
74
+ 2818: { address: CANONICAL, block: 3654913 }, // Morph
75
+ 4663: { address: CANONICAL, block: 84683398 }, // Robinhood Chain,部署区块未查到
76
+ 4689: { address: CANONICAL, block: 53225909 }, // IoTeX,部署区块未查到
77
+ 9745: { address: CANONICAL, block: 0 }, // Plasma,创世预置
78
+ 34443: { address: CANONICAL, block: 2465882 }, // Mode
79
+ 57073: { address: CANONICAL, block: 58104502 }, // Ink,部署区块未查到
80
+ 59144: { address: CANONICAL, block: 42 }, // Linea
81
+ 80094: { address: CANONICAL, block: 0 }, // Berachain,创世预置
82
+ 534352: { address: CANONICAL, block: 14 }, // Scroll
83
+ 86233268: { address: CANONICAL, block: 10854614 }, // Winchain,部署区块未查到
84
+ // 以下两条链不支持 deployless(合约创建式 eth_call 返回空 / 格式不对),只能用 Multicall3;
85
+ // 早于部署区块的历史查询会失败
86
+ 42262: { address: CANONICAL, block: 22380155 }, // Oasis Emerald,部署区块未查到
87
+ 1313161554: { address: CANONICAL, block: 62907816 }, // Aurora
56
88
  }
57
89
 
58
90
  export function getMulticall3(chainId: number): Multicall | null {
package/src/nft.ts CHANGED
@@ -118,6 +118,7 @@ function expandUri(uri: string, tokenId: bigint, gateway?: string): string {
118
118
  return expanded
119
119
  }
120
120
 
121
+ /** 批量查 ERC721 持有人(ownerOf),可混合多个集合:`await nftOwners(multi, [{ contract: BAYC, tokenId: 1 }])` */
121
122
  export async function nftOwners(provider: Provider, items: readonly NftItem[], overrides?: CallOverrides): Promise<NftOwner[]> {
122
123
  // Tron 上返回 T 开头的地址,方便与 tronWeb.defaultAddress.base58 等直接比较
123
124
  const tron = isTronChain(await provider.getChainId())
@@ -132,6 +133,7 @@ export async function nftOwners(provider: Provider, items: readonly NftItem[], o
132
133
  })
133
134
  }
134
135
 
136
+ /** 批量查 ERC721 持有数量(balanceOf):`await nftBalances(multi, user, [BAYC, MAYC])` */
135
137
  export async function nftBalances(
136
138
  provider: Provider,
137
139
  owner: string,
@@ -146,6 +148,7 @@ export async function nftBalances(
146
148
  })
147
149
  }
148
150
 
151
+ /** 批量查 ERC1155 余额:`await erc1155Balances(multi, user, [{ contract, tokenId: 1 }])` */
149
152
  export async function erc1155Balances(
150
153
  provider: Provider,
151
154
  owner: string,
@@ -169,6 +172,7 @@ export async function erc1155Balances(
169
172
  })
170
173
  }
171
174
 
175
+ /** 批量查 NFT 元数据地址:兼容 ERC721 tokenURI 与 ERC1155 uri({id} 按规范替换),可转换 ipfs:// */
172
176
  export async function nftTokenUris(provider: Provider, items: readonly NftItem[], options: NftTokenUriOptions = {}): Promise<NftTokenUri[]> {
173
177
  const { ipfsGateway, ...overrides } = options
174
178
  const chainId = await provider.getChainId()
@@ -227,6 +231,7 @@ export async function nftTokenUris(provider: Provider, items: readonly NftItem[]
227
231
  })
228
232
  }
229
233
 
234
+ /** 批量查 NFT 集合信息(标准 / name / symbol / totalSupply),字段可选 */
230
235
  export async function nftCollections<F extends NftCollectionField = DefaultNftCollectionField>(
231
236
  provider: Provider,
232
237
  collections: readonly string[],
package/src/owner.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { CallOverrides } from './aggregate.js'
2
- import { NATIVE_TOKEN, formatAmount, type BalanceToken } from './erc20.js'
2
+ import { NATIVE_TOKEN, formatUnits, type BalanceToken } from './erc20.js'
3
3
  import type { Provider } from './provider.js'
4
4
  import { transferScan, type TransferScanOptions } from './scan.js'
5
5
  import { isTronAddress } from './tron.js'
@@ -555,6 +555,15 @@ export interface OwnedToken {
555
555
  source: string
556
556
  }
557
557
 
558
+ /**
559
+ * 列出持有人拥有的代币(资产列表):从代币来源拿候选代币,再用 multicall 在链上核对余额。
560
+ * 默认来源是公开代币列表,**只能发现列表里的代币**,局限性见 README「资产列表」一节。
561
+ *
562
+ * @deprecated 不推荐使用:EVM 链上无法只靠节点可靠地列出地址持有的全部代币,这个方法只是尽力而为——
563
+ * 默认来源只能发现公开列表里的代币,依赖第三方免费服务(可能限流、改格式或停止服务),要查几千个代币的余额、耗时数秒,
564
+ * 价格也不一定可信;传 `alchemy` / `nodereal` 来源能查全,但同样受第三方服务的额度和可用性约束。
565
+ * 已知要查哪些代币时请用 `getBalances`;需要可靠的完整持仓请直接接入索引服务的接口或自己的索引。局限性详见 README「资产列表」。
566
+ */
558
567
  export async function ownerTokens(provider: Provider, owner: string, options: OwnerTokensOptions = {}): Promise<OwnedToken[]> {
559
568
  const { source: baseSource = defaultTokenSource(), prices, minUsd, includeNative = true, fetch: fetchOption, scanTransfers, ...overrides } = options
560
569
  const source = scanTransfers ? combine(baseSource, transferScan(scanTransfers === true ? {} : scanTransfers)) : baseSource
@@ -606,7 +615,7 @@ export async function ownerTokens(provider: Provider, owner: string, options: Ow
606
615
  return []
607
616
  }
608
617
  const decimals = chain.decimals
609
- const formatted = decimals === balance.decimals ? balance.formatted : formatAmount(BigInt(balance.balance), decimals)
618
+ const formatted = decimals === balance.decimals ? balance.formatted : formatUnits(BigInt(balance.balance), decimals)
610
619
  const price = priceMap.get(addressKey(meta.address))
611
620
  return [
612
621
  {