@w3lib/evm-batch-call 0.2.0 → 0.3.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 +11 -0
- package/README.md +91 -0
- package/dist/aggregate.d.ts +5 -2
- package/dist/aggregate.d.ts.map +1 -1
- package/dist/aggregate.js.map +1 -1
- package/dist/fallback.d.ts +23 -0
- package/dist/fallback.d.ts.map +1 -1
- package/dist/fallback.js +117 -3
- package/dist/fallback.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/owner.d.ts +136 -0
- package/dist/owner.d.ts.map +1 -0
- package/dist/owner.js +501 -0
- package/dist/owner.js.map +1 -0
- package/dist/provider.d.ts +19 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +37 -0
- 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 +18 -0
- package/dist/shortcuts.d.ts.map +1 -1
- package/dist/shortcuts.js +18 -0
- package/dist/shortcuts.js.map +1 -1
- package/dist/source.d.ts +2 -0
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +14 -0
- package/dist/source.js.map +1 -1
- package/package.json +1 -1
- package/src/aggregate.ts +5 -2
- package/src/fallback.ts +120 -3
- package/src/index.ts +35 -0
- package/src/owner.ts +651 -0
- package/src/provider.ts +42 -0
- package/src/scan.ts +293 -0
- package/src/shortcuts.ts +22 -0
- package/src/source.ts +16 -0
package/src/provider.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { CallFailedError, isExecutionError } from './errors.js'
|
|
25
25
|
import { MULTICALL3_ADDRESS, getMulticall3, type Multicall } from './multicall.js'
|
|
26
26
|
import { getNativeCurrency } from './chains.js'
|
|
27
|
+
import { ownerTokens, type OwnedToken, type OwnerTokensOptions } from './owner.js'
|
|
27
28
|
import {
|
|
28
29
|
erc1155Balances,
|
|
29
30
|
nftBalances,
|
|
@@ -364,6 +365,19 @@ export class Provider implements ContractRunner {
|
|
|
364
365
|
})
|
|
365
366
|
}
|
|
366
367
|
|
|
368
|
+
/**
|
|
369
|
+
* 列出持有人拥有的代币(资产列表):从代币来源拿候选代币,再用 multicall 在链上核对余额。
|
|
370
|
+
* 默认来源是公开代币列表,**只能发现列表里的代币**,局限性见 README「资产列表」一节。
|
|
371
|
+
*
|
|
372
|
+
* @deprecated 不推荐使用:EVM 链上无法只靠节点可靠地列出地址持有的全部代币,这个方法只是尽力而为——
|
|
373
|
+
* 默认来源只能发现公开列表里的代币,依赖第三方免费服务(可能限流、改格式或停止服务),要查几千个代币的余额、耗时数秒,
|
|
374
|
+
* 价格也不一定可信;传 `alchemy` / `nodereal` 来源能查全,但同样受第三方服务的额度和可用性约束。
|
|
375
|
+
* 已知要查哪些代币时请用 `getBalances`;需要可靠的完整持仓请直接接入索引服务的接口或自己的索引。局限性详见 README「资产列表」。
|
|
376
|
+
*/
|
|
377
|
+
ownerTokens(owner: string, options?: OwnerTokensOptions): Promise<OwnedToken[]> {
|
|
378
|
+
return ownerTokens(this, owner, options)
|
|
379
|
+
}
|
|
380
|
+
|
|
367
381
|
/** 批量查 NFT 集合信息(标准 / name / symbol / totalSupply),字段可选 */
|
|
368
382
|
nftCollections<const F extends NftCollectionField = DefaultNftCollectionField>(
|
|
369
383
|
collections: readonly string[],
|
|
@@ -648,6 +662,34 @@ export class Provider implements ContractRunner {
|
|
|
648
662
|
}
|
|
649
663
|
}
|
|
650
664
|
|
|
665
|
+
/** 当前区块号(底层节点需支持 getBlockNumber) */
|
|
666
|
+
async getBlockNumber(): Promise<number> {
|
|
667
|
+
const { provider } = await this.#ensureReady()
|
|
668
|
+
if (!provider.getBlockNumber) {
|
|
669
|
+
throw new Error('getBlockNumber is not supported by this provider')
|
|
670
|
+
}
|
|
671
|
+
return provider.getBlockNumber()
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** eth_getLogs(底层节点需支持 getLogs)。多节点时依次尝试,全部失败抛 GetLogsError */
|
|
675
|
+
async getLogs(filter: Parameters<NonNullable<EthersLikeProvider['getLogs']>>[0]): Promise<Awaited<ReturnType<NonNullable<EthersLikeProvider['getLogs']>>>> {
|
|
676
|
+
const { provider } = await this.#ensureReady()
|
|
677
|
+
if (!provider.getLogs) {
|
|
678
|
+
throw new Error('getLogs is not supported by this provider')
|
|
679
|
+
}
|
|
680
|
+
return provider.getLogs(filter)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** 是否是按主币处理的地址(config.nativeTokens,默认 0xeeee…eeee 和零地址) */
|
|
684
|
+
isNativeToken(address: string): boolean {
|
|
685
|
+
return this.#nativeTokens.has(address.toLowerCase())
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** 按主币处理的地址(小写),顺序同 config.nativeTokens */
|
|
689
|
+
get nativeTokens(): readonly string[] {
|
|
690
|
+
return [...this.#nativeTokens]
|
|
691
|
+
}
|
|
692
|
+
|
|
651
693
|
/** 底层连接(多节点时是 FallbackRpc)。只传节点构造时,需在 ready() 之后读取 */
|
|
652
694
|
get rpc(): EthersLikeProvider {
|
|
653
695
|
if (!this.#ctx) {
|
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,4 +1,5 @@
|
|
|
1
1
|
import type { CallOverrides } from './aggregate.js'
|
|
2
|
+
import type { OwnedToken, OwnerTokensOptions } from './owner.js'
|
|
2
3
|
import type { BalanceToken, DefaultTokenField, TokenAllowance, TokenBalance, TokenDetails, TokenField } from './erc20.js'
|
|
3
4
|
import type {
|
|
4
5
|
DefaultNftCollectionField,
|
|
@@ -245,6 +246,27 @@ export function getErc1155Balances(owner: string, items: readonly NftItem[], opt
|
|
|
245
246
|
return provider.erc1155Balances(owner, items, overrides)
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
export interface GetOwnerTokensOptions extends OwnerTokensOptions, ShortcutProviderOptions {}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 列出持有人拥有的代币(资产列表)。默认用免费公开代币列表发现代币,余额用 multicall 在链上核对。
|
|
253
|
+
*
|
|
254
|
+
* ```ts
|
|
255
|
+
* await getOwnerTokens(user, { chainId: 56 }) // 免费,只能发现公开列表里的代币
|
|
256
|
+
* await getOwnerTokens(user, { chainId: 1, prices: true, minUsd: 1 }) // 带美元价值,过滤零头和垃圾币
|
|
257
|
+
* await getOwnerTokens(user, { chainId: 56, source: alchemy({ apiKey }) }) // 用 Alchemy 查全部历史持仓
|
|
258
|
+
* ```
|
|
259
|
+
*
|
|
260
|
+
* @deprecated 不推荐使用:EVM 链上无法只靠节点可靠地列出地址持有的全部代币,这个方法只是尽力而为——
|
|
261
|
+
* 默认来源只能发现公开列表里的代币,依赖第三方免费服务(可能限流、改格式或停止服务),要查几千个代币的余额、耗时数秒,
|
|
262
|
+
* 价格也不一定可信;传 `alchemy` / `nodereal` 来源能查全,但同样受第三方服务的额度和可用性约束。
|
|
263
|
+
* 已知要查哪些代币时请用 `getBalances`;需要可靠的完整持仓请直接接入索引服务的接口或自己的索引。局限性详见 README「资产列表」。
|
|
264
|
+
*/
|
|
265
|
+
export function getOwnerTokens(owner: string, options: GetOwnerTokensOptions = {}): Promise<OwnedToken[]> {
|
|
266
|
+
const { provider, overrides, own } = resolve(options, ['source', 'prices', 'minUsd', 'includeNative', 'fetch', 'scanTransfers'])
|
|
267
|
+
return provider.ownerTokens(owner, { ...overrides, ...own })
|
|
268
|
+
}
|
|
269
|
+
|
|
248
270
|
/** 测试用:按参数取(或创建)Provider,用于验证缓存复用(不在包的公开导出里) */
|
|
249
271
|
export function resolveProviderForTest(options: ShortcutOptions): Provider {
|
|
250
272
|
return resolve(options).provider
|
package/src/source.ts
CHANGED
|
@@ -120,6 +120,22 @@ export class ChainCheckedProvider implements EthersLikeProvider {
|
|
|
120
120
|
return this.#run(() => this.inner.getBalance(...args))
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
getBlockNumber(): Promise<number> {
|
|
124
|
+
const inner = this.inner
|
|
125
|
+
if (!inner.getBlockNumber) {
|
|
126
|
+
return Promise.reject(new Error('getBlockNumber is not supported by this node'))
|
|
127
|
+
}
|
|
128
|
+
return this.#run(() => (inner.getBlockNumber as () => Promise<number>)())
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
getLogs(filter: Parameters<NonNullable<EthersLikeProvider['getLogs']>>[0]): Promise<Awaited<ReturnType<NonNullable<EthersLikeProvider['getLogs']>>>> {
|
|
132
|
+
const inner = this.inner
|
|
133
|
+
if (!inner.getLogs) {
|
|
134
|
+
return Promise.reject(new Error('getLogs is not supported by this node'))
|
|
135
|
+
}
|
|
136
|
+
return this.#run(() => (inner.getLogs as NonNullable<EthersLikeProvider['getLogs']>)(filter))
|
|
137
|
+
}
|
|
138
|
+
|
|
123
139
|
async getChainId(): Promise<number> {
|
|
124
140
|
return this.chainId
|
|
125
141
|
}
|