@w3lib/solana-batch-call 0.5.0 → 0.6.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 +14 -0
- package/README.md +113 -1
- package/dist/batchPlan.d.ts +52 -0
- package/dist/batchPlan.d.ts.map +1 -0
- package/dist/batchPlan.js +85 -0
- package/dist/batchPlan.js.map +1 -0
- package/dist/claim.d.ts +161 -0
- package/dist/claim.d.ts.map +1 -0
- package/dist/claim.js +197 -0
- package/dist/claim.js.map +1 -0
- package/dist/decode.d.ts +22 -0
- package/dist/decode.d.ts.map +1 -0
- package/dist/decode.js +187 -0
- package/dist/decode.js.map +1 -0
- package/dist/describe.d.ts +26 -0
- package/dist/describe.d.ts.map +1 -0
- package/dist/describe.js +58 -0
- package/dist/describe.js.map +1 -0
- package/dist/history.d.ts +125 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +231 -0
- package/dist/history.js.map +1 -0
- package/dist/historyHelius.d.ts +52 -0
- package/dist/historyHelius.d.ts.map +1 -0
- package/dist/historyHelius.js +125 -0
- package/dist/historyHelius.js.map +1 -0
- package/dist/liquidity/orca.d.ts +6 -0
- package/dist/liquidity/orca.d.ts.map +1 -1
- package/dist/liquidity/orca.js +14 -3
- package/dist/liquidity/orca.js.map +1 -1
- package/dist/liquidity/raydiumClmm.d.ts +6 -0
- package/dist/liquidity/raydiumClmm.d.ts.map +1 -1
- package/dist/liquidity/raydiumClmm.js +12 -5
- package/dist/liquidity/raydiumClmm.js.map +1 -1
- package/dist/lp.d.ts +47 -0
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +91 -15
- package/dist/lp.js.map +1 -1
- package/dist/lpMath.d.ts +22 -0
- package/dist/lpMath.d.ts.map +1 -1
- package/dist/lpMath.js +21 -0
- package/dist/lpMath.js.map +1 -1
- package/dist/programs.d.ts +19 -0
- package/dist/programs.d.ts.map +1 -0
- package/dist/programs.js +175 -0
- package/dist/programs.js.map +1 -0
- package/dist/reclaim.d.ts +190 -0
- package/dist/reclaim.d.ts.map +1 -0
- package/dist/reclaim.js +399 -0
- package/dist/reclaim.js.map +1 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +4 -2
- package/dist/rpc.js.map +1 -1
- package/dist/simulate.d.ts +106 -0
- package/dist/simulate.d.ts.map +1 -0
- package/dist/simulate.js +418 -0
- package/dist/simulate.js.map +1 -0
- package/dist/subpaths/claim.d.ts +31 -0
- package/dist/subpaths/claim.d.ts.map +1 -0
- package/dist/subpaths/claim.js +31 -0
- package/dist/subpaths/claim.js.map +1 -0
- package/dist/subpaths/history.d.ts +21 -0
- package/dist/subpaths/history.d.ts.map +1 -0
- package/dist/subpaths/history.js +22 -0
- package/dist/subpaths/history.js.map +1 -0
- package/dist/subpaths/lp.d.ts +2 -2
- package/dist/subpaths/lp.d.ts.map +1 -1
- package/dist/subpaths/lp.js +2 -2
- package/dist/subpaths/lp.js.map +1 -1
- package/dist/subpaths/reclaim.d.ts +34 -0
- package/dist/subpaths/reclaim.d.ts.map +1 -0
- package/dist/subpaths/reclaim.js +34 -0
- package/dist/subpaths/reclaim.js.map +1 -0
- package/dist/subpaths/simulate.d.ts +22 -0
- package/dist/subpaths/simulate.d.ts.map +1 -0
- package/dist/subpaths/simulate.js +23 -0
- package/dist/subpaths/simulate.js.map +1 -0
- package/dist/write/instructions.d.ts +2 -0
- package/dist/write/instructions.d.ts.map +1 -1
- package/dist/write/instructions.js +16 -0
- package/dist/write/instructions.js.map +1 -1
- package/package.json +21 -1
- package/src/batchPlan.ts +137 -0
- package/src/claim.ts +323 -0
- package/src/decode.ts +202 -0
- package/src/describe.ts +77 -0
- package/src/history.ts +336 -0
- package/src/historyHelius.ts +136 -0
- package/src/liquidity/orca.ts +15 -5
- package/src/liquidity/raydiumClmm.ts +13 -5
- package/src/lp.ts +140 -15
- package/src/lpMath.ts +38 -0
- package/src/programs.ts +216 -0
- package/src/reclaim.ts +547 -0
- package/src/rpc.ts +4 -2
- package/src/simulate.ts +535 -0
- package/src/subpaths/claim.ts +53 -0
- package/src/subpaths/history.ts +38 -0
- package/src/subpaths/lp.ts +5 -1
- package/src/subpaths/reclaim.ts +57 -0
- package/src/subpaths/simulate.ts +37 -0
- package/src/write/instructions.ts +17 -0
package/src/history.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 交易历史(子路径 `@w3lib/solana-batch-call/history`):把地址的交易整理成统一的 Activity(类型、方向、对手方、资产变化、手续费)。
|
|
3
|
+
* 数据源可以换:默认 rpcHistory(getSignaturesForAddress 翻页 + getTransaction,免费节点就能用,节点失败自动切换),
|
|
4
|
+
* 也可以传 heliusHistory 等;传多个时按顺序尝试。游标就是最后一条签名,换数据源也能接着翻
|
|
5
|
+
*/
|
|
6
|
+
import { base58 } from '@scure/base'
|
|
7
|
+
|
|
8
|
+
import { isAddress } from './address.js'
|
|
9
|
+
import type { RequestOptions, SolanaClient } from './client.js'
|
|
10
|
+
import { NATIVE_MINT, SOL_DECIMALS, SYSTEM_PROGRAM_ID } from './constants.js'
|
|
11
|
+
import { decodeInstruction, type DecodedInstruction } from './decode.js'
|
|
12
|
+
import { classify, describe, type AssetDelta, type Classified } from './describe.js'
|
|
13
|
+
import { formatUnits } from './layout.js'
|
|
14
|
+
|
|
15
|
+
export type ActivityType = 'transfer' | 'swap' | 'approve' | 'liquidity' | 'mint' | 'contract' | 'other'
|
|
16
|
+
|
|
17
|
+
export interface ActivityChange extends AssetDelta {}
|
|
18
|
+
|
|
19
|
+
export interface Activity {
|
|
20
|
+
/** 交易签名 */
|
|
21
|
+
hash: string
|
|
22
|
+
/** 区块时间(unix 秒);节点没有给时为 0 */
|
|
23
|
+
time: number
|
|
24
|
+
/** slot */
|
|
25
|
+
block?: number
|
|
26
|
+
status: 'success' | 'failed'
|
|
27
|
+
type: ActivityType
|
|
28
|
+
/** 对这个地址来说:资产只增加 in、只减少 out、转给自己 self;兑换等有增有减时不填 */
|
|
29
|
+
direction?: 'in' | 'out' | 'self'
|
|
30
|
+
/** 转账的对手方 */
|
|
31
|
+
counterparty?: string
|
|
32
|
+
/** 一句话说明,如 “Swap 1 SOL for 150 USDC on Jupiter” */
|
|
33
|
+
label?: string
|
|
34
|
+
/** 这个地址的资产变化(SOL 不含手续费,Wrapped SOL 并入 SOL;asset 原生为 'native',其他是 mint) */
|
|
35
|
+
changes: ActivityChange[]
|
|
36
|
+
/** 这个地址付的手续费(lamports,十进制字符串);不是手续费支付者时不填 */
|
|
37
|
+
fee?: string
|
|
38
|
+
protocol?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface HistoryPage {
|
|
42
|
+
items: Activity[]
|
|
43
|
+
/** 还有更多时:下一页的游标(最后一条处理过的签名);没有更多时不返回 */
|
|
44
|
+
nextCursor?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface HistoryContext {
|
|
48
|
+
client: SolanaClient
|
|
49
|
+
address: string
|
|
50
|
+
limit: number
|
|
51
|
+
cursor?: string
|
|
52
|
+
/** 只要这个时间(unix 秒)之前 / 之后的 */
|
|
53
|
+
before?: number
|
|
54
|
+
after?: number
|
|
55
|
+
types?: readonly ActivityType[]
|
|
56
|
+
fetch: typeof fetch
|
|
57
|
+
signal?: AbortSignal
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 历史数据源:返回统一格式的 Activity(代币的 symbol 先用缩写的 mint,getHistory 统一补全);游标是签名 */
|
|
61
|
+
export interface HistorySource {
|
|
62
|
+
readonly name: string
|
|
63
|
+
getHistory(ctx: HistoryContext): Promise<HistoryPage>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface HistoryOptions extends RequestOptions {
|
|
67
|
+
/** 每页条数,默认 20,最多 100 */
|
|
68
|
+
limit?: number
|
|
69
|
+
/** 上一页的 nextCursor */
|
|
70
|
+
cursor?: string
|
|
71
|
+
/** 区块时间上限(不含)/ 下限(含),unix 秒 */
|
|
72
|
+
before?: number
|
|
73
|
+
after?: number
|
|
74
|
+
/** 只要这些类型(不够 limit 时会往后多找,最多看 limit × 5 笔) */
|
|
75
|
+
types?: readonly ActivityType[]
|
|
76
|
+
/** 数据源,默认 rpcHistory();传数组时按顺序尝试,前一个失败换下一个 */
|
|
77
|
+
source?: HistorySource | readonly HistorySource[]
|
|
78
|
+
/** 补全代币符号(读 mint 的元数据),默认 true */
|
|
79
|
+
symbols?: boolean
|
|
80
|
+
fetch?: typeof fetch
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const MAX_HISTORY_LIMIT = 100
|
|
84
|
+
/** getTransaction 支持的交易版本:主网已经有 v1 格式的交易(2026-10),只给 0 时遇到它们整个请求报错(-32015);json 编码下 v1 的账户和指令字段与 v0 相同 */
|
|
85
|
+
const MAX_TX_VERSION = 1
|
|
86
|
+
|
|
87
|
+
const short = (a: string) => `${a.slice(0, 4)}…${a.slice(-4)}`
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 查地址的交易历史。
|
|
91
|
+
*
|
|
92
|
+
* ```ts
|
|
93
|
+
* const page = await history(client, address, { limit: 20 })
|
|
94
|
+
* const next = await history(client, address, { cursor: page.nextCursor })
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export async function history(client: SolanaClient, address: string, options: HistoryOptions = {}): Promise<HistoryPage> {
|
|
98
|
+
if (!isAddress(address)) throw new Error(`Invalid address: ${address}`)
|
|
99
|
+
const limit = options.limit ?? 20
|
|
100
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_HISTORY_LIMIT) throw new RangeError(`limit must be an integer between 1 and ${MAX_HISTORY_LIMIT}`)
|
|
101
|
+
const sources = options.source === undefined ? [rpcHistory()] : Array.isArray(options.source) ? options.source : [options.source as HistorySource]
|
|
102
|
+
if (!sources.length) throw new Error('No history source')
|
|
103
|
+
const ctx: HistoryContext = {
|
|
104
|
+
client,
|
|
105
|
+
address,
|
|
106
|
+
limit,
|
|
107
|
+
cursor: options.cursor,
|
|
108
|
+
before: options.before,
|
|
109
|
+
after: options.after,
|
|
110
|
+
types: options.types?.length ? options.types : undefined,
|
|
111
|
+
fetch: options.fetch ?? ((...args) => fetch(...args)),
|
|
112
|
+
signal: options.signal,
|
|
113
|
+
}
|
|
114
|
+
const errors: string[] = []
|
|
115
|
+
for (const source of sources) {
|
|
116
|
+
try {
|
|
117
|
+
const page = await (source as HistorySource).getHistory(ctx)
|
|
118
|
+
if (options.symbols !== false) await fillSymbols(client, page.items, options.signal)
|
|
119
|
+
return page
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (options.signal?.aborted) throw err
|
|
122
|
+
errors.push(`${(source as HistorySource).name}: ${err instanceof Error ? err.message : String(err)}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
throw new Error(`All history sources failed (${errors.join('; ')})`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 补全还是缩写 mint 的 symbol(一次 tokens 查询,label 里的同步替换);读不到的用缩写的 mint */
|
|
129
|
+
async function fillSymbols(client: SolanaClient, items: Activity[], signal: AbortSignal | undefined): Promise<void> {
|
|
130
|
+
const pending = (c: ActivityChange) => c.symbol === short(c.asset)
|
|
131
|
+
const missing = [...new Set(items.flatMap((a) => a.changes.filter(pending).map((c) => c.asset)))]
|
|
132
|
+
if (!missing.length) return
|
|
133
|
+
const details = await client.tokens(missing, { fields: ['symbol'], signal }).catch(() => null)
|
|
134
|
+
const symbols = new Map(missing.map((m, i) => [m, details?.[i]?.symbol ?? short(m)]))
|
|
135
|
+
for (const a of items) {
|
|
136
|
+
for (const c of a.changes) {
|
|
137
|
+
if (!pending(c)) continue
|
|
138
|
+
const symbol = symbols.get(c.asset) as string
|
|
139
|
+
if (a.label) a.label = a.label.split(c.symbol).join(symbol)
|
|
140
|
+
c.symbol = symbol
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// RPC 数据源
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
export interface RpcHistoryOptions {
|
|
150
|
+
/** 同时取多少笔交易的详情,默认 6(公共节点限频很严,节点自己会对 429 退避重试) */
|
|
151
|
+
concurrency?: number
|
|
152
|
+
/** 一次翻页最多检查多少笔签名(含被 types / 时间过滤掉的),默认 limit × 5,最多 500 */
|
|
153
|
+
maxScan?: number
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface RawTransaction {
|
|
157
|
+
slot: number
|
|
158
|
+
blockTime: number | null
|
|
159
|
+
meta: {
|
|
160
|
+
err: unknown
|
|
161
|
+
fee: number
|
|
162
|
+
preBalances: number[]
|
|
163
|
+
postBalances: number[]
|
|
164
|
+
preTokenBalances?: RawTokenBalance[]
|
|
165
|
+
postTokenBalances?: RawTokenBalance[]
|
|
166
|
+
loadedAddresses?: { writable: string[]; readonly: string[] }
|
|
167
|
+
} | null
|
|
168
|
+
transaction: {
|
|
169
|
+
signatures: string[]
|
|
170
|
+
message: { accountKeys: string[]; instructions: Array<{ programIdIndex: number; accounts: number[]; data: string }> }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface RawTokenBalance {
|
|
175
|
+
accountIndex: number
|
|
176
|
+
mint: string
|
|
177
|
+
owner?: string
|
|
178
|
+
uiTokenAmount: { amount: string; decimals: number }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface SignatureInfo {
|
|
182
|
+
signature: string
|
|
183
|
+
slot: number
|
|
184
|
+
err: unknown
|
|
185
|
+
blockTime: number | null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function pool<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
189
|
+
const results = new Array<R>(items.length)
|
|
190
|
+
let next = 0
|
|
191
|
+
const worker = async () => {
|
|
192
|
+
while (next < items.length) {
|
|
193
|
+
const i = next++
|
|
194
|
+
results[i] = await fn(items[i] as T)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker))
|
|
198
|
+
return results
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 默认数据源:getSignaturesForAddress(用 before 翻页)+ getTransaction(逐笔,json 编码、支持 v0)。
|
|
203
|
+
* 先按区块时间过滤签名再取详情,只取需要的笔数;失败的交易也列出(status 为 failed)。每页的请求数 ≈ limit + 1
|
|
204
|
+
*/
|
|
205
|
+
export function rpcHistory(options: RpcHistoryOptions = {}): HistorySource {
|
|
206
|
+
const concurrency = options.concurrency ?? 6
|
|
207
|
+
return {
|
|
208
|
+
name: 'rpc',
|
|
209
|
+
async getHistory(ctx) {
|
|
210
|
+
const { client, address, limit, signal } = ctx
|
|
211
|
+
const maxScan = Math.min(options.maxScan ?? limit * 5, 500)
|
|
212
|
+
const items: Activity[] = []
|
|
213
|
+
let cursor = ctx.cursor
|
|
214
|
+
let scanned = 0
|
|
215
|
+
let more = true
|
|
216
|
+
while (items.length < limit && scanned < maxScan && more) {
|
|
217
|
+
const pageSize = Math.min(1000, Math.max(limit, 20) * (ctx.types ? 3 : 1))
|
|
218
|
+
const commitment = client.commitment === 'processed' ? 'confirmed' : client.commitment
|
|
219
|
+
const sigs = await client.request<SignatureInfo[]>('getSignaturesForAddress', [address, { limit: pageSize, ...(cursor ? { before: cursor } : {}), commitment }], { signal })
|
|
220
|
+
if (!sigs.length) {
|
|
221
|
+
more = false
|
|
222
|
+
break
|
|
223
|
+
}
|
|
224
|
+
// 按时间过滤:比 before 新的跳过;比 after 旧的之后都不用看了(签名按从新到旧排列)
|
|
225
|
+
let rows = sigs.filter((s) => ctx.before === undefined || s.blockTime === null || s.blockTime < ctx.before)
|
|
226
|
+
const old = ctx.after === undefined ? -1 : rows.findIndex((s) => s.blockTime !== null && s.blockTime < (ctx.after as number))
|
|
227
|
+
if (old >= 0) rows = rows.slice(0, old)
|
|
228
|
+
let i = 0
|
|
229
|
+
while (i < rows.length && items.length < limit && scanned < maxScan) {
|
|
230
|
+
const chunk = rows.slice(i, i + Math.max(1, Math.min(limit - items.length, maxScan - scanned)))
|
|
231
|
+
const txs = await pool(chunk, concurrency, (row) => client.request<RawTransaction | null>('getTransaction', [row.signature, { encoding: 'json', maxSupportedTransactionVersion: MAX_TX_VERSION, commitment }], { signal }))
|
|
232
|
+
for (const [j, tx] of txs.entries()) {
|
|
233
|
+
i++
|
|
234
|
+
scanned++
|
|
235
|
+
cursor = (chunk[j] as SignatureInfo).signature
|
|
236
|
+
const activity = tx ? activityFromTransaction(address, tx) : null
|
|
237
|
+
if (activity && (!ctx.types || ctx.types.includes(activity.type))) items.push(activity)
|
|
238
|
+
if (items.length >= limit) break
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (i >= rows.length) {
|
|
242
|
+
// 这一页看完了:接着从这一页最后一条往后翻;没有更多(不足一页、或已经到 after)时结束
|
|
243
|
+
cursor = (sigs[sigs.length - 1] as SignatureInfo).signature
|
|
244
|
+
more = sigs.length >= pageSize && old < 0
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return { items, ...(more && cursor ? { nextCursor: cursor } : {}) }
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const SOL: Pick<AssetDelta, 'asset' | 'symbol' | 'decimals'> = { asset: 'native', symbol: 'SOL', decimals: SOL_DECIMALS }
|
|
253
|
+
|
|
254
|
+
/** 合并资产变化:同一资产相加,去掉 0 */
|
|
255
|
+
function toChanges(native: bigint, tokens: Map<string, { delta: bigint; decimals: number }>): ActivityChange[] {
|
|
256
|
+
const out: ActivityChange[] = []
|
|
257
|
+
if (native !== 0n) out.push({ ...SOL, delta: native.toString(), formatted: formatUnits(native, SOL_DECIMALS) })
|
|
258
|
+
for (const [asset, { delta, decimals }] of tokens) {
|
|
259
|
+
if (delta !== 0n) out.push({ asset, symbol: short(asset), decimals, delta: delta.toString(), formatted: formatUnits(delta, decimals) })
|
|
260
|
+
}
|
|
261
|
+
return out
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function directionOf(changes: readonly ActivityChange[], counterparty: string | undefined, address: string): Activity['direction'] {
|
|
265
|
+
if (!changes.length) return counterparty === address ? 'self' : undefined
|
|
266
|
+
const negative = changes.filter((c) => c.delta.startsWith('-')).length
|
|
267
|
+
if (negative === 0) return 'in'
|
|
268
|
+
return negative === changes.length ? 'out' : undefined
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const toActivityType = (kind: Classified | null): ActivityType => (kind ? (kind.type === 'unknown' ? 'contract' : kind.type) : 'other')
|
|
272
|
+
|
|
273
|
+
/** 一笔 getTransaction(json 编码)整理成 Activity,address 的角度;导出供测试和自己取到交易的场景使用 */
|
|
274
|
+
export function activityFromTransaction(address: string, tx: RawTransaction): Activity | null {
|
|
275
|
+
const meta = tx.meta
|
|
276
|
+
const hash = tx.transaction.signatures[0]
|
|
277
|
+
if (!meta || !hash) return null
|
|
278
|
+
const message = tx.transaction.message
|
|
279
|
+
const keys = [...message.accountKeys, ...(meta.loadedAddresses?.writable ?? []), ...(meta.loadedAddresses?.readonly ?? [])]
|
|
280
|
+
const failed = meta.err !== null && meta.err !== undefined
|
|
281
|
+
const ixs: DecodedInstruction[] = message.instructions.map((ix, i) => {
|
|
282
|
+
const programId = keys[ix.programIdIndex] as string
|
|
283
|
+
return decodeInstruction(i, programId, ix.accounts.map((a) => keys[a] as string), base58.decode(ix.data))
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
const index = keys.indexOf(address)
|
|
287
|
+
const isPayer = index === 0
|
|
288
|
+
const fee = BigInt(meta.fee)
|
|
289
|
+
let native = index >= 0 ? BigInt(meta.postBalances[index] ?? 0) - BigInt(meta.preBalances[index] ?? 0) + (isPayer ? fee : 0n) : 0n
|
|
290
|
+
const tokens = new Map<string, { delta: bigint; decimals: number }>()
|
|
291
|
+
const ownerOf = new Map<number, string>()
|
|
292
|
+
for (const [list, sign] of [[meta.preTokenBalances ?? [], -1n], [meta.postTokenBalances ?? [], 1n]] as const) {
|
|
293
|
+
for (const b of list) {
|
|
294
|
+
if (b.owner) ownerOf.set(b.accountIndex, b.owner)
|
|
295
|
+
if (b.owner !== address) continue
|
|
296
|
+
const prev = tokens.get(b.mint)
|
|
297
|
+
tokens.set(b.mint, { delta: (prev?.delta ?? 0n) + sign * BigInt(b.uiTokenAmount.amount), decimals: b.uiTokenAmount.decimals })
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Wrapped SOL 并入 SOL
|
|
301
|
+
const wsol = tokens.get(NATIVE_MINT)
|
|
302
|
+
if (wsol) {
|
|
303
|
+
native += wsol.delta
|
|
304
|
+
tokens.delete(NATIVE_MINT)
|
|
305
|
+
}
|
|
306
|
+
const changes = failed ? [] : toChanges(native, tokens)
|
|
307
|
+
|
|
308
|
+
// 对手方:第一笔转账的另一头(代币转账取代币账户的 owner)
|
|
309
|
+
let counterparty: string | undefined
|
|
310
|
+
for (const ix of ixs) {
|
|
311
|
+
if (ix.type !== 'transfer') continue
|
|
312
|
+
const args = ix.args as Record<string, string> | undefined
|
|
313
|
+
const owner = (account: string | undefined) => (account ? (ownerOf.get(keys.indexOf(account)) ?? (ix.programId === SYSTEM_PROGRAM_ID ? account : undefined)) : undefined)
|
|
314
|
+
const from = owner(args?.from ?? args?.source)
|
|
315
|
+
const to = owner(args?.to ?? args?.destination)
|
|
316
|
+
counterparty = from === address ? to : from
|
|
317
|
+
if (counterparty) break
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const kind = classify(ixs, changes)
|
|
321
|
+
const type = toActivityType(kind)
|
|
322
|
+
const label = describe(kind, ixs, changes, counterparty)
|
|
323
|
+
return {
|
|
324
|
+
hash,
|
|
325
|
+
time: tx.blockTime ?? 0,
|
|
326
|
+
block: tx.slot,
|
|
327
|
+
status: failed ? 'failed' : 'success',
|
|
328
|
+
type,
|
|
329
|
+
direction: type === 'transfer' || type === 'other' ? directionOf(changes, counterparty, address) : undefined,
|
|
330
|
+
counterparty,
|
|
331
|
+
label: failed ? `Failed: ${label}` : label,
|
|
332
|
+
changes,
|
|
333
|
+
...(isPayer ? { fee: fee.toString() } : {}),
|
|
334
|
+
protocol: kind?.protocol,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** Helius 增强交易历史数据源(需要应用自己的 API key):把 `/v0/addresses/{address}/transactions` 的结果整理成和 rpcHistory 一样的 Activity */
|
|
2
|
+
import { NATIVE_MINT, SOL_DECIMALS } from './constants.js'
|
|
3
|
+
import { formatUnits } from './layout.js'
|
|
4
|
+
import type { Activity, ActivityChange, ActivityType, HistorySource } from './history.js'
|
|
5
|
+
|
|
6
|
+
export interface HeliusHistoryOptions {
|
|
7
|
+
apiKey: string
|
|
8
|
+
/** 默认 https://api.helius.xyz */
|
|
9
|
+
baseUrl?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface HeliusItem {
|
|
13
|
+
signature: string
|
|
14
|
+
timestamp?: number
|
|
15
|
+
slot?: number
|
|
16
|
+
fee?: number
|
|
17
|
+
feePayer?: string
|
|
18
|
+
type?: string
|
|
19
|
+
source?: string
|
|
20
|
+
description?: string
|
|
21
|
+
transactionError?: unknown
|
|
22
|
+
nativeTransfers?: Array<{ fromUserAccount: string; toUserAccount: string; amount: number }>
|
|
23
|
+
tokenTransfers?: Array<{ fromUserAccount: string | null; toUserAccount: string | null; tokenAmount: number; mint: string }>
|
|
24
|
+
accountData?: Array<{
|
|
25
|
+
account: string
|
|
26
|
+
nativeBalanceChange?: number
|
|
27
|
+
tokenBalanceChanges?: Array<{ userAccount: string; mint: string; rawTokenAmount: { tokenAmount: string; decimals: number } }>
|
|
28
|
+
}>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const short = (a: string) => `${a.slice(0, 4)}…${a.slice(-4)}`
|
|
32
|
+
|
|
33
|
+
/** Helius 的交易类型 → Activity 类型(认不出的按资产变化判断) */
|
|
34
|
+
function typeOf(item: HeliusItem, changes: readonly ActivityChange[]): ActivityType {
|
|
35
|
+
const t = item.type ?? ''
|
|
36
|
+
if (/SWAP/.test(t)) return 'swap'
|
|
37
|
+
if (t === 'TRANSFER') return 'transfer'
|
|
38
|
+
if (/LIQUIDITY|POSITION|POOL/.test(t)) return 'liquidity'
|
|
39
|
+
if (/MINT/.test(t)) return 'mint'
|
|
40
|
+
if (/APPROVE|REVOKE|DELEGATE/.test(t)) return 'approve'
|
|
41
|
+
const out = changes.filter((c) => c.delta.startsWith('-'))
|
|
42
|
+
if (out.length && changes.some((c) => !c.delta.startsWith('-') && out.some((o) => o.asset !== c.asset))) return 'swap'
|
|
43
|
+
return t && t !== 'UNKNOWN' ? 'contract' : 'other'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function activityFromHelius(address: string, item: HeliusItem): Activity {
|
|
47
|
+
const me = item.accountData?.find((a) => a.account === address)
|
|
48
|
+
const isPayer = item.feePayer === address
|
|
49
|
+
const fee = BigInt(item.fee ?? 0)
|
|
50
|
+
let native = BigInt(me?.nativeBalanceChange ?? 0) + (isPayer ? fee : 0n)
|
|
51
|
+
const tokens = new Map<string, { delta: bigint; decimals: number }>()
|
|
52
|
+
for (const entry of item.accountData ?? []) {
|
|
53
|
+
for (const t of entry.tokenBalanceChanges ?? []) {
|
|
54
|
+
if (t.userAccount !== address) continue
|
|
55
|
+
const prev = tokens.get(t.mint)
|
|
56
|
+
tokens.set(t.mint, { delta: (prev?.delta ?? 0n) + BigInt(t.rawTokenAmount.tokenAmount), decimals: t.rawTokenAmount.decimals })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const wsol = tokens.get(NATIVE_MINT)
|
|
60
|
+
if (wsol) {
|
|
61
|
+
native += wsol.delta
|
|
62
|
+
tokens.delete(NATIVE_MINT)
|
|
63
|
+
}
|
|
64
|
+
const failed = item.transactionError !== null && item.transactionError !== undefined
|
|
65
|
+
const changes: ActivityChange[] = []
|
|
66
|
+
if (!failed) {
|
|
67
|
+
if (native !== 0n) changes.push({ asset: 'native', symbol: 'SOL', decimals: SOL_DECIMALS, delta: native.toString(), formatted: formatUnits(native, SOL_DECIMALS) })
|
|
68
|
+
for (const [asset, { delta, decimals }] of tokens) {
|
|
69
|
+
if (delta !== 0n) changes.push({ asset, symbol: short(asset), decimals, delta: delta.toString(), formatted: formatUnits(delta, decimals) })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const type = typeOf(item, changes)
|
|
73
|
+
let counterparty: string | undefined
|
|
74
|
+
for (const t of [...(item.nativeTransfers ?? []), ...(item.tokenTransfers ?? []).map((x) => ({ fromUserAccount: x.fromUserAccount ?? '', toUserAccount: x.toUserAccount ?? '' }))]) {
|
|
75
|
+
if (t.fromUserAccount === address && t.toUserAccount) counterparty = t.toUserAccount
|
|
76
|
+
else if (t.toUserAccount === address && t.fromUserAccount) counterparty = t.fromUserAccount
|
|
77
|
+
if (counterparty) break
|
|
78
|
+
}
|
|
79
|
+
const negative = changes.filter((c) => c.delta.startsWith('-')).length
|
|
80
|
+
const protocol = item.source && !/^(SYSTEM_PROGRAM|UNKNOWN)$/.test(item.source) ? item.source[0] + item.source.slice(1).toLowerCase().replace(/_/g, ' ') : undefined
|
|
81
|
+
return {
|
|
82
|
+
hash: item.signature,
|
|
83
|
+
time: item.timestamp ?? 0,
|
|
84
|
+
block: item.slot,
|
|
85
|
+
status: failed ? 'failed' : 'success',
|
|
86
|
+
type,
|
|
87
|
+
direction: (type === 'transfer' || type === 'other') && changes.length ? (negative === 0 ? 'in' : negative === changes.length ? 'out' : undefined) : undefined,
|
|
88
|
+
counterparty,
|
|
89
|
+
label: item.description || undefined,
|
|
90
|
+
changes,
|
|
91
|
+
...(isPayer ? { fee: fee.toString() } : {}),
|
|
92
|
+
protocol,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Helius 增强交易历史(`GET /v0/addresses/{address}/transactions`,按 before 签名翻页)。apiKey 由应用传入,库里不带。
|
|
98
|
+
* 按交易类型过滤时翻页最多再取 3 页
|
|
99
|
+
*
|
|
100
|
+
* ```ts
|
|
101
|
+
* await getHistory(address, { source: [heliusHistory({ apiKey }), rpcHistory()] }) // Helius 失败时用节点
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
export function heliusHistory(options: HeliusHistoryOptions): HistorySource {
|
|
105
|
+
if (!options.apiKey) throw new Error('heliusHistory needs an apiKey')
|
|
106
|
+
const base = (options.baseUrl ?? 'https://api.helius.xyz').replace(/\/+$/, '')
|
|
107
|
+
return {
|
|
108
|
+
name: 'helius',
|
|
109
|
+
async getHistory(ctx) {
|
|
110
|
+
const items: Activity[] = []
|
|
111
|
+
let cursor = ctx.cursor
|
|
112
|
+
let more = true
|
|
113
|
+
for (let page = 0; page < (ctx.types ? 4 : 1) && items.length < ctx.limit && more; page++) {
|
|
114
|
+
const url = `${base}/v0/addresses/${ctx.address}/transactions?api-key=${encodeURIComponent(options.apiKey)}&limit=${Math.min(100, ctx.limit)}${cursor ? `&before=${encodeURIComponent(cursor)}` : ''}`
|
|
115
|
+
const res = await ctx.fetch(url, { signal: ctx.signal })
|
|
116
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
117
|
+
const raw = (await res.json()) as HeliusItem[]
|
|
118
|
+
if (!Array.isArray(raw)) throw new Error('Unexpected response')
|
|
119
|
+
more = raw.length >= Math.min(100, ctx.limit)
|
|
120
|
+
for (const item of raw) {
|
|
121
|
+
cursor = item.signature
|
|
122
|
+
const t = item.timestamp ?? 0
|
|
123
|
+
if (ctx.before !== undefined && t >= ctx.before) continue
|
|
124
|
+
if (ctx.after !== undefined && t < ctx.after) {
|
|
125
|
+
more = false
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
const activity = activityFromHelius(ctx.address, item)
|
|
129
|
+
if (!ctx.types || ctx.types.includes(activity.type)) items.push(activity)
|
|
130
|
+
if (items.length >= ctx.limit) break
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { items, ...(more && cursor ? { nextCursor: cursor } : {}) }
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/liquidity/orca.ts
CHANGED
|
@@ -223,6 +223,20 @@ export async function orcaAdd(ctx: Ctx, params: SolanaIncreaseLiquidityParams |
|
|
|
223
223
|
})
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
/**
|
|
227
|
+
* 关闭已清空的仓位(流动性、手续费、奖励都为 0):销毁仓位 NFT、关闭仓位账户和 NFT 的代币账户(nftAccount,默认 owner 的 ATA),租金退给 receiver。
|
|
228
|
+
* NFT 是 Token-2022(带元数据扩展)时连 mint 一起关闭,否则(Metaplex 时代的 SPL Token NFT)mint 留着
|
|
229
|
+
*/
|
|
230
|
+
export function orcaClosePosition(owner: string, receiver: string, positionMint: string, nftProgram: string, nftAccount = getAssociatedTokenAddress(owner, positionMint, nftProgram)): Instruction {
|
|
231
|
+
const position = positionAddress(positionMint)
|
|
232
|
+
const token2022 = nftProgram === TOKEN_2022_PROGRAM_ID
|
|
233
|
+
return {
|
|
234
|
+
programId: PROGRAM,
|
|
235
|
+
keys: [r(owner, true), w(receiver), w(position), w(positionMint), w(nftAccount), r(nftProgram)],
|
|
236
|
+
data: anchorIx(token2022 ? 'close_position_with_token_extensions' : 'close_position'),
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
226
240
|
/** 移除(decrease + 领取手续费和奖励)/ 只领取(collectOnly) */
|
|
227
241
|
export async function orcaRemove(ctx: Ctx, params: SolanaRemoveLiquidityParams, collectOnly = false): Promise<SolanaLiquidityPlan> {
|
|
228
242
|
const mint = refMint(params.position)
|
|
@@ -304,11 +318,7 @@ export async function orcaRemove(ctx: Ctx, params: SolanaRemoveLiquidityParams,
|
|
|
304
318
|
})
|
|
305
319
|
})
|
|
306
320
|
if (burn) {
|
|
307
|
-
main.push(
|
|
308
|
-
nftProgram === TOKEN_2022_PROGRAM_ID
|
|
309
|
-
? { programId: PROGRAM, keys: [r(ctx.owner, true), w(ctx.owner), w(positionAccount), w(mint), w(nftAccount), r(TOKEN_2022_PROGRAM_ID)], data: anchorIx('close_position_with_token_extensions') }
|
|
310
|
-
: { programId: PROGRAM, keys: [r(ctx.owner, true), w(ctx.owner), w(positionAccount), w(mint), w(nftAccount), r(TOKEN_PROGRAM_ID)], data: anchorIx('close_position') },
|
|
311
|
-
)
|
|
321
|
+
main.push(orcaClosePosition(ctx.owner, ctx.owner, mint, nftProgram))
|
|
312
322
|
}
|
|
313
323
|
return makePlan(ctx, {
|
|
314
324
|
action: collectOnly ? 'collect' : 'remove',
|
|
@@ -249,6 +249,18 @@ export async function raydiumAdd(ctx: Ctx, params: SolanaIncreaseLiquidityParams
|
|
|
249
249
|
})
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* 关闭已清空的仓位(流动性、手续费、奖励都为 0):销毁仓位 NFT、关闭 NFT 的代币账户和仓位账户,租金退给 owner(程序固定,不能指定别人)。
|
|
254
|
+
* pool 是仓位所在的池子(官方 SDK 也带着它)
|
|
255
|
+
*/
|
|
256
|
+
export function raydiumClosePosition(owner: string, positionMint: string, nftProgram: string, pool: string, nftAccount = getAssociatedTokenAddress(owner, positionMint, nftProgram)): Instruction {
|
|
257
|
+
return {
|
|
258
|
+
programId: PROGRAM,
|
|
259
|
+
keys: [w(owner, true), w(positionMint), w(nftAccount), w(personalPositionAddress(positionMint)), r(SYSTEM_PROGRAM_ID), r(nftProgram), w(pool)],
|
|
260
|
+
data: anchorIx('close_position'),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
252
264
|
/** 移除(liquidity > 0)/ 只领取手续费和奖励(collectOnly:流动性为 0 的 decrease) */
|
|
253
265
|
export async function raydiumRemove(ctx: Ctx, params: SolanaRemoveLiquidityParams, collectOnly = false): Promise<SolanaLiquidityPlan> {
|
|
254
266
|
const mint = refMint(params.position)
|
|
@@ -308,11 +320,7 @@ export async function raydiumRemove(ctx: Ctx, params: SolanaRemoveLiquidityParam
|
|
|
308
320
|
}
|
|
309
321
|
const main: Instruction[] = [decrease]
|
|
310
322
|
if (burn) {
|
|
311
|
-
main.push(
|
|
312
|
-
programId: PROGRAM,
|
|
313
|
-
keys: [w(ctx.owner, true), w(mint), w(nftAta), w(personal), r(SYSTEM_PROGRAM_ID), r(nftProgram), w(pool.address)],
|
|
314
|
-
data: anchorIx('close_position'),
|
|
315
|
-
})
|
|
323
|
+
main.push(raydiumClosePosition(ctx.owner, mint, nftProgram, pool.address))
|
|
316
324
|
}
|
|
317
325
|
return makePlan(ctx, {
|
|
318
326
|
action: collectOnly ? 'collect' : 'remove',
|