@w3lib/solana-batch-call 0.3.0 → 0.5.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 +43 -0
- package/README.md +304 -3
- package/dist/address.d.ts +2 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +8 -0
- package/dist/address.js.map +1 -1
- package/dist/approvals.d.ts +129 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +221 -0
- package/dist/approvals.js.map +1 -0
- package/dist/fees.d.ts +89 -0
- package/dist/fees.d.ts.map +1 -0
- package/dist/fees.js +166 -0
- package/dist/fees.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +2 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +2 -2
- package/dist/layout.js.map +1 -1
- package/dist/liquidity/amm.d.ts +7 -0
- package/dist/liquidity/amm.d.ts.map +1 -0
- package/dist/liquidity/amm.js +307 -0
- package/dist/liquidity/amm.js.map +1 -0
- package/dist/liquidity/clmm.d.ts +18 -0
- package/dist/liquidity/clmm.d.ts.map +1 -0
- package/dist/liquidity/clmm.js +85 -0
- package/dist/liquidity/clmm.js.map +1 -0
- package/dist/liquidity/common.d.ts +94 -0
- package/dist/liquidity/common.d.ts.map +1 -0
- package/dist/liquidity/common.js +228 -0
- package/dist/liquidity/common.js.map +1 -0
- package/dist/liquidity/index.d.ts +34 -0
- package/dist/liquidity/index.d.ts.map +1 -0
- package/dist/liquidity/index.js +157 -0
- package/dist/liquidity/index.js.map +1 -0
- package/dist/liquidity/orca.d.ts +6 -0
- package/dist/liquidity/orca.d.ts.map +1 -0
- package/dist/liquidity/orca.js +303 -0
- package/dist/liquidity/orca.js.map +1 -0
- package/dist/liquidity/raydiumClmm.d.ts +6 -0
- package/dist/liquidity/raydiumClmm.d.ts.map +1 -0
- package/dist/liquidity/raydiumClmm.js +307 -0
- package/dist/liquidity/raydiumClmm.js.map +1 -0
- package/dist/liquidity/types.d.ts +150 -0
- package/dist/liquidity/types.d.ts.map +1 -0
- package/dist/liquidity/types.js +2 -0
- package/dist/liquidity/types.js.map +1 -0
- package/dist/lp.d.ts +53 -15
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +15 -38
- package/dist/lp.js.map +1 -1
- package/dist/lpMath.d.ts +45 -0
- package/dist/lpMath.d.ts.map +1 -1
- package/dist/lpMath.js +189 -0
- package/dist/lpMath.js.map +1 -1
- package/dist/names.d.ts +68 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +257 -0
- package/dist/names.js.map +1 -0
- package/dist/pools.d.ts +9 -0
- package/dist/pools.d.ts.map +1 -0
- package/dist/pools.js +45 -0
- package/dist/pools.js.map +1 -0
- package/dist/portfolio.d.ts +64 -0
- package/dist/portfolio.d.ts.map +1 -0
- package/dist/portfolio.js +57 -0
- package/dist/portfolio.js.map +1 -0
- package/dist/price.d.ts +90 -0
- package/dist/price.d.ts.map +1 -0
- package/dist/price.js +287 -0
- package/dist/price.js.map +1 -0
- package/dist/risk.d.ts +84 -0
- package/dist/risk.d.ts.map +1 -0
- package/dist/risk.js +211 -0
- package/dist/risk.js.map +1 -0
- package/dist/rpc.d.ts +2 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +33 -9
- package/dist/rpc.js.map +1 -1
- package/dist/subpaths/approvals.d.ts +29 -0
- package/dist/subpaths/approvals.d.ts.map +1 -0
- package/dist/subpaths/approvals.js +30 -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/liquidity.d.ts +23 -0
- package/dist/subpaths/liquidity.d.ts.map +1 -0
- package/dist/subpaths/liquidity.js +19 -0
- package/dist/subpaths/liquidity.js.map +1 -0
- package/dist/subpaths/lp.d.ts +1 -1
- package/dist/subpaths/lp.d.ts.map +1 -1
- package/dist/subpaths/lp.js +1 -1
- package/dist/subpaths/lp.js.map +1 -1
- package/dist/subpaths/names.d.ts +37 -0
- package/dist/subpaths/names.d.ts.map +1 -0
- package/dist/subpaths/names.js +27 -0
- package/dist/subpaths/names.js.map +1 -0
- package/dist/subpaths/price.d.ts +40 -0
- package/dist/subpaths/price.d.ts.map +1 -0
- package/dist/subpaths/price.js +43 -0
- package/dist/subpaths/price.js.map +1 -0
- package/dist/subpaths/risk.d.ts +28 -0
- package/dist/subpaths/risk.d.ts.map +1 -0
- package/dist/subpaths/risk.js +30 -0
- package/dist/subpaths/risk.js.map +1 -0
- package/dist/subpaths/swap.d.ts +22 -0
- package/dist/subpaths/swap.d.ts.map +1 -0
- package/dist/subpaths/swap.js +21 -0
- package/dist/subpaths/swap.js.map +1 -0
- package/dist/subpaths/write.d.ts +1 -1
- package/dist/subpaths/write.d.ts.map +1 -1
- package/dist/subpaths/write.js +1 -1
- package/dist/subpaths/write.js.map +1 -1
- package/dist/swap/amm.d.ts +20 -0
- package/dist/swap/amm.d.ts.map +1 -0
- package/dist/swap/amm.js +77 -0
- package/dist/swap/amm.js.map +1 -0
- package/dist/swap/clmmMath.d.ts +24 -0
- package/dist/swap/clmmMath.d.ts.map +1 -0
- package/dist/swap/clmmMath.js +82 -0
- package/dist/swap/clmmMath.js.map +1 -0
- package/dist/swap/common.d.ts +68 -0
- package/dist/swap/common.d.ts.map +1 -0
- package/dist/swap/common.js +65 -0
- package/dist/swap/common.js.map +1 -0
- package/dist/swap/cpmm.d.ts +28 -0
- package/dist/swap/cpmm.d.ts.map +1 -0
- package/dist/swap/cpmm.js +113 -0
- package/dist/swap/cpmm.js.map +1 -0
- package/dist/swap/index.d.ts +145 -0
- package/dist/swap/index.d.ts.map +1 -0
- package/dist/swap/index.js +349 -0
- package/dist/swap/index.js.map +1 -0
- package/dist/swap/orca.d.ts +50 -0
- package/dist/swap/orca.d.ts.map +1 -0
- package/dist/swap/orca.js +382 -0
- package/dist/swap/orca.js.map +1 -0
- package/dist/swap/raydiumClmm.d.ts +62 -0
- package/dist/swap/raydiumClmm.d.ts.map +1 -0
- package/dist/swap/raydiumClmm.js +447 -0
- package/dist/swap/raydiumClmm.js.map +1 -0
- package/dist/tradeCheck.d.ts +45 -0
- package/dist/tradeCheck.d.ts.map +1 -0
- package/dist/tradeCheck.js +131 -0
- package/dist/tradeCheck.js.map +1 -0
- package/dist/write/instructions.d.ts +7 -0
- package/dist/write/instructions.d.ts.map +1 -1
- package/dist/write/instructions.js +24 -1
- package/dist/write/instructions.js.map +1 -1
- package/dist/write/transaction.d.ts +23 -0
- package/dist/write/transaction.d.ts.map +1 -1
- package/dist/write/transaction.js +50 -0
- package/dist/write/transaction.js.map +1 -1
- package/package.json +41 -2
- package/src/address.ts +9 -0
- package/src/approvals.ts +358 -0
- package/src/fees.ts +259 -0
- package/src/index.ts +1 -1
- package/src/layout.ts +2 -2
- package/src/liquidity/amm.ts +353 -0
- package/src/liquidity/clmm.ts +108 -0
- package/src/liquidity/common.ts +285 -0
- package/src/liquidity/index.ts +177 -0
- package/src/liquidity/orca.ts +344 -0
- package/src/liquidity/raydiumClmm.ts +348 -0
- package/src/liquidity/types.ts +158 -0
- package/src/lp.ts +17 -52
- package/src/lpMath.ts +215 -0
- package/src/names.ts +329 -0
- package/src/pools.ts +43 -0
- package/src/portfolio.ts +124 -0
- package/src/price.ts +366 -0
- package/src/risk.ts +243 -0
- package/src/rpc.ts +36 -9
- package/src/subpaths/approvals.ts +47 -0
- package/src/subpaths/fees.ts +41 -0
- package/src/subpaths/liquidity.ts +45 -0
- package/src/subpaths/lp.ts +9 -0
- package/src/subpaths/names.ts +45 -0
- package/src/subpaths/price.ts +62 -0
- package/src/subpaths/risk.ts +36 -0
- package/src/subpaths/swap.ts +41 -0
- package/src/subpaths/write.ts +2 -0
- package/src/swap/amm.ts +88 -0
- package/src/swap/clmmMath.ts +96 -0
- package/src/swap/common.ts +113 -0
- package/src/swap/cpmm.ts +132 -0
- package/src/swap/index.ts +467 -0
- package/src/swap/orca.ts +397 -0
- package/src/swap/raydiumClmm.ts +464 -0
- package/src/tradeCheck.ts +166 -0
- package/src/write/instructions.ts +26 -1
- package/src/write/transaction.ts +62 -0
package/src/price.ts
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 价格(子路径 `@w3lib/solana-batch-call/price`):DefiLlama(当前 / 历史)、链上池子价格(Raydium CLMM / CPMM、Orca Whirlpool,按 PDA 推导池子地址,
|
|
3
|
+
* 不需要 getProgramAccounts)、组合多个价格源。价格只用于展示和估值,不要用来计算交易的最小数量
|
|
4
|
+
*/
|
|
5
|
+
import { isAddress } from './address.js'
|
|
6
|
+
import type { RequestOptions, SolanaClient } from './client.js'
|
|
7
|
+
import { NATIVE_MINT } from './constants.js'
|
|
8
|
+
import { formatUnits, parseMint, parseTokenAccount, type AccountInfo } from './layout.js'
|
|
9
|
+
import { Bytes, ORCA_WHIRLPOOL_PROGRAM_ID, RAYDIUM_CLMM_PROGRAM_ID, parseAmmPool } from './lp.js'
|
|
10
|
+
import { sqrtPriceToPrice } from './lpMath.js'
|
|
11
|
+
import { poolAddresses, type PdaPoolProtocol } from './pools.js'
|
|
12
|
+
|
|
13
|
+
export interface SolanaPriceSourceContext {
|
|
14
|
+
/** 代币 mint(原生 SOL 用 NATIVE_MINT So111…112 查) */
|
|
15
|
+
mints: readonly string[]
|
|
16
|
+
fetch: typeof fetch
|
|
17
|
+
signal?: AbortSignal
|
|
18
|
+
/** 查询用的客户端(链上价格源 onchainPrices 用它读池子) */
|
|
19
|
+
client?: SolanaClient
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SolanaPriceSource {
|
|
23
|
+
readonly name: string
|
|
24
|
+
/** 返回 mint → 美元价格;没有价格的不返回 */
|
|
25
|
+
prices(ctx: SolanaPriceSourceContext): Promise<Map<string, number>>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DefillamaPriceOptions {
|
|
29
|
+
/** 置信度下限(0~1),DefiLlama 给出的置信度低于它视为没有价格,默认 0.9 */
|
|
30
|
+
minConfidence?: number
|
|
31
|
+
/** 历史价格:这个时间点(unix 秒或 Date)附近的价格;不传为当前价格 */
|
|
32
|
+
at?: number | Date
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* DefiLlama 价格(免费、不需要 Key,按 100 个一批查询)。
|
|
37
|
+
* 注意:流动性很差的代币价格可能虚高,展示总资产时建议配合 minUsd 或自己的价格源。
|
|
38
|
+
*
|
|
39
|
+
* ```ts
|
|
40
|
+
* defillamaPrices() // 当前价格
|
|
41
|
+
* defillamaPrices({ at: new Date('2026-01-01') }) // 历史价格
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export function defillamaPrices(options: DefillamaPriceOptions = {}): SolanaPriceSource {
|
|
45
|
+
const minConfidence = options.minConfidence ?? 0.9
|
|
46
|
+
const at = options.at instanceof Date ? Math.floor(options.at.getTime() / 1000) : options.at === undefined ? undefined : Math.floor(options.at)
|
|
47
|
+
const endpoint = at === undefined ? 'https://coins.llama.fi/prices/current' : `https://coins.llama.fi/prices/historical/${at}`
|
|
48
|
+
return {
|
|
49
|
+
name: at === undefined ? 'defillama' : `defillama@${at}`,
|
|
50
|
+
async prices({ mints, fetch: fetchFn, signal }) {
|
|
51
|
+
const result = new Map<string, number>()
|
|
52
|
+
const batches: string[][] = []
|
|
53
|
+
for (let i = 0; i < mints.length; i += 100) {
|
|
54
|
+
batches.push(mints.slice(i, i + 100))
|
|
55
|
+
}
|
|
56
|
+
await Promise.all(
|
|
57
|
+
batches.map(async (batch) => {
|
|
58
|
+
const res = await fetchFn(`${endpoint}/${batch.map((m) => `solana:${m}`).join(',')}`, { signal })
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
throw new Error(`DefiLlama prices: HTTP ${res.status}`)
|
|
61
|
+
}
|
|
62
|
+
const body = (await res.json()) as { coins?: Record<string, { price?: number; confidence?: number }> }
|
|
63
|
+
for (const [key, coin] of Object.entries(body.coins ?? {})) {
|
|
64
|
+
if (typeof coin.price === 'number' && Number.isFinite(coin.price) && (coin.confidence === undefined || coin.confidence >= minConfidence)) {
|
|
65
|
+
result.set(key.slice(key.indexOf(':') + 1), coin.price)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}),
|
|
69
|
+
)
|
|
70
|
+
return result
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// 组合
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 依次使用多个价格源:前面的源没给出价格的代币交给下一个源。某个源出错时跳过它(错误交给 onError;取消查询时照常 reject)。
|
|
81
|
+
*
|
|
82
|
+
* ```ts
|
|
83
|
+
* combinePrices([defillamaPrices(), onchainPrices()])
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export function combinePrices(sources: readonly SolanaPriceSource[], options: { onError?: (source: SolanaPriceSource, err: unknown) => void } = {}): SolanaPriceSource {
|
|
87
|
+
return {
|
|
88
|
+
name: sources.map((s) => s.name).join('+'),
|
|
89
|
+
async prices(ctx) {
|
|
90
|
+
const found = await runSources(sources, ctx, options.onError)
|
|
91
|
+
return new Map([...found].map(([k, v]) => [k, v.price]))
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 依次查询,记下每个价格来自哪个源 */
|
|
97
|
+
async function runSources(
|
|
98
|
+
sources: readonly SolanaPriceSource[],
|
|
99
|
+
ctx: SolanaPriceSourceContext,
|
|
100
|
+
onError?: (source: SolanaPriceSource, err: unknown) => void,
|
|
101
|
+
): Promise<Map<string, { price: number; source: string }>> {
|
|
102
|
+
const result = new Map<string, { price: number; source: string }>()
|
|
103
|
+
let mints = [...ctx.mints]
|
|
104
|
+
for (const source of sources) {
|
|
105
|
+
if (!mints.length) break
|
|
106
|
+
let found: Map<string, number>
|
|
107
|
+
try {
|
|
108
|
+
found = await source.prices({ ...ctx, mints })
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (ctx.signal?.aborted) {
|
|
111
|
+
throw err
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
onError?.(source, err)
|
|
115
|
+
} catch {
|
|
116
|
+
// 回调出错不影响查询结果
|
|
117
|
+
}
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
for (const [mint, price] of found) {
|
|
121
|
+
if (!result.has(mint) && Number.isFinite(price)) result.set(mint, { price, source: source.name })
|
|
122
|
+
}
|
|
123
|
+
mints = mints.filter((m) => !result.has(m))
|
|
124
|
+
}
|
|
125
|
+
return result
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// 链上价格
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/** 按 1 美元计的稳定币(链上价格的计价锚点之一;另一个是 Wrapped SOL):USDC、USDT */
|
|
133
|
+
export const STABLECOINS: readonly string[] = ['EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB']
|
|
134
|
+
|
|
135
|
+
export type OnchainPriceProtocol = PdaPoolProtocol
|
|
136
|
+
|
|
137
|
+
export interface OnchainPriceOptions {
|
|
138
|
+
/** 池子里计价币一侧的价值(美元)下限,低于它的池子不用(防止小池子的价格被操纵),默认 10000 */
|
|
139
|
+
minLiquidityUsd?: number
|
|
140
|
+
/** 按 1 美元计的稳定币,默认 STABLECOINS(USDC、USDT) */
|
|
141
|
+
stablecoins?: readonly string[]
|
|
142
|
+
/** 用哪些协议的池子,默认全部:'raydium-clmm'、'orca-whirlpool'、'raydium-cpmm' */
|
|
143
|
+
protocols?: readonly OnchainPriceProtocol[]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 账户至少要读到的长度(Raydium PoolState 1544 字节,Orca Whirlpool 653 字节) */
|
|
147
|
+
const RAYDIUM_POOL_SIZE = 1544
|
|
148
|
+
const ORCA_POOL_SIZE = 653
|
|
149
|
+
|
|
150
|
+
interface Candidate {
|
|
151
|
+
protocol: OnchainPriceProtocol
|
|
152
|
+
address: string
|
|
153
|
+
token: string
|
|
154
|
+
anchor: string
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 两个 mint 在所有协议里可能的池子地址 */
|
|
158
|
+
function candidates(token: string, anchor: string, protocols: ReadonlySet<OnchainPriceProtocol>): Candidate[] {
|
|
159
|
+
return poolAddresses(token, anchor, protocols).map((p) => ({ ...p, token, anchor }))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 池子里定价要用的字段:集中流动性读当前价格和活跃流动性,CPMM 读金库扣掉手续费后的余额 */
|
|
163
|
+
type PoolRead =
|
|
164
|
+
| { kind: 'clmm'; mint0: string; mint1: string; vault0: string; vault1: string; sqrtPrice: bigint; liquidity: bigint }
|
|
165
|
+
| { kind: 'cpmm'; mint0: string; mint1: string; vault0: string; vault1: string; reserved0: bigint; reserved1: bigint }
|
|
166
|
+
|
|
167
|
+
function readPool(c: Candidate, account: AccountInfo): PoolRead | null {
|
|
168
|
+
const b = new Bytes(account.data)
|
|
169
|
+
if (c.protocol === 'raydium-clmm' && account.owner === RAYDIUM_CLMM_PROGRAM_ID && account.data.length >= RAYDIUM_POOL_SIZE) {
|
|
170
|
+
// PoolState:mint 73 / 105、vault 137 / 169、liquidity 237、sqrt_price 253
|
|
171
|
+
return { kind: 'clmm', mint0: b.key(73), mint1: b.key(105), vault0: b.key(137), vault1: b.key(169), liquidity: b.u128(237), sqrtPrice: b.u128(253) }
|
|
172
|
+
}
|
|
173
|
+
if (c.protocol === 'orca-whirlpool' && account.owner === ORCA_WHIRLPOOL_PROGRAM_ID && account.data.length >= ORCA_POOL_SIZE) {
|
|
174
|
+
// Whirlpool:liquidity 49、sqrt_price 65、mint_a 101、vault_a 133、mint_b 181、vault_b 213
|
|
175
|
+
return { kind: 'clmm', mint0: b.key(101), mint1: b.key(181), vault0: b.key(133), vault1: b.key(213), liquidity: b.u128(49), sqrtPrice: b.u128(65) }
|
|
176
|
+
}
|
|
177
|
+
if (c.protocol === 'raydium-cpmm') {
|
|
178
|
+
const pool = parseAmmPool(account)
|
|
179
|
+
return pool?.protocol === 'raydium-cpmm'
|
|
180
|
+
? { kind: 'cpmm', mint0: pool.mint0, mint1: pool.mint1, vault0: pool.vault0, vault1: pool.vault1, reserved0: pool.reserved0, reserved1: pool.reserved1 }
|
|
181
|
+
: null
|
|
182
|
+
}
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface Quote {
|
|
187
|
+
token: string
|
|
188
|
+
anchor: string
|
|
189
|
+
/** 1 个代币值多少个计价币 */
|
|
190
|
+
price: number
|
|
191
|
+
/** 池子里计价币的数量(按精度换算) */
|
|
192
|
+
depth: number
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 链上价格:按 PDA 推导代币对 Wrapped SOL、稳定币的池子(Raydium CLMM 的每个 AmmConfig、Orca 的每个费率档位、Raydium CPMM 的每个 AmmConfig),
|
|
197
|
+
* 用计价币一侧最深的池子的价格。稳定币按 1 美元;Wrapped SOL(和原生 SOL)按它对稳定币最深的池子。
|
|
198
|
+
* 不需要 getProgramAccounts(免费公共节点大多不支持);每个代币约推导 190 个地址,读取用 getMultipleAccounts 合并(一共两轮:池子和 mint、金库)。
|
|
199
|
+
* 深度:CPMM 是金库余额;集中流动性取金库余额和当前活跃流动性折算的储备中较小的(流动性都在区间外的池子价格不可信,不会被选中)。
|
|
200
|
+
* 适合 DefiLlama 没有收录的新币;价格是池子当前的价格,可能被单个区块内的交易影响,只用于展示。
|
|
201
|
+
* Raydium AMM v4、Meteora、PumpSwap 等池子地址不能只靠两个 mint 推导,不在查找范围内
|
|
202
|
+
*/
|
|
203
|
+
export function onchainPrices(options: OnchainPriceOptions = {}): SolanaPriceSource {
|
|
204
|
+
return {
|
|
205
|
+
name: 'onchain',
|
|
206
|
+
async prices({ mints, client, signal }) {
|
|
207
|
+
if (!client) throw new Error('onchainPrices needs a SolanaClient (ctx.client)')
|
|
208
|
+
const stables = options.stablecoins ?? STABLECOINS
|
|
209
|
+
const protocols = new Set(options.protocols ?? (['raydium-clmm', 'orca-whirlpool', 'raydium-cpmm'] as const))
|
|
210
|
+
const minLiquidity = options.minLiquidityUsd ?? 10_000
|
|
211
|
+
const result = new Map<string, number>()
|
|
212
|
+
const stableSet = new Set(stables)
|
|
213
|
+
for (const m of mints) if (stableSet.has(m)) result.set(m, 1)
|
|
214
|
+
const rest = [...new Set(mints)].filter((m) => !stableSet.has(m) && m !== NATIVE_MINT && isAddress(m))
|
|
215
|
+
const wantSol = mints.includes(NATIVE_MINT)
|
|
216
|
+
if (!stables.length || !protocols.size || (!rest.length && !wantSol)) return result
|
|
217
|
+
|
|
218
|
+
// 1. 一次读:Wrapped SOL 对稳定币、其他代币对 Wrapped SOL 和稳定币的候选池子,以及这些代币的 mint(精度)
|
|
219
|
+
const list = [
|
|
220
|
+
...stables.flatMap((s) => candidates(NATIVE_MINT, s, protocols)),
|
|
221
|
+
...rest.flatMap((t) => [NATIVE_MINT, ...stables].flatMap((anchor) => candidates(t, anchor, protocols))),
|
|
222
|
+
]
|
|
223
|
+
const mintList = [...new Set([NATIVE_MINT, ...stables, ...rest])]
|
|
224
|
+
const first = await client.accounts([...list.map((c) => c.address), ...mintList], { signal })
|
|
225
|
+
const decimals = new Map<string, number>()
|
|
226
|
+
mintList.forEach((m, i) => {
|
|
227
|
+
const account = first[list.length + i]
|
|
228
|
+
try {
|
|
229
|
+
if (account) decimals.set(m, parseMint(account.data).decimals)
|
|
230
|
+
} catch {
|
|
231
|
+
// 不是代币 mint
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
const pools = list.flatMap((c, i) => {
|
|
235
|
+
const account = first[i]
|
|
236
|
+
const pool = account ? readPool(c, account) : null
|
|
237
|
+
// 池子里的两个 mint 要和推导时的一致(PDA 上可能是别的账户)
|
|
238
|
+
const ok = pool && ((pool.mint0 === c.token && pool.mint1 === c.anchor) || (pool.mint1 === c.token && pool.mint0 === c.anchor))
|
|
239
|
+
return ok ? [{ ...c, pool }] : []
|
|
240
|
+
})
|
|
241
|
+
if (!pools.length) return result
|
|
242
|
+
|
|
243
|
+
// 2. 一次读:金库(集中流动性只读计价币一侧,CPMM 两侧都读)
|
|
244
|
+
const vaultsOf = (p: (typeof pools)[number]) => {
|
|
245
|
+
const anchorIs0 = p.pool.mint0 === p.anchor
|
|
246
|
+
return p.pool.kind === 'cpmm' ? [p.pool.vault0, p.pool.vault1] : [anchorIs0 ? p.pool.vault0 : p.pool.vault1]
|
|
247
|
+
}
|
|
248
|
+
const vaultAddresses = [...new Set(pools.flatMap(vaultsOf))]
|
|
249
|
+
const vaultAccounts = await client.accounts(vaultAddresses, { signal })
|
|
250
|
+
const balances = new Map<string, bigint>()
|
|
251
|
+
vaultAddresses.forEach((address, i) => {
|
|
252
|
+
const account = vaultAccounts[i]
|
|
253
|
+
try {
|
|
254
|
+
if (account) balances.set(address, parseTokenAccount(account.data).amount)
|
|
255
|
+
} catch {
|
|
256
|
+
// 不是代币账户
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
const quotes = pools.flatMap((p): Quote[] => {
|
|
260
|
+
const q = quote(p.pool, p.token, p.anchor, decimals, balances)
|
|
261
|
+
return q && Number.isFinite(q.price) && q.price > 0 && Number.isFinite(q.depth) ? [q] : []
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// 3. 先算 Wrapped SOL(对稳定币),再按 SOL 和稳定币的美元价格算其他代币
|
|
265
|
+
const anchorsUsd = new Map(stables.map((s) => [s, 1]))
|
|
266
|
+
const sol = best(quotes.filter((q) => q.token === NATIVE_MINT), anchorsUsd, minLiquidity)
|
|
267
|
+
if (sol !== null) {
|
|
268
|
+
anchorsUsd.set(NATIVE_MINT, sol)
|
|
269
|
+
if (wantSol) result.set(NATIVE_MINT, sol)
|
|
270
|
+
}
|
|
271
|
+
for (const t of rest) {
|
|
272
|
+
const usd = best(quotes.filter((q) => q.token === t), anchorsUsd, minLiquidity)
|
|
273
|
+
if (usd !== null) result.set(t, usd)
|
|
274
|
+
}
|
|
275
|
+
return result
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 一个池子给出的价格(计价币 / 代币)和计价币一侧的深度;缺精度、金库时为 null */
|
|
281
|
+
function quote(pool: PoolRead, token: string, anchor: string, decimals: ReadonlyMap<string, number>, balances: ReadonlyMap<string, bigint>): Quote | null {
|
|
282
|
+
const d0 = decimals.get(pool.mint0)
|
|
283
|
+
const d1 = decimals.get(pool.mint1)
|
|
284
|
+
if (d0 === undefined || d1 === undefined) return null
|
|
285
|
+
const tokenIs0 = pool.mint0 === token
|
|
286
|
+
const da = tokenIs0 ? d1 : d0
|
|
287
|
+
if (pool.kind === 'cpmm') {
|
|
288
|
+
const v0 = balances.get(pool.vault0)
|
|
289
|
+
const v1 = balances.get(pool.vault1)
|
|
290
|
+
if (v0 === undefined || v1 === undefined) return null
|
|
291
|
+
// 与程序一致:金库余额扣掉协议 / 基金 / 创建者手续费才是储备
|
|
292
|
+
const r0 = v0 > pool.reserved0 ? v0 - pool.reserved0 : 0n
|
|
293
|
+
const r1 = v1 > pool.reserved1 ? v1 - pool.reserved1 : 0n
|
|
294
|
+
if (r0 === 0n || r1 === 0n) return null
|
|
295
|
+
const [rt, ra, dt] = tokenIs0 ? [r0, r1, d0] : [r1, r0, d1]
|
|
296
|
+
const depth = Number(formatUnits(ra, da))
|
|
297
|
+
return { token, anchor, price: depth / Number(formatUnits(rt, dt)), depth }
|
|
298
|
+
}
|
|
299
|
+
const vault = balances.get(tokenIs0 ? pool.vault1 : pool.vault0)
|
|
300
|
+
if (vault === undefined || pool.sqrtPrice === 0n) return null
|
|
301
|
+
// price = token1 / token0(按精度换算)
|
|
302
|
+
const price1Per0 = sqrtPriceToPrice(pool.sqrtPrice, d0, d1)
|
|
303
|
+
// 当前活跃流动性折算的计价币储备(最小单位):token1 = L × √P,token0 = L / √P(√P = sqrtPrice / 2^64)
|
|
304
|
+
const sqrt = Number(pool.sqrtPrice) / 2 ** 64
|
|
305
|
+
const virtual = (tokenIs0 ? Number(pool.liquidity) * sqrt : Number(pool.liquidity) / sqrt) / 10 ** da
|
|
306
|
+
return { token, anchor, price: tokenIs0 ? price1Per0 : 1 / price1Per0, depth: Math.min(Number(formatUnits(vault, da)), virtual) }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** 深度(美元)达到下限的池子里最深的一个给出的美元价格;没有时为 null */
|
|
310
|
+
function best(quotes: readonly Quote[], anchorsUsd: ReadonlyMap<string, number>, minLiquidityUsd: number): number | null {
|
|
311
|
+
let found: { usd: number; depthUsd: number } | null = null
|
|
312
|
+
for (const q of quotes) {
|
|
313
|
+
const anchorUsd = anchorsUsd.get(q.anchor)
|
|
314
|
+
if (anchorUsd === undefined) continue
|
|
315
|
+
const depthUsd = q.depth * anchorUsd
|
|
316
|
+
if (depthUsd >= minLiquidityUsd && (!found || depthUsd > found.depthUsd)) found = { usd: q.price * anchorUsd, depthUsd }
|
|
317
|
+
}
|
|
318
|
+
return found ? found.usd : null
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// tokenPrices
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
export interface TokenPricesOptions extends RequestOptions {
|
|
326
|
+
/** 价格源,依次使用(前面没有价格的交给后面的),默认 [defillamaPrices(), onchainPrices()] */
|
|
327
|
+
sources?: readonly SolanaPriceSource[]
|
|
328
|
+
/** 某个价格源出错时调用(继续用下一个源);回调本身出错会被忽略 */
|
|
329
|
+
onError?: (source: SolanaPriceSource, err: unknown) => void
|
|
330
|
+
/** 查价格用的 fetch(代理等场景),默认全局 fetch */
|
|
331
|
+
fetch?: typeof fetch
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export interface TokenPrice {
|
|
335
|
+
/** 传入的 mint(原样) */
|
|
336
|
+
token: string
|
|
337
|
+
/** 按原生 SOL 处理(客户端的 nativeMints,默认 So111…112 和 11111…1),价格就是 SOL 的价格 */
|
|
338
|
+
native: boolean
|
|
339
|
+
/** 美元价格;所有价格源都没有时为 null */
|
|
340
|
+
price: number | null
|
|
341
|
+
/** 给出价格的价格源(如 'defillama'、'onchain') */
|
|
342
|
+
source: string | null
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 查代币的美元价格(SOL 用 NATIVE_MINT)。默认先查 DefiLlama,没有收录的再从链上池子算。
|
|
347
|
+
*
|
|
348
|
+
* ```ts
|
|
349
|
+
* await tokenPrices(client, [NATIVE_MINT, USDC, newToken])
|
|
350
|
+
* // [{ token: 'So111…112', native: true, price: 187.4, source: 'defillama' }, …, { token: newToken, price: 0.0012, source: 'onchain' }]
|
|
351
|
+
* await tokenPrices(client, mints, { sources: [defillamaPrices({ at: new Date('2026-01-01') })] }) // 历史价格
|
|
352
|
+
* ```
|
|
353
|
+
*/
|
|
354
|
+
export async function tokenPrices(client: SolanaClient, mints: readonly string[], options: TokenPricesOptions = {}): Promise<TokenPrice[]> {
|
|
355
|
+
const { sources = [defillamaPrices(), onchainPrices()], onError, fetch: fetchOption, signal } = options
|
|
356
|
+
const fetchFn = fetchOption ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
|
|
357
|
+
const native = mints.map((m) => client.isNativeMint(m))
|
|
358
|
+
// 原生 SOL 按 Wrapped SOL 的 mint 查;不是合法地址的不查
|
|
359
|
+
const keys = mints.map((m, i) => (native[i] ? NATIVE_MINT : m))
|
|
360
|
+
const query = [...new Set(keys.filter((k) => isAddress(k)))]
|
|
361
|
+
const found = await runSources(sources, { mints: query, fetch: fetchFn, signal, client }, onError)
|
|
362
|
+
return mints.map((token, i) => {
|
|
363
|
+
const hit = found.get(keys[i] as string)
|
|
364
|
+
return { token, native: native[i] as boolean, price: hit?.price ?? null, source: hit?.source ?? null }
|
|
365
|
+
})
|
|
366
|
+
}
|
package/src/risk.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 代币风险检查(子路径 `@w3lib/solana-batch-call/risk`):mint 的权限(还能不能增发、能不能冻结持有人)和 Token-2022 扩展里
|
|
3
|
+
* 影响持有人的设置(转账手续费、永久授权、转账 hook、不可转让、新账户默认冻结、可暂停、保密转账、mint 可关闭等),一次 getMultipleAccounts 读完。
|
|
4
|
+
* 可选:持币集中度(getTokenLargestAccounts,免费公共节点大多不支持);能不能买、能不能卖用 tradeChecks(在主网模拟“买入 → 卖出”)
|
|
5
|
+
*/
|
|
6
|
+
import { isAddress } from './address.js'
|
|
7
|
+
import type { RequestOptions, SolanaClient } from './client.js'
|
|
8
|
+
import { NATIVE_MINT, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from './constants.js'
|
|
9
|
+
import { findExtension, parseMint, parseTransferFeeConfig, type AccountInfo } from './layout.js'
|
|
10
|
+
import { Bytes } from './lp.js'
|
|
11
|
+
import { CLOCK_SYSVAR_ID, parseClock } from './swap/common.js'
|
|
12
|
+
|
|
13
|
+
/** Token-2022 扩展的类型编号(spl-token-2022 ExtensionType) */
|
|
14
|
+
const EXT = {
|
|
15
|
+
transferFeeConfig: 1,
|
|
16
|
+
mintCloseAuthority: 3,
|
|
17
|
+
confidentialTransferMint: 4,
|
|
18
|
+
defaultAccountState: 6,
|
|
19
|
+
nonTransferable: 9,
|
|
20
|
+
interestBearingConfig: 10,
|
|
21
|
+
permanentDelegate: 12,
|
|
22
|
+
transferHook: 14,
|
|
23
|
+
scaledUiAmount: 25,
|
|
24
|
+
pausable: 26,
|
|
25
|
+
permissionedBurn: 28,
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
const ZERO = '11111111111111111111111111111111'
|
|
29
|
+
const key = (b: Bytes, o: number) => {
|
|
30
|
+
const k = b.key(o)
|
|
31
|
+
return k === ZERO ? null : k
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SolanaTokenRisk {
|
|
35
|
+
mint: string
|
|
36
|
+
/** mint 账户存在且是 SPL Token / Token-2022 的 mint */
|
|
37
|
+
exists: boolean
|
|
38
|
+
program: 'spl-token' | 'token-2022' | null
|
|
39
|
+
decimals: number | null
|
|
40
|
+
/** 总量(最小单位) */
|
|
41
|
+
supply: string | null
|
|
42
|
+
/** 增发权限:不为 null 时还能继续增发 */
|
|
43
|
+
mintAuthority: string | null
|
|
44
|
+
/** 冻结权限:不为 null 时可以冻结任何持有人的代币账户 */
|
|
45
|
+
freezeAuthority: string | null
|
|
46
|
+
/** Token-2022 转账手续费(当前 epoch 生效的);authority 不为 null 时费率还能改(新费率两个 epoch 后生效) */
|
|
47
|
+
transferFee: { basisPoints: number; maximumFee: string; authority: string | null; next: { basisPoints: number; maximumFee: string; epoch: string } | null } | null
|
|
48
|
+
/** 永久授权:这个地址可以转走 / 销毁任何持有人的代币 */
|
|
49
|
+
permanentDelegate: string | null
|
|
50
|
+
/** 转账 hook:每次转账都会调用这个程序(可以拒绝转账);authority 不为 null 时程序还能换 */
|
|
51
|
+
transferHook: { program: string | null; authority: string | null } | null
|
|
52
|
+
/** 不可转让(灵魂绑定) */
|
|
53
|
+
nonTransferable: boolean
|
|
54
|
+
/** 新建的代币账户默认是冻结的(要冻结权限解冻后才能用) */
|
|
55
|
+
defaultFrozen: boolean
|
|
56
|
+
/** 可暂停:authority 可以暂停全部转账、增发、销毁;paused 为当前状态 */
|
|
57
|
+
pausable: { paused: boolean; authority: string | null } | null
|
|
58
|
+
/** 保密转账(余额和转账数量加密) */
|
|
59
|
+
confidentialTransfer: { authority: string | null; autoApprove: boolean } | null
|
|
60
|
+
/** mint 关闭权限(总量为 0 时可以关闭 mint) */
|
|
61
|
+
mintCloseAuthority: string | null
|
|
62
|
+
/** 显示数量会随时间 / 系数变化(InterestBearingConfig、ScaledUiAmount),authority 可以改利率 / 系数 */
|
|
63
|
+
uiAmountAuthority: string | null
|
|
64
|
+
/** 只有这个地址能批准销毁(PermissionedBurn) */
|
|
65
|
+
burnAuthority: string | null
|
|
66
|
+
/** 持币集中度(concentration: true 时):最大的 20 个代币账户里前 10 个占总量的比例;节点不支持或总量为 0 时为 null */
|
|
67
|
+
topHolders: { top10Share: number; accounts: Array<{ address: string; amount: string; share: number }> } | null
|
|
68
|
+
/**
|
|
69
|
+
* 汇总的风险标记:not-found、mintable、freezable、transfer-fee(> 0)、high-transfer-fee(> 10%)、fee-changeable、permanent-delegate、
|
|
70
|
+
* transfer-hook、non-transferable、default-frozen、pausable、paused、confidential-transfer、closable、ui-amount-changes、permissioned-burn、
|
|
71
|
+
* concentrated(前 10 个账户 > 50%,含池子金库、交易所地址)
|
|
72
|
+
*/
|
|
73
|
+
flags: string[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TokenRisksOptions extends RequestOptions {
|
|
77
|
+
/** 查持币集中度(getTokenLargestAccounts,每个 mint 一次请求;免费公共节点大多不支持,不支持时 topHolders 为 null),默认 false */
|
|
78
|
+
concentration?: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 检查代币的风险(mint 和 Clock 一次 getMultipleAccounts 读完)。原生 SOL(客户端的 nativeMints)按 Wrapped SOL 的 mint 查。
|
|
83
|
+
*
|
|
84
|
+
* ```ts
|
|
85
|
+
* const [risk] = await tokenRisks(client, [PYUSD])
|
|
86
|
+
* // { program: 'token-2022', mintAuthority: '22mK…', freezeAuthority: '2apB…', permanentDelegate: '2apB…', flags: ['mintable', 'freezable', 'permanent-delegate', …] }
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export async function tokenRisks(client: SolanaClient, mints: readonly string[], options: TokenRisksOptions = {}): Promise<SolanaTokenRisk[]> {
|
|
90
|
+
const keys = mints.map((m) => (client.isNativeMint(m) ? NATIVE_MINT : m))
|
|
91
|
+
for (const m of keys) {
|
|
92
|
+
if (!isAddress(m)) throw new Error(`Invalid mint address: ${m}`)
|
|
93
|
+
}
|
|
94
|
+
const unique = [...new Set(keys)]
|
|
95
|
+
const accounts = await client.accounts([CLOCK_SYSVAR_ID, ...unique], { signal: options.signal })
|
|
96
|
+
const clock = accounts[0]
|
|
97
|
+
const epoch = clock ? parseClock(clock.data).epoch : null
|
|
98
|
+
const byMint = new Map(unique.map((m, i) => [m, accounts[i + 1] ?? null]))
|
|
99
|
+
const holders = options.concentration ? await largestAccounts(client, unique, options.signal) : new Map<string, Array<{ address: string; amount: bigint }> | null>()
|
|
100
|
+
return mints.map((mint, i) => {
|
|
101
|
+
const k = keys[i] as string
|
|
102
|
+
const risk = parseRisk(mint, byMint.get(k) ?? null, epoch)
|
|
103
|
+
const list = holders.get(k)
|
|
104
|
+
if (list && risk.supply && BigInt(risk.supply) > 0n) {
|
|
105
|
+
const supply = BigInt(risk.supply)
|
|
106
|
+
const share = (a: bigint) => Number((a * 1_000_000n) / supply) / 1_000_000
|
|
107
|
+
const sorted = [...list].sort((a, b) => (a.amount < b.amount ? 1 : a.amount > b.amount ? -1 : 0))
|
|
108
|
+
risk.topHolders = {
|
|
109
|
+
top10Share: share(sorted.slice(0, 10).reduce((s, a) => s + a.amount, 0n)),
|
|
110
|
+
accounts: sorted.map((a) => ({ address: a.address, amount: a.amount.toString(), share: share(a.amount) })),
|
|
111
|
+
}
|
|
112
|
+
if (risk.topHolders.top10Share > 0.5) risk.flags.push('concentrated')
|
|
113
|
+
}
|
|
114
|
+
return risk
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function largestAccounts(client: SolanaClient, mints: readonly string[], signal: AbortSignal | undefined) {
|
|
119
|
+
const out = new Map<string, Array<{ address: string; amount: bigint }> | null>()
|
|
120
|
+
await Promise.all(
|
|
121
|
+
mints.map(async (mint) => {
|
|
122
|
+
try {
|
|
123
|
+
const res = await client.request<{ value: Array<{ address: string; amount: string }> }>('getTokenLargestAccounts', [mint, { commitment: client.commitment }], { signal })
|
|
124
|
+
out.set(
|
|
125
|
+
mint,
|
|
126
|
+
res.value.map((a) => ({ address: a.address, amount: BigInt(a.amount) })),
|
|
127
|
+
)
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (signal?.aborted) throw err
|
|
130
|
+
out.set(mint, null) // 节点不支持
|
|
131
|
+
}
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function empty(mint: string): SolanaTokenRisk {
|
|
138
|
+
return {
|
|
139
|
+
mint,
|
|
140
|
+
exists: false,
|
|
141
|
+
program: null,
|
|
142
|
+
decimals: null,
|
|
143
|
+
supply: null,
|
|
144
|
+
mintAuthority: null,
|
|
145
|
+
freezeAuthority: null,
|
|
146
|
+
transferFee: null,
|
|
147
|
+
permanentDelegate: null,
|
|
148
|
+
transferHook: null,
|
|
149
|
+
nonTransferable: false,
|
|
150
|
+
defaultFrozen: false,
|
|
151
|
+
pausable: null,
|
|
152
|
+
confidentialTransfer: null,
|
|
153
|
+
mintCloseAuthority: null,
|
|
154
|
+
uiAmountAuthority: null,
|
|
155
|
+
burnAuthority: null,
|
|
156
|
+
topHolders: null,
|
|
157
|
+
flags: ['not-found'],
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 解析一个 mint 账户的风险项(包内使用,测试也用);epoch 用来选转账手续费的新旧配置(null 时按新配置) */
|
|
162
|
+
export function parseRisk(mint: string, account: AccountInfo | null, epoch: bigint | null): SolanaTokenRisk {
|
|
163
|
+
if (!account || (account.owner !== TOKEN_PROGRAM_ID && account.owner !== TOKEN_2022_PROGRAM_ID)) return empty(mint)
|
|
164
|
+
let info: ReturnType<typeof parseMint>
|
|
165
|
+
try {
|
|
166
|
+
info = parseMint(account.data)
|
|
167
|
+
} catch {
|
|
168
|
+
return empty(mint)
|
|
169
|
+
}
|
|
170
|
+
const risk: SolanaTokenRisk = {
|
|
171
|
+
...empty(mint),
|
|
172
|
+
exists: true,
|
|
173
|
+
program: account.owner === TOKEN_PROGRAM_ID ? 'spl-token' : 'token-2022',
|
|
174
|
+
decimals: info.decimals,
|
|
175
|
+
supply: info.supply.toString(),
|
|
176
|
+
mintAuthority: info.mintAuthority,
|
|
177
|
+
freezeAuthority: info.freezeAuthority,
|
|
178
|
+
flags: [],
|
|
179
|
+
}
|
|
180
|
+
if (account.owner === TOKEN_2022_PROGRAM_ID) {
|
|
181
|
+
const data = account.data
|
|
182
|
+
const ext = (type: number) => findExtension(data, type)
|
|
183
|
+
const fee = parseTransferFeeConfig(data)
|
|
184
|
+
if (fee) {
|
|
185
|
+
const current = epoch === null || epoch >= fee.newer.epoch ? fee.newer : fee.older
|
|
186
|
+
const pending = epoch !== null && epoch < fee.newer.epoch ? fee.newer : null
|
|
187
|
+
risk.transferFee = {
|
|
188
|
+
basisPoints: current.basisPoints,
|
|
189
|
+
maximumFee: current.maximumFee.toString(),
|
|
190
|
+
authority: fee.transferFeeConfigAuthority,
|
|
191
|
+
next: pending ? { basisPoints: pending.basisPoints, maximumFee: pending.maximumFee.toString(), epoch: pending.epoch.toString() } : null,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const close = ext(EXT.mintCloseAuthority)
|
|
195
|
+
if (close && close.length >= 32) risk.mintCloseAuthority = key(new Bytes(close), 0)
|
|
196
|
+
const confidential = ext(EXT.confidentialTransferMint)
|
|
197
|
+
if (confidential && confidential.length >= 33) risk.confidentialTransfer = { authority: key(new Bytes(confidential), 0), autoApprove: confidential[32] === 1 }
|
|
198
|
+
const state = ext(EXT.defaultAccountState)
|
|
199
|
+
// AccountState:0 未初始化、1 正常、2 冻结
|
|
200
|
+
risk.defaultFrozen = !!state && state[0] === 2
|
|
201
|
+
risk.nonTransferable = ext(EXT.nonTransferable) !== null
|
|
202
|
+
const delegate = ext(EXT.permanentDelegate)
|
|
203
|
+
if (delegate && delegate.length >= 32) risk.permanentDelegate = key(new Bytes(delegate), 0)
|
|
204
|
+
const hook = ext(EXT.transferHook)
|
|
205
|
+
if (hook && hook.length >= 64) {
|
|
206
|
+
const b = new Bytes(hook)
|
|
207
|
+
risk.transferHook = { authority: key(b, 0), program: key(b, 32) }
|
|
208
|
+
}
|
|
209
|
+
const pausable = ext(EXT.pausable)
|
|
210
|
+
if (pausable && pausable.length >= 33) risk.pausable = { authority: key(new Bytes(pausable), 0), paused: pausable[32] === 1 }
|
|
211
|
+
for (const type of [EXT.interestBearingConfig, EXT.scaledUiAmount]) {
|
|
212
|
+
const e = ext(type)
|
|
213
|
+
if (e && e.length >= 32) risk.uiAmountAuthority ??= key(new Bytes(e), 0) ?? null
|
|
214
|
+
}
|
|
215
|
+
const burn = ext(EXT.permissionedBurn)
|
|
216
|
+
if (burn && burn.length >= 32) risk.burnAuthority = key(new Bytes(burn), 0)
|
|
217
|
+
}
|
|
218
|
+
risk.flags = flagsOf(risk)
|
|
219
|
+
return risk
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function flagsOf(r: SolanaTokenRisk): string[] {
|
|
223
|
+
const flags: string[] = []
|
|
224
|
+
if (r.mintAuthority) flags.push('mintable')
|
|
225
|
+
if (r.freezeAuthority) flags.push('freezable')
|
|
226
|
+
if (r.transferFee) {
|
|
227
|
+
const bps = Math.max(r.transferFee.basisPoints, r.transferFee.next?.basisPoints ?? 0)
|
|
228
|
+
if (bps > 0) flags.push('transfer-fee')
|
|
229
|
+
if (bps > 1000) flags.push('high-transfer-fee')
|
|
230
|
+
if (r.transferFee.authority) flags.push('fee-changeable')
|
|
231
|
+
}
|
|
232
|
+
if (r.permanentDelegate) flags.push('permanent-delegate')
|
|
233
|
+
if (r.transferHook?.program || r.transferHook?.authority) flags.push('transfer-hook')
|
|
234
|
+
if (r.nonTransferable) flags.push('non-transferable')
|
|
235
|
+
if (r.defaultFrozen) flags.push('default-frozen')
|
|
236
|
+
if (r.pausable) flags.push('pausable')
|
|
237
|
+
if (r.pausable?.paused) flags.push('paused')
|
|
238
|
+
if (r.confidentialTransfer) flags.push('confidential-transfer')
|
|
239
|
+
if (r.mintCloseAuthority) flags.push('closable')
|
|
240
|
+
if (r.uiAmountAuthority) flags.push('ui-amount-changes')
|
|
241
|
+
if (r.burnAuthority) flags.push('permissioned-burn')
|
|
242
|
+
return flags
|
|
243
|
+
}
|