@w3lib/solana-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 +33 -0
- package/README.md +16 -8
- package/dist/accountReader.d.ts +57 -0
- package/dist/accountReader.d.ts.map +1 -0
- package/dist/accountReader.js +149 -0
- package/dist/accountReader.js.map +1 -0
- package/dist/address.d.ts +9 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +36 -6
- package/dist/address.js.map +1 -1
- package/dist/approvals.d.ts +5 -3
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +25 -36
- package/dist/approvals.js.map +1 -1
- package/dist/balances.d.ts +178 -0
- package/dist/balances.d.ts.map +1 -0
- package/dist/balances.js +524 -0
- package/dist/balances.js.map +1 -0
- package/dist/batchPlan.d.ts +2 -2
- package/dist/batchPlan.d.ts.map +1 -1
- package/dist/batchPlan.js +5 -26
- package/dist/batchPlan.js.map +1 -1
- package/dist/bytes.d.ts +31 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +74 -0
- package/dist/bytes.js.map +1 -0
- package/dist/claim.d.ts +5 -3
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +10 -10
- package/dist/claim.js.map +1 -1
- package/dist/client.d.ts +26 -41
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +41 -986
- package/dist/client.js.map +1 -1
- package/dist/clientConfig.d.ts +11 -0
- package/dist/clientConfig.d.ts.map +1 -0
- package/dist/clientConfig.js +3 -0
- package/dist/clientConfig.js.map +1 -0
- package/dist/decode.d.ts.map +1 -1
- package/dist/decode.js +2 -29
- package/dist/decode.js.map +1 -1
- package/dist/fees.d.ts.map +1 -1
- package/dist/fees.js +2 -12
- package/dist/fees.js.map +1 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +4 -15
- package/dist/history.js.map +1 -1
- package/dist/index.d.ts +3 -3
- 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 +21 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +50 -69
- package/dist/layout.js.map +1 -1
- package/dist/liquidity/common.js +2 -2
- package/dist/liquidity/common.js.map +1 -1
- package/dist/lp.d.ts +9 -13
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +67 -55
- package/dist/lp.js.map +1 -1
- package/dist/portfolio.d.ts.map +1 -1
- package/dist/portfolio.js +3 -8
- package/dist/portfolio.js.map +1 -1
- package/dist/reclaim.d.ts +4 -2
- package/dist/reclaim.d.ts.map +1 -1
- package/dist/reclaim.js +16 -16
- package/dist/reclaim.js.map +1 -1
- package/dist/risk.js +2 -2
- package/dist/risk.js.map +1 -1
- package/dist/rpc.d.ts +30 -7
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +400 -109
- package/dist/rpc.js.map +1 -1
- package/dist/simulate.d.ts.map +1 -1
- package/dist/simulate.js +3 -26
- package/dist/simulate.js.map +1 -1
- package/dist/source.d.ts +8 -4
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +11 -4
- package/dist/source.js.map +1 -1
- package/dist/subpaths/approvals.d.ts +3 -3
- package/dist/subpaths/approvals.d.ts.map +1 -1
- package/dist/subpaths/approvals.js.map +1 -1
- package/dist/subpaths/claim.d.ts +3 -3
- package/dist/subpaths/claim.d.ts.map +1 -1
- package/dist/subpaths/claim.js.map +1 -1
- package/dist/subpaths/reclaim.d.ts +3 -3
- package/dist/subpaths/reclaim.d.ts.map +1 -1
- package/dist/subpaths/reclaim.js.map +1 -1
- package/dist/swap/index.js +3 -3
- package/dist/swap/index.js.map +1 -1
- package/dist/tokenDetails.d.ts +12 -0
- package/dist/tokenDetails.d.ts.map +1 -0
- package/dist/tokenDetails.js +196 -0
- package/dist/tokenDetails.js.map +1 -0
- package/dist/tokenMeta.d.ts +63 -0
- package/dist/tokenMeta.d.ts.map +1 -0
- package/dist/tokenMeta.js +226 -0
- package/dist/tokenMeta.js.map +1 -0
- package/dist/tradeCheck.js +3 -3
- package/dist/tradeCheck.js.map +1 -1
- package/dist/util.d.ts +22 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +107 -0
- package/dist/util.js.map +1 -0
- package/dist/write/chain.d.ts +51 -0
- package/dist/write/chain.d.ts.map +1 -0
- package/dist/write/chain.js +184 -0
- package/dist/write/chain.js.map +1 -0
- package/dist/write/collect.js +2 -2
- package/dist/write/collect.js.map +1 -1
- package/dist/write/errors.d.ts +10 -0
- package/dist/write/errors.d.ts.map +1 -0
- package/dist/write/errors.js +77 -0
- package/dist/write/errors.js.map +1 -0
- package/dist/write/pack.d.ts +28 -0
- package/dist/write/pack.d.ts.map +1 -0
- package/dist/write/pack.js +125 -0
- package/dist/write/pack.js.map +1 -0
- package/dist/write/send.d.ts +23 -9
- package/dist/write/send.d.ts.map +1 -1
- package/dist/write/send.js +53 -351
- package/dist/write/send.js.map +1 -1
- package/dist/write/tracker.d.ts +24 -0
- package/dist/write/tracker.d.ts.map +1 -0
- package/dist/write/tracker.js +129 -0
- package/dist/write/tracker.js.map +1 -0
- package/package.json +1 -1
- package/src/accountReader.ts +187 -0
- package/src/address.ts +41 -6
- package/src/approvals.ts +36 -44
- package/src/balances.ts +678 -0
- package/src/batchPlan.ts +6 -26
- package/src/bytes.ts +89 -0
- package/src/claim.ts +15 -11
- package/src/client.ts +59 -1239
- package/src/clientConfig.ts +12 -0
- package/src/decode.ts +4 -30
- package/src/fees.ts +2 -9
- package/src/history.ts +4 -15
- package/src/index.ts +13 -1
- package/src/layout.ts +63 -82
- package/src/liquidity/common.ts +2 -2
- package/src/lp.ts +86 -60
- package/src/portfolio.ts +8 -11
- package/src/reclaim.ts +20 -17
- package/src/risk.ts +2 -2
- package/src/rpc.ts +472 -116
- package/src/simulate.ts +3 -21
- package/src/source.ts +14 -5
- package/src/subpaths/approvals.ts +3 -2
- package/src/subpaths/claim.ts +3 -2
- package/src/subpaths/reclaim.ts +3 -2
- package/src/swap/index.ts +3 -3
- package/src/tokenDetails.ts +240 -0
- package/src/tokenMeta.ts +270 -0
- package/src/tradeCheck.ts +3 -3
- package/src/util.ts +110 -0
- package/src/write/chain.ts +218 -0
- package/src/write/collect.ts +2 -2
- package/src/write/errors.ts +82 -0
- package/src/write/pack.ts +142 -0
- package/src/write/send.ts +74 -407
- package/src/write/tracker.ts +152 -0
package/src/tokenMeta.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// 代币信息缓存(decimals / 所属程序 / name / symbol)和“数据为空的钱包账户”记录:余额、代币详情共用
|
|
2
|
+
|
|
3
|
+
import { isAddress } from './address.js'
|
|
4
|
+
import { SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from './constants.js'
|
|
5
|
+
import { parseMetaplexMetadata, parseMint, type AccountInfo, type MetaplexMetadata, type MintInfo } from './layout.js'
|
|
6
|
+
|
|
7
|
+
export interface TokenMeta {
|
|
8
|
+
decimals?: number
|
|
9
|
+
tokenProgram?: string
|
|
10
|
+
/** tokenProgram 来自导入的缓存、还没从链上确认过:ATA 模式第一次用时按程序未知处理(推导两个程序的 ATA、读 mint 确认) */
|
|
11
|
+
programUnverified?: boolean
|
|
12
|
+
/** null:确认没有元数据 */
|
|
13
|
+
name?: string | null
|
|
14
|
+
symbol?: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface CachedMeta extends TokenMeta {
|
|
18
|
+
/** name / symbol 的写入时间 */
|
|
19
|
+
namesAt?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 代币信息缓存(按 网络 + mint):decimals / 所属程序不会变,永久缓存;
|
|
23
|
+
// name / symbol 来自可修改的元数据,缓存 1 小时。条目数有上限,超出时淘汰最早写入的
|
|
24
|
+
const NAME_TTL = 60 * 60 * 1000
|
|
25
|
+
const MAX_CACHED_TOKENS = 50_000
|
|
26
|
+
const tokenMetaCache = new Map<string, CachedMeta>()
|
|
27
|
+
|
|
28
|
+
// 已知数据为空的钱包账户(系统账户或不存在):ATA 模式下和代币账户放在同一个请求里读,不必单独带 dataSlice 多发一个请求。
|
|
29
|
+
// 按 scope + 地址记,最多 1 万个,超出淘汰最早的;读到不是系统账户(如程序账户)时移除
|
|
30
|
+
const MAX_SMALL_OWNERS = 10_000
|
|
31
|
+
const smallOwners = new Set<string>()
|
|
32
|
+
|
|
33
|
+
export function isSmallOwner(scope: string, owner: string): boolean {
|
|
34
|
+
return smallOwners.has(`${scope}:${owner}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 根据只读 lamports 的结果判断钱包账户是否“数据为空”(dataSlice 读不到数据长度,按所属程序判断:系统账户的数据为空) */
|
|
38
|
+
export function learnOwner(scope: string, owner: string, account: AccountInfo | null): void {
|
|
39
|
+
const key = `${scope}:${owner}`
|
|
40
|
+
if (account === null || account.owner === SYSTEM_PROGRAM_ID) {
|
|
41
|
+
smallOwners.delete(key)
|
|
42
|
+
smallOwners.add(key)
|
|
43
|
+
if (smallOwners.size > MAX_SMALL_OWNERS) {
|
|
44
|
+
smallOwners.delete(smallOwners.values().next().value as string)
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
smallOwners.delete(key)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 缓存写入监听(persistTokenMetaCache 用) */
|
|
52
|
+
const cacheWriteListeners = new Set<() => void>()
|
|
53
|
+
|
|
54
|
+
/** 测试用:清空代币信息缓存 */
|
|
55
|
+
export function resetTokenMetaCache(): void {
|
|
56
|
+
smallOwners.clear()
|
|
57
|
+
tokenMetaCache.clear()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** exportTokenMetaCache() 的结果,可以 JSON 序列化 */
|
|
61
|
+
export interface TokenMetaSnapshot {
|
|
62
|
+
version: 1
|
|
63
|
+
/** [网络:mint, 信息];namesAt 是 name / symbol 的写入时间(毫秒时间戳),导入后继续按 1 小时过期 */
|
|
64
|
+
entries: Array<[string, { decimals?: number; tokenProgram?: string; name?: string | null; symbol?: string | null; namesAt?: number }]>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SHARED_SCOPES = new Set<string>(['mainnet', 'devnet', 'testnet'])
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 导出代币信息缓存(decimals / 所属程序 / name / symbol),用于持久化:刷新页面后导入,不必重查 decimals。
|
|
71
|
+
* 只导出按网络共享的条目(自定义节点且没指定 cluster 的客户端,缓存只在该客户端内,不导出)。
|
|
72
|
+
* maxEntries:只导出最近写入的这么多条
|
|
73
|
+
*/
|
|
74
|
+
export function exportTokenMetaCache(maxEntries = Number.POSITIVE_INFINITY): TokenMetaSnapshot {
|
|
75
|
+
const entries: TokenMetaSnapshot['entries'] = []
|
|
76
|
+
for (const [key, meta] of tokenMetaCache) {
|
|
77
|
+
if (SHARED_SCOPES.has(key.slice(0, key.indexOf(':')))) {
|
|
78
|
+
const { programUnverified: _, ...exported } = meta
|
|
79
|
+
entries.push([key, exported])
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { version: 1, entries: entries.slice(Math.max(0, entries.length - maxEntries)) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 导入 exportTokenMetaCache() 的结果;格式不对的条目跳过,内存里已有的条目不覆盖 */
|
|
86
|
+
export function importTokenMetaCache(snapshot: unknown): void {
|
|
87
|
+
const entries = (snapshot as Partial<TokenMetaSnapshot> | null)?.entries
|
|
88
|
+
if ((snapshot as Partial<TokenMetaSnapshot> | null)?.version !== 1 || !Array.isArray(entries)) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const meta = validMeta(entry)
|
|
93
|
+
if (meta && !tokenMetaCache.has(entry[0])) {
|
|
94
|
+
tokenMetaCache.set(entry[0], meta)
|
|
95
|
+
if (tokenMetaCache.size > MAX_CACHED_TOKENS) {
|
|
96
|
+
tokenMetaCache.delete(tokenMetaCache.keys().next().value as string)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validMeta(entry: unknown): CachedMeta | null {
|
|
103
|
+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string') {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
const [key, raw] = entry as [string, Record<string, unknown>]
|
|
107
|
+
const colon = key.indexOf(':')
|
|
108
|
+
if (!SHARED_SCOPES.has(key.slice(0, colon)) || !isAddress(key.slice(colon + 1)) || !raw || typeof raw !== 'object') {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
const meta: CachedMeta = {}
|
|
112
|
+
if (typeof raw.decimals === 'number' && Number.isInteger(raw.decimals) && raw.decimals >= 0 && raw.decimals <= 255) meta.decimals = raw.decimals
|
|
113
|
+
if (raw.tokenProgram === TOKEN_PROGRAM_ID || raw.tokenProgram === TOKEN_2022_PROGRAM_ID) {
|
|
114
|
+
meta.tokenProgram = raw.tokenProgram
|
|
115
|
+
meta.programUnverified = true
|
|
116
|
+
}
|
|
117
|
+
// 写入时间只接受有限值、且不晚于现在(留 1 分钟时钟误差):未来的时间会让 name / symbol 永不过期,截到现在
|
|
118
|
+
if (
|
|
119
|
+
typeof raw.namesAt === 'number' &&
|
|
120
|
+
Number.isFinite(raw.namesAt) &&
|
|
121
|
+
(typeof raw.name === 'string' || raw.name === null) &&
|
|
122
|
+
(typeof raw.symbol === 'string' || raw.symbol === null)
|
|
123
|
+
) {
|
|
124
|
+
meta.name = raw.name
|
|
125
|
+
meta.symbol = raw.symbol
|
|
126
|
+
meta.namesAt = Math.min(raw.namesAt, Date.now())
|
|
127
|
+
}
|
|
128
|
+
return Object.keys(meta).length ? meta : null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface PersistTokenMetaOptions {
|
|
132
|
+
/** 存储的键,默认 'w3lib:solana-token-meta' */
|
|
133
|
+
key?: string
|
|
134
|
+
/** 最多保存多少条(最近写入的优先),默认 5000 */
|
|
135
|
+
maxEntries?: number
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 把代币信息缓存持久化到同步的键值存储(如 localStorage):调用时读入,之后缓存有写入就防抖(约 1 秒)整体写回。
|
|
140
|
+
* 返回停止函数(会立即写回还没写的变化)。读到格式不对的数据会丢弃;写入失败(如存储已满)忽略。
|
|
141
|
+
*
|
|
142
|
+
* ```ts
|
|
143
|
+
* persistTokenMetaCache(localStorage) // 刷新页面后 decimals 不必重查
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
export function persistTokenMetaCache(
|
|
147
|
+
store: { getItem(key: string): string | null; setItem(key: string, value: string): void },
|
|
148
|
+
options: PersistTokenMetaOptions = {},
|
|
149
|
+
): () => void {
|
|
150
|
+
const key = options.key ?? 'w3lib:solana-token-meta'
|
|
151
|
+
const maxEntries = options.maxEntries ?? 5000
|
|
152
|
+
try {
|
|
153
|
+
const raw = store.getItem(key)
|
|
154
|
+
if (raw) {
|
|
155
|
+
importTokenMetaCache(JSON.parse(raw))
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// 格式不对:丢弃
|
|
159
|
+
}
|
|
160
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
161
|
+
const write = () => {
|
|
162
|
+
timer = null
|
|
163
|
+
try {
|
|
164
|
+
store.setItem(key, JSON.stringify(exportTokenMetaCache(maxEntries)))
|
|
165
|
+
} catch {
|
|
166
|
+
// 存储已满等:忽略
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const onWrite = () => {
|
|
170
|
+
if (!timer) {
|
|
171
|
+
timer = setTimeout(write, 1000)
|
|
172
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
cacheWriteListeners.add(onWrite)
|
|
176
|
+
return () => {
|
|
177
|
+
cacheWriteListeners.delete(onWrite)
|
|
178
|
+
if (timer) {
|
|
179
|
+
clearTimeout(timer)
|
|
180
|
+
write()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 从 mint 账户 / 元数据账户里学到的信息写入缓存,返回合并后的结果。
|
|
187
|
+
* lookedForNames:本次同时读了 mint 和元数据账户,都没有 name / symbol 时记为“确认没有”(null),之后不再重查
|
|
188
|
+
*/
|
|
189
|
+
export function learnMeta(scope: string, mint: string, mintAccount: AccountInfo | null, metadataAccount: AccountInfo | null, lookedForNames = false): TokenMeta {
|
|
190
|
+
const update: TokenMeta = {}
|
|
191
|
+
const info = mintAccount ? safeParseMint(mintAccount) : null
|
|
192
|
+
if (info && mintAccount) {
|
|
193
|
+
update.decimals = info.decimals
|
|
194
|
+
update.tokenProgram = mintAccount.owner
|
|
195
|
+
if (info.metadata) {
|
|
196
|
+
update.name = info.metadata.name
|
|
197
|
+
update.symbol = info.metadata.symbol
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (metadataAccount && update.name === undefined) {
|
|
201
|
+
const metaplex = safeParseMetaplex(metadataAccount)
|
|
202
|
+
if (metaplex) {
|
|
203
|
+
update.name = metaplex.name
|
|
204
|
+
update.symbol = metaplex.symbol
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (lookedForNames && info && update.name === undefined) {
|
|
208
|
+
update.name = null
|
|
209
|
+
update.symbol = null
|
|
210
|
+
}
|
|
211
|
+
setMeta(scope, mint, update)
|
|
212
|
+
return getMeta(scope, mint)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function safeParseMint(account: AccountInfo): MintInfo | null {
|
|
216
|
+
if (account.owner !== TOKEN_PROGRAM_ID && account.owner !== TOKEN_2022_PROGRAM_ID) {
|
|
217
|
+
return null
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const mint = parseMint(account.data)
|
|
221
|
+
// 未初始化的 mint(创建和初始化分在两笔交易里,恰好在中间读到):decimals 还是 0,不能当真、更不能永久缓存
|
|
222
|
+
return mint.isInitialized ? mint : null
|
|
223
|
+
} catch {
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function safeParseMetaplex(account: AccountInfo): MetaplexMetadata | null {
|
|
229
|
+
try {
|
|
230
|
+
return parseMetaplexMetadata(account.data)
|
|
231
|
+
} catch {
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function getMeta(scope: string, mint: string): TokenMeta {
|
|
237
|
+
const cached = tokenMetaCache.get(`${scope}:${mint}`)
|
|
238
|
+
if (!cached) {
|
|
239
|
+
return {}
|
|
240
|
+
}
|
|
241
|
+
const { namesAt, ...meta } = cached
|
|
242
|
+
if (namesAt === undefined || Date.now() - namesAt > NAME_TTL) {
|
|
243
|
+
delete meta.name
|
|
244
|
+
delete meta.symbol
|
|
245
|
+
}
|
|
246
|
+
return meta
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function setMeta(scope: string, mint: string, meta: TokenMeta): void {
|
|
250
|
+
const defined: CachedMeta = Object.fromEntries(Object.entries(meta).filter(([, v]) => v !== undefined))
|
|
251
|
+
if (Object.keys(defined).length === 0) {
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
if ('name' in defined || 'symbol' in defined) {
|
|
255
|
+
defined.namesAt = Date.now()
|
|
256
|
+
}
|
|
257
|
+
const key = `${scope}:${mint}`
|
|
258
|
+
const merged = { ...tokenMetaCache.get(key), ...defined }
|
|
259
|
+
if ('tokenProgram' in defined && !defined.programUnverified) {
|
|
260
|
+
delete merged.programUnverified // 从链上学到了所属程序
|
|
261
|
+
}
|
|
262
|
+
tokenMetaCache.delete(key) // 重新插入,保持“最近写入在后”的顺序
|
|
263
|
+
tokenMetaCache.set(key, merged)
|
|
264
|
+
if (tokenMetaCache.size > MAX_CACHED_TOKENS) {
|
|
265
|
+
tokenMetaCache.delete(tokenMetaCache.keys().next().value as string)
|
|
266
|
+
}
|
|
267
|
+
for (const listener of cacheWriteListeners) {
|
|
268
|
+
listener()
|
|
269
|
+
}
|
|
270
|
+
}
|
package/src/tradeCheck.ts
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { base64 } from '@scure/base'
|
|
6
6
|
|
|
7
|
-
import { getAssociatedTokenAddress, isAddress } from './address.js'
|
|
7
|
+
import { getAssociatedTokenAddress, InvalidAddressError, isAddress } from './address.js'
|
|
8
8
|
import type { RequestOptions, SolanaClient } from './client.js'
|
|
9
9
|
import { NATIVE_MINT, TOKEN_PROGRAM_ID } from './constants.js'
|
|
10
10
|
import { parseTokenAccount, parseUnits } from './layout.js'
|
|
11
11
|
import { buildSwap, type SolanaSwapOptions, type SolanaSwapPlan } from './swap/index.js'
|
|
12
12
|
import { MAX_COMPUTE_UNITS, setComputeUnitLimit } from './write/instructions.js'
|
|
13
|
-
import { transactionErrorText } from './write/
|
|
13
|
+
import { transactionErrorText } from './write/errors.js'
|
|
14
14
|
import { MAX_ACCOUNT_LOCKS, PACKET_DATA_SIZE, compileMessage, serializeTransaction, transactionSize, type Instruction } from './write/transaction.js'
|
|
15
15
|
|
|
16
16
|
export type TradeStage = 'ok' | 'no-pool' | 'buy-failed' | 'sell-failed'
|
|
@@ -55,7 +55,7 @@ export interface TradeCheckOptions extends RequestOptions, Pick<SolanaSwapOption
|
|
|
55
55
|
* ```
|
|
56
56
|
*/
|
|
57
57
|
export async function tradeChecks(client: SolanaClient, mints: readonly string[], options: TradeCheckOptions): Promise<SolanaTradeCheck[]> {
|
|
58
|
-
if (!isAddress(options.owner)) throw new
|
|
58
|
+
if (!isAddress(options.owner)) throw new InvalidAddressError(`Invalid owner address: ${options.owner}`, options.owner)
|
|
59
59
|
if (options.amount !== undefined && options.amountFormatted !== undefined) throw new Error('Pass either amount or amountFormatted, not both')
|
|
60
60
|
const spent = options.amountFormatted !== undefined ? parseUnits(options.amountFormatted, 9) : options.amount !== undefined ? BigInt(options.amount) : 50_000_000n
|
|
61
61
|
if (spent <= 0n) throw new RangeError('amount must be greater than 0')
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** 内部公共工具:并发池、可取消的等待、多个调用方共用一次操作时的取消 */
|
|
2
|
+
|
|
3
|
+
/** 并发数为 limit 的 map,结果按输入顺序。某一项出错时整体 reject,其余 worker 不再取后面的项(已开始的照常结束) */
|
|
4
|
+
export async function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
|
5
|
+
const results = new Array<R>(items.length)
|
|
6
|
+
let next = 0
|
|
7
|
+
let failed = false
|
|
8
|
+
const worker = async () => {
|
|
9
|
+
while (!failed && next < items.length) {
|
|
10
|
+
const i = next++
|
|
11
|
+
try {
|
|
12
|
+
results[i] = await fn(items[i] as T, i)
|
|
13
|
+
} catch (err) {
|
|
14
|
+
failed = true
|
|
15
|
+
throw err
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker))
|
|
20
|
+
return results
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 等待 ms 毫秒。signal 取消时:rejectOnAbort 为 true(默认)时立即 reject(reason 为 signal.reason),
|
|
25
|
+
* 为 false 时提前 resolve(用于“取消后不再等,由调用方自己检查 signal”的场景)
|
|
26
|
+
*/
|
|
27
|
+
export function sleep(ms: number, signal?: AbortSignal, rejectOnAbort = true): Promise<void> {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
if (signal?.aborted) {
|
|
30
|
+
if (rejectOnAbort) {
|
|
31
|
+
reject(signal.reason)
|
|
32
|
+
} else {
|
|
33
|
+
resolve()
|
|
34
|
+
}
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
signal?.removeEventListener('abort', onAbort)
|
|
39
|
+
resolve()
|
|
40
|
+
}, ms)
|
|
41
|
+
const onAbort = () => {
|
|
42
|
+
clearTimeout(timer)
|
|
43
|
+
if (rejectOnAbort) {
|
|
44
|
+
reject(signal?.reason)
|
|
45
|
+
} else {
|
|
46
|
+
resolve()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 多个调用方共用一次操作(在途去重)时的取消:所有加入的调用方都取消后才中止;
|
|
55
|
+
* 有一个调用方没传 signal 就永远不会中止。操作结束后调用 done() 移除监听
|
|
56
|
+
*/
|
|
57
|
+
export class SharedAbort {
|
|
58
|
+
readonly #controller = new AbortController()
|
|
59
|
+
/** 还没取消的调用方数量(只算传了 signal 的) */
|
|
60
|
+
#active = 0
|
|
61
|
+
/** 有调用方没传 signal */
|
|
62
|
+
#forever = false
|
|
63
|
+
#cleanups: Array<() => void> = []
|
|
64
|
+
#done = false
|
|
65
|
+
|
|
66
|
+
get signal(): AbortSignal {
|
|
67
|
+
return this.#controller.signal
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 加入;已经中止或已结束时返回 false(调用方应另起一次操作) */
|
|
71
|
+
join(signal: AbortSignal | undefined): boolean {
|
|
72
|
+
if (this.#controller.signal.aborted || this.#done) {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
if (!signal) {
|
|
76
|
+
this.#forever = true
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
if (signal.aborted) {
|
|
80
|
+
// 调用方已经取消(外层会直接 reject):不计入
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
this.#active++
|
|
84
|
+
const onAbort = () => {
|
|
85
|
+
if (--this.#active === 0 && !this.#forever && !this.#done) {
|
|
86
|
+
this.#controller.abort(signal.reason)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
90
|
+
this.#cleanups.push(() => signal.removeEventListener('abort', onAbort))
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
done(): void {
|
|
95
|
+
this.#done = true
|
|
96
|
+
for (const cleanup of this.#cleanups) {
|
|
97
|
+
cleanup()
|
|
98
|
+
}
|
|
99
|
+
this.#cleanups = []
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 往有条目上限的 Map 里写入(先删再插保持“最近写入在后”),超出时淘汰最早写入的 */
|
|
104
|
+
export function boundedSet<K, V>(map: Map<K, V>, key: K, value: V, max: number): void {
|
|
105
|
+
map.delete(key)
|
|
106
|
+
map.set(key, value)
|
|
107
|
+
while (map.size > max) {
|
|
108
|
+
map.delete(map.keys().next().value as K)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha2.js'
|
|
2
|
+
import { base64 } from '@scure/base'
|
|
3
|
+
|
|
4
|
+
import type { SolanaClient } from '../client.js'
|
|
5
|
+
import { FallbackRpc, RpcError, isNodeFault, type RpcTransport } from '../rpc.js'
|
|
6
|
+
import { clientSourceConfigs } from '../clientConfig.js'
|
|
7
|
+
import { sleep } from '../util.js'
|
|
8
|
+
import { errorText } from './errors.js'
|
|
9
|
+
import type { SendOptions } from './send.js'
|
|
10
|
+
import { serializeTransaction, type CompiledMessage } from './transaction.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 最近发出过的交易消息(sha256):同样的消息签名也相同,再发一次只会被节点当成同一笔。
|
|
14
|
+
* 在签名之前检查(钱包签名的交易也适用),重复时调整计算单元上限让消息不同
|
|
15
|
+
*/
|
|
16
|
+
const recentMessages = new Set<string>()
|
|
17
|
+
|
|
18
|
+
export function messageKey(message: Uint8Array): string {
|
|
19
|
+
return base64.encode(sha256(message))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isRecentMessage(message: Uint8Array): boolean {
|
|
23
|
+
return recentMessages.has(messageKey(message))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function remember(message: Uint8Array): void {
|
|
27
|
+
recentMessages.add(messageKey(message))
|
|
28
|
+
if (recentMessages.size > 10_000) {
|
|
29
|
+
recentMessages.delete(recentMessages.values().next().value as string)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Blockhash {
|
|
34
|
+
blockhash: string
|
|
35
|
+
lastValidBlockHeight: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SimulationValue {
|
|
39
|
+
err: unknown
|
|
40
|
+
logs?: string[] | null
|
|
41
|
+
unitsConsumed?: number
|
|
42
|
+
/** 请求了 accounts 时:模拟执行后这些账户的状态(已扣手续费);账户不存在时为 null */
|
|
43
|
+
accounts?: Array<{ lamports: number | string } | null> | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 'auto' 优先费用:最近费用里去掉 0 再取 75 分位。
|
|
48
|
+
* 不活跃的账户大多数 slot 没有相关交易,返回 0(2026-10 主网实测 USDC mint 150 个 slot 里 135 个是 0),
|
|
49
|
+
* 带上 0 取分位数几乎总是 0,等于不加优先费用
|
|
50
|
+
*/
|
|
51
|
+
export function autoPriorityFee(fees: readonly number[], cap: bigint): bigint {
|
|
52
|
+
const paid = fees
|
|
53
|
+
.filter((fee) => fee > 0)
|
|
54
|
+
.map((fee) => BigInt(fee))
|
|
55
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
56
|
+
const p75 = paid.length ? (paid[Math.min(paid.length - 1, Math.floor(paid.length * 0.75))] as bigint) : 0n
|
|
57
|
+
return p75 < cap ? p75 : cap
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 一批发送共用的节点访问:blockhash(缓存 10 秒)、优先费用(按账户缓存 10 秒)、模拟、广播 */
|
|
61
|
+
export class Chain {
|
|
62
|
+
#blockhash: { at: number; value: Promise<Blockhash> } | null = null
|
|
63
|
+
readonly #fees = new Map<string, { at: number; value: Promise<bigint> }>()
|
|
64
|
+
#broadcastTargets: Promise<RpcTransport[]> | null = null
|
|
65
|
+
|
|
66
|
+
constructor(
|
|
67
|
+
readonly client: SolanaClient,
|
|
68
|
+
readonly options: SendOptions,
|
|
69
|
+
) {}
|
|
70
|
+
|
|
71
|
+
get commitment(): 'confirmed' | 'finalized' {
|
|
72
|
+
return this.options.commitment ?? 'confirmed'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#walletChain: Promise<string | undefined> | null = null
|
|
76
|
+
|
|
77
|
+
/** Wallet Standard 的链标识('solana:mainnet' 等);网络未知时为 undefined */
|
|
78
|
+
walletChain(): Promise<string | undefined> {
|
|
79
|
+
this.#walletChain ??= this.client.getCluster().then(
|
|
80
|
+
(cluster) => (cluster ? `solana:${cluster}` : undefined),
|
|
81
|
+
() => undefined,
|
|
82
|
+
)
|
|
83
|
+
return this.#walletChain
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
blockhash(fresh = false): Promise<Blockhash> {
|
|
87
|
+
if (fresh || !this.#blockhash || Date.now() - this.#blockhash.at > 10_000) {
|
|
88
|
+
const value = this.client
|
|
89
|
+
.request<{ value: Blockhash }>('getLatestBlockhash', [{ commitment: 'confirmed' }])
|
|
90
|
+
.then((res) => res.value)
|
|
91
|
+
value.catch(() => {
|
|
92
|
+
if (this.#blockhash?.value === value) this.#blockhash = null
|
|
93
|
+
})
|
|
94
|
+
this.#blockhash = { at: Date.now(), value }
|
|
95
|
+
}
|
|
96
|
+
return this.#blockhash.value
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async priorityFee(writable: readonly string[]): Promise<bigint> {
|
|
100
|
+
const option = this.options.priorityFee ?? 'auto'
|
|
101
|
+
if (option !== 'auto') {
|
|
102
|
+
return BigInt(option)
|
|
103
|
+
}
|
|
104
|
+
const accounts = [...writable].sort().slice(0, 128)
|
|
105
|
+
const key = accounts.join(',')
|
|
106
|
+
let cached = this.#fees.get(key)
|
|
107
|
+
if (!cached || Date.now() - cached.at > 10_000) {
|
|
108
|
+
const cap = BigInt(this.options.maxPriorityFee ?? 1_000_000)
|
|
109
|
+
const value = this.client
|
|
110
|
+
.request<Array<{ prioritizationFee: number }>>('getRecentPrioritizationFees', [accounts])
|
|
111
|
+
.then((list) => autoPriorityFee(list.map((f) => f.prioritizationFee), cap))
|
|
112
|
+
.catch(() => 0n) // 节点不支持时不加优先费用
|
|
113
|
+
cached = { at: Date.now(), value }
|
|
114
|
+
this.#fees.set(key, cached)
|
|
115
|
+
}
|
|
116
|
+
return cached.value
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 模拟;watch 是要返回模拟后状态的账户(如手续费支付者,用来判断付不付得起优先费用) */
|
|
120
|
+
async simulate(message: CompiledMessage, minContextSlot: number | undefined, watch?: readonly string[]): Promise<SimulationValue> {
|
|
121
|
+
const raw = base64.encode(serializeTransaction(message, message.signers.map(() => null)))
|
|
122
|
+
const config: Record<string, unknown> = { encoding: 'base64', sigVerify: false, replaceRecentBlockhash: true, commitment: 'confirmed' }
|
|
123
|
+
if (minContextSlot !== undefined) {
|
|
124
|
+
config.minContextSlot = minContextSlot
|
|
125
|
+
}
|
|
126
|
+
if (watch?.length) {
|
|
127
|
+
config.accounts = { encoding: 'base64', addresses: watch }
|
|
128
|
+
}
|
|
129
|
+
// 节点暂时的问题(限频、高度还没到 minContextSlot 等)重试几次,不当成调用失败
|
|
130
|
+
for (let attempt = 0; ; attempt++) {
|
|
131
|
+
try {
|
|
132
|
+
const res = await this.client.request<{ value: SimulationValue }>('simulateTransaction', [raw, config])
|
|
133
|
+
return res.value
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (attempt >= 3 || (err instanceof RpcError && !isNodeFault(err))) throw err
|
|
136
|
+
await sleep(500 * (attempt + 1), this.options.signal, false)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 账户当前的 lamports;读不到时为 null(调用方按“不知道”处理) */
|
|
142
|
+
async balance(address: string, minContextSlot: number | undefined): Promise<bigint | null> {
|
|
143
|
+
try {
|
|
144
|
+
const config: Record<string, unknown> = { commitment: 'confirmed' }
|
|
145
|
+
if (minContextSlot !== undefined) config.minContextSlot = minContextSlot
|
|
146
|
+
const res = await this.client.request<{ value: number | string }>('getBalance', [address, config])
|
|
147
|
+
return BigInt(res.value)
|
|
148
|
+
} catch {
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 广播目标:sendRpc(不传时用 client 的节点)。sendRpc 按 client 的网络和节点选项(headers / fetch / timeout 等)解析,
|
|
155
|
+
* 并校验和 client 在同一个网络(不一致时报 NetworkMismatchError)。
|
|
156
|
+
* broadcast 为 'all' 时拆成各个节点(每个单独包一层 FallbackRpc,照常触发 onRequest、记录健康状态),否则整体按故障切换顺序只发给第一个可用的节点。
|
|
157
|
+
* 解析出错时抛出、不缓存(下次重试)。source.ts 只在传了 sendRpc 时按需加载(/write 的入口本身不静态依赖它)
|
|
158
|
+
*/
|
|
159
|
+
#targets(): Promise<RpcTransport[]> {
|
|
160
|
+
this.#broadcastTargets ??= this.#resolveTargets().catch((err: unknown) => {
|
|
161
|
+
this.#broadcastTargets = null
|
|
162
|
+
throw err
|
|
163
|
+
})
|
|
164
|
+
return this.#broadcastTargets
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async #resolveTargets(): Promise<RpcTransport[]> {
|
|
168
|
+
let sender = this.client.transport
|
|
169
|
+
if (this.options.sendRpc !== undefined) {
|
|
170
|
+
const { resolveSource } = await import('../source.js')
|
|
171
|
+
const config = clientSourceConfigs.get(this.client)
|
|
172
|
+
sender = resolveSource(this.options.sendRpc, config?.cluster, config?.options, config?.genesis).transport
|
|
173
|
+
}
|
|
174
|
+
const nodes = sender instanceof FallbackRpc ? sender.nodes : [sender]
|
|
175
|
+
return this.options.broadcast === 'all' && nodes.length > 1 ? nodes.map((node) => new FallbackRpc([node])) : [sender]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 广播:'accepted'(第一个接受的节点返回就结束,其余节点在后台继续);没有节点接受时,有节点明确拒绝(或节点不在同一个网络)则抛出,
|
|
180
|
+
* 否则返回错误说明(节点都连不上,交易可能已经发出)
|
|
181
|
+
*/
|
|
182
|
+
async broadcast(raw: string): Promise<'accepted' | { uncertain: string }> {
|
|
183
|
+
const params = [raw, { encoding: 'base64', skipPreflight: true, maxRetries: 0 }]
|
|
184
|
+
const attempts = (await this.#targets()).map((target) =>
|
|
185
|
+
target.request('sendTransaction', params).then(
|
|
186
|
+
() => 'accepted' as const,
|
|
187
|
+
(err: unknown) => {
|
|
188
|
+
if (/already (been )?processed/i.test(errorText(err))) {
|
|
189
|
+
return 'accepted' as const
|
|
190
|
+
}
|
|
191
|
+
throw err
|
|
192
|
+
},
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
// 返回后还没结束的节点照常在后台完成:吞掉它们的拒绝,避免未处理的 rejection
|
|
196
|
+
attempts.forEach((attempt) => attempt.catch(() => {}))
|
|
197
|
+
try {
|
|
198
|
+
return await Promise.any(attempts)
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const errors: unknown[] = err instanceof AggregateError ? err.errors : [err]
|
|
201
|
+
const rejected = errors.find((e) => (e instanceof RpcError && !isNodeFault(e)) || isNetworkMismatch(e))
|
|
202
|
+
if (rejected) {
|
|
203
|
+
throw rejected
|
|
204
|
+
}
|
|
205
|
+
return { uncertain: `broadcast not acknowledged: ${errors.map(errorText).join(' | ')}` }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 按 SendOptions 取优先费用单价(micro-lamports / 计算单元);writable 是交易里的可写账户 */
|
|
211
|
+
export function priorityFeeFor(client: SolanaClient, options: SendOptions, writable: readonly string[]): Promise<bigint> {
|
|
212
|
+
return new Chain(client, options).priorityFee(writable)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 节点不在同一个网络(source.ts 的 NetworkMismatchError):按 name 判断,免得为 instanceof 静态带上 source.ts */
|
|
216
|
+
function isNetworkMismatch(err: unknown): boolean {
|
|
217
|
+
return err instanceof Error && err.name === 'NetworkMismatchError'
|
|
218
|
+
}
|
package/src/write/collect.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getAssociatedTokenAddress, isAddress } from '../address.js'
|
|
1
|
+
import { getAssociatedTokenAddress, InvalidAddressError, isAddress } from '../address.js'
|
|
2
2
|
import type { SolanaClient } from '../client.js'
|
|
3
3
|
import { formatUnits } from '../layout.js'
|
|
4
4
|
import type { TokenBalance } from '../types.js'
|
|
@@ -71,7 +71,7 @@ interface Item {
|
|
|
71
71
|
*/
|
|
72
72
|
export async function batchCollect(client: SolanaClient, wallets: readonly SignerInput[], to: string, options: BatchCollectOptions): Promise<CollectResult> {
|
|
73
73
|
if (!isAddress(to)) {
|
|
74
|
-
throw new
|
|
74
|
+
throw new InvalidAddressError(`Invalid collect address: ${to}`, to)
|
|
75
75
|
}
|
|
76
76
|
const cache = new Map()
|
|
77
77
|
const signers = await Promise.all(wallets.map((w) => resolveSigner(w, cache)))
|