@w3lib/solana-batch-call 0.5.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 +47 -0
- package/README.md +129 -9
- 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 +52 -0
- package/dist/batchPlan.d.ts.map +1 -0
- package/dist/batchPlan.js +64 -0
- package/dist/batchPlan.js.map +1 -0
- 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 +163 -0
- package/dist/claim.d.ts.map +1 -0
- package/dist/claim.js +197 -0
- package/dist/claim.js.map +1 -0
- 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 +22 -0
- package/dist/decode.d.ts.map +1 -0
- package/dist/decode.js +160 -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/fees.d.ts.map +1 -1
- package/dist/fees.js +2 -12
- package/dist/fees.js.map +1 -1
- package/dist/history.d.ts +125 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +220 -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/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/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 +55 -12
- package/dist/lp.d.ts.map +1 -1
- package/dist/lp.js +158 -70
- 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/portfolio.d.ts.map +1 -1
- package/dist/portfolio.js +3 -8
- package/dist/portfolio.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 +192 -0
- package/dist/reclaim.d.ts.map +1 -0
- package/dist/reclaim.js +399 -0
- package/dist/reclaim.js.map +1 -0
- 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 +404 -111
- 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 +395 -0
- package/dist/simulate.js.map +1 -0
- 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 +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/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/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/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 +21 -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 +117 -0
- package/src/bytes.ts +89 -0
- package/src/claim.ts +327 -0
- package/src/client.ts +59 -1239
- package/src/clientConfig.ts +12 -0
- package/src/decode.ts +176 -0
- package/src/describe.ts +77 -0
- package/src/fees.ts +2 -9
- package/src/history.ts +325 -0
- package/src/historyHelius.ts +136 -0
- package/src/index.ts +13 -1
- package/src/layout.ts +63 -82
- package/src/liquidity/common.ts +2 -2
- package/src/liquidity/orca.ts +15 -5
- package/src/liquidity/raydiumClmm.ts +13 -5
- package/src/lp.ts +226 -75
- package/src/lpMath.ts +38 -0
- package/src/portfolio.ts +8 -11
- package/src/programs.ts +216 -0
- package/src/reclaim.ts +550 -0
- package/src/risk.ts +2 -2
- package/src/rpc.ts +476 -118
- package/src/simulate.ts +517 -0
- package/src/source.ts +14 -5
- package/src/subpaths/approvals.ts +3 -2
- package/src/subpaths/claim.ts +54 -0
- package/src/subpaths/history.ts +38 -0
- package/src/subpaths/lp.ts +5 -1
- package/src/subpaths/reclaim.ts +58 -0
- package/src/subpaths/simulate.ts +37 -0
- 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/instructions.ts +17 -0
- package/src/write/pack.ts +142 -0
- package/src/write/send.ts +74 -407
- package/src/write/tracker.ts +152 -0
package/src/batchPlan.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 批量计划的公共部分(/reclaim、/claim 用):一组“调用”(每个调用的指令原子执行)按交易大小打包、先模拟再发送。
|
|
3
|
+
* 打包规则同 batchCall(相邻的调用放进同一笔交易,不超过 1232 字节和 64 个账户)
|
|
4
|
+
*/
|
|
5
|
+
import type { SolanaClient } from './client.js'
|
|
6
|
+
import { InvalidAddressError, isAddress } from './address.js'
|
|
7
|
+
import type { SimulationResult } from './simulate.js'
|
|
8
|
+
import type { BatchCallOptions } from './write/batch.js'
|
|
9
|
+
import { packUnits } from './write/pack.js'
|
|
10
|
+
import type { WriteResult } from './write/send.js'
|
|
11
|
+
import type { SignerInput } from './write/signer.js'
|
|
12
|
+
import type { Instruction } from './write/transaction.js'
|
|
13
|
+
|
|
14
|
+
/** 一个调用:要签名的地址(代币账户 / 仓位的 owner)和原子执行的指令 */
|
|
15
|
+
export interface PlannedCall {
|
|
16
|
+
owner: string
|
|
17
|
+
instructions: Instruction[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 把调用按顺序打包成交易,返回每笔交易包含的调用下标。单个调用放不进一笔交易时自己成一笔(发送时会报“transaction too large”);规则同 batchCall(packUnits) */
|
|
21
|
+
export function packCalls(payer: string, calls: readonly PlannedCall[], batchSize = Number.POSITIVE_INFINITY): number[][] {
|
|
22
|
+
return packUnits(payer, calls, { batchSize }).map((pack) => pack.indices)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PlanSimulationOptions {
|
|
26
|
+
/** 手续费支付者,默认第一个调用的 owner */
|
|
27
|
+
feePayer?: string
|
|
28
|
+
/** 每笔交易最多几个调用,默认不限(只受交易大小限制) */
|
|
29
|
+
batchSize?: number
|
|
30
|
+
/** 额外要看的地址(如收款地址) */
|
|
31
|
+
watch?: readonly string[]
|
|
32
|
+
signal?: AbortSignal
|
|
33
|
+
/** 算美元价值(/simulate 的 prices) */
|
|
34
|
+
prices?: boolean
|
|
35
|
+
fetch?: typeof fetch
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PlanSimulation {
|
|
39
|
+
/** 所有交易都模拟通过 */
|
|
40
|
+
success: boolean
|
|
41
|
+
/** 打包后的每笔交易:包含的调用下标和 /simulate 的结果 */
|
|
42
|
+
transactions: Array<{ calls: number[]; simulation: SimulationResult }>
|
|
43
|
+
/** 模拟失败的调用(整笔失败时逐个重新模拟找出来):下标和原因 */
|
|
44
|
+
failed: Array<{ index: number; error: string }>
|
|
45
|
+
/** 各笔交易手续费之和(lamports,十进制字符串) */
|
|
46
|
+
fee: string
|
|
47
|
+
/** 手续费支付者的 SOL 变化之和(lamports,带符号,不含手续费:/simulate 的 SOL 余额变化已经把手续费加回) */
|
|
48
|
+
payerNativeDelta: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 打包后逐笔用 /simulate 模拟(不签名、不发送);失败的交易逐个调用重新模拟,找出是哪些调用失败 */
|
|
52
|
+
export async function simulatePlan(client: SolanaClient, calls: readonly PlannedCall[], options: PlanSimulationOptions = {}): Promise<PlanSimulation> {
|
|
53
|
+
const first = calls[0]
|
|
54
|
+
if (!first) {
|
|
55
|
+
return { success: true, transactions: [], failed: [], fee: '0', payerNativeDelta: '0' }
|
|
56
|
+
}
|
|
57
|
+
const payer = options.feePayer ?? first.owner
|
|
58
|
+
if (!isAddress(payer)) {
|
|
59
|
+
throw new InvalidAddressError(`Invalid fee payer: ${payer}`, payer)
|
|
60
|
+
}
|
|
61
|
+
const { simulate } = await import('./simulate.js')
|
|
62
|
+
const run = (list: readonly PlannedCall[]) =>
|
|
63
|
+
simulate(
|
|
64
|
+
client,
|
|
65
|
+
{ feePayer: payer, instructions: list.flatMap((c) => c.instructions) },
|
|
66
|
+
{ signers: [...new Set(list.map((c) => c.owner))], watch: options.watch, signal: options.signal, prices: options.prices, fetch: options.fetch },
|
|
67
|
+
)
|
|
68
|
+
const packs = packCalls(payer, calls, options.batchSize)
|
|
69
|
+
const transactions: PlanSimulation['transactions'] = []
|
|
70
|
+
const failed: PlanSimulation['failed'] = []
|
|
71
|
+
for (const pack of packs) {
|
|
72
|
+
const simulation = await run(pack.map((i) => calls[i] as PlannedCall))
|
|
73
|
+
transactions.push({ calls: pack, simulation })
|
|
74
|
+
if (!simulation.success) {
|
|
75
|
+
if (pack.length === 1) {
|
|
76
|
+
failed.push({ index: pack[0] as number, error: simulation.error ?? 'simulation failed' })
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
for (const i of pack) {
|
|
80
|
+
const single = await run([calls[i] as PlannedCall])
|
|
81
|
+
if (!single.success) {
|
|
82
|
+
failed.push({ index: i, error: single.error ?? 'simulation failed' })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
let fee = 0n
|
|
88
|
+
let delta = 0n
|
|
89
|
+
for (const { simulation } of transactions) {
|
|
90
|
+
fee += BigInt(simulation.fee ?? '0')
|
|
91
|
+
for (const change of simulation.balanceChanges) {
|
|
92
|
+
if (change.address === payer && change.asset === 'native') {
|
|
93
|
+
delta += BigInt(change.delta)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { success: transactions.every((t) => t.simulation.success), transactions, failed, fee: fee.toString(), payerNativeDelta: delta.toString() }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 用 from 签名发送:调用的 owner 都必须是签名者本人;一个调用失败不影响后面的(continueOnError),feePayer 可以替 owner 付手续费 */
|
|
101
|
+
export async function sendCalls(client: SolanaClient, from: SignerInput, calls: readonly PlannedCall[], options: BatchCallOptions = {}): Promise<WriteResult[]> {
|
|
102
|
+
if (!calls.length) {
|
|
103
|
+
return []
|
|
104
|
+
}
|
|
105
|
+
// 发送的代码按需加载:只查询、模拟时打包不会带上
|
|
106
|
+
const [{ batchCall }, { resolveSigner }] = await Promise.all([import('./write/batch.js'), import('./write/signer.js')])
|
|
107
|
+
const { address } = await resolveSigner(from)
|
|
108
|
+
const other = calls.find((c) => c.owner !== address)
|
|
109
|
+
if (other) {
|
|
110
|
+
throw new Error(`The account belongs to ${other.owner}, not the signer ${address}`)
|
|
111
|
+
}
|
|
112
|
+
return batchCall(
|
|
113
|
+
client,
|
|
114
|
+
calls.map((c) => ({ from, instructions: c.instructions })),
|
|
115
|
+
{ continueOnError: true, ...options },
|
|
116
|
+
)
|
|
117
|
+
}
|
package/src/bytes.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { encodeAddress } from './address.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 小端读取二进制数据(账户数据、指令数据共用)。两种用法:
|
|
5
|
+
* - 传偏移量:按绝对位置读,不移动游标(如 `b.u64(333)`)
|
|
6
|
+
* - 不传:从游标处顺序读并前进(如 `r.u32(); r.pubkey()`)
|
|
7
|
+
* 越界时抛 RangeError
|
|
8
|
+
*/
|
|
9
|
+
export class Bytes {
|
|
10
|
+
readonly view: DataView
|
|
11
|
+
/** 顺序读取的游标 */
|
|
12
|
+
offset: number
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
readonly data: Uint8Array,
|
|
16
|
+
offset = 0,
|
|
17
|
+
) {
|
|
18
|
+
this.view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
19
|
+
this.offset = offset
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get bytes(): Uint8Array {
|
|
23
|
+
return this.data
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 要读的位置:传了 at 用它,否则用游标并前进 n 字节;检查越界 */
|
|
27
|
+
#pos(at: number | undefined, n: number): number {
|
|
28
|
+
const pos = at ?? this.offset
|
|
29
|
+
if (pos < 0 || pos + n > this.data.length) {
|
|
30
|
+
throw new RangeError('Account data is shorter than expected')
|
|
31
|
+
}
|
|
32
|
+
if (at === undefined) {
|
|
33
|
+
this.offset += n
|
|
34
|
+
}
|
|
35
|
+
return pos
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
skip(n: number): void {
|
|
39
|
+
this.#pos(undefined, n)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
u8(at?: number): number {
|
|
43
|
+
return this.data[this.#pos(at, 1)] as number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
u16(at?: number): number {
|
|
47
|
+
return this.view.getUint16(this.#pos(at, 2), true)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
u32(at?: number): number {
|
|
51
|
+
return this.view.getUint32(this.#pos(at, 4), true)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
i32(at?: number): number {
|
|
55
|
+
return this.view.getInt32(this.#pos(at, 4), true)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
u64(at?: number): bigint {
|
|
59
|
+
return this.view.getBigUint64(this.#pos(at, 8), true)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
u128(at?: number): bigint {
|
|
63
|
+
const pos = this.#pos(at, 16)
|
|
64
|
+
return (this.view.getBigUint64(pos + 8, true) << 64n) | this.view.getBigUint64(pos, true)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 32 字节地址(base58) */
|
|
68
|
+
key(at?: number): string {
|
|
69
|
+
const pos = this.#pos(at, 32)
|
|
70
|
+
return encodeAddress(this.data.subarray(pos, pos + 32))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 同 key(顺序读取时的习惯叫法) */
|
|
74
|
+
pubkey(at?: number): string {
|
|
75
|
+
return this.key(at)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** borsh string:u32 长度 + UTF-8;去掉 Metaplex 用来补齐长度的 \0 */
|
|
79
|
+
string(): string {
|
|
80
|
+
const len = this.u32()
|
|
81
|
+
const pos = this.#pos(undefined, len)
|
|
82
|
+
return new TextDecoder().decode(this.data.subarray(pos, pos + len)).replace(/\0+$/, '').trim()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** borsh Option:u8 标记(1 有)+ 值 */
|
|
86
|
+
option<T>(read: () => T): T | null {
|
|
87
|
+
return this.u8() === 1 ? read() : null
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/claim.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 批量领取(子路径 `@w3lib/solana-batch-call/claim`):Orca Whirlpool、Raydium CLMM 仓位的未领取手续费和挖矿奖励(Orca 的奖励 slot、Raydium 的农场奖励)。
|
|
3
|
+
* 先只读预览(每个仓位能领多少、美元价值),再把全部(或选中的)仓位的领取指令打包成交易,发送前用 /simulate 模拟。
|
|
4
|
+
* 领取的指令就是 /liquidity 的 collectFees(手续费 + 全部奖励,SOL 自动解包、缺少的代币账户自动创建),这里只做批量和打包
|
|
5
|
+
*/
|
|
6
|
+
import type { RequestOptions, SolanaClient } from './client.js'
|
|
7
|
+
import { InvalidAddressError, isAddress } from './address.js'
|
|
8
|
+
import { buildCollectFees } from './liquidity/index.js'
|
|
9
|
+
import type { SolanaLiquidityPlan } from './liquidity/types.js'
|
|
10
|
+
import { lpPositions, type SolanaLpClmmPosition, type SolanaLpProtocol, type SolanaLpToken } from './lp.js'
|
|
11
|
+
import { defillamaPrices, type SolanaPriceSource } from './price.js'
|
|
12
|
+
import { simulatePlan, sendCalls, type PlanSimulation, type PlanSimulationOptions, type PlannedCall } from './batchPlan.js'
|
|
13
|
+
import { formatUnits } from './layout.js'
|
|
14
|
+
import type { SimulatedBalanceChange } from './simulate.js'
|
|
15
|
+
import type { BatchCallOptions } from './write/batch.js'
|
|
16
|
+
import { mapPool } from './util.js'
|
|
17
|
+
import type { WriteResult } from './write/send.js'
|
|
18
|
+
import type { SignerInput } from './write/signer.js'
|
|
19
|
+
|
|
20
|
+
export type ClaimProtocol = Extract<SolanaLpProtocol, 'orca-whirlpool' | 'raydium-clmm'>
|
|
21
|
+
|
|
22
|
+
/** 一项能领取的东西:手续费或挖矿奖励 */
|
|
23
|
+
export interface ClaimableAmount {
|
|
24
|
+
mint: string
|
|
25
|
+
symbol: string | null
|
|
26
|
+
decimals: number | null
|
|
27
|
+
/** 数量(最小单位);读不到两端的 tick 数据时为 null */
|
|
28
|
+
amount: string | null
|
|
29
|
+
formatted: string | null
|
|
30
|
+
/** 美元价值(查到价格时;没有价格为 null;数量为 0 时为 0) */
|
|
31
|
+
usd: number | null
|
|
32
|
+
/** 只在按代币汇总(`Claimable.tokens`)里:true 表示有仓位的数量读不到,`amount` 只是已读到部分的合计(偏低),`usd` 为 null */
|
|
33
|
+
incomplete?: true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ClaimablePosition {
|
|
37
|
+
protocol: ClaimProtocol
|
|
38
|
+
protocolName: string
|
|
39
|
+
/** 仓位 NFT 的 mint 和仓位账户 */
|
|
40
|
+
positionMint: string
|
|
41
|
+
position: string
|
|
42
|
+
pool: string
|
|
43
|
+
token0: SolanaLpToken
|
|
44
|
+
token1: SolanaLpToken
|
|
45
|
+
liquidity: string
|
|
46
|
+
inRange: boolean
|
|
47
|
+
/** token0 / token1 的未领取手续费 */
|
|
48
|
+
fees: [ClaimableAmount, ClaimableAmount]
|
|
49
|
+
/** 池子里已初始化的挖矿奖励(Orca 的奖励 slot / Raydium 的 reward_infos),数量按程序的方式结算到当前时间 */
|
|
50
|
+
rewards: Array<ClaimableAmount & { index: number }>
|
|
51
|
+
feesUsd: number | null
|
|
52
|
+
rewardsUsd: number | null
|
|
53
|
+
/** 手续费 + 奖励的美元价值:有数量的项缺价格或读不到时为 null */
|
|
54
|
+
totalUsd: number | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Claimable {
|
|
58
|
+
owner: string
|
|
59
|
+
/** 有手续费或奖励可领的仓位(没有流动性也没有可领的不在内),按美元价值从大到小 */
|
|
60
|
+
positions: ClaimablePosition[]
|
|
61
|
+
/** 按代币汇总:手续费 + 奖励,所有仓位合计 */
|
|
62
|
+
tokens: ClaimableAmount[]
|
|
63
|
+
/** true:有仓位的数量读不到(tick 数据读不到),`tokens` 的数量和各项合计都可能偏低 */
|
|
64
|
+
incomplete: boolean
|
|
65
|
+
feesUsd: number | null
|
|
66
|
+
rewardsUsd: number | null
|
|
67
|
+
totalUsd: number | null
|
|
68
|
+
warnings: string[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ClaimableOptions extends RequestOptions {
|
|
72
|
+
/** 只看这些仓位 NFT 的 mint(读 ATA,免费公共节点也能用);不传时列出全部代币账户(getTokenAccountsByOwner,需要支持它的节点) */
|
|
73
|
+
mints?: readonly string[]
|
|
74
|
+
/** 协议,默认两个都查 */
|
|
75
|
+
protocols?: readonly ClaimProtocol[]
|
|
76
|
+
/** 价格:true(默认)用 DefiLlama;false 不查;也可以传自己的价格源(如 combinePrices([defillamaPrices(), onchainPrices()])) */
|
|
77
|
+
prices?: boolean | SolanaPriceSource
|
|
78
|
+
/** 奖励结算到哪个时刻(unix 秒),默认当前时间 */
|
|
79
|
+
now?: number
|
|
80
|
+
fetch?: typeof fetch
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @deprecated 改名为 ClaimableOptions(与其他查询的 options 命名统一),旧名保留 */
|
|
84
|
+
export type ClaimQuery = ClaimableOptions
|
|
85
|
+
|
|
86
|
+
const MAX_ERROR = 200
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 预览能领取的手续费和挖矿奖励(只读取,不构建交易):每个仓位的两种手续费、每个奖励的数量和美元价值,以及按代币汇总。
|
|
90
|
+
* 奖励照搬程序的结算:池子的奖励增长按时间摊给当前流动性,再按仓位区间内的增长计算
|
|
91
|
+
*
|
|
92
|
+
* ```ts
|
|
93
|
+
* const c = await claimable(client, owner)
|
|
94
|
+
* c.totalUsd // 全部能领的美元价值
|
|
95
|
+
* c.positions[0].rewards // [{ index: 0, symbol: 'RAY', formatted: '0.042698', usd: 0.083 }]
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export async function claimable(client: SolanaClient, owner: string, options: ClaimableOptions = {}): Promise<Claimable> {
|
|
99
|
+
if (!isAddress(owner)) {
|
|
100
|
+
throw new InvalidAddressError(`Invalid owner address: ${owner}`, owner)
|
|
101
|
+
}
|
|
102
|
+
const { signal } = options
|
|
103
|
+
const protocols: ClaimProtocol[] = options.protocols ? [...options.protocols] : ['orca-whirlpool', 'raydium-clmm']
|
|
104
|
+
const list = (
|
|
105
|
+
await lpPositions(client, owner, { protocols, mints: options.mints, rewards: true, now: options.now, signal })
|
|
106
|
+
).filter((p): p is SolanaLpClmmPosition => p.type === 'clmm')
|
|
107
|
+
|
|
108
|
+
const warnings: string[] = []
|
|
109
|
+
const mints = [...new Set(list.flatMap((p) => [p.token0.mint, p.token1.mint, ...(p.rewards ?? []).map((r) => r.mint)]))]
|
|
110
|
+
const source = options.prices === false ? null : options.prices && options.prices !== true ? options.prices : defillamaPrices()
|
|
111
|
+
let priceMap = new Map<string, number>()
|
|
112
|
+
if (source && mints.length) {
|
|
113
|
+
try {
|
|
114
|
+
priceMap = await source.prices({ mints, fetch: options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args)), signal, client })
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (signal?.aborted) throw err
|
|
117
|
+
warnings.push(`Price lookup failed (${(err instanceof Error ? err.message : String(err)).slice(0, MAX_ERROR)}): usd values are null`)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const amountOf = (token: { mint: string; symbol: string | null; decimals: number | null }, amount: bigint | null): ClaimableAmount => {
|
|
121
|
+
const formatted = amount === null || token.decimals === null ? null : formatUnits(amount, token.decimals)
|
|
122
|
+
const price = priceMap.get(token.mint)
|
|
123
|
+
// 数量为 0 的不需要价格
|
|
124
|
+
const usd = amount === 0n ? 0 : formatted === null || price === undefined || !source ? null : Number(formatted) * price
|
|
125
|
+
return { mint: token.mint, symbol: token.symbol, decimals: token.decimals, amount: amount === null ? null : amount.toString(), formatted, usd }
|
|
126
|
+
}
|
|
127
|
+
const sum = (items: readonly ClaimableAmount[]) => (items.some((i) => i.usd === null) ? null : items.reduce((acc, i) => acc + (i.usd as number), 0))
|
|
128
|
+
|
|
129
|
+
const positions: ClaimablePosition[] = []
|
|
130
|
+
for (const p of list) {
|
|
131
|
+
const fees: [ClaimableAmount, ClaimableAmount] = [amountOf(p.token0, p.fees0 === null ? null : BigInt(p.fees0)), amountOf(p.token1, p.fees1 === null ? null : BigInt(p.fees1))]
|
|
132
|
+
const rewards = (p.rewards ?? []).map((r) => ({ ...amountOf(r, r.amount === null ? null : BigInt(r.amount)), index: r.index }))
|
|
133
|
+
const unknown = [...fees, ...rewards].some((x) => x.amount === null)
|
|
134
|
+
if (!unknown && [...fees, ...rewards].every((x) => x.amount === '0')) continue
|
|
135
|
+
positions.push({
|
|
136
|
+
protocol: p.protocol as ClaimProtocol,
|
|
137
|
+
protocolName: p.protocolName,
|
|
138
|
+
positionMint: p.positionMint,
|
|
139
|
+
position: p.position,
|
|
140
|
+
pool: p.pool,
|
|
141
|
+
token0: p.token0,
|
|
142
|
+
token1: p.token1,
|
|
143
|
+
liquidity: p.liquidity,
|
|
144
|
+
inRange: p.inRange,
|
|
145
|
+
fees,
|
|
146
|
+
rewards,
|
|
147
|
+
feesUsd: sum(fees),
|
|
148
|
+
rewardsUsd: sum(rewards),
|
|
149
|
+
totalUsd: sum([...fees, ...rewards]),
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
positions.sort((a, b) => (b.totalUsd ?? -1) - (a.totalUsd ?? -1))
|
|
153
|
+
if (positions.some((p) => [...p.fees, ...p.rewards].some((x) => x.amount === null))) {
|
|
154
|
+
warnings.push("Some positions' tick arrays could not be read: their amounts are null (they are still included)")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 按代币汇总
|
|
158
|
+
const byMint = new Map<string, { token: { mint: string; symbol: string | null; decimals: number | null }; amount: bigint; unknown: boolean }>()
|
|
159
|
+
for (const item of positions.flatMap((p) => [...p.fees, ...p.rewards])) {
|
|
160
|
+
const entry = byMint.get(item.mint) ?? { token: item, amount: 0n, unknown: false }
|
|
161
|
+
entry.amount += BigInt(item.amount ?? 0)
|
|
162
|
+
entry.unknown ||= item.amount === null
|
|
163
|
+
byMint.set(item.mint, entry)
|
|
164
|
+
}
|
|
165
|
+
// 有读不到的数量时保留该代币(即使已读到的部分为 0),标记 incomplete,美元价值为 null
|
|
166
|
+
const tokens = [...byMint.values()]
|
|
167
|
+
.filter((e) => e.unknown || e.amount !== 0n)
|
|
168
|
+
.map((e): ClaimableAmount => (e.unknown ? { ...amountOf(e.token, e.amount), usd: null, incomplete: true } : amountOf(e.token, e.amount)))
|
|
169
|
+
const sumKnown = (items: ReadonlyArray<number | null>) => (items.some((x) => x === null) ? null : items.reduce<number>((acc, x) => acc + (x as number), 0))
|
|
170
|
+
return {
|
|
171
|
+
owner,
|
|
172
|
+
positions,
|
|
173
|
+
tokens,
|
|
174
|
+
incomplete: tokens.some((t) => t.incomplete),
|
|
175
|
+
feesUsd: sumKnown(positions.map((p) => p.feesUsd)),
|
|
176
|
+
rewardsUsd: sumKnown(positions.map((p) => p.rewardsUsd)),
|
|
177
|
+
totalUsd: sumKnown(positions.map((p) => p.totalUsd)),
|
|
178
|
+
warnings,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// 构建、模拟、发送
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/** 要领取的仓位:claimable 的结果,或只给协议 + 仓位 NFT 的 mint(getLpPositions 的结果也可以) */
|
|
187
|
+
export type ClaimTarget = Pick<ClaimablePosition, 'protocol' | 'positionMint'> & { pool?: string }
|
|
188
|
+
|
|
189
|
+
export interface BuildClaimOptions extends RequestOptions {
|
|
190
|
+
/** wSOL(池子的一边或奖励)原来没有账户时用完关闭、换回 SOL,默认 true(同 /liquidity) */
|
|
191
|
+
native?: boolean
|
|
192
|
+
/** 同时构建几个仓位(每个要读几次账户),默认 4 */
|
|
193
|
+
concurrency?: number
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface ClaimCall extends PlannedCall {
|
|
197
|
+
protocol: ClaimProtocol
|
|
198
|
+
positionMint: string
|
|
199
|
+
/** /liquidity 的领取计划(预计的手续费、奖励的代币、步骤) */
|
|
200
|
+
plan: SolanaLiquidityPlan
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface ClaimPlan {
|
|
204
|
+
owner: string
|
|
205
|
+
/** 每个仓位一个调用(创建缺少的代币账户 + 领取手续费 + 领取每个奖励,原子执行) */
|
|
206
|
+
calls: ClaimCall[]
|
|
207
|
+
/** 构建失败的仓位:仓位不在 owner 名下、不支持的代币(带转账手续费的 Token-2022 池子)等 */
|
|
208
|
+
failed: Array<{ position: ClaimTarget; error: string }>
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 构建批量领取(只读取、不签名):每个仓位一个调用,指令来自 /liquidity 的 buildCollectFees,构建时重新读取仓位。
|
|
213
|
+
* 打包成交易由 simulateClaim / claimAll(batchCall)完成
|
|
214
|
+
*
|
|
215
|
+
* ```ts
|
|
216
|
+
* const c = await claimable(client, owner)
|
|
217
|
+
* const plan = await buildClaimAll(client, owner, c.positions)
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
export async function buildClaimAll(client: SolanaClient, owner: string, positions: readonly ClaimTarget[], options: BuildClaimOptions = {}): Promise<ClaimPlan> {
|
|
221
|
+
if (!isAddress(owner)) {
|
|
222
|
+
throw new InvalidAddressError(`Invalid owner address: ${owner}`, owner)
|
|
223
|
+
}
|
|
224
|
+
const unique = [...new Map(positions.map((p) => [p.positionMint, p])).values()]
|
|
225
|
+
const calls: ClaimCall[] = []
|
|
226
|
+
const failed: ClaimPlan['failed'] = []
|
|
227
|
+
const built = await mapPool(unique, options.concurrency ?? 4, async (position) => {
|
|
228
|
+
try {
|
|
229
|
+
const plan = await buildCollectFees(client, owner, { position: { protocol: position.protocol, positionMint: position.positionMint, pool: position.pool } }, { native: options.native, signal: options.signal })
|
|
230
|
+
return { position, plan, error: null }
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (options.signal?.aborted) throw err
|
|
233
|
+
return { position, plan: null, error: (err instanceof Error ? err.message : String(err)).slice(0, MAX_ERROR) }
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
for (const item of built) {
|
|
237
|
+
if (item.plan === null) {
|
|
238
|
+
failed.push({ position: item.position, error: item.error })
|
|
239
|
+
} else {
|
|
240
|
+
calls.push({ owner, protocol: item.position.protocol as ClaimProtocol, positionMint: item.position.positionMint, plan: item.plan, instructions: item.plan.steps.flatMap((s) => s.instructions) })
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { owner, calls, failed }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface ClaimSimulation extends PlanSimulation {
|
|
247
|
+
/** 模拟里收到的代币合计(手续费支付者 / owner 的余额变化,按资产汇总;SOL 为 'native') */
|
|
248
|
+
received: Array<{ asset: string; symbol: string; decimals: number; delta: string; formatted: string; usd?: number | null }>
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 用 /simulate 模拟构建好的批量领取(不签名、不发送):逐笔交易的结果、哪些仓位会失败、收到的代币合计 */
|
|
252
|
+
export async function simulateClaim(client: SolanaClient, plan: ClaimPlan, options: Omit<PlanSimulationOptions, 'watch'> = {}): Promise<ClaimSimulation> {
|
|
253
|
+
const sim = await simulatePlan(client, plan.calls, options)
|
|
254
|
+
const totals = new Map<string, { change: SimulatedBalanceChange; delta: bigint; usd: number | null | undefined }>()
|
|
255
|
+
const payer = options.feePayer ?? plan.owner
|
|
256
|
+
for (const { simulation } of sim.transactions) {
|
|
257
|
+
for (const change of simulation.balanceChanges) {
|
|
258
|
+
if (change.address !== payer && change.address !== plan.owner) continue
|
|
259
|
+
const entry = totals.get(change.asset) ?? { change, delta: 0n, usd: undefined }
|
|
260
|
+
entry.delta += BigInt(change.delta)
|
|
261
|
+
// 美元价值是所有变化的合计(任何一笔没有价格就是 null);都没有 usd 字段时不给
|
|
262
|
+
if (change.usd !== undefined) entry.usd = entry.usd === null || change.usd === null ? null : (entry.usd ?? 0) + change.usd
|
|
263
|
+
totals.set(change.asset, entry)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const received = [...totals.values()]
|
|
267
|
+
.filter((e) => e.delta !== 0n)
|
|
268
|
+
.map((e) => ({
|
|
269
|
+
asset: e.change.asset,
|
|
270
|
+
symbol: e.change.symbol,
|
|
271
|
+
decimals: e.change.decimals,
|
|
272
|
+
delta: e.delta.toString(),
|
|
273
|
+
formatted: `${e.delta < 0n ? '-' : ''}${formatUnits(e.delta < 0n ? -e.delta : e.delta, e.change.decimals)}`,
|
|
274
|
+
...(e.usd !== undefined ? { usd: e.usd } : {}),
|
|
275
|
+
}))
|
|
276
|
+
return { ...sim, received }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface ClaimOptions extends BatchCallOptions, BuildClaimOptions {
|
|
280
|
+
/** 发送前先用 /simulate 模拟,模拟失败的仓位不发送,默认 true */
|
|
281
|
+
simulate?: boolean
|
|
282
|
+
/** 只领取预计价值不低于它的仓位(美元);没有价格的仓位照常领取。默认 0 */
|
|
283
|
+
minUsd?: number
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface ClaimCallResult extends WriteResult {
|
|
287
|
+
protocol: ClaimProtocol
|
|
288
|
+
positionMint: string
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface ClaimResult {
|
|
292
|
+
plan: ClaimPlan
|
|
293
|
+
/** 发送前的模拟(simulate: false 时为 null) */
|
|
294
|
+
simulation: ClaimSimulation | null
|
|
295
|
+
/** 与 plan.calls 一一对应;dryRun 时 status 为 'simulated' / 'failed',没有签名 */
|
|
296
|
+
results: ClaimCallResult[]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 批量领取:构建 → 模拟 → 用 from 签名发送(钱包同 /write 的 batchCall,多个仓位打包进同一笔交易,feePayer 可以替 owner 付手续费)。
|
|
301
|
+
* positions 不传时先预览、领取全部有东西可领的仓位;一个仓位失败不影响其他的。dryRun: true 只构建和模拟(不签名,from 只用来取地址)
|
|
302
|
+
*
|
|
303
|
+
* ```ts
|
|
304
|
+
* await claimAll(client, key) // 全部
|
|
305
|
+
* await claimAll(client, key, c.positions.slice(0, 5), { dryRun: true })
|
|
306
|
+
* ```
|
|
307
|
+
*/
|
|
308
|
+
export async function claimAll(client: SolanaClient, from: SignerInput, positions?: readonly (ClaimTarget & Partial<Pick<ClaimablePosition, 'totalUsd'>>)[], options: ClaimOptions = {}): Promise<ClaimResult> {
|
|
309
|
+
const { resolveSigner } = await import('./write/signer.js')
|
|
310
|
+
const owner = (await resolveSigner(from)).address
|
|
311
|
+
const minUsd = options.minUsd ?? 0
|
|
312
|
+
const targets = (positions ?? (await claimable(client, owner, { signal: options.signal })).positions).filter((p) => p.totalUsd === undefined || p.totalUsd === null || p.totalUsd >= minUsd)
|
|
313
|
+
const plan = await buildClaimAll(client, owner, targets, options)
|
|
314
|
+
const feePayer = options.feePayer === undefined ? undefined : (await resolveSigner(options.feePayer)).address
|
|
315
|
+
const simulation = options.simulate === false && !options.dryRun ? null : await simulateClaim(client, plan, { feePayer, signal: options.signal, batchSize: (options.mode ?? 'pack') === 'tx' ? 1 : options.batchSize })
|
|
316
|
+
const failed = new Map(simulation?.failed.map((f) => [f.index, f.error]))
|
|
317
|
+
const sent = options.dryRun ? [] : await sendCalls(client, from, plan.calls.filter((_, i) => !failed.has(i)), options)
|
|
318
|
+
let next = 0
|
|
319
|
+
const results = plan.calls.map((call, index): ClaimCallResult => {
|
|
320
|
+
const base = { protocol: call.protocol, positionMint: call.positionMint }
|
|
321
|
+
const error = failed.get(index)
|
|
322
|
+
if (error !== undefined) return { index, from: owner, status: 'failed', signature: null, slot: null, fee: null, computeUnits: null, error, ...base }
|
|
323
|
+
if (options.dryRun) return { index, from: owner, status: 'simulated', signature: null, slot: null, fee: null, computeUnits: null, error: null, ...base }
|
|
324
|
+
return { ...(sent[next++] as WriteResult), index, ...base }
|
|
325
|
+
})
|
|
326
|
+
return { plan, simulation, results }
|
|
327
|
+
}
|