@w3lib/solana-batch-call 0.2.2 → 0.3.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +132 -2
  3. package/dist/client.d.ts +4 -0
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +8 -0
  6. package/dist/client.js.map +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/layout.d.ts +5 -0
  12. package/dist/layout.d.ts.map +1 -1
  13. package/dist/layout.js +16 -0
  14. package/dist/layout.js.map +1 -1
  15. package/dist/lp.d.ts +123 -0
  16. package/dist/lp.d.ts.map +1 -0
  17. package/dist/lp.js +631 -0
  18. package/dist/lp.js.map +1 -0
  19. package/dist/lpMath.d.ts +37 -0
  20. package/dist/lpMath.d.ts.map +1 -0
  21. package/dist/lpMath.js +176 -0
  22. package/dist/lpMath.js.map +1 -0
  23. package/dist/shortcuts.d.ts +8 -0
  24. package/dist/shortcuts.d.ts.map +1 -1
  25. package/dist/shortcuts.js +1 -1
  26. package/dist/shortcuts.js.map +1 -1
  27. package/dist/subpaths/lp.d.ts +31 -0
  28. package/dist/subpaths/lp.d.ts.map +1 -0
  29. package/dist/subpaths/lp.js +33 -0
  30. package/dist/subpaths/lp.js.map +1 -0
  31. package/dist/subpaths/write.d.ts +22 -0
  32. package/dist/subpaths/write.d.ts.map +1 -0
  33. package/dist/subpaths/write.js +19 -0
  34. package/dist/subpaths/write.js.map +1 -0
  35. package/dist/write/batch.d.ts +41 -0
  36. package/dist/write/batch.d.ts.map +1 -0
  37. package/dist/write/batch.js +46 -0
  38. package/dist/write/batch.js.map +1 -0
  39. package/dist/write/collect.d.ts +50 -0
  40. package/dist/write/collect.d.ts.map +1 -0
  41. package/dist/write/collect.js +220 -0
  42. package/dist/write/collect.js.map +1 -0
  43. package/dist/write/instructions.d.ts +16 -0
  44. package/dist/write/instructions.d.ts.map +1 -0
  45. package/dist/write/instructions.js +89 -0
  46. package/dist/write/instructions.js.map +1 -0
  47. package/dist/write/send.d.ts +115 -0
  48. package/dist/write/send.d.ts.map +1 -0
  49. package/dist/write/send.js +720 -0
  50. package/dist/write/send.js.map +1 -0
  51. package/dist/write/signer.d.ts +94 -0
  52. package/dist/write/signer.d.ts.map +1 -0
  53. package/dist/write/signer.js +197 -0
  54. package/dist/write/signer.js.map +1 -0
  55. package/dist/write/transaction.d.ts +75 -0
  56. package/dist/write/transaction.d.ts.map +1 -0
  57. package/dist/write/transaction.js +133 -0
  58. package/dist/write/transaction.js.map +1 -0
  59. package/dist/write/transfer.d.ts +72 -0
  60. package/dist/write/transfer.d.ts.map +1 -0
  61. package/dist/write/transfer.js +127 -0
  62. package/dist/write/transfer.js.map +1 -0
  63. package/package.json +23 -12
  64. package/src/client.ts +10 -0
  65. package/src/index.ts +1 -0
  66. package/src/layout.ts +17 -0
  67. package/src/lp.ts +849 -0
  68. package/src/lpMath.ts +205 -0
  69. package/src/shortcuts.ts +1 -1
  70. package/src/subpaths/lp.ts +62 -0
  71. package/src/subpaths/write.ts +29 -0
  72. package/src/write/batch.ts +78 -0
  73. package/src/write/collect.ts +286 -0
  74. package/src/write/instructions.ts +98 -0
  75. package/src/write/send.ts +876 -0
  76. package/src/write/signer.ts +314 -0
  77. package/src/write/transaction.ts +181 -0
  78. package/src/write/transfer.ts +189 -0
package/src/lpMath.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * 集中流动性(Raydium CLMM / Orca Whirlpool)的定点数计算,与链上程序逐位一致(Q64.64,bigint)。
3
+ * 两个程序的 tick → 价格换算用的常量和步骤不同(结果在最后几位可能不同),各自照搬;
4
+ * 数量和手续费的公式两边相同:撤出流动性时向下取整,手续费增长按 u128 回绕相减。
5
+ */
6
+
7
+ const Q64 = 1n << 64n
8
+ const U128_MAX = (1n << 128n) - 1n
9
+ const U64_MAX = (1n << 64n) - 1n
10
+
11
+ export const MIN_TICK = -443636
12
+ export const MAX_TICK = 443636
13
+
14
+ function checkTick(tick: number): void {
15
+ if (!Number.isInteger(tick) || tick < MIN_TICK || tick > MAX_TICK) {
16
+ throw new RangeError(`Tick out of range: ${tick}`)
17
+ }
18
+ }
19
+
20
+ // raydium-clmm programs/amm/src/libraries/tick_math.rs(第 0 项对应 abs_tick & 0x1)
21
+ const RAYDIUM_RATIOS = [
22
+ 0xfffcb933bd6fb800n,
23
+ 0xfff97272373d4000n,
24
+ 0xfff2e50f5f657000n,
25
+ 0xffe5caca7e10f000n,
26
+ 0xffcb9843d60f7000n,
27
+ 0xff973b41fa98e800n,
28
+ 0xff2ea16466c9b000n,
29
+ 0xfe5dee046a9a3800n,
30
+ 0xfcbe86c7900bb000n,
31
+ 0xf987a7253ac65800n,
32
+ 0xf3392b0822bb6000n,
33
+ 0xe7159475a2caf000n,
34
+ 0xd097f3bdfd2f2000n,
35
+ 0xa9f746462d9f8000n,
36
+ 0x70d869a156f31c00n,
37
+ 0x31be135f97ed3200n,
38
+ 0x9aa508b5b85a500n,
39
+ 0x5d6af8dedc582cn,
40
+ 0x2216e584f5fan,
41
+ ]
42
+
43
+ /** Raydium CLMM:tick → sqrt 价格(Q64.64) */
44
+ export function raydiumSqrtPriceAtTick(tick: number): bigint {
45
+ checkTick(tick)
46
+ const abs = Math.abs(tick)
47
+ let ratio = abs & 1 ? (RAYDIUM_RATIOS[0] as bigint) : Q64
48
+ for (let i = 1; i < RAYDIUM_RATIOS.length; i++) {
49
+ if (abs & (1 << i)) {
50
+ ratio = (ratio * (RAYDIUM_RATIOS[i] as bigint)) >> 64n
51
+ }
52
+ }
53
+ return tick > 0 ? U128_MAX / ratio : ratio
54
+ }
55
+
56
+ // orca whirlpools programs/whirlpool/src/math/tick_math.rs:正 tick 用 Q96 常量,负 tick 用 Q64 常量
57
+ const ORCA_POSITIVE = [
58
+ 79232123823359799118286999567n,
59
+ 79236085330515764027303304731n,
60
+ 79244008939048815603706035061n,
61
+ 79259858533276714757314932305n,
62
+ 79291567232598584799939703904n,
63
+ 79355022692464371645785046466n,
64
+ 79482085999252804386437311141n,
65
+ 79736823300114093921829183326n,
66
+ 80248749790819932309965073892n,
67
+ 81282483887344747381513967011n,
68
+ 83390072131320151908154831281n,
69
+ 87770609709833776024991924138n,
70
+ 97234110755111693312479820773n,
71
+ 119332217159966728226237229890n,
72
+ 179736315981702064433883588727n,
73
+ 407748233172238350107850275304n,
74
+ 2098478828474011932436660412517n,
75
+ 55581415166113811149459800483533n,
76
+ 38992368544603139932233054999993551n,
77
+ ]
78
+ const ORCA_NEGATIVE = [
79
+ 18445821805675392311n,
80
+ 18444899583751176498n,
81
+ 18443055278223354162n,
82
+ 18439367220385604838n,
83
+ 18431993317065449817n,
84
+ 18417254355718160513n,
85
+ 18387811781193591352n,
86
+ 18329067761203520168n,
87
+ 18212142134806087854n,
88
+ 17980523815641551639n,
89
+ 17526086738831147013n,
90
+ 16651378430235024244n,
91
+ 15030750278693429944n,
92
+ 12247334978882834399n,
93
+ 8131365268884726200n,
94
+ 3584323654723342297n,
95
+ 696457651847595233n,
96
+ 26294789957452057n,
97
+ 37481735321082n,
98
+ ]
99
+
100
+ /** Orca Whirlpool:tick → sqrt 价格(Q64.64) */
101
+ export function orcaSqrtPriceAtTick(tick: number): bigint {
102
+ checkTick(tick)
103
+ if (tick >= 0) {
104
+ let ratio = tick & 1 ? (ORCA_POSITIVE[0] as bigint) : 1n << 96n
105
+ for (let i = 1; i < ORCA_POSITIVE.length; i++) {
106
+ if (tick & (1 << i)) {
107
+ ratio = (ratio * (ORCA_POSITIVE[i] as bigint)) >> 96n
108
+ }
109
+ }
110
+ return ratio >> 32n
111
+ }
112
+ const abs = -tick
113
+ let ratio = abs & 1 ? (ORCA_NEGATIVE[0] as bigint) : Q64
114
+ for (let i = 1; i < ORCA_NEGATIVE.length; i++) {
115
+ if (abs & (1 << i)) {
116
+ ratio = (ratio * (ORCA_NEGATIVE[i] as bigint)) >> 64n
117
+ }
118
+ }
119
+ return ratio
120
+ }
121
+
122
+ /**
123
+ * 撤出全部流动性能拿到的两种代币数量(向下取整)。两个程序都按当前 tick(不是价格)判断区间:
124
+ * tick < 下限只有 token0;tick ≥ 上限只有 token1;否则两种都有
125
+ */
126
+ export function getAmountsForLiquidity(tick: number, sqrtPrice: bigint, sqrtLower: bigint, sqrtUpper: bigint, liquidity: bigint, tickLower: number, tickUpper: number): [bigint, bigint] {
127
+ if (liquidity === 0n) {
128
+ return [0n, 0n]
129
+ }
130
+ if (tick < tickLower) {
131
+ return [amount0Delta(sqrtLower, sqrtUpper, liquidity), 0n]
132
+ }
133
+ if (tick < tickUpper) {
134
+ return [amount0Delta(sqrtPrice, sqrtUpper, liquidity), amount1Delta(sqrtLower, sqrtPrice, liquidity)]
135
+ }
136
+ return [0n, amount1Delta(sqrtLower, sqrtUpper, liquidity)]
137
+ }
138
+
139
+ /** floor(L · 2^64 · (b − a) / (a · b)) */
140
+ function amount0Delta(a: bigint, b: bigint, liquidity: bigint): bigint {
141
+ const [lo, hi] = a < b ? [a, b] : [b, a]
142
+ return lo === 0n ? 0n : ((liquidity << 64n) * (hi - lo)) / (lo * hi)
143
+ }
144
+
145
+ /** floor(L · (b − a) / 2^64) */
146
+ function amount1Delta(a: bigint, b: bigint, liquidity: bigint): bigint {
147
+ const [lo, hi] = a < b ? [a, b] : [b, a]
148
+ return (liquidity * (hi - lo)) >> 64n
149
+ }
150
+
151
+ /** 一个 tick 上用于计算手续费的数据;Orca 的 tick 未初始化时 initialized 为 false */
152
+ export interface TickFeeData {
153
+ initialized: boolean
154
+ feeGrowthOutside0: bigint
155
+ feeGrowthOutside1: bigint
156
+ }
157
+
158
+ const wrap = (x: bigint) => ((x % (1n << 128n)) + (1n << 128n)) % (1n << 128n)
159
+
160
+ /**
161
+ * 区间内的手续费增长(u128 回绕)。Raydium 不区分是否初始化(未初始化的 tick 各项为 0,传 initialized: true 即可);
162
+ * Orca 未初始化的 tick:下限按“下方增长 = 全局”、上限按“上方增长 = 0”处理(与程序一致)
163
+ */
164
+ export function getFeeGrowthInside(
165
+ tick: number,
166
+ tickLower: number,
167
+ lower: TickFeeData,
168
+ tickUpper: number,
169
+ upper: TickFeeData,
170
+ global0: bigint,
171
+ global1: bigint,
172
+ ): [bigint, bigint] {
173
+ const below = !lower.initialized
174
+ ? [global0, global1]
175
+ : tick < tickLower
176
+ ? [wrap(global0 - lower.feeGrowthOutside0), wrap(global1 - lower.feeGrowthOutside1)]
177
+ : [lower.feeGrowthOutside0, lower.feeGrowthOutside1]
178
+ const above = !upper.initialized
179
+ ? [0n, 0n]
180
+ : tick < tickUpper
181
+ ? [upper.feeGrowthOutside0, upper.feeGrowthOutside1]
182
+ : [wrap(global0 - upper.feeGrowthOutside0), wrap(global1 - upper.feeGrowthOutside1)]
183
+ return [wrap(global0 - (below[0] as bigint) - (above[0] as bigint)), wrap(global1 - (below[1] as bigint) - (above[1] as bigint))]
184
+ }
185
+
186
+ /**
187
+ * 未领取的手续费:已记账的 owed + L · (区间内增长 − 上次记录) / 2^64(向下取整)。
188
+ * 新增部分超过 u64 时:Orca 程序记为 0(checked_mul_shift_right 失败),这里同样记为 0(正常仓位不会碰到)
189
+ */
190
+ export function getFeesOwed(owed: bigint, liquidity: bigint, growthInside: bigint, growthLast: bigint): bigint {
191
+ const delta = (liquidity * wrap(growthInside - growthLast)) >> 64n
192
+ return owed + (delta > U64_MAX ? 0n : delta)
193
+ }
194
+
195
+ /** Q64.64 sqrt 价格 → token1 / token0 的价格(按精度换算,浮点数,只用于展示) */
196
+ export function sqrtPriceToPrice(sqrtPrice: bigint, decimals0: number, decimals1: number): number {
197
+ // 先取高位再转浮点,避免 2^128 级别的整数转换丢精度后再平方
198
+ const ratio = Number(sqrtPrice) / 2 ** 64
199
+ return ratio * ratio * 10 ** (decimals0 - decimals1)
200
+ }
201
+
202
+ /** tick → token1 / token0 的价格(1.0001^tick,按精度换算,只用于展示) */
203
+ export function tickToPrice(tick: number, decimals0: number, decimals1: number): number {
204
+ return 1.0001 ** tick * 10 ** (decimals0 - decimals1)
205
+ }
package/src/shortcuts.ts CHANGED
@@ -81,7 +81,7 @@ function sourceKey(source: ShortcutOptions['provider']): string | object | null
81
81
  * 拆出节点参数和函数自己的参数(ownKeys),其余作为客户端配置;值为 undefined 的参数忽略。
82
82
  * signal 总是作为函数自己的参数(放进客户端配置会让每次调用都新建客户端)
83
83
  */
84
- function resolve<T extends ShortcutOptions, K extends keyof T & string = never>(
84
+ export function resolve<T extends ShortcutOptions, K extends keyof T & string = never>(
85
85
  options: T,
86
86
  ownKeys: readonly K[] = [],
87
87
  ): { client: SolanaClient; own: Pick<T, K> } {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * LP 仓位(子路径 `@w3lib/solana-batch-call/lp`):Raydium CLMM / Orca Whirlpool 的仓位 NFT,Raydium AMM v4 / CPMM 的 LP 代币,
3
+ * 代币数量、未领取的手续费、可选的美元价值。不放在主入口,不用时打包不会带上这部分代码。
4
+ *
5
+ * ```ts
6
+ * import { getLpPositions } from '@w3lib/solana-batch-call/lp'
7
+ *
8
+ * await getLpPositions(owner, { provider: 'https://my-rpc…' }) // 扫描全部代币账户(需要支持 getTokenAccountsByOwner 的节点)
9
+ * await getLpPositions(owner, { mints: [positionNftMint], prices: true }) // 只看这些代币:免费公共节点也能用
10
+ * ```
11
+ */
12
+ import { lpPositions, type LpPositionsOptions, type SolanaLpPosition } from '../lp.js'
13
+ import { resolve, type ShortcutOptions } from '../shortcuts.js'
14
+
15
+ export {
16
+ ORCA_WHIRLPOOL_PROGRAM_ID,
17
+ RAYDIUM_AMM_PROGRAM_ID,
18
+ RAYDIUM_CLMM_PROGRAM_ID,
19
+ RAYDIUM_CPMM_PROGRAM_ID,
20
+ SOLANA_LP_PROTOCOLS,
21
+ defillamaPrices,
22
+ lpPositions,
23
+ type LpPositionsOptions,
24
+ type SolanaLpAmmPosition,
25
+ type SolanaLpClmmPosition,
26
+ type SolanaLpPosition,
27
+ type SolanaLpProtocol,
28
+ type SolanaLpToken,
29
+ type SolanaPriceSource,
30
+ } from '../lp.js'
31
+ export {
32
+ MAX_TICK,
33
+ MIN_TICK,
34
+ getAmountsForLiquidity,
35
+ getFeeGrowthInside,
36
+ getFeesOwed,
37
+ orcaSqrtPriceAtTick,
38
+ raydiumSqrtPriceAtTick,
39
+ sqrtPriceToPrice,
40
+ tickToPrice,
41
+ type TickFeeData,
42
+ } from '../lpMath.js'
43
+
44
+ export interface GetLpPositionsOptions extends LpPositionsOptions, ShortcutOptions {}
45
+
46
+ /**
47
+ * 查一个地址的 LP 仓位。
48
+ *
49
+ * ```ts
50
+ * await getLpPositions(owner, { provider: 'https://my-rpc…' })
51
+ * // [{ type: 'clmm', protocol: 'raydium-clmm', pool, positionMint, token0: { symbol: 'SOL', … }, token1: { symbol: 'USDC', … },
52
+ * // amount0Formatted: '140.26', amount1Formatted: '6852.2', fees0Formatted: '0.546', fees1Formatted: '59.5', inRange: true, … },
53
+ * // { type: 'amm', protocol: 'raydium-amm', pool, lpMint, balance, share: 0.18, amount0Formatted, amount1Formatted, … }]
54
+ * await getLpPositions(owner, { mints: [nftMint, lpMint], pools: [ammPool] }) // 免费公共节点:指定代币和池子
55
+ * await getLpPositions(owner, { provider, prices: true }) // 带美元价值(DefiLlama),按价值排序
56
+ * ```
57
+ */
58
+ export function getLpPositions(owner: string, options: GetLpPositionsOptions = {}): Promise<SolanaLpPosition[]> {
59
+ // fetch 同时给节点请求(客户端配置)和查价用,与其他快捷函数一致
60
+ const { client, own } = resolve(options, ['protocols', 'mints', 'pools', 'includeClosed', 'prices', 'onPriceError', 'signal'])
61
+ return lpPositions(client, owner, { ...own, ...(options.fetch ? { fetch: options.fetch } : {}) })
62
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 批量写操作(子路径 `@w3lib/solana-batch-call/write`):批量调用程序、一对多转账、多钱包归集。
3
+ * 不放在主入口,只做查询时打包不会带上这部分代码。不依赖 Solana SDK,签名用运行环境的 WebCrypto(Ed25519)。
4
+ *
5
+ * ```ts
6
+ * import { SolanaClient, NATIVE_MINT } from '@w3lib/solana-batch-call'
7
+ * import { batchCall, batchCollect, batchTransfer } from '@w3lib/solana-batch-call/write'
8
+ *
9
+ * const client = new SolanaClient(rpcUrl)
10
+ * await batchTransfer(client, secretKey, [{ to: a, formatted: '0.5' }, { to: b, token: USDC, formatted: '20' }])
11
+ * await batchCollect(client, [key1, key2], hotWallet, { tokens: [USDC, NATIVE_MINT], feePayer: gasKey })
12
+ * await batchCall(client, [{ from: key1, instructions: [ix1, ix2] }])
13
+ * ```
14
+ */
15
+ export { batchCall, type BatchCallMode, type BatchCallOptions, type WriteCall } from '../write/batch.js'
16
+ export { batchCollect, type BatchCollectOptions, type CollectItemResult, type CollectResult } from '../write/collect.js'
17
+ export { batchTransfer, type BatchTransferOptions, type TransferItem, type TransferMode, type TransferResult } from '../write/transfer.js'
18
+ export {
19
+ COMPUTE_BUDGET_PROGRAM_ID,
20
+ closeAccount,
21
+ createAssociatedTokenAccountIdempotent,
22
+ setComputeUnitLimit,
23
+ setComputeUnitPrice,
24
+ systemTransfer,
25
+ transferChecked,
26
+ } from '../write/instructions.js'
27
+ export type { SendOptions, WriteResult, WriteStatus } from '../write/send.js'
28
+ export type { SignerInput, WriteSigner } from '../write/signer.js'
29
+ export type { AccountMeta, Instruction, InstructionInput } from '../write/transaction.js'
@@ -0,0 +1,78 @@
1
+ import type { SolanaClient } from '../client.js'
2
+ import { emptyOutcome, sendUnits, toWriteResult, type SendOptions, type Unit, type WriteResult } from './send.js'
3
+ import { resolveSigner, type SignerInput } from './signer.js'
4
+ import { COMPUTE_BUDGET_PROGRAM_ID } from './instructions.js'
5
+ import { normalizeInstruction, type InstructionInput } from './transaction.js'
6
+
7
+ /** 一个写调用:一组要原子执行的指令 */
8
+ export interface WriteCall {
9
+ /** 发送的钱包(指令里的签名者之一);不传 feePayer 时也由它付手续费 */
10
+ from: SignerInput
11
+ /** 指令:@solana/web3.js 的 TransactionInstruction、@solana/kit 的 Instruction 或本库格式,一个或多个 */
12
+ instructions: InstructionInput | readonly InstructionInput[]
13
+ /** 指令需要的其他签名者(如新建账户的 Keypair) */
14
+ signers?: readonly SignerInput[]
15
+ }
16
+
17
+ /**
18
+ * - 'pack'(默认):同一手续费支付者的相邻调用打包进同一笔交易(不超过 1232 字节、64 个账户),
19
+ * 模拟时失败的调用会被剔除、其余照常发送;同一笔交易上链后才失败时一起回滚
20
+ * - 'tx':每个调用一笔交易
21
+ */
22
+ export type BatchCallMode = 'pack' | 'tx'
23
+
24
+ export interface BatchCallOptions extends SendOptions {
25
+ mode?: BatchCallMode
26
+ /** 'pack' 时每笔交易最多几个调用,默认不限(只受交易大小限制) */
27
+ batchSize?: number
28
+ /**
29
+ * 某个调用发送前失败(模拟失败等)后,同一支付者后面的调用是否继续。默认 false(后面的标记为 skipped)。
30
+ * 交易不等上一笔确认就连续发出,已经发出、之后上链失败的交易拦不住后面已发出的交易
31
+ */
32
+ continueOnError?: boolean
33
+ }
34
+
35
+ /**
36
+ * 批量调用程序(写操作)。结果与 calls 一一对应。
37
+ *
38
+ * ```ts
39
+ * await batchCall(client, [
40
+ * { from: key1, instructions: [ix1, ix2] }, // 同一个调用里的指令原子执行
41
+ * { from: key2, instructions: ix3 },
42
+ * ], { feePayer: gasKey }) // 手续费都由 gasKey 付,不同钱包的调用也能打包进同一笔交易
43
+ * ```
44
+ */
45
+ export async function batchCall(client: SolanaClient, calls: readonly WriteCall[], options: BatchCallOptions = {}): Promise<WriteResult[]> {
46
+ const cache = new Map()
47
+ const feePayer = options.feePayer === undefined ? undefined : await resolveSigner(options.feePayer, cache)
48
+ const prepared = await Promise.all(
49
+ calls.map(async (call, i) => {
50
+ const from = await resolveSigner(call.from, cache)
51
+ const signers = [from, ...(await Promise.all((call.signers ?? []).map((s) => resolveSigner(s, cache))))]
52
+ const list = Array.isArray(call.instructions) ? (call.instructions as readonly InstructionInput[]) : [call.instructions as InstructionInput]
53
+ // 库会自动加计算单元上限和优先费用指令;调用里自带的同类指令去掉(重复会让整笔交易失败),优先费用用 priorityFee 选项
54
+ const instructions = list.map(normalizeInstruction).filter((ix) => !(ix.programId === COMPUTE_BUDGET_PROGRAM_ID && (ix.data[0] === 2 || ix.data[0] === 3)))
55
+ const payer = feePayer ?? from
56
+ const known = new Set([payer.address, ...signers.map((s) => s.address)])
57
+ const missing = instructions.flatMap((ix) => ix.keys).find((k) => k.isSigner && !known.has(k.pubkey))
58
+ if (missing) {
59
+ throw new Error(`calls[${i}]: an instruction requires the signature of ${missing.pubkey}; pass it in signers`)
60
+ }
61
+ return { from, signers, instructions, payer }
62
+ }),
63
+ )
64
+ const results = prepared.map((p, i) => toWriteResult(i, p.from.address, emptyOutcome('skipped')))
65
+ const units: Unit[] = prepared.map((p, i) => ({
66
+ instructions: p.instructions,
67
+ payer: p.payer,
68
+ signers: p.signers.filter((s) => s.address !== p.payer.address),
69
+ continueOnError: options.continueOnError,
70
+ onUpdate: (outcome) => {
71
+ results[i] = toWriteResult(i, p.from.address, outcome)
72
+ options.onUpdate?.(results[i] as WriteResult)
73
+ },
74
+ }))
75
+ await sendUnits(client, units, options, { batchSize: (options.mode ?? 'pack') === 'tx' ? 1 : options.batchSize })
76
+ return results
77
+ }
78
+
@@ -0,0 +1,286 @@
1
+ import { getAssociatedTokenAddress, isAddress } from '../address.js'
2
+ import type { SolanaClient } from '../client.js'
3
+ import { formatUnits } from '../layout.js'
4
+ import type { TokenBalance } from '../types.js'
5
+ import { closeAccount, createAssociatedTokenAccountIdempotent, systemTransfer } from './instructions.js'
6
+ import { emptyOutcome, priorityFeeFor, sendUnits, toWriteResult, transactionFee, type SendOptions, type TxOutcome, type Unit, type WriteResult } from './send.js'
7
+ import { resolveSigner, type ResolvedSigner, type SignerInput } from './signer.js'
8
+ import { transferInstructions } from './transfer.js'
9
+
10
+ /** 扫空 SOL 的交易(一条 System 转账 + 两条计算预算指令)用的计算单元上限 */
11
+ const SWEEP_COMPUTE_UNITS = 1_000
12
+
13
+ export interface BatchCollectOptions extends SendOptions {
14
+ /**
15
+ * 要归集的代币 mint。包含原生 SOL(NATIVE_MINT)时,在代币归集确认后把剩余 SOL 全部转走:
16
+ * 传了 feePayer 时整个余额转走(手续费由 feePayer 付),否则扣掉这笔的手续费后转走,钱包余额归零
17
+ */
18
+ tokens: readonly string[]
19
+ /**
20
+ * 代币转完后关闭钱包的代币账户(ATA),租金(约 0.00204 SOL / 个)退回钱包本身;同时扫 SOL 时一并转走。默认 false。
21
+ * Token-2022 账户里有未提取的转账手续费等情况关不掉,只影响 close 那一项
22
+ */
23
+ closeAccounts?: boolean
24
+ /** 各代币的最小归集数量(最小单位),余额低于它的不归集:`{ [USDC]: 1_000_000n }`;对 SOL 同样有效 */
25
+ minAmount?: Readonly<Record<string, bigint | number | string>>
26
+ /** 每笔交易最多几个调用,默认不限(按交易大小) */
27
+ batchSize?: number
28
+ }
29
+
30
+ /** 单个钱包单个代币的归集结果;from 是钱包,to 是归集地址 */
31
+ export interface CollectItemResult extends WriteResult {
32
+ to: string
33
+ token: string
34
+ native: boolean
35
+ /** 归集的数量(最小单位);SOL 扫尾在发送前为 '0' */
36
+ amount: string
37
+ formatted: string | null
38
+ /** closeAccounts 时关闭代币账户的结果;没有关闭(未开启、SOL、没有归集)时为 null */
39
+ close: WriteResult | null
40
+ }
41
+
42
+ export interface CollectResult {
43
+ /** 按 wallets × tokens 顺序(第 i 个钱包第 j 个代币在 i × tokens.length + j) */
44
+ results: CollectItemResult[]
45
+ }
46
+
47
+ interface Item {
48
+ index: number
49
+ signer: ResolvedSigner
50
+ token: string
51
+ native: boolean
52
+ amount: bigint
53
+ decimals: number | null
54
+ tokenProgram: string | null
55
+ result: CollectItemResult
56
+ }
57
+
58
+ /**
59
+ * 归集:多个钱包的代币 / SOL 转到同一个地址。
60
+ *
61
+ * ```ts
62
+ * const { results } = await batchCollect(client, [key1, key2, key3], hotWallet, {
63
+ * tokens: [USDC, NATIVE_MINT], // 包含 SOL 表示代币归集完后扫空 SOL
64
+ * feePayer: gasKey, // 手续费都由 gasKey 付:子钱包不需要有 SOL,多个钱包的转账打包进同一笔交易
65
+ * })
66
+ * ```
67
+ *
68
+ * 流程:一次读出所有钱包的余额 → 归集地址没有该代币的 ATA 时随第一批转账创建 → 打包发送代币转账(可选关闭代币账户)
69
+ * →(包含 SOL 时)等代币归集确认,按最新余额扫空 SOL。
70
+ * 不传 feePayer 时每个钱包自己付手续费,没有 SOL 的钱包对应的项失败(Solana 上不需要先补 gas:传 feePayer 即可)
71
+ */
72
+ export async function batchCollect(client: SolanaClient, wallets: readonly SignerInput[], to: string, options: BatchCollectOptions): Promise<CollectResult> {
73
+ if (!isAddress(to)) {
74
+ throw new Error(`Invalid collect address: ${to}`)
75
+ }
76
+ const cache = new Map()
77
+ const signers = await Promise.all(wallets.map((w) => resolveSigner(w, cache)))
78
+ const feePayer = options.feePayer === undefined ? null : await resolveSigner(options.feePayer, cache)
79
+ const tokens = options.tokens
80
+ const sweep = tokens.some((t) => client.isNativeMint(t))
81
+ const minAmount = new Map(Object.entries(options.minAmount ?? {}).map(([k, v]) => [k, BigInt(v)]))
82
+ const balances = await client.multiBalances(
83
+ signers.map((s) => ({ owner: s.address, mints: tokens })),
84
+ { signal: options.signal },
85
+ )
86
+
87
+ const items: Item[] = []
88
+ const seen = new Set<string>()
89
+ signers.forEach((signer, w) => {
90
+ const duplicate = seen.has(signer.address)
91
+ seen.add(signer.address)
92
+ tokens.forEach((token, t) => {
93
+ const balance = balances[w]?.[t] as TokenBalance
94
+ const native = client.isNativeMint(token)
95
+ const item: Item = {
96
+ index: w * tokens.length + t,
97
+ signer,
98
+ token,
99
+ native,
100
+ amount: native || !balance.success ? 0n : BigInt(balance.balance),
101
+ decimals: balance.success ? balance.decimals : null,
102
+ tokenProgram: balance.tokenProgram,
103
+ result: null as unknown as CollectItemResult,
104
+ }
105
+ const skip = !balance.success
106
+ ? `cannot read balance: ${balance.error ?? 'unknown error'}`
107
+ : signer.address === to
108
+ ? 'skipped: wallet is the collect address'
109
+ : duplicate
110
+ ? 'skipped: duplicate wallet'
111
+ : native && feePayer?.address === signer.address
112
+ ? 'skipped: wallet is the fee payer'
113
+ : !native && item.amount === 0n
114
+ ? 'skipped: zero balance'
115
+ : !native && item.amount < (minAmount.get(token) ?? 0n)
116
+ ? 'skipped: below minAmount'
117
+ : null
118
+ item.result = toCollectResult(item, to, emptyOutcome(skip ? (balance.success ? 'skipped' : 'failed') : 'skipped', skip), null)
119
+ items.push(item)
120
+ })
121
+ })
122
+ const results = items.map((i) => i.result)
123
+ const report = (item: Item, outcome: TxOutcome, close: WriteResult | null = item.result.close) => {
124
+ const hint =
125
+ !feePayer && outcome.status === 'failed' && /transaction fee|fee payer/.test(outcome.error ?? '') && !outcome.error?.includes('feePayer')
126
+ ? ' (pass feePayer to pay fees from another wallet)'
127
+ : ''
128
+ item.result = toCollectResult(item, to, { ...outcome, error: outcome.error && outcome.error + hint }, close)
129
+ results[item.index] = item.result
130
+ options.onUpdate?.(item.result)
131
+ }
132
+ const active = (item: Item) => item.result.error === null
133
+
134
+ // 归集地址的 ATA:没有的话随转账创建(创建指令是幂等的,同一代币的多笔转账都带上,只有第一笔真正创建)
135
+ const tokenItems = items.filter((i) => !i.native && active(i))
136
+ const targetAtas = new Map(tokenItems.map((i) => [i.token, getAssociatedTokenAddress(to, i.token, i.tokenProgram as string)]))
137
+ const missing = new Set<string>()
138
+ if (targetAtas.size) {
139
+ const keys = [...targetAtas.values()]
140
+ const accounts = await client.accounts(keys, { signal: options.signal })
141
+ keys.forEach((k, i) => !accounts[i] && missing.add(k))
142
+ }
143
+
144
+ // 不传 feePayer 时每个钱包自己付费:如果每笔转账都带创建指令,模拟时每个钱包都要付得起租金(实际只创建一次)。
145
+ // 先由第一个有这种代币的钱包单独创建,确认后再归集;创建失败时退回到每笔都带创建指令
146
+ if (!feePayer && missing.size && !options.dryRun) {
147
+ const setup: Unit[] = []
148
+ for (const [mint, ata] of targetAtas) {
149
+ if (!missing.has(ata)) continue
150
+ const first = tokenItems.find((i) => i.token === mint) as Item
151
+ setup.push({
152
+ instructions: [createAssociatedTokenAccountIdempotent(first.signer.address, ata, to, mint, first.tokenProgram as string)],
153
+ payer: first.signer,
154
+ signers: [],
155
+ continueOnError: true,
156
+ onUpdate: (outcome) => outcome.status === 'confirmed' && missing.delete(ata),
157
+ })
158
+ }
159
+ await sendUnits(client, setup, { ...options, wait: true }, { batchSize: 1 })
160
+ }
161
+
162
+ const units: Unit[] = []
163
+ /** 每个钱包最后一笔上链交易的 slot(扫 SOL 时按不早于它的状态读余额) */
164
+ let minSlot: number | undefined
165
+ const track = (outcome: TxOutcome) => {
166
+ if (outcome.slot !== null) minSlot = Math.max(minSlot ?? 0, outcome.slot)
167
+ }
168
+ for (const item of tokenItems) {
169
+ const payer = feePayer ?? item.signer
170
+ const signerList = payer.address === item.signer.address ? [] : [item.signer]
171
+ const target = targetAtas.get(item.token) as string
172
+ units.push({
173
+ instructions: transferInstructions(item.signer, payer, { ...item, to }, target, missing.has(target)),
174
+ payer,
175
+ signers: signerList,
176
+ continueOnError: true,
177
+ onUpdate: (outcome) => {
178
+ track(outcome)
179
+ report(item, outcome)
180
+ },
181
+ })
182
+ if (options.closeAccounts) {
183
+ const program = item.tokenProgram as string
184
+ const account = getAssociatedTokenAddress(item.signer.address, item.token, program)
185
+ units.push({
186
+ instructions: [closeAccount(account, item.signer.address, item.signer.address, program)],
187
+ payer,
188
+ signers: signerList,
189
+ continueOnError: true,
190
+ onUpdate: (outcome) => {
191
+ track(outcome)
192
+ report(item, outcomeOf(item.result), toWriteResult(item.index, item.signer.address, outcome))
193
+ },
194
+ })
195
+ }
196
+ }
197
+ // 要扫 SOL 时必须等代币归集确认(扫的是最新余额,关闭账户退回的租金也要算进去)
198
+ await sendUnits(client, units, sweep ? { ...options, wait: true } : options, { batchSize: options.batchSize })
199
+
200
+ if (!sweep) {
201
+ return { results }
202
+ }
203
+ // 代币没有全部归集成功的钱包不扫 SOL:留着付之后重新归集的手续费
204
+ items
205
+ .filter((i) => i.native && active(i))
206
+ .forEach((native) => {
207
+ // dryRun 时代币项是 simulated,也算成功
208
+ const done = (status: string) => status === 'confirmed' || status === 'skipped' || (options.dryRun && status === 'simulated')
209
+ const unfinished = items.find((i) => !i.native && i.signer.address === native.signer.address && !done(i.result.status))
210
+ if (unfinished) {
211
+ report(native, emptyOutcome('skipped', `skipped: collecting ${unfinished.token} did not succeed (${unfinished.result.status})`))
212
+ }
213
+ })
214
+ const sweepItems = items.filter((i) => i.native && active(i))
215
+ if (!sweepItems.length || options.signal?.aborted) {
216
+ return { results }
217
+ }
218
+ if (options.dryRun) {
219
+ // 代币归集没有真的执行,扫 SOL 的金额要等归集之后才知道:只标记 simulated,不做模拟
220
+ sweepItems.forEach((i) => report(i, emptyOutcome('simulated')))
221
+ return { results }
222
+ }
223
+ let lamports: Awaited<ReturnType<SolanaClient['solBalances']>>
224
+ try {
225
+ lamports = await client.solBalances(
226
+ sweepItems.map((i) => i.signer.address),
227
+ { minContextSlot: minSlot, signal: options.signal },
228
+ )
229
+ } catch (err) {
230
+ // 代币归集已经发出:不能整体抛错(调用方会拿不到这些结果),扫 SOL 的项标记失败
231
+ sweepItems.forEach((i) => report(i, emptyOutcome('failed', `cannot read SOL balance: ${err instanceof Error ? err.message : String(err)}`)))
232
+ return { results }
233
+ }
234
+ const prepared = await Promise.all(
235
+ sweepItems.map(async (item, k): Promise<Unit | null> => {
236
+ const balance = BigInt(lamports[k]?.balance ?? '0')
237
+ let amount = balance
238
+ let fixed: Unit['fixed']
239
+ if (!feePayer) {
240
+ // 自己付手续费:单独一笔、手续费事先算好,转走剩下的全部(余额归零,不受租金下限限制)
241
+ const price = await priorityFeeFor(client, options, [item.signer.address, to])
242
+ fixed = { computeUnits: SWEEP_COMPUTE_UNITS, price }
243
+ amount = balance - transactionFee(1, SWEEP_COMPUTE_UNITS, price)
244
+ }
245
+ const min = minAmount.get(item.token) ?? 0n
246
+ if (amount <= 0n || amount < min) {
247
+ report(item, emptyOutcome('skipped', amount <= 0n ? 'skipped: SOL balance does not cover the fee' : 'skipped: below minAmount'))
248
+ return null
249
+ }
250
+ item.amount = amount
251
+ const payer = feePayer ?? item.signer
252
+ return {
253
+ instructions: [systemTransfer(item.signer.address, to, amount)],
254
+ payer,
255
+ signers: payer.address === item.signer.address ? [] : [item.signer],
256
+ fixed,
257
+ continueOnError: true,
258
+ onUpdate: (outcome) => report(item, outcome),
259
+ }
260
+ }),
261
+ )
262
+ await sendUnits(
263
+ client,
264
+ prepared.filter((u): u is Unit => u !== null),
265
+ options,
266
+ { batchSize: options.batchSize },
267
+ )
268
+ return { results }
269
+ }
270
+
271
+ /** 结果转回交易结果(更新 close 字段时保留转账本身的状态) */
272
+ function outcomeOf(r: WriteResult): TxOutcome {
273
+ return { status: r.status, signature: r.signature, slot: r.slot, fee: r.fee === null ? null : BigInt(r.fee), computeUnits: r.computeUnits, error: r.error }
274
+ }
275
+
276
+ function toCollectResult(item: Item, to: string, outcome: TxOutcome, close: WriteResult | null): CollectItemResult {
277
+ return {
278
+ ...toWriteResult(item.index, item.signer.address, outcome),
279
+ to,
280
+ token: item.token,
281
+ native: item.native,
282
+ amount: item.amount.toString(),
283
+ formatted: item.decimals === null ? null : formatUnits(item.amount, item.decimals),
284
+ close,
285
+ }
286
+ }