@w3lib/evm-batch-call 0.2.0 → 0.4.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/CHANGELOG.md +56 -0
- package/README.md +184 -30
- package/contracts/DeploylessMulticall3.sol +5 -0
- package/dist/aggregate.d.ts +25 -3
- package/dist/aggregate.d.ts.map +1 -1
- package/dist/aggregate.js +143 -5
- package/dist/aggregate.js.map +1 -1
- package/dist/batcher.d.ts +6 -1
- package/dist/batcher.d.ts.map +1 -1
- package/dist/batcher.js +59 -9
- package/dist/batcher.js.map +1 -1
- package/dist/call.d.ts +27 -2
- package/dist/call.d.ts.map +1 -1
- package/dist/call.js +22 -0
- package/dist/call.js.map +1 -1
- package/dist/chains.d.ts +7 -0
- package/dist/chains.d.ts.map +1 -1
- package/dist/chains.js +11 -0
- package/dist/chains.js.map +1 -1
- package/dist/deployless.d.ts +1 -1
- package/dist/deployless.d.ts.map +1 -1
- package/dist/deployless.js +1 -1
- package/dist/deployless.js.map +1 -1
- package/dist/erc20.d.ts +70 -4
- package/dist/erc20.d.ts.map +1 -1
- package/dist/erc20.js +121 -5
- package/dist/erc20.js.map +1 -1
- package/dist/events.d.ts +25 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +25 -0
- package/dist/events.js.map +1 -0
- package/dist/fallback.d.ts +54 -3
- package/dist/fallback.d.ts.map +1 -1
- package/dist/fallback.js +273 -21
- package/dist/fallback.js.map +1 -1
- package/dist/index.d.ts +10 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/multicall.d.ts.map +1 -1
- package/dist/multicall.js +33 -2
- package/dist/multicall.js.map +1 -1
- package/dist/nft.d.ts +5 -0
- package/dist/nft.d.ts.map +1 -1
- package/dist/nft.js +5 -0
- package/dist/nft.js.map +1 -1
- package/dist/owner.d.ts +145 -0
- package/dist/owner.d.ts.map +1 -0
- package/dist/owner.js +510 -0
- package/dist/owner.js.map +1 -0
- package/dist/provider.d.ts +63 -18
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +166 -100
- package/dist/provider.js.map +1 -1
- package/dist/scan.d.ts +59 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +245 -0
- package/dist/scan.js.map +1 -0
- package/dist/shortcuts.d.ts +34 -35
- package/dist/shortcuts.d.ts.map +1 -1
- package/dist/shortcuts.js +41 -73
- package/dist/shortcuts.js.map +1 -1
- package/dist/source.d.ts +6 -2
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +49 -6
- package/dist/source.js.map +1 -1
- package/dist/subpaths/nft.d.ts +46 -0
- package/dist/subpaths/nft.d.ts.map +1 -0
- package/dist/subpaths/nft.js +55 -0
- package/dist/subpaths/nft.js.map +1 -0
- package/dist/subpaths/owner.d.ts +33 -0
- package/dist/subpaths/owner.d.ts.map +1 -0
- package/dist/subpaths/owner.js +34 -0
- package/dist/subpaths/owner.js.map +1 -0
- package/dist/tron.d.ts +2 -0
- package/dist/tron.d.ts.map +1 -1
- package/dist/tron.js +5 -0
- package/dist/tron.js.map +1 -1
- package/dist/util.d.ts +5 -0
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +23 -0
- package/dist/util.js.map +1 -1
- package/dist/watch.d.ts +25 -0
- package/dist/watch.d.ts.map +1 -0
- package/dist/watch.js +105 -0
- package/dist/watch.js.map +1 -0
- package/package.json +19 -10
- package/src/aggregate.ts +177 -9
- package/src/batcher.ts +69 -9
- package/src/call.ts +42 -2
- package/src/chains.ts +15 -0
- package/src/deployless.ts +1 -1
- package/src/erc20.ts +171 -8
- package/src/events.ts +42 -0
- package/src/fallback.ts +306 -22
- package/src/index.ts +20 -27
- package/src/multicall.ts +34 -2
- package/src/nft.ts +5 -0
- package/src/owner.ts +660 -0
- package/src/provider.ts +255 -112
- package/src/scan.ts +293 -0
- package/src/shortcuts.ts +76 -88
- package/src/source.ts +51 -8
- package/src/subpaths/nft.ts +107 -0
- package/src/subpaths/owner.ts +69 -0
- package/src/tron.ts +7 -0
- package/src/util.ts +27 -0
- package/src/watch.ts +149 -0
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,17 +1,6 @@
|
|
|
1
1
|
import type { CallOverrides } from './aggregate.js'
|
|
2
|
-
import type { BalanceToken, DefaultTokenField, TokenAllowance, TokenBalance, TokenDetails, TokenField } from './erc20.js'
|
|
3
|
-
import type
|
|
4
|
-
DefaultNftCollectionField,
|
|
5
|
-
Erc1155Balance,
|
|
6
|
-
NftBalance,
|
|
7
|
-
NftCollection,
|
|
8
|
-
NftCollectionField,
|
|
9
|
-
NftCollectionsOptions,
|
|
10
|
-
NftItem,
|
|
11
|
-
NftOwner,
|
|
12
|
-
NftTokenUri,
|
|
13
|
-
} from './nft.js'
|
|
14
|
-
import { Provider, type BalancesOptions, type ProviderConfig, type TokensOptions } from './provider.js'
|
|
2
|
+
import type { BalanceToken, DefaultTokenField, RawTokenAllowance, RawTokenBalance, TokenAllowance, TokenBalance, TokenDetails, TokenField } from './erc20.js'
|
|
3
|
+
import { Provider, type AllowancesOptions, type BalancesOptions, type BalancesQuery, type ProviderConfig, type TokensOptions } from './provider.js'
|
|
15
4
|
import type { ProviderSource } from './source.js'
|
|
16
5
|
|
|
17
6
|
/** getBalances / getTokens 共用的节点参数 */
|
|
@@ -24,17 +13,21 @@ export interface ShortcutProviderOptions extends ProviderConfig {
|
|
|
24
13
|
* - ethers Provider
|
|
25
14
|
* - 浏览器插件钱包:`window.ethereum`(EIP-1193)、`window.tronWeb`
|
|
26
15
|
* - 混合数组,如 `[window.ethereum, 'https://…']`:钱包出错或不在这条链上时自动用后面的节点
|
|
16
|
+
* - 已创建的 Provider 实例:直接使用(chainId 和其他节点配置以实例为准,不能再传)
|
|
27
17
|
*/
|
|
28
|
-
provider?: ProviderSource | readonly ProviderSource[]
|
|
18
|
+
provider?: ProviderSource | readonly ProviderSource[] | Provider
|
|
29
19
|
}
|
|
30
20
|
|
|
31
21
|
export interface GetBalancesOptions extends BalancesOptions, ShortcutProviderOptions {}
|
|
32
22
|
|
|
33
23
|
export interface GetTokensOptions<F extends TokenField = DefaultTokenField> extends TokensOptions<F>, ShortcutProviderOptions {}
|
|
34
24
|
|
|
25
|
+
export interface GetAllowancesOptions extends AllowancesOptions, ShortcutProviderOptions {}
|
|
26
|
+
|
|
35
27
|
// 复用 Provider:多次调用共享连接、multicall 地址判定和自动合并队列。
|
|
36
|
-
// 按 “chainId + 配置内容” 分组,组内 URL 按字符串、钱包 / Provider 对象按引用(WeakMap
|
|
37
|
-
//
|
|
28
|
+
// 按 “chainId + 配置内容” 分组,组内 URL 按字符串、钱包 / Provider 对象按引用(WeakMap,不阻止回收);
|
|
29
|
+
// 钱包 + URL 的混合数组按 “对象编号 + URL” 组合成字符串 key(对象编号用 WeakMap 分配,缓存条数有上限)。
|
|
30
|
+
// 不缓存的情况:配置里有函数等无法比较内容的值;没传 chainId 且节点里有对象
|
|
38
31
|
// (钱包可能切链,每次按当前链新建;chainId 识别本身有缓存,切链时自动失效,不会每次都发请求)
|
|
39
32
|
interface ProviderGroup {
|
|
40
33
|
byString: Map<string, Provider>
|
|
@@ -66,7 +59,20 @@ function configKey(config: ProviderConfig): string | null {
|
|
|
66
59
|
}
|
|
67
60
|
}
|
|
68
61
|
|
|
69
|
-
|
|
62
|
+
/** 对象来源(钱包、ethers Provider)的编号,用于混合数组的缓存 key;WeakMap 不阻止对象被回收 */
|
|
63
|
+
const objectIds = new WeakMap<object, number>()
|
|
64
|
+
let nextObjectId = 0
|
|
65
|
+
|
|
66
|
+
function objectId(value: object): number {
|
|
67
|
+
let id = objectIds.get(value)
|
|
68
|
+
if (id === undefined) {
|
|
69
|
+
id = ++nextObjectId
|
|
70
|
+
objectIds.set(value, id)
|
|
71
|
+
}
|
|
72
|
+
return id
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sourceKey(chainId: number | undefined, source: ProviderSource | readonly ProviderSource[] | undefined): string | object | null {
|
|
70
76
|
if (source === undefined) {
|
|
71
77
|
return 'default'
|
|
72
78
|
}
|
|
@@ -75,12 +81,23 @@ function sourceKey(chainId: number | undefined, source: ShortcutProviderOptions[
|
|
|
75
81
|
}
|
|
76
82
|
if (Array.isArray(source)) {
|
|
77
83
|
// URL 数组每次调用都是新数组,按内容缓存
|
|
78
|
-
|
|
84
|
+
if (source.every((item) => typeof item === 'string')) {
|
|
85
|
+
return `urls:${source.join('\n')}`
|
|
86
|
+
}
|
|
87
|
+
// 钱包 + URL:对象按编号、URL 按内容
|
|
88
|
+
return chainId === undefined ? null : `mixed:${source.map((item) => (typeof item === 'string' ? `url:${item}` : `obj:${objectId(item as object)}`)).join('\n')}`
|
|
79
89
|
}
|
|
80
90
|
return chainId === undefined ? null : (source as object)
|
|
81
91
|
}
|
|
82
92
|
|
|
83
|
-
function getProvider(chainId: number | undefined,
|
|
93
|
+
function getProvider(chainId: number | undefined, input: ShortcutProviderOptions['provider'], config: ProviderConfig): Provider {
|
|
94
|
+
if (input instanceof Provider) {
|
|
95
|
+
if (chainId !== undefined || Object.keys(config).length) {
|
|
96
|
+
throw new Error('chainId and provider config must be set on the Provider instance itself, not passed alongside it')
|
|
97
|
+
}
|
|
98
|
+
return input
|
|
99
|
+
}
|
|
100
|
+
const source = input
|
|
84
101
|
if (chainId === undefined && source === undefined) {
|
|
85
102
|
throw new Error('getBalances / getTokens requires chainId or provider')
|
|
86
103
|
}
|
|
@@ -112,12 +129,15 @@ function getProvider(chainId: number | undefined, source: ShortcutProviderOption
|
|
|
112
129
|
|
|
113
130
|
export interface ShortcutOptions extends CallOverrides, ShortcutProviderOptions {}
|
|
114
131
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
132
|
+
/**
|
|
133
|
+
* 拆出节点参数、调用参数和函数自己的参数(ownKeys),其余作为 Provider 配置;值为 undefined 的参数忽略。
|
|
134
|
+
* 子路径(/nft、/owner)的快捷函数也用它,与 getBalances 共享 Provider 缓存
|
|
135
|
+
*/
|
|
136
|
+
export function resolve<T extends ShortcutOptions, K extends keyof T & string = never>(
|
|
117
137
|
options: T,
|
|
118
138
|
ownKeys: readonly K[] = [],
|
|
119
139
|
): { provider: Provider; overrides: CallOverrides; own: Pick<T, K> } {
|
|
120
|
-
const { chainId, provider, blockTag, from, ...others } = options
|
|
140
|
+
const { chainId, provider, blockTag, from, signal, minBlock, ...others } = options
|
|
121
141
|
const own: Record<string, unknown> = {}
|
|
122
142
|
const config: Record<string, unknown> = {}
|
|
123
143
|
for (const [key, value] of Object.entries(others)) {
|
|
@@ -128,7 +148,7 @@ function resolve<T extends ShortcutOptions, K extends keyof T & string = never>(
|
|
|
128
148
|
return {
|
|
129
149
|
// null / 空字符串(如状态里还没有 chainId)按“未传”处理,走自动识别,而不是变成 chainId 0
|
|
130
150
|
provider: getProvider(chainId === undefined || chainId === null || chainId === '' ? undefined : Number(chainId), provider, config as ProviderConfig),
|
|
131
|
-
overrides: { blockTag, from },
|
|
151
|
+
overrides: { blockTag, from, signal, minBlock },
|
|
132
152
|
own: own as Pick<T, K>,
|
|
133
153
|
}
|
|
134
154
|
}
|
|
@@ -144,12 +164,29 @@ function resolve<T extends ShortcutOptions, K extends keyof T & string = never>(
|
|
|
144
164
|
* await getBalances(user, tokens, { provider: 'https://bsc-dataseed.bnbchain.org' })
|
|
145
165
|
* await getBalances(user, tokens, { chainId: 56, provider: [window.ethereum, 'https://…'] }) // 钱包优先,失败用公共节点
|
|
146
166
|
* await getBalances(user, tokens, { chainId: 56, symbol: true }) // 同时返回 symbol
|
|
167
|
+
* await getBalances(user, tokens, { chainId: 56, decimals: false }) // 只查余额,不带 decimals / formatted
|
|
147
168
|
* // [{ token: NATIVE_TOKEN, native: true, balance: '1500000000000000000', decimals: 18, formatted: '1.5', success: true }, ...]
|
|
148
169
|
* ```
|
|
149
170
|
*/
|
|
150
|
-
export function getBalances(owner: string, tokens: readonly BalanceToken[], options: GetBalancesOptions
|
|
151
|
-
|
|
152
|
-
|
|
171
|
+
export function getBalances(owner: string, tokens: readonly BalanceToken[], options: GetBalancesOptions & { decimals: false }): Promise<RawTokenBalance[]>
|
|
172
|
+
export function getBalances(owner: string, tokens: readonly BalanceToken[], options?: GetBalancesOptions): Promise<TokenBalance[]>
|
|
173
|
+
export function getBalances(owner: string, tokens: readonly BalanceToken[], options: GetBalancesOptions = {}): Promise<Array<TokenBalance | RawTokenBalance>> {
|
|
174
|
+
const { provider, overrides, own } = resolve(options, ['symbol', 'decimals', 'withBlock'])
|
|
175
|
+
return provider.balances(owner, tokens, { ...overrides, ...own })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 多个钱包一次查(同一条链上合成一次 multicall),结果与 queries 一一对应。节点参数与 getBalances 相同。
|
|
180
|
+
*
|
|
181
|
+
* ```ts
|
|
182
|
+
* const [a, b] = await getMultiBalances([{ owner: walletA, tokens: [NATIVE_TOKEN, USDT] }, { owner: walletB, tokens: [USDT] }], { chainId: 56 })
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
export function getMultiBalances(queries: readonly BalancesQuery[], options: GetBalancesOptions & { decimals: false }): Promise<RawTokenBalance[][]>
|
|
186
|
+
export function getMultiBalances(queries: readonly BalancesQuery[], options?: GetBalancesOptions): Promise<TokenBalance[][]>
|
|
187
|
+
export function getMultiBalances(queries: readonly BalancesQuery[], options: GetBalancesOptions = {}): Promise<Array<Array<TokenBalance | RawTokenBalance>>> {
|
|
188
|
+
const { provider, overrides, own } = resolve(options, ['symbol', 'decimals', 'withBlock'])
|
|
189
|
+
return provider.multiBalances(queries, { ...overrides, ...own })
|
|
153
190
|
}
|
|
154
191
|
|
|
155
192
|
/**
|
|
@@ -167,8 +204,8 @@ export function getTokens<const F extends TokenField = DefaultTokenField>(
|
|
|
167
204
|
tokens: readonly string[],
|
|
168
205
|
options: GetTokensOptions<F> = {},
|
|
169
206
|
): Promise<TokenDetails<F>[]> {
|
|
170
|
-
const { provider, overrides, own } = resolve(options, ['fields'])
|
|
171
|
-
return provider.tokens<F>(tokens, { ...overrides,
|
|
207
|
+
const { provider, overrides, own } = resolve(options, ['fields', 'withBlock'])
|
|
208
|
+
return provider.tokens<F>(tokens, { ...overrides, ...own })
|
|
172
209
|
}
|
|
173
210
|
|
|
174
211
|
/**
|
|
@@ -183,66 +220,17 @@ export function getAllowances(
|
|
|
183
220
|
owner: string,
|
|
184
221
|
spender: string,
|
|
185
222
|
tokens: readonly BalanceToken[],
|
|
186
|
-
options:
|
|
187
|
-
): Promise<
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
* 批量查 NFT 集合信息,字段可选(默认 standard / name / symbol)。
|
|
198
|
-
*
|
|
199
|
-
* ```ts
|
|
200
|
-
* await getNftCollections([BAYC, MAYC], { chainId: 1, fields: ['standard', 'name', 'totalSupply'] })
|
|
201
|
-
* // [{ address: BAYC, standard: 'ERC721', name: 'BoredApeYachtClub', totalSupply: '10000', success: true }, ...]
|
|
202
|
-
* ```
|
|
203
|
-
*/
|
|
204
|
-
export function getNftCollections<const F extends NftCollectionField = DefaultNftCollectionField>(
|
|
205
|
-
collections: readonly string[],
|
|
206
|
-
options: GetNftCollectionsOptions<F> = {},
|
|
207
|
-
): Promise<NftCollection<F>[]> {
|
|
208
|
-
const { provider, overrides, own } = resolve(options, ['fields'])
|
|
209
|
-
return provider.nftCollections<F>(collections, { ...overrides, fields: own.fields })
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** 批量查 ERC721 持有数量:`await getNftBalances(user, [BAYC, MAYC], { chainId: 1 })` */
|
|
213
|
-
export function getNftBalances(owner: string, collections: readonly string[], options: ShortcutOptions = {}): Promise<NftBalance[]> {
|
|
214
|
-
const { provider, overrides } = resolve(options)
|
|
215
|
-
return provider.nftBalances(owner, collections, overrides)
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** 批量查 ERC721 持有人:`await getNftOwners([{ contract: BAYC, tokenId: 1 }], { chainId: 1 })` */
|
|
219
|
-
export function getNftOwners(items: readonly NftItem[], options: ShortcutOptions = {}): Promise<NftOwner[]> {
|
|
220
|
-
const { provider, overrides } = resolve(options)
|
|
221
|
-
return provider.nftOwners(items, overrides)
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export interface GetNftTokenUrisOptions extends ShortcutOptions {
|
|
225
|
-
/** 把 ipfs://xxx 转成 `${ipfsGateway}xxx`,如 'https://ipfs.io/ipfs/' */
|
|
226
|
-
ipfsGateway?: string
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* 批量查 NFT 元数据地址(ERC721 tokenURI / ERC1155 uri 自动兼容)。
|
|
231
|
-
*
|
|
232
|
-
* ```ts
|
|
233
|
-
* await getNftTokenUris([{ contract: BAYC, tokenId: 1 }], { chainId: 1, ipfsGateway: 'https://ipfs.io/ipfs/' })
|
|
234
|
-
* // [{ contract: BAYC, tokenId: '1', uri: 'https://ipfs.io/ipfs/Qm…/1', success: true }]
|
|
235
|
-
* ```
|
|
236
|
-
*/
|
|
237
|
-
export function getNftTokenUris(items: readonly NftItem[], options: GetNftTokenUrisOptions = {}): Promise<NftTokenUri[]> {
|
|
238
|
-
const { provider, overrides, own } = resolve(options, ['ipfsGateway'])
|
|
239
|
-
return provider.nftTokenUris(items, { ...overrides, ipfsGateway: own.ipfsGateway })
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** 批量查 ERC1155 余额:`await getErc1155Balances(user, [{ contract, tokenId: 1 }], { chainId: 137 })` */
|
|
243
|
-
export function getErc1155Balances(owner: string, items: readonly NftItem[], options: ShortcutOptions = {}): Promise<Erc1155Balance[]> {
|
|
244
|
-
const { provider, overrides } = resolve(options)
|
|
245
|
-
return provider.erc1155Balances(owner, items, overrides)
|
|
223
|
+
options: GetAllowancesOptions & { decimals: false },
|
|
224
|
+
): Promise<RawTokenAllowance[]>
|
|
225
|
+
export function getAllowances(owner: string, spender: string, tokens: readonly BalanceToken[], options?: GetAllowancesOptions): Promise<TokenAllowance[]>
|
|
226
|
+
export function getAllowances(
|
|
227
|
+
owner: string,
|
|
228
|
+
spender: string,
|
|
229
|
+
tokens: readonly BalanceToken[],
|
|
230
|
+
options: GetAllowancesOptions = {},
|
|
231
|
+
): Promise<Array<TokenAllowance | RawTokenAllowance>> {
|
|
232
|
+
const { provider, overrides, own } = resolve(options, ['decimals', 'withBlock'])
|
|
233
|
+
return provider.allowances(owner, spender, tokens, { ...overrides, ...own })
|
|
246
234
|
}
|
|
247
235
|
|
|
248
236
|
/** 测试用:按参数取(或创建)Provider,用于验证缓存复用(不在包的公开导出里) */
|
package/src/source.ts
CHANGED
|
@@ -2,9 +2,9 @@ import { BrowserProvider, FetchRequest, JsonRpcProvider, makeError, type Eip1193
|
|
|
2
2
|
|
|
3
3
|
import type { EthersLikeProvider } from './aggregate.js'
|
|
4
4
|
import { canDetectChainId, detectChainId, detectChainIdCached } from './detect.js'
|
|
5
|
-
import { FallbackRpc, type FallbackOptions } from './fallback.js'
|
|
5
|
+
import { FallbackRpc, type FallbackNodeInfo, type FallbackOptions } from './fallback.js'
|
|
6
6
|
import { DEFAULT_RPC_URLS, DEFAULT_TRON_HOSTS } from './rpcNodes.js'
|
|
7
|
-
import { TRON_CHAIN_ID, TronProvider, type TronProviderOptions, type TronWebLike } from './tron.js'
|
|
7
|
+
import { TRON_CHAIN_ID, TronProvider, isTronChain, type TronProviderOptions, type TronWebLike } from './tron.js'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* 可传给 Provider 的底层连接(也可以传数组,按顺序作为主节点 + 备用节点):
|
|
@@ -22,11 +22,7 @@ export interface SourceOptions {
|
|
|
22
22
|
tron?: Omit<TronProviderOptions, 'fullHost' | 'request'>
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export function isTronChain(chainId: number): boolean {
|
|
28
|
-
return TRON_CHAIN_IDS.has(chainId)
|
|
29
|
-
}
|
|
25
|
+
export { isTronChain }
|
|
30
26
|
|
|
31
27
|
/** 链的默认节点:EVM 用内置公共节点表,Tron 主网用内置全节点列表 */
|
|
32
28
|
export function getDefaultRpcUrls(chainId: number): readonly string[] {
|
|
@@ -57,7 +53,38 @@ export function resolveSource(
|
|
|
57
53
|
// 内置公共节点本来就是按 chainId 选出来的,不需要再校验
|
|
58
54
|
return source === undefined ? node : withChainCheck(chainId, item, node, timeout)
|
|
59
55
|
})
|
|
60
|
-
|
|
56
|
+
const info: FallbackNodeInfo[] = list.map((item) => ({ label: nodeLabel(item), key: item as string | object }))
|
|
57
|
+
// 单节点也经过 FallbackRpc(统一触发 onRequest 事件、抛原始错误)。外层超时只对单个 EVM URL 节点加(FetchRequest 本来就有同样的超时);
|
|
58
|
+
// 钱包等对象节点、Tron URL(TronProvider 有自己的超时和 429 退避、限流队列,外层超时会把排队 / 退避中的请求截断)不传 timeout 时不加,与原来一致
|
|
59
|
+
const singleEvmUrl = typeof list[0] === 'string' && !isTronChain(chainId)
|
|
60
|
+
const fallback = multiple || singleEvmUrl || options.fallback?.timeout !== undefined ? options.fallback : { ...options.fallback, timeout: 0 }
|
|
61
|
+
return new FallbackRpc(nodes, fallback, info)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 节点标识(错误信息、onRequest 事件用):URL 只取 host,不含 path / query(常带 API Key) */
|
|
65
|
+
export function nodeLabel(source: ProviderSource): string {
|
|
66
|
+
if (typeof source === 'string') {
|
|
67
|
+
try {
|
|
68
|
+
return new URL(source).host || 'url'
|
|
69
|
+
} catch {
|
|
70
|
+
return 'url'
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const value = source as Partial<EthersLikeProvider & Eip1193Provider & TronWebLike>
|
|
74
|
+
if (value instanceof JsonRpcProvider) {
|
|
75
|
+
try {
|
|
76
|
+
return new URL(value._getConnection().url).host || 'provider'
|
|
77
|
+
} catch {
|
|
78
|
+
return 'provider'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (value.fullNode) {
|
|
82
|
+
return 'tronWeb'
|
|
83
|
+
}
|
|
84
|
+
if (typeof value.call !== 'function' && typeof value.request === 'function') {
|
|
85
|
+
return 'wallet'
|
|
86
|
+
}
|
|
87
|
+
return 'provider'
|
|
61
88
|
}
|
|
62
89
|
|
|
63
90
|
/** 识别失败后多久内不再重试识别(期间请求照常发出,不做校验) */
|
|
@@ -120,6 +147,22 @@ export class ChainCheckedProvider implements EthersLikeProvider {
|
|
|
120
147
|
return this.#run(() => this.inner.getBalance(...args))
|
|
121
148
|
}
|
|
122
149
|
|
|
150
|
+
getBlockNumber(): Promise<number> {
|
|
151
|
+
const inner = this.inner
|
|
152
|
+
if (!inner.getBlockNumber) {
|
|
153
|
+
return Promise.reject(new Error('getBlockNumber is not supported by this node'))
|
|
154
|
+
}
|
|
155
|
+
return this.#run(() => (inner.getBlockNumber as () => Promise<number>)())
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getLogs(filter: Parameters<NonNullable<EthersLikeProvider['getLogs']>>[0]): Promise<Awaited<ReturnType<NonNullable<EthersLikeProvider['getLogs']>>>> {
|
|
159
|
+
const inner = this.inner
|
|
160
|
+
if (!inner.getLogs) {
|
|
161
|
+
return Promise.reject(new Error('getLogs is not supported by this node'))
|
|
162
|
+
}
|
|
163
|
+
return this.#run(() => (inner.getLogs as NonNullable<EthersLikeProvider['getLogs']>)(filter))
|
|
164
|
+
}
|
|
165
|
+
|
|
123
166
|
async getChainId(): Promise<number> {
|
|
124
167
|
return this.chainId
|
|
125
168
|
}
|