@w3lib/evm-batch-call 0.6.0 → 0.7.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 +24 -0
- package/README.md +161 -2
- package/contracts/TokenProbe.sol +103 -0
- package/dist/aggregate.d.ts.map +1 -1
- package/dist/aggregate.js +114 -3
- package/dist/aggregate.js.map +1 -1
- package/dist/approvals.d.ts +169 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +310 -0
- package/dist/approvals.js.map +1 -0
- package/dist/chains.d.ts.map +1 -1
- package/dist/chains.js +4 -0
- package/dist/chains.js.map +1 -1
- package/dist/fees.d.ts +97 -0
- package/dist/fees.d.ts.map +1 -0
- package/dist/fees.js +115 -0
- package/dist/fees.js.map +1 -0
- package/dist/http.d.ts +10 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +30 -0
- package/dist/http.js.map +1 -0
- package/dist/lp.d.ts +2 -1
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +2 -1
- package/dist/lp.js.map +1 -1
- package/dist/multicall.d.ts.map +1 -1
- package/dist/multicall.js +3 -0
- package/dist/multicall.js.map +1 -1
- package/dist/names.d.ts +55 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +93 -0
- package/dist/names.js.map +1 -0
- package/dist/owner.d.ts +4 -26
- package/dist/owner.d.ts.map +1 -1
- package/dist/owner.js +7 -112
- package/dist/owner.js.map +1 -1
- package/dist/portfolio.d.ts +54 -0
- package/dist/portfolio.d.ts.map +1 -0
- package/dist/portfolio.js +49 -0
- package/dist/portfolio.js.map +1 -0
- package/dist/price.d.ts +95 -0
- package/dist/price.d.ts.map +1 -0
- package/dist/price.js +329 -0
- package/dist/price.js.map +1 -0
- package/dist/risk.d.ts +42 -0
- package/dist/risk.d.ts.map +1 -0
- package/dist/risk.js +134 -0
- package/dist/risk.js.map +1 -0
- package/dist/rpcNodes.d.ts.map +1 -1
- package/dist/rpcNodes.js +18 -0
- package/dist/rpcNodes.js.map +1 -1
- package/dist/subpaths/approvals.d.ts +26 -0
- package/dist/subpaths/approvals.d.ts.map +1 -0
- package/dist/subpaths/approvals.js +27 -0
- package/dist/subpaths/approvals.js.map +1 -0
- package/dist/subpaths/fees.d.ts +23 -0
- package/dist/subpaths/fees.d.ts.map +1 -0
- package/dist/subpaths/fees.js +25 -0
- package/dist/subpaths/fees.js.map +1 -0
- package/dist/subpaths/names.d.ts +35 -0
- package/dist/subpaths/names.d.ts.map +1 -0
- package/dist/subpaths/names.js +39 -0
- package/dist/subpaths/names.js.map +1 -0
- package/dist/subpaths/price.d.ts +38 -0
- package/dist/subpaths/price.d.ts.map +1 -0
- package/dist/subpaths/price.js +40 -0
- package/dist/subpaths/price.js.map +1 -0
- package/dist/subpaths/risk.d.ts +19 -0
- package/dist/subpaths/risk.d.ts.map +1 -0
- package/dist/subpaths/risk.js +20 -0
- package/dist/subpaths/risk.js.map +1 -0
- package/dist/subpaths/swap.d.ts +19 -0
- package/dist/subpaths/swap.d.ts.map +1 -0
- package/dist/subpaths/swap.js +20 -0
- package/dist/subpaths/swap.js.map +1 -0
- package/dist/swap.d.ts +128 -0
- package/dist/swap.d.ts.map +1 -0
- package/dist/swap.js +291 -0
- package/dist/swap.js.map +1 -0
- package/dist/tokenProbe.d.ts +3 -0
- package/dist/tokenProbe.d.ts.map +1 -0
- package/dist/tokenProbe.js +4 -0
- package/dist/tokenProbe.js.map +1 -0
- package/dist/write/tron.d.ts +25 -0
- package/dist/write/tron.d.ts.map +1 -1
- package/dist/write/tron.js +26 -0
- package/dist/write/tron.js.map +1 -1
- package/package.json +39 -16
- package/src/aggregate.ts +110 -3
- package/src/approvals.ts +462 -0
- package/src/chains.ts +4 -0
- package/src/fees.ts +201 -0
- package/src/http.ts +34 -0
- package/src/lp.ts +3 -1
- package/src/multicall.ts +3 -0
- package/src/names.ts +141 -0
- package/src/owner.ts +7 -138
- package/src/portfolio.ts +91 -0
- package/src/price.ts +415 -0
- package/src/risk.ts +166 -0
- package/src/rpcNodes.ts +18 -0
- package/src/subpaths/approvals.ts +48 -0
- package/src/subpaths/fees.ts +43 -0
- package/src/subpaths/names.ts +43 -0
- package/src/subpaths/price.ts +60 -0
- package/src/subpaths/risk.ts +23 -0
- package/src/subpaths/swap.ts +36 -0
- package/src/swap.ts +413 -0
- package/src/tokenProbe.ts +5 -0
- package/src/write/tron.ts +43 -0
package/src/fees.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 手续费估算(子路径 `@w3lib/evm-batch-call/fees`):EIP-1559 的 baseFee 和三档优先费(按最近区块的 eth_feeHistory),
|
|
3
|
+
* 一笔交易的手续费(含 OP Stack / Scroll 的 L1 数据费);Tron 按能量 / 带宽和账户剩余的资源算要烧的 TRX
|
|
4
|
+
*/
|
|
5
|
+
import type { BigNumberish } from 'ethers'
|
|
6
|
+
|
|
7
|
+
import { getNativeCurrency } from './chains.js'
|
|
8
|
+
import { formatUnits } from './erc20.js'
|
|
9
|
+
import type { Provider } from './provider.js'
|
|
10
|
+
import { isTronChain } from './tron.js'
|
|
11
|
+
|
|
12
|
+
export type FeeLevel = 'slow' | 'normal' | 'fast'
|
|
13
|
+
|
|
14
|
+
/** 一档费用(wei 的十进制字符串):EIP-1559 链是 maxFeePerGas / maxPriorityFeePerGas,legacy 链是 gasPrice */
|
|
15
|
+
export type FeeLevelEstimate = { maxFeePerGas: string; maxPriorityFeePerGas: string } | { gasPrice: string }
|
|
16
|
+
|
|
17
|
+
export interface FeeEstimate {
|
|
18
|
+
type: 'eip1559' | 'legacy'
|
|
19
|
+
/** 下一个区块的 baseFee(wei);legacy 链为 null */
|
|
20
|
+
baseFee: string | null
|
|
21
|
+
slow: FeeLevelEstimate
|
|
22
|
+
normal: FeeLevelEstimate
|
|
23
|
+
fast: FeeLevelEstimate
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface EstimateFeesOptions {
|
|
27
|
+
/** 看最近多少个区块,默认 20 */
|
|
28
|
+
blocks?: number
|
|
29
|
+
/** 三档优先费取各区块交易优先费的百分位,默认 [10, 50, 90] */
|
|
30
|
+
percentiles?: readonly [number, number, number]
|
|
31
|
+
/** maxFeePerGas = baseFee × baseFeeMultiplier + 优先费,默认 2(baseFee 连涨几个区块也够) */
|
|
32
|
+
baseFeeMultiplier?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface FeeHistory {
|
|
36
|
+
baseFeePerGas?: string[]
|
|
37
|
+
reward?: string[][]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 节点的 JSON-RPC send(ethers 的 JsonRpcProvider / BrowserProvider) */
|
|
41
|
+
type Sender = { send(method: string, params: unknown[]): Promise<unknown> }
|
|
42
|
+
|
|
43
|
+
async function send<T>(provider: Provider, method: string, params: unknown[]): Promise<T> {
|
|
44
|
+
const { runOnNodes } = await import('./write/rpc.js')
|
|
45
|
+
return runOnNodes(provider.rpc, method, async (node) => {
|
|
46
|
+
if (typeof (node as Partial<Sender>).send !== 'function') throw new Error(`${method} is not supported by this node; pass an RPC URL or an ethers Provider`)
|
|
47
|
+
return (await (node as unknown as Sender).send(method, params)) as T
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const median = (values: bigint[]): bigint => {
|
|
52
|
+
if (!values.length) return 0n
|
|
53
|
+
const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
54
|
+
return sorted[Math.floor(sorted.length / 2)] as bigint
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 当前的手续费建议(EVM 链):baseFee 取下一个区块的,三档优先费是最近区块里交易优先费的百分位的中位数;
|
|
59
|
+
* 节点不支持 eth_feeHistory 或链上没有 baseFee 时按 eth_gasPrice(三档为 1、1、1.2 倍)。Tron 用 estimateCost
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* const fees = await estimateFees(provider)
|
|
63
|
+
* // { type: 'eip1559', baseFee: '1200000000', slow: { maxFeePerGas, maxPriorityFeePerGas }, normal: {…}, fast: {…} }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export async function estimateFees(provider: Provider, options: EstimateFeesOptions = {}): Promise<FeeEstimate> {
|
|
67
|
+
if (isTronChain(await provider.getChainId())) throw new Error('estimateFees is for EVM chains; use estimateCost on Tron')
|
|
68
|
+
const blocks = Math.max(1, Math.min(1024, Math.floor(options.blocks ?? 20)))
|
|
69
|
+
const percentiles = options.percentiles ?? [10, 50, 90]
|
|
70
|
+
if (!percentiles.every((p, i) => p >= 0 && p <= 100 && (i === 0 || p >= (percentiles[i - 1] as number)))) throw new RangeError('percentiles must be ascending numbers in [0, 100]')
|
|
71
|
+
const multiplier = options.baseFeeMultiplier ?? 2
|
|
72
|
+
let history: FeeHistory | null = null
|
|
73
|
+
try {
|
|
74
|
+
history = await send<FeeHistory>(provider, 'eth_feeHistory', [`0x${blocks.toString(16)}`, 'latest', [...percentiles]])
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// 只有节点 / 链不支持 feeHistory 时才按 gasPrice;网络等其他错误照常抛出(不要在 EIP-1559 链上悄悄退回 legacy)
|
|
77
|
+
if (!/not (supported|available|found)|unsupported|does not exist|unknown method|-32601|method.*not/i.test(err instanceof Error ? err.message : String(err))) throw err
|
|
78
|
+
history = null
|
|
79
|
+
}
|
|
80
|
+
const baseFees = history?.baseFeePerGas ?? []
|
|
81
|
+
const next = baseFees.length ? BigInt(baseFees[baseFees.length - 1] as string) : 0n
|
|
82
|
+
if (!history || !baseFees.length || next === 0n) {
|
|
83
|
+
const gasPrice = BigInt(await send<string>(provider, 'eth_gasPrice', []))
|
|
84
|
+
const level = (n: bigint, d: bigint) => ({ gasPrice: ((gasPrice * n) / d).toString() })
|
|
85
|
+
return { type: 'legacy', baseFee: null, slow: level(1n, 1n), normal: level(1n, 1n), fast: level(6n, 5n) }
|
|
86
|
+
}
|
|
87
|
+
const rewards = history.reward ?? []
|
|
88
|
+
// 每档:各区块这个百分位的优先费取中位数(空区块的 0 不算)
|
|
89
|
+
const tip = (k: number) => median(rewards.map((r) => BigInt(r[k] ?? '0')).filter((v) => v > 0n))
|
|
90
|
+
const base = (next * BigInt(Math.round(multiplier * 1000))) / 1000n
|
|
91
|
+
const level = (priority: bigint) => ({ maxFeePerGas: (base + priority).toString(), maxPriorityFeePerGas: priority.toString() })
|
|
92
|
+
const [slow, normal, fast] = [tip(0), tip(1), tip(2)]
|
|
93
|
+
// 优先费不递减(区块很空时三档可能相同)
|
|
94
|
+
return { type: 'eip1559', baseFee: next.toString(), slow: level(slow), normal: level(normal > slow ? normal : slow), fast: level(fast > normal ? fast : normal > slow ? normal : slow) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface CostCall {
|
|
98
|
+
/** 发送者(预估 gas、Tron 上按它剩余的资源算) */
|
|
99
|
+
from: string
|
|
100
|
+
to: string
|
|
101
|
+
data?: string
|
|
102
|
+
value?: BigNumberish
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface EvmCostEstimate {
|
|
106
|
+
type: 'evm'
|
|
107
|
+
gasLimit: string
|
|
108
|
+
/** 这一档的每 gas 价格:EIP-1559 是 baseFee + 优先费(预计实际付的),legacy 是 gasPrice */
|
|
109
|
+
gasPrice: string
|
|
110
|
+
/** gasLimit × gasPrice(wei) */
|
|
111
|
+
fee: string
|
|
112
|
+
/** 最多付多少:gasLimit × maxFeePerGas(legacy 同 fee) */
|
|
113
|
+
maxFee: string
|
|
114
|
+
/** OP Stack / Scroll 的 L1 数据费(wei,含 25% 余量),其他链为 0 */
|
|
115
|
+
l1Fee: string
|
|
116
|
+
/** fee + l1Fee(wei)和按主币精度换算的值 */
|
|
117
|
+
total: string
|
|
118
|
+
totalFormatted: string
|
|
119
|
+
symbol: string | null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface TronCost {
|
|
123
|
+
type: 'tron'
|
|
124
|
+
energy: number
|
|
125
|
+
bandwidth: number
|
|
126
|
+
/** 剩余能量 / 带宽不够时要烧的 TRX(sun) */
|
|
127
|
+
energyBurn: string
|
|
128
|
+
bandwidthBurn: string
|
|
129
|
+
/** 转 TRX 给没激活的地址(激活费算在 bandwidthBurn 里) */
|
|
130
|
+
activates: boolean
|
|
131
|
+
energyPrice: string
|
|
132
|
+
bandwidthPrice: string
|
|
133
|
+
/** energyBurn + bandwidthBurn(sun)和按 TRX 换算的值 */
|
|
134
|
+
total: string
|
|
135
|
+
totalFormatted: string
|
|
136
|
+
symbol: 'TRX'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type CostEstimate = EvmCostEstimate | TronCost
|
|
140
|
+
|
|
141
|
+
export interface EstimateCostOptions extends EstimateFeesOptions {
|
|
142
|
+
/** 用哪一档,默认 'normal' */
|
|
143
|
+
level?: FeeLevel
|
|
144
|
+
/** 已经查好的费用建议(多笔交易共用,不再查一次) */
|
|
145
|
+
fees?: FeeEstimate
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 估算一笔交易的手续费:EVM 预估 gas × 这一档的价格(+ L1 数据费);Tron 模拟能量,按发送者剩余的资源算要烧的 TRX。
|
|
150
|
+
* 预估 gas 会执行一遍交易,revert 时抛出
|
|
151
|
+
*
|
|
152
|
+
* ```ts
|
|
153
|
+
* await estimateCost(provider, { from: user, to: USDT, data }) // { type: 'evm', gasLimit, fee, total, totalFormatted: '0.00042', symbol: 'BNB', … }
|
|
154
|
+
* await estimateCost(tronProvider, { from, to: USDT, data }) // { type: 'tron', energy: 64285, energyBurn: '6750000', totalFormatted: '7.0', … }
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
export async function estimateCost(provider: Provider, call: CostCall, options: EstimateCostOptions = {}): Promise<CostEstimate> {
|
|
158
|
+
const chainId = await provider.getChainId()
|
|
159
|
+
const value = BigInt(call.value ?? 0)
|
|
160
|
+
if (isTronChain(chainId)) {
|
|
161
|
+
const { tronEstimateCost } = await import('./write/tron.js')
|
|
162
|
+
const c = await tronEstimateCost(provider, { from: call.from, to: call.to, ...(call.data ? { data: call.data } : {}), value })
|
|
163
|
+
const total = c.energyBurn + c.bandwidthBurn
|
|
164
|
+
return {
|
|
165
|
+
type: 'tron',
|
|
166
|
+
energy: c.energy,
|
|
167
|
+
bandwidth: c.bandwidth,
|
|
168
|
+
energyBurn: c.energyBurn.toString(),
|
|
169
|
+
bandwidthBurn: c.bandwidthBurn.toString(),
|
|
170
|
+
activates: c.activates,
|
|
171
|
+
energyPrice: c.energyPrice.toString(),
|
|
172
|
+
bandwidthPrice: c.bandwidthPrice.toString(),
|
|
173
|
+
total: total.toString(),
|
|
174
|
+
totalFormatted: formatUnits(total, 6),
|
|
175
|
+
symbol: 'TRX',
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const { WriteRpc, l1Fee } = await import('./write/rpc.js')
|
|
179
|
+
const rpc = await WriteRpc.from(provider)
|
|
180
|
+
const tx = { from: call.from, to: call.to, data: call.data ?? '0x', value }
|
|
181
|
+
const [gasLimit, fees] = await Promise.all([rpc.estimateGas(tx), options.fees ? Promise.resolve(options.fees) : estimateFees(provider, options)])
|
|
182
|
+
const level = fees[options.level ?? 'normal']
|
|
183
|
+
const perGas = 'gasPrice' in level ? BigInt(level.gasPrice) : BigInt(fees.baseFee ?? '0') + BigInt(level.maxPriorityFeePerGas)
|
|
184
|
+
const maxPerGas = 'gasPrice' in level ? perGas : BigInt(level.maxFeePerGas)
|
|
185
|
+
const feeFields = 'gasPrice' in level ? { type: 0, gasPrice: perGas } : { type: 2, maxFeePerGas: maxPerGas, maxPriorityFeePerGas: BigInt(level.maxPriorityFeePerGas) }
|
|
186
|
+
const l1 = await l1Fee(rpc, { ...tx, from: undefined, gasLimit, ...feeFields })
|
|
187
|
+
const fee = gasLimit * perGas
|
|
188
|
+
const total = fee + l1
|
|
189
|
+
const native = getNativeCurrency(chainId)
|
|
190
|
+
return {
|
|
191
|
+
type: 'evm',
|
|
192
|
+
gasLimit: gasLimit.toString(),
|
|
193
|
+
gasPrice: perGas.toString(),
|
|
194
|
+
fee: fee.toString(),
|
|
195
|
+
maxFee: (gasLimit * maxPerGas).toString(),
|
|
196
|
+
l1Fee: l1.toString(),
|
|
197
|
+
total: total.toString(),
|
|
198
|
+
totalFormatted: formatUnits(total, native?.decimals ?? 18),
|
|
199
|
+
symbol: native?.symbol ?? null,
|
|
200
|
+
}
|
|
201
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** HTTP 请求(代币列表、价格服务):超时、状态码检查 */
|
|
2
|
+
|
|
3
|
+
const REQUEST_TIMEOUT = 20_000
|
|
4
|
+
|
|
5
|
+
export class HttpStatusError extends Error {
|
|
6
|
+
constructor(
|
|
7
|
+
readonly status: number,
|
|
8
|
+
target: string,
|
|
9
|
+
) {
|
|
10
|
+
super(`HTTP ${status} from ${target}`)
|
|
11
|
+
this.name = 'HttpStatusError'
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param secret 请求里带的 Key:出错时错误信息只写域名,不写完整地址,避免 Key 出现在日志里
|
|
17
|
+
*/
|
|
18
|
+
export function fetchJson<T>(fetchFn: typeof fetch, url: string, init?: RequestInit, secret?: string): Promise<T> {
|
|
19
|
+
return fetchFn(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT) }).then(async (res) => {
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
throw new HttpStatusError(res.status, secret ? safeHost(url) : url)
|
|
22
|
+
}
|
|
23
|
+
return (await res.json()) as T
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeHost(url: string): string {
|
|
28
|
+
try {
|
|
29
|
+
return new URL(url).host
|
|
30
|
+
} catch {
|
|
31
|
+
return 'request'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
package/src/lp.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { Call } from './call.js'
|
|
|
3
3
|
import { formatUnits } from './erc20.js'
|
|
4
4
|
import { getAmountsForLiquidity, getFeeGrowthInside, getFeesEarned, getSqrtRatioAtTick, sqrtPriceToPrice, tickToPrice } from './lpMath.js'
|
|
5
5
|
import { v4Positions, type LpV4Options } from './lpV4.js'
|
|
6
|
-
import
|
|
6
|
+
import type { TokenSource } from './owner.js'
|
|
7
|
+
import { defillamaPrices, type PriceSource } from './price.js'
|
|
7
8
|
import type { Provider } from './provider.js'
|
|
8
9
|
import { TRON_CHAIN_ID, isTronAddress, isTronChain, toEvmAddress, toTronAddress } from './tron.js'
|
|
9
10
|
import { withSignal } from './util.js'
|
|
@@ -629,6 +630,7 @@ async function finalize(provider: Provider, chainId: number, raw: RawPosition[],
|
|
|
629
630
|
tokens: addresses.filter((a) => !info.get(a)?.native).map((a) => (tron ? toTronAddress(a) : a)),
|
|
630
631
|
nativeSymbol: nativeToken ? nativeToken.symbol : null,
|
|
631
632
|
fetch: fetchFn,
|
|
633
|
+
provider,
|
|
632
634
|
})
|
|
633
635
|
} catch (err) {
|
|
634
636
|
// 同步抛错和 reject 都在这里接住
|
package/src/multicall.ts
CHANGED
|
@@ -81,6 +81,9 @@ const MULTICALL3: Record<number, Multicall> = {
|
|
|
81
81
|
80094: { address: CANONICAL, block: 0 }, // Berachain,创世预置
|
|
82
82
|
534352: { address: CANONICAL, block: 14 }, // Scroll
|
|
83
83
|
86233268: { address: CANONICAL, block: 10854614 }, // Winchain,部署区块未查到
|
|
84
|
+
1030: { address: CANONICAL, block: 159584122 }, // Conflux eSpace,部署区块未查到
|
|
85
|
+
1672: { address: CANONICAL, block: 20812125 }, // Pharos,部署区块未查到
|
|
86
|
+
1776: { address: CANONICAL, block: 188237272 }, // Injective EVM,部署区块未查到
|
|
84
87
|
// 以下两条链不支持 deployless(合约创建式 eth_call 返回空 / 格式不对),只能用 Multicall3;
|
|
85
88
|
// 早于部署区块的历史查询会失败
|
|
86
89
|
42262: { address: CANONICAL, block: 22380155 }, // Oasis Emerald,部署区块未查到
|
package/src/names.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 域名解析(子路径 `@w3lib/evm-batch-call/names`):ENS(Ethereum)、SPACE ID(BSC 的 .bnb)、Basenames(Base 的 .base.eth),
|
|
3
|
+
* 名字 → 地址、地址 → 名字(反查后再正查核对)。都是 ENS 式的注册表合约,读取合成几次 multicall
|
|
4
|
+
*/
|
|
5
|
+
import { ensNormalize, namehash } from 'ethers'
|
|
6
|
+
|
|
7
|
+
import type { Provider } from './provider.js'
|
|
8
|
+
import { isTronChain } from './tron.js'
|
|
9
|
+
|
|
10
|
+
export interface NameService {
|
|
11
|
+
id: string
|
|
12
|
+
name: string
|
|
13
|
+
chainId: number
|
|
14
|
+
/** ENS 式的注册表合约(resolver(node)) */
|
|
15
|
+
registry: string
|
|
16
|
+
/** 这个服务的名字后缀(如 ['bnb']);null 表示任意名字都交给它(ENS 还支持导入的 DNS 域名) */
|
|
17
|
+
suffixes: readonly string[] | null
|
|
18
|
+
/** 反查用的节点后缀:ENS 是 addr.reverse,L2 上按 ENSIP-19 是 <coinType 十六进制>.reverse */
|
|
19
|
+
reverseSuffix: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 内置的域名服务(2026-10 在链上核对过注册表和正查 / 反查) */
|
|
23
|
+
export const NAME_SERVICES: readonly NameService[] = [
|
|
24
|
+
{ id: 'ens', name: 'ENS', chainId: 1, registry: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', suffixes: null, reverseSuffix: 'addr.reverse' },
|
|
25
|
+
{ id: 'spaceid', name: 'SPACE ID', chainId: 56, registry: '0x08CEd32a7f3eeC915Ba84415e9C07a7286977956', suffixes: ['bnb'], reverseSuffix: 'addr.reverse' },
|
|
26
|
+
{ id: 'basenames', name: 'Basenames', chainId: 8453, registry: '0xb94704422c2a1e396835a571837aa5ae53285a95', suffixes: ['base.eth'], reverseSuffix: '80002105.reverse' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
export interface NamesOptions {
|
|
30
|
+
/** 用哪些域名服务,默认 NAME_SERVICES 里 Provider 这条链的 */
|
|
31
|
+
services?: readonly NameService[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ResolvedName {
|
|
35
|
+
/** 传入的名字(原样) */
|
|
36
|
+
name: string
|
|
37
|
+
/** 规范化后的名字(ENSIP-15);名字不合法时为 null */
|
|
38
|
+
normalized: string | null
|
|
39
|
+
/** 解析出的地址;没有注册、没有设置地址、名字不合法时为 null */
|
|
40
|
+
address: string | null
|
|
41
|
+
/** 解析这个名字的服务 id;没有服务认这个后缀时为 null */
|
|
42
|
+
service: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AddressName {
|
|
46
|
+
/** 传入的地址(原样) */
|
|
47
|
+
address: string
|
|
48
|
+
/** 反查到、并且正查回到同一个地址的名字;没有时为 null */
|
|
49
|
+
name: string | null
|
|
50
|
+
service: string | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const REGISTRY_ABI = ['function resolver(bytes32 node) view returns (address)']
|
|
54
|
+
const RESOLVER_ABI = ['function addr(bytes32 node) view returns (address)', 'function name(bytes32 node) view returns (string)']
|
|
55
|
+
const ZERO = '0x0000000000000000000000000000000000000000'
|
|
56
|
+
const present = (a: string | null | undefined): a is string => !!a && a.toLowerCase() !== ZERO
|
|
57
|
+
|
|
58
|
+
function servicesOf(chainId: number, options: NamesOptions): readonly NameService[] {
|
|
59
|
+
return options.services ?? NAME_SERVICES.filter((s) => s.chainId === chainId)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 名字归哪个服务:后缀最长的匹配优先,其次是不限后缀的服务 */
|
|
63
|
+
function serviceFor(name: string, services: readonly NameService[]): NameService | null {
|
|
64
|
+
let best: { s: NameService; len: number } | null = null
|
|
65
|
+
for (const s of services) {
|
|
66
|
+
for (const suffix of s.suffixes ?? []) {
|
|
67
|
+
if ((name === suffix || name.endsWith(`.${suffix}`)) && (!best || suffix.length > best.len)) best = { s, len: suffix.length }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return best?.s ?? services.find((s) => s.suffixes === null) ?? null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalize(name: string): string | null {
|
|
74
|
+
try {
|
|
75
|
+
const n = ensNormalize(name.trim())
|
|
76
|
+
return n.includes('.') ? n : null
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 名字 → 地址(读注册表的 resolver,再读 resolver 的 addr,两次 multicall)。
|
|
84
|
+
* 不支持通配解析(ENSIP-10)和链下解析(CCIP-Read,如 cb.id 等子域名),这些名字返回 null
|
|
85
|
+
*
|
|
86
|
+
* ```ts
|
|
87
|
+
* await resolveNames(new Provider(1), ['vitalik.eth', 'nick.eth'])
|
|
88
|
+
* // [{ name: 'vitalik.eth', normalized: 'vitalik.eth', address: '0xd8dA…6045', service: 'ens' }, …]
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export async function resolveNames(provider: Provider, names: readonly string[], options: NamesOptions = {}): Promise<ResolvedName[]> {
|
|
92
|
+
const chainId = await provider.getChainId()
|
|
93
|
+
const services = isTronChain(chainId) ? [] : servicesOf(chainId, options)
|
|
94
|
+
const items = names.map((name) => {
|
|
95
|
+
const normalized = normalize(name)
|
|
96
|
+
const service = normalized ? serviceFor(normalized, services) : null
|
|
97
|
+
return { name, normalized, service, node: normalized && service ? namehash(normalized) : null }
|
|
98
|
+
})
|
|
99
|
+
const todo = items.filter((x) => x.node && x.service)
|
|
100
|
+
const resolvers = todo.length ? await provider.tryAll<string>(todo.map((x) => provider.contract((x.service as NameService).registry, REGISTRY_ABI).resolver(x.node as string))) : []
|
|
101
|
+
const withResolver = todo.flatMap((x, i) => (present(resolvers[i]) ? [{ x, resolver: resolvers[i] as string }] : []))
|
|
102
|
+
const addresses = withResolver.length ? await provider.tryAll<string>(withResolver.map(({ x, resolver }) => provider.contract(resolver, RESOLVER_ABI).addr(x.node as string))) : []
|
|
103
|
+
const found = new Map(withResolver.map(({ x }, i) => [x, present(addresses[i]) ? (addresses[i] as string) : null]))
|
|
104
|
+
return items.map((x) => ({ name: x.name, normalized: x.normalized, address: found.get(x) ?? null, service: x.service?.id ?? null }))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 地址 → 名字:读反查记录(主名字),再正查核对名字确实指回这个地址(没有核对的反查记录可以随便设置,不能信)。
|
|
109
|
+
* 共四次 multicall;Tron 上没有域名服务,都返回 null
|
|
110
|
+
*
|
|
111
|
+
* ```ts
|
|
112
|
+
* await lookupAddresses(new Provider(56), [user]) // [{ address: user, name: 'alice.bnb', service: 'spaceid' }]
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export async function lookupAddresses(provider: Provider, addresses: readonly string[], options: NamesOptions = {}): Promise<AddressName[]> {
|
|
116
|
+
const chainId = await provider.getChainId()
|
|
117
|
+
const services = isTronChain(chainId) ? [] : servicesOf(chainId, options)
|
|
118
|
+
const queries = addresses.flatMap((address, i) =>
|
|
119
|
+
/^0x[0-9a-fA-F]{40}$/.test(address) ? services.map((s) => ({ i, s, node: namehash(`${address.slice(2).toLowerCase()}.${s.reverseSuffix}`) })) : [],
|
|
120
|
+
)
|
|
121
|
+
const resolvers = queries.length ? await provider.tryAll<string>(queries.map((q) => provider.contract(q.s.registry, REGISTRY_ABI).resolver(q.node))) : []
|
|
122
|
+
const withResolver = queries.flatMap((q, j) => (present(resolvers[j]) ? [{ q, resolver: resolvers[j] as string }] : []))
|
|
123
|
+
const names = withResolver.length ? await provider.tryAll<string>(withResolver.map(({ q, resolver }) => provider.contract(resolver, RESOLVER_ABI).name(q.node))) : []
|
|
124
|
+
const claimed = withResolver.flatMap(({ q }, j) => (typeof names[j] === 'string' && names[j] ? [{ q, name: names[j] as string }] : []))
|
|
125
|
+
// 正查核对:名字要由同一个服务解析回这个地址
|
|
126
|
+
const forward = await resolveNames(
|
|
127
|
+
provider,
|
|
128
|
+
claimed.map((c) => c.name),
|
|
129
|
+
{ services },
|
|
130
|
+
)
|
|
131
|
+
const result: AddressName[] = addresses.map((address) => ({ address, name: null, service: null }))
|
|
132
|
+
claimed.forEach((c, j) => {
|
|
133
|
+
const f = forward[j]
|
|
134
|
+
const out = result[c.q.i] as AddressName
|
|
135
|
+
if (out.name === null && f?.address && f.service === c.q.s.id && f.address.toLowerCase() === out.address.toLowerCase()) {
|
|
136
|
+
out.name = f.normalized
|
|
137
|
+
out.service = c.q.s.id
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
return result
|
|
141
|
+
}
|
package/src/owner.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { CallOverrides } from './aggregate.js'
|
|
2
2
|
import { NATIVE_TOKEN, formatUnits, type BalanceToken } from './erc20.js'
|
|
3
3
|
import type { Provider } from './provider.js'
|
|
4
|
+
import { HttpStatusError, fetchJson } from './http.js'
|
|
5
|
+
import { defillamaPrices, type PriceSource } from './price.js'
|
|
4
6
|
import { transferScan, type TransferScanOptions } from './scan.js'
|
|
5
|
-
import {
|
|
7
|
+
import { isTronAddress, isTronChain, toTronAddress } from './tron.js'
|
|
6
8
|
|
|
7
9
|
// ---------------------------------------------------------------------------
|
|
8
10
|
// 代币来源:负责“这个地址可能持有哪些代币”。余额和精度一律由本库用 multicall 在链上核对。
|
|
@@ -31,41 +33,10 @@ export interface TokenSource {
|
|
|
31
33
|
discover(ctx: TokenSourceContext): Promise<DiscoveredToken[] | null>
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
const REQUEST_TIMEOUT = 20_000
|
|
35
36
|
const LIST_TTL = 60 * 60 * 1000
|
|
36
37
|
const MAX_CACHED_LISTS = 16
|
|
37
38
|
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
|
|
38
39
|
|
|
39
|
-
export class HttpStatusError extends Error {
|
|
40
|
-
constructor(
|
|
41
|
-
readonly status: number,
|
|
42
|
-
target: string,
|
|
43
|
-
) {
|
|
44
|
-
super(`HTTP ${status} from ${target}`)
|
|
45
|
-
this.name = 'HttpStatusError'
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* @param secret 请求里带的 Key:出错时错误信息只写域名,不写完整地址,避免 Key 出现在日志里
|
|
51
|
-
*/
|
|
52
|
-
function fetchJson<T>(fetchFn: typeof fetch, url: string, init?: RequestInit, secret?: string): Promise<T> {
|
|
53
|
-
return fetchFn(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT) }).then(async (res) => {
|
|
54
|
-
if (!res.ok) {
|
|
55
|
-
throw new HttpStatusError(res.status, secret ? safeHost(url) : url)
|
|
56
|
-
}
|
|
57
|
-
return (await res.json()) as T
|
|
58
|
-
})
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function safeHost(url: string): string {
|
|
62
|
-
try {
|
|
63
|
-
return new URL(url).host
|
|
64
|
-
} catch {
|
|
65
|
-
return 'request'
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
40
|
// 公开代币列表:缓存“按链过滤、转换后”的结果(不缓存原始 JSON),1 小时;缓存 Promise,同时发起的请求共用,失败不缓存。
|
|
70
41
|
// 公开列表与使用哪个 fetch 无关,所以缓存键只看列表和链
|
|
71
42
|
const listCache = new Map<string, { at: number; promise: Promise<DiscoveredToken[] | null> }>()
|
|
@@ -410,111 +381,9 @@ function dedupe<T extends DiscoveredToken>(tokens: T[]): T[] {
|
|
|
410
381
|
return [...seen.values()]
|
|
411
382
|
}
|
|
412
383
|
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
export interface PriceSourceContext {
|
|
418
|
-
chainId: number
|
|
419
|
-
/** 代币地址(不含主币) */
|
|
420
|
-
tokens: readonly string[]
|
|
421
|
-
/** 主币 symbol(需要主币价格时);不需要时为 null */
|
|
422
|
-
nativeSymbol: string | null
|
|
423
|
-
fetch: typeof fetch
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
export interface PriceSource {
|
|
427
|
-
readonly name: string
|
|
428
|
-
/** 返回 去重键 → 美元价格:EVM 是小写 0x 地址;Tron 是 T 开头的 base58 地址(区分大小写);主币用键 'native'。没有价格的不返回 */
|
|
429
|
-
prices(ctx: PriceSourceContext): Promise<Map<string, number>>
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/** DefiLlama 的链标识:chainId → slug(2026-10 实测可用) */
|
|
433
|
-
export const DEFILLAMA_CHAINS: Readonly<Record<number, string>> = {
|
|
434
|
-
1: 'ethereum',
|
|
435
|
-
10: 'optimism',
|
|
436
|
-
56: 'bsc',
|
|
437
|
-
130: 'unichain',
|
|
438
|
-
137: 'polygon',
|
|
439
|
-
143: 'monad',
|
|
440
|
-
196: 'xlayer',
|
|
441
|
-
204: 'op_bnb',
|
|
442
|
-
324: 'era',
|
|
443
|
-
999: 'hyperliquid',
|
|
444
|
-
1116: 'core',
|
|
445
|
-
2818: 'morph',
|
|
446
|
-
5000: 'mantle',
|
|
447
|
-
8453: 'base',
|
|
448
|
-
9745: 'plasma',
|
|
449
|
-
34443: 'mode',
|
|
450
|
-
42161: 'arbitrum',
|
|
451
|
-
43114: 'avax',
|
|
452
|
-
57073: 'ink',
|
|
453
|
-
59144: 'linea',
|
|
454
|
-
80094: 'berachain',
|
|
455
|
-
81457: 'blast',
|
|
456
|
-
534352: 'scroll',
|
|
457
|
-
[TRON_CHAIN_ID.mainnet]: 'tron',
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/** 主币价格:按主币 symbol 对应 CoinGecko 的币种 id(DefiLlama 用 coingecko:<id> 查) */
|
|
461
|
-
const NATIVE_PRICE_IDS: Readonly<Record<string, string>> = {
|
|
462
|
-
ETH: 'ethereum',
|
|
463
|
-
BNB: 'binancecoin',
|
|
464
|
-
POL: 'polygon-ecosystem-token',
|
|
465
|
-
AVAX: 'avalanche-2',
|
|
466
|
-
MNT: 'mantle',
|
|
467
|
-
OKB: 'okb',
|
|
468
|
-
HYPE: 'hyperliquid',
|
|
469
|
-
BERA: 'berachain-bera',
|
|
470
|
-
CORE: 'coredaoorg',
|
|
471
|
-
MON: 'monad',
|
|
472
|
-
XPL: 'plasma',
|
|
473
|
-
TRX: 'tron',
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* DefiLlama 价格(免费、不需要 Key)。minConfidence:DefiLlama 给出的置信度下限(0~1),低于它视为没有价格,默认 0.9。
|
|
478
|
-
* 注意:流动性很差的代币价格可能虚高,展示总资产时建议配合 minUsd 或自己的价格源。
|
|
479
|
-
*/
|
|
480
|
-
export function defillamaPrices(options: { minConfidence?: number } = {}): PriceSource {
|
|
481
|
-
const minConfidence = options.minConfidence ?? 0.9
|
|
482
|
-
return {
|
|
483
|
-
name: 'defillama',
|
|
484
|
-
async prices({ chainId, tokens, nativeSymbol, fetch: fetchFn }) {
|
|
485
|
-
const result = new Map<string, number>()
|
|
486
|
-
const slug = DEFILLAMA_CHAINS[chainId]
|
|
487
|
-
const nativeId = nativeSymbol ? NATIVE_PRICE_IDS[nativeSymbol.toUpperCase()] : undefined
|
|
488
|
-
// 查询键 → 结果里用的去重键:EVM 地址小写;Tron 地址区分大小写,DefiLlama 要用原样的 base58(T…)地址
|
|
489
|
-
const resultKey = new Map<string, string>()
|
|
490
|
-
const tokenKeys = slug
|
|
491
|
-
? tokens.map((t) => {
|
|
492
|
-
const tron = slug === 'tron'
|
|
493
|
-
const query = `${slug}:${tron ? toTronAddress(t) : t.toLowerCase()}`
|
|
494
|
-
resultKey.set(query.toLowerCase(), tron ? toTronAddress(t) : t.toLowerCase())
|
|
495
|
-
return query
|
|
496
|
-
})
|
|
497
|
-
: []
|
|
498
|
-
const keys = [...(nativeId ? [`coingecko:${nativeId}`] : []), ...tokenKeys]
|
|
499
|
-
const batches: string[][] = []
|
|
500
|
-
for (let i = 0; i < keys.length; i += 100) {
|
|
501
|
-
batches.push(keys.slice(i, i + 100))
|
|
502
|
-
}
|
|
503
|
-
const responses = await Promise.all(
|
|
504
|
-
batches.map((batch) => fetchJson<{ coins?: Record<string, { price?: number; confidence?: number }> }>(fetchFn, `https://coins.llama.fi/prices/current/${batch.join(',')}`)),
|
|
505
|
-
)
|
|
506
|
-
for (const res of responses) {
|
|
507
|
-
for (const [key, coin] of Object.entries(res.coins ?? {})) {
|
|
508
|
-
if (typeof coin.price !== 'number' || !Number.isFinite(coin.price) || (coin.confidence !== undefined && coin.confidence < minConfidence)) {
|
|
509
|
-
continue
|
|
510
|
-
}
|
|
511
|
-
result.set(key.startsWith('coingecko:') ? 'native' : (resultKey.get(key.toLowerCase()) ?? key.slice(key.indexOf(':') + 1).toLowerCase()), coin.price)
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
return result
|
|
515
|
-
},
|
|
516
|
-
}
|
|
517
|
-
}
|
|
384
|
+
// 价格来源在 price.ts(子路径 /price);这里继续导出,旧的 import 不受影响
|
|
385
|
+
export { DEFILLAMA_CHAINS, defillamaPrices, type PriceSource, type PriceSourceContext } from './price.js'
|
|
386
|
+
export { HttpStatusError }
|
|
518
387
|
|
|
519
388
|
// ---------------------------------------------------------------------------
|
|
520
389
|
// ownerTokens
|
|
@@ -617,7 +486,7 @@ export async function ownerTokens(provider: Provider, owner: string, options: Ow
|
|
|
617
486
|
// 价格源的键:Tron 上统一用 T 地址(代币来源给的可能是 0x 格式)
|
|
618
487
|
const priceKey = (address: string) => (isTronChain(chainId) ? toTronAddress(address) : addressKey(address))
|
|
619
488
|
const priceMap = priceSource
|
|
620
|
-
? await priceSource.prices({ chainId, tokens: held.map((x) => (isTronChain(chainId) ? toTronAddress(x.meta.address) : x.meta.address)), nativeSymbol: nativeAddress ? (nativeInfo?.symbol ?? null) : null, fetch: fetchFn })
|
|
489
|
+
? await priceSource.prices({ chainId, tokens: held.map((x) => (isTronChain(chainId) ? toTronAddress(x.meta.address) : x.meta.address)), nativeSymbol: nativeAddress ? (nativeInfo?.symbol ?? null) : null, fetch: fetchFn, provider })
|
|
621
490
|
: new Map<string, number>()
|
|
622
491
|
const valueOf = (formatted: string, price: number | undefined) => (price === undefined ? null : Number(formatted) * price)
|
|
623
492
|
|
package/src/portfolio.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 资产总值(子路径 `@w3lib/evm-batch-call/price`):已知代币的余额 × 价格,可选加上 LP 仓位的价值
|
|
3
|
+
*/
|
|
4
|
+
import { lpPositions, type LpPosition, type LpPositionsOptions } from './lp.js'
|
|
5
|
+
import { combinePrices, defillamaPrices, onchainPrices, tokenPrices, type PriceSource } from './price.js'
|
|
6
|
+
import type { Provider } from './provider.js'
|
|
7
|
+
|
|
8
|
+
export interface PortfolioOptions {
|
|
9
|
+
/** 要计入的代币(含主币:NATIVE_TOKEN),余额用 multicall 一次读出 */
|
|
10
|
+
tokens: readonly string[]
|
|
11
|
+
/** 价格源,依次使用,默认 [defillamaPrices(), onchainPrices()] */
|
|
12
|
+
sources?: readonly PriceSource[]
|
|
13
|
+
/** 同时计入 LP 仓位(V2 LP 代币、V3 仓位 NFT;参数同 lpPositions,价格源用上面的 sources),默认不计入 */
|
|
14
|
+
lp?: boolean | Omit<LpPositionsOptions, 'prices'>
|
|
15
|
+
/** 价值低于它的代币不列出(不影响合计);大于 0 时没有价格的代币也不列出(计入 unpriced),默认 0 */
|
|
16
|
+
minUsd?: number
|
|
17
|
+
/** 某个价格源出错时调用 */
|
|
18
|
+
onPriceError?: (err: unknown) => void
|
|
19
|
+
fetch?: typeof fetch
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PortfolioToken {
|
|
23
|
+
token: string
|
|
24
|
+
native: boolean
|
|
25
|
+
symbol: string | null
|
|
26
|
+
balance: string
|
|
27
|
+
decimals: number
|
|
28
|
+
formatted: string
|
|
29
|
+
price: number | null
|
|
30
|
+
/** 美元价值;没有价格时为 null */
|
|
31
|
+
value: number | null
|
|
32
|
+
/** 给出价格的价格源 */
|
|
33
|
+
priceSource: string | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Portfolio {
|
|
37
|
+
/** 合计(代币 + LP;没有价格的不计入) */
|
|
38
|
+
totalUsd: number
|
|
39
|
+
tokensUsd: number
|
|
40
|
+
lpUsd: number
|
|
41
|
+
/** 有余额的代币,按价值从高到低 */
|
|
42
|
+
tokens: PortfolioToken[]
|
|
43
|
+
/** LP 仓位(开启 lp 时) */
|
|
44
|
+
lp: LpPosition[]
|
|
45
|
+
/** 有余额但没有价格的代币数(LP 仓位里缺价格的也算) */
|
|
46
|
+
unpriced: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 一个地址的资产总值:代币余额(只看传入的代币,EVM 不能只靠节点列出持仓)× 价格,可选加上 LP 仓位。
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* const p = await portfolio(provider, user, { tokens: [NATIVE_TOKEN, USDT, USDC], lp: true })
|
|
54
|
+
* p.totalUsd // 1234.5
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export async function portfolio(provider: Provider, owner: string, options: PortfolioOptions): Promise<Portfolio> {
|
|
58
|
+
const { tokens, sources = [defillamaPrices(), onchainPrices()], lp, minUsd = 0, onPriceError, fetch: fetchFn } = options
|
|
59
|
+
const onError = onPriceError ? (_: PriceSource, err: unknown) => onPriceError(err) : undefined
|
|
60
|
+
const [balances, positions] = await Promise.all([
|
|
61
|
+
tokens.length ? provider.balances(owner, tokens, { symbol: true }) : Promise.resolve([]),
|
|
62
|
+
lp ? lpPositions(provider, owner, { ...(lp === true ? {} : lp), prices: combinePrices(sources, { onError }), ...(fetchFn ? { fetch: fetchFn } : {}), ...(onPriceError ? { onPriceError } : {}) }) : Promise.resolve([]),
|
|
63
|
+
])
|
|
64
|
+
const held = balances.filter((b) => b.success && BigInt(b.balance) > 0n)
|
|
65
|
+
const prices = held.length ? await tokenPrices(provider, held.map((b) => b.token), { sources, ...(onError ? { onError } : {}), ...(fetchFn ? { fetch: fetchFn } : {}) }) : []
|
|
66
|
+
const list: PortfolioToken[] = held.map((b, i) => {
|
|
67
|
+
const p = prices[i]
|
|
68
|
+
const price = p?.price ?? null
|
|
69
|
+
return {
|
|
70
|
+
token: b.token,
|
|
71
|
+
native: b.native,
|
|
72
|
+
symbol: b.symbol ?? null,
|
|
73
|
+
balance: b.balance,
|
|
74
|
+
decimals: b.decimals,
|
|
75
|
+
formatted: b.formatted,
|
|
76
|
+
price,
|
|
77
|
+
value: price === null ? null : Number(b.formatted) * price,
|
|
78
|
+
priceSource: p?.source ?? null,
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
const tokensUsd = list.reduce((sum, t) => sum + (t.value ?? 0), 0)
|
|
82
|
+
const lpUsd = positions.reduce((sum, p) => sum + (p.valueUsd ?? 0), 0)
|
|
83
|
+
return {
|
|
84
|
+
totalUsd: tokensUsd + lpUsd,
|
|
85
|
+
tokensUsd,
|
|
86
|
+
lpUsd,
|
|
87
|
+
tokens: list.filter((t) => (t.value ?? 0) >= minUsd || (minUsd === 0 && t.value === null)).sort((a, b) => (b.value ?? -1) - (a.value ?? -1)),
|
|
88
|
+
lp: positions,
|
|
89
|
+
unpriced: list.filter((t) => t.price === null).length + positions.filter((p) => p.valueUsd === null).length,
|
|
90
|
+
}
|
|
91
|
+
}
|