@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.
Files changed (220) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +129 -9
  3. package/dist/accountReader.d.ts +57 -0
  4. package/dist/accountReader.d.ts.map +1 -0
  5. package/dist/accountReader.js +149 -0
  6. package/dist/accountReader.js.map +1 -0
  7. package/dist/address.d.ts +9 -0
  8. package/dist/address.d.ts.map +1 -1
  9. package/dist/address.js +36 -6
  10. package/dist/address.js.map +1 -1
  11. package/dist/approvals.d.ts +5 -3
  12. package/dist/approvals.d.ts.map +1 -1
  13. package/dist/approvals.js +25 -36
  14. package/dist/approvals.js.map +1 -1
  15. package/dist/balances.d.ts +178 -0
  16. package/dist/balances.d.ts.map +1 -0
  17. package/dist/balances.js +524 -0
  18. package/dist/balances.js.map +1 -0
  19. package/dist/batchPlan.d.ts +52 -0
  20. package/dist/batchPlan.d.ts.map +1 -0
  21. package/dist/batchPlan.js +64 -0
  22. package/dist/batchPlan.js.map +1 -0
  23. package/dist/bytes.d.ts +31 -0
  24. package/dist/bytes.d.ts.map +1 -0
  25. package/dist/bytes.js +74 -0
  26. package/dist/bytes.js.map +1 -0
  27. package/dist/claim.d.ts +163 -0
  28. package/dist/claim.d.ts.map +1 -0
  29. package/dist/claim.js +197 -0
  30. package/dist/claim.js.map +1 -0
  31. package/dist/client.d.ts +26 -41
  32. package/dist/client.d.ts.map +1 -1
  33. package/dist/client.js +41 -986
  34. package/dist/client.js.map +1 -1
  35. package/dist/clientConfig.d.ts +11 -0
  36. package/dist/clientConfig.d.ts.map +1 -0
  37. package/dist/clientConfig.js +3 -0
  38. package/dist/clientConfig.js.map +1 -0
  39. package/dist/decode.d.ts +22 -0
  40. package/dist/decode.d.ts.map +1 -0
  41. package/dist/decode.js +160 -0
  42. package/dist/decode.js.map +1 -0
  43. package/dist/describe.d.ts +26 -0
  44. package/dist/describe.d.ts.map +1 -0
  45. package/dist/describe.js +58 -0
  46. package/dist/describe.js.map +1 -0
  47. package/dist/fees.d.ts.map +1 -1
  48. package/dist/fees.js +2 -12
  49. package/dist/fees.js.map +1 -1
  50. package/dist/history.d.ts +125 -0
  51. package/dist/history.d.ts.map +1 -0
  52. package/dist/history.js +220 -0
  53. package/dist/history.js.map +1 -0
  54. package/dist/historyHelius.d.ts +52 -0
  55. package/dist/historyHelius.d.ts.map +1 -0
  56. package/dist/historyHelius.js +125 -0
  57. package/dist/historyHelius.js.map +1 -0
  58. package/dist/index.d.ts +3 -3
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +1 -1
  61. package/dist/index.js.map +1 -1
  62. package/dist/layout.d.ts +21 -0
  63. package/dist/layout.d.ts.map +1 -1
  64. package/dist/layout.js +50 -69
  65. package/dist/layout.js.map +1 -1
  66. package/dist/liquidity/common.js +2 -2
  67. package/dist/liquidity/common.js.map +1 -1
  68. package/dist/liquidity/orca.d.ts +6 -0
  69. package/dist/liquidity/orca.d.ts.map +1 -1
  70. package/dist/liquidity/orca.js +14 -3
  71. package/dist/liquidity/orca.js.map +1 -1
  72. package/dist/liquidity/raydiumClmm.d.ts +6 -0
  73. package/dist/liquidity/raydiumClmm.d.ts.map +1 -1
  74. package/dist/liquidity/raydiumClmm.js +12 -5
  75. package/dist/liquidity/raydiumClmm.js.map +1 -1
  76. package/dist/lp.d.ts +55 -12
  77. package/dist/lp.d.ts.map +1 -1
  78. package/dist/lp.js +158 -70
  79. package/dist/lp.js.map +1 -1
  80. package/dist/lpMath.d.ts +22 -0
  81. package/dist/lpMath.d.ts.map +1 -1
  82. package/dist/lpMath.js +21 -0
  83. package/dist/lpMath.js.map +1 -1
  84. package/dist/portfolio.d.ts.map +1 -1
  85. package/dist/portfolio.js +3 -8
  86. package/dist/portfolio.js.map +1 -1
  87. package/dist/programs.d.ts +19 -0
  88. package/dist/programs.d.ts.map +1 -0
  89. package/dist/programs.js +175 -0
  90. package/dist/programs.js.map +1 -0
  91. package/dist/reclaim.d.ts +192 -0
  92. package/dist/reclaim.d.ts.map +1 -0
  93. package/dist/reclaim.js +399 -0
  94. package/dist/reclaim.js.map +1 -0
  95. package/dist/risk.js +2 -2
  96. package/dist/risk.js.map +1 -1
  97. package/dist/rpc.d.ts +30 -7
  98. package/dist/rpc.d.ts.map +1 -1
  99. package/dist/rpc.js +404 -111
  100. package/dist/rpc.js.map +1 -1
  101. package/dist/simulate.d.ts +106 -0
  102. package/dist/simulate.d.ts.map +1 -0
  103. package/dist/simulate.js +395 -0
  104. package/dist/simulate.js.map +1 -0
  105. package/dist/source.d.ts +8 -4
  106. package/dist/source.d.ts.map +1 -1
  107. package/dist/source.js +11 -4
  108. package/dist/source.js.map +1 -1
  109. package/dist/subpaths/approvals.d.ts +3 -3
  110. package/dist/subpaths/approvals.d.ts.map +1 -1
  111. package/dist/subpaths/approvals.js.map +1 -1
  112. package/dist/subpaths/claim.d.ts +31 -0
  113. package/dist/subpaths/claim.d.ts.map +1 -0
  114. package/dist/subpaths/claim.js +31 -0
  115. package/dist/subpaths/claim.js.map +1 -0
  116. package/dist/subpaths/history.d.ts +21 -0
  117. package/dist/subpaths/history.d.ts.map +1 -0
  118. package/dist/subpaths/history.js +22 -0
  119. package/dist/subpaths/history.js.map +1 -0
  120. package/dist/subpaths/lp.d.ts +2 -2
  121. package/dist/subpaths/lp.d.ts.map +1 -1
  122. package/dist/subpaths/lp.js +2 -2
  123. package/dist/subpaths/lp.js.map +1 -1
  124. package/dist/subpaths/reclaim.d.ts +34 -0
  125. package/dist/subpaths/reclaim.d.ts.map +1 -0
  126. package/dist/subpaths/reclaim.js +34 -0
  127. package/dist/subpaths/reclaim.js.map +1 -0
  128. package/dist/subpaths/simulate.d.ts +22 -0
  129. package/dist/subpaths/simulate.d.ts.map +1 -0
  130. package/dist/subpaths/simulate.js +23 -0
  131. package/dist/subpaths/simulate.js.map +1 -0
  132. package/dist/swap/index.js +3 -3
  133. package/dist/swap/index.js.map +1 -1
  134. package/dist/tokenDetails.d.ts +12 -0
  135. package/dist/tokenDetails.d.ts.map +1 -0
  136. package/dist/tokenDetails.js +196 -0
  137. package/dist/tokenDetails.js.map +1 -0
  138. package/dist/tokenMeta.d.ts +63 -0
  139. package/dist/tokenMeta.d.ts.map +1 -0
  140. package/dist/tokenMeta.js +226 -0
  141. package/dist/tokenMeta.js.map +1 -0
  142. package/dist/tradeCheck.js +3 -3
  143. package/dist/tradeCheck.js.map +1 -1
  144. package/dist/util.d.ts +22 -0
  145. package/dist/util.d.ts.map +1 -0
  146. package/dist/util.js +107 -0
  147. package/dist/util.js.map +1 -0
  148. package/dist/write/chain.d.ts +51 -0
  149. package/dist/write/chain.d.ts.map +1 -0
  150. package/dist/write/chain.js +184 -0
  151. package/dist/write/chain.js.map +1 -0
  152. package/dist/write/collect.js +2 -2
  153. package/dist/write/collect.js.map +1 -1
  154. package/dist/write/errors.d.ts +10 -0
  155. package/dist/write/errors.d.ts.map +1 -0
  156. package/dist/write/errors.js +77 -0
  157. package/dist/write/errors.js.map +1 -0
  158. package/dist/write/instructions.d.ts +2 -0
  159. package/dist/write/instructions.d.ts.map +1 -1
  160. package/dist/write/instructions.js +16 -0
  161. package/dist/write/instructions.js.map +1 -1
  162. package/dist/write/pack.d.ts +28 -0
  163. package/dist/write/pack.d.ts.map +1 -0
  164. package/dist/write/pack.js +125 -0
  165. package/dist/write/pack.js.map +1 -0
  166. package/dist/write/send.d.ts +23 -9
  167. package/dist/write/send.d.ts.map +1 -1
  168. package/dist/write/send.js +53 -351
  169. package/dist/write/send.js.map +1 -1
  170. package/dist/write/tracker.d.ts +24 -0
  171. package/dist/write/tracker.d.ts.map +1 -0
  172. package/dist/write/tracker.js +129 -0
  173. package/dist/write/tracker.js.map +1 -0
  174. package/package.json +21 -1
  175. package/src/accountReader.ts +187 -0
  176. package/src/address.ts +41 -6
  177. package/src/approvals.ts +36 -44
  178. package/src/balances.ts +678 -0
  179. package/src/batchPlan.ts +117 -0
  180. package/src/bytes.ts +89 -0
  181. package/src/claim.ts +327 -0
  182. package/src/client.ts +59 -1239
  183. package/src/clientConfig.ts +12 -0
  184. package/src/decode.ts +176 -0
  185. package/src/describe.ts +77 -0
  186. package/src/fees.ts +2 -9
  187. package/src/history.ts +325 -0
  188. package/src/historyHelius.ts +136 -0
  189. package/src/index.ts +13 -1
  190. package/src/layout.ts +63 -82
  191. package/src/liquidity/common.ts +2 -2
  192. package/src/liquidity/orca.ts +15 -5
  193. package/src/liquidity/raydiumClmm.ts +13 -5
  194. package/src/lp.ts +226 -75
  195. package/src/lpMath.ts +38 -0
  196. package/src/portfolio.ts +8 -11
  197. package/src/programs.ts +216 -0
  198. package/src/reclaim.ts +550 -0
  199. package/src/risk.ts +2 -2
  200. package/src/rpc.ts +476 -118
  201. package/src/simulate.ts +517 -0
  202. package/src/source.ts +14 -5
  203. package/src/subpaths/approvals.ts +3 -2
  204. package/src/subpaths/claim.ts +54 -0
  205. package/src/subpaths/history.ts +38 -0
  206. package/src/subpaths/lp.ts +5 -1
  207. package/src/subpaths/reclaim.ts +58 -0
  208. package/src/subpaths/simulate.ts +37 -0
  209. package/src/swap/index.ts +3 -3
  210. package/src/tokenDetails.ts +240 -0
  211. package/src/tokenMeta.ts +270 -0
  212. package/src/tradeCheck.ts +3 -3
  213. package/src/util.ts +110 -0
  214. package/src/write/chain.ts +218 -0
  215. package/src/write/collect.ts +2 -2
  216. package/src/write/errors.ts +82 -0
  217. package/src/write/instructions.ts +17 -0
  218. package/src/write/pack.ts +142 -0
  219. package/src/write/send.ts +74 -407
  220. package/src/write/tracker.ts +152 -0
package/src/lp.ts CHANGED
@@ -1,11 +1,25 @@
1
1
  import { sha256 } from '@noble/hashes/sha2.js'
2
2
  import { base64 } from '@scure/base'
3
3
 
4
- import { decodeAddress, encodeAddress, findProgramAddress, isAddress } from './address.js'
4
+ import { decodeAddress, findProgramAddress, InvalidAddressError, isAddress } from './address.js'
5
5
  import type { RequestOptions, SolanaClient } from './client.js'
6
6
  import { formatUnits, parseMint, parseTokenAccount, type AccountInfo } from './layout.js'
7
7
  import { AllNodesFailedError, HttpError, RpcError } from './rpc.js'
8
- import { getAmountsForLiquidity, getFeeGrowthInside, getFeesOwed, orcaSqrtPriceAtTick, raydiumSqrtPriceAtTick, sqrtPriceToPrice, tickToPrice, type TickFeeData } from './lpMath.js'
8
+ import { boundedSet, mapPool } from './util.js'
9
+ import { Bytes } from './bytes.js'
10
+ import {
11
+ getAmountsForLiquidity,
12
+ getFeeGrowthInside,
13
+ getFeesOwed,
14
+ getRewardGrowthInside,
15
+ orcaSqrtPriceAtTick,
16
+ raydiumSqrtPriceAtTick,
17
+ rewardGrowthGlobal,
18
+ sqrtPriceToPrice,
19
+ tickToPrice,
20
+ type RewardEmission,
21
+ type TickFeeData,
22
+ } from './lpMath.js'
9
23
  import { defillamaPrices, type SolanaPriceSource } from './price.js'
10
24
 
11
25
  export const RAYDIUM_CLMM_PROGRAM_ID = 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK'
@@ -97,6 +111,24 @@ export interface SolanaLpClmmPosition extends SolanaLpPositionBase {
97
111
  fees1Formatted: string | null
98
112
  /** 开启 prices 时:未领取手续费的美元价值(手续费为 null 或缺价格时为 null) */
99
113
  feesUsd?: number | null
114
+ /** 开启 rewards 时:未领取的挖矿奖励(池子里已初始化的每个奖励一项,数量为 0 的也在) */
115
+ rewards?: SolanaLpReward[]
116
+ /** 开启 rewards 和 prices 时:未领取挖矿奖励的美元价值(有数量的奖励缺价格或读不到时为 null) */
117
+ rewardsUsd?: number | null
118
+ }
119
+
120
+ /** 集中流动性仓位未领取的挖矿奖励(Raydium CLMM reward_infos / Orca Whirlpool reward_infos) */
121
+ export interface SolanaLpReward {
122
+ /** 奖励在池子里的位置(0~2) */
123
+ index: number
124
+ mint: string
125
+ symbol: string | null
126
+ decimals: number | null
127
+ /** 未领取的数量(最小单位):已记账的 + 按池子最新状态结算的新增;读不到两端的 tick 数据时为 null */
128
+ amount: string | null
129
+ formatted: string | null
130
+ /** 开启 prices 时:美元价值(没有价格时为 null) */
131
+ usd?: number | null
100
132
  }
101
133
 
102
134
  export type SolanaLpPosition = SolanaLpAmmPosition | SolanaLpClmmPosition
@@ -116,6 +148,13 @@ export interface LpPositionsOptions extends RequestOptions {
116
148
  pools?: readonly string[]
117
149
  /** 返回已清空的仓位(流动性为 0、手续费也领完,NFT 还在)。默认 false */
118
150
  includeClosed?: boolean
151
+ /**
152
+ * 集中流动性仓位同时算未领取的挖矿奖励(Raydium CLMM 农场奖励、Orca 的奖励 slot),放在 rewards 字段,照搬程序的结算:
153
+ * 池子的奖励增长按时间摊给当前流动性,再按仓位区间内的增长计算。默认 false。有奖励没领的仓位不算“已清空”
154
+ */
155
+ rewards?: boolean
156
+ /** rewards 结算到哪个时刻(unix 秒),默认当前时间;链上用的是区块时间,差几秒影响很小 */
157
+ now?: number
119
158
  /** 查美元价值:true 用 DefiLlama,也可以传自己的 SolanaPriceSource(如 /price 的 combinePrices([defillamaPrices(), onchainPrices()]))。默认不查 */
120
159
  prices?: boolean | SolanaPriceSource
121
160
  /**
@@ -131,35 +170,10 @@ export interface LpPositionsOptions extends RequestOptions {
131
170
  // 账户布局(各程序的 Anchor / 原生账户,偏移量含 8 字节判别符)
132
171
  // ---------------------------------------------------------------------------
133
172
 
134
- /** 小端读取账户数据(添加 / 移除流动性也用) */
135
- export class Bytes {
136
- readonly #view: DataView
137
- constructor(readonly data: Uint8Array) {
138
- this.#view = new DataView(data.buffer, data.byteOffset, data.byteLength)
139
- }
140
- u8(o: number): number {
141
- return this.#view.getUint8(o)
142
- }
143
- u16(o: number): number {
144
- return this.#view.getUint16(o, true)
145
- }
146
- u32(o: number): number {
147
- return this.#view.getUint32(o, true)
148
- }
149
- i32(o: number): number {
150
- return this.#view.getInt32(o, true)
151
- }
152
- u64(o: number): bigint {
153
- return this.#view.getBigUint64(o, true)
154
- }
155
- u128(o: number): bigint {
156
- return (this.#view.getBigUint64(o + 8, true) << 64n) | this.#view.getBigUint64(o, true)
157
- }
158
- key(o: number): string {
159
- return encodeAddress(this.data.subarray(o, o + 32))
160
- }
161
- }
173
+ // 小端读取账户数据:公共的 Bytes(添加 / 移除流动性也从这里引用)
174
+ export { Bytes }
162
175
 
176
+ const ZERO_ADDRESS = '11111111111111111111111111111111'
163
177
  const RAYDIUM_POSITION_SIZE = 281
164
178
  const ORCA_POSITION_SIZE = 216
165
179
  /** 池子账户至少要读到的长度(Raydium PoolState 实际 1544 字节,Orca Whirlpool 653 字节) */
@@ -172,7 +186,7 @@ const RAYDIUM_TICK_SIZE = 168
172
186
  const ORCA_TICK_ARRAY_SIZE = 88
173
187
  const ORCA_TICK_SIZE = 113
174
188
 
175
- interface ClmmPositionState {
189
+ export interface ClmmPositionState {
176
190
  protocol: 'raydium-clmm' | 'orca-whirlpool'
177
191
  address: string
178
192
  mint: string
@@ -184,8 +198,12 @@ interface ClmmPositionState {
184
198
  growthLast1: bigint
185
199
  owed0: bigint
186
200
  owed1: bigint
201
+ /** 三个挖矿奖励:上次记录的区间内增长、已记账未领取的数量 */
202
+ rewards: Array<{ growthLast: bigint; owed: bigint }>
187
203
  }
188
204
 
205
+ const positionRewards = (b: Bytes, offset: number) => [0, 1, 2].map((i) => ({ growthLast: b.u128(offset + i * 24), owed: b.u64(offset + i * 24 + 16) }))
206
+
189
207
  function raydiumPosition(address: string, b: Bytes): ClmmPositionState {
190
208
  return {
191
209
  protocol: 'raydium-clmm',
@@ -199,6 +217,7 @@ function raydiumPosition(address: string, b: Bytes): ClmmPositionState {
199
217
  growthLast1: b.u128(113),
200
218
  owed0: b.u64(129),
201
219
  owed1: b.u64(137),
220
+ rewards: positionRewards(b, 145),
202
221
  }
203
222
  }
204
223
 
@@ -215,7 +234,19 @@ function orcaPosition(address: string, b: Bytes): ClmmPositionState {
215
234
  owed0: b.u64(112),
216
235
  growthLast1: b.u128(120),
217
236
  owed1: b.u64(136),
237
+ rewards: positionRewards(b, 144),
238
+ }
239
+ }
240
+
241
+ /** 按程序和账户大小解析仓位账户(Raydium PersonalPositionState 281 字节、Orca Position 216 字节);不是仓位账户时为 null */
242
+ export function parseClmmPosition(protocol: 'raydium-clmm' | 'orca-whirlpool', address: string, account: AccountInfo): ClmmPositionState | null {
243
+ if (protocol === 'raydium-clmm' && account.owner === RAYDIUM_CLMM_PROGRAM_ID && account.data.length === RAYDIUM_POSITION_SIZE) {
244
+ return raydiumPosition(address, new Bytes(account.data))
218
245
  }
246
+ if (protocol === 'orca-whirlpool' && account.owner === ORCA_WHIRLPOOL_PROGRAM_ID && account.data.length === ORCA_POSITION_SIZE) {
247
+ return orcaPosition(address, new Bytes(account.data))
248
+ }
249
+ return null
219
250
  }
220
251
 
221
252
  interface ClmmPoolState {
@@ -231,6 +262,41 @@ interface ClmmPoolState {
231
262
  /** Orca:池子里的费率;Raydium:在 AmmConfig 里,ammConfig 为它的地址 */
232
263
  fee: number | null
233
264
  ammConfig: string | null
265
+ /** 当前区间内的流动性 */
266
+ liquidity: bigint
267
+ /** 已初始化的挖矿奖励(index 是在池子里的位置) */
268
+ rewards: Array<{ index: number; mint: string; emission: RewardEmission }>
269
+ }
270
+
271
+ const U64_MAX_VALUE = (1n << 64n) - 1n
272
+
273
+ /** Raydium reward_infos(每个 169 字节,从 397 开始):open_time +1、end_time +9、last_update_time +17、emissions_per_second_x64 +25、mint +57、growth_global +153 */
274
+ function raydiumRewards(b: Bytes): ClmmPoolState['rewards'] {
275
+ if (b.data.length < 397 + 3 * 169) {
276
+ return []
277
+ }
278
+ return [0, 1, 2].flatMap((index) => {
279
+ const o = 397 + index * 169
280
+ const mint = b.key(o + 57)
281
+ return mint === ZERO_ADDRESS
282
+ ? []
283
+ : [{ index, mint, emission: { initialized: true, openTime: b.u64(o + 1), endTime: b.u64(o + 9), lastUpdated: b.u64(o + 17), emissionsPerSecondX64: b.u128(o + 25), growthGlobalX64: b.u128(o + 153) } }]
284
+ })
285
+ }
286
+
287
+ /** Orca reward_last_updated_timestamp(261)和 reward_infos(每个 128 字节,从 269 开始):mint +0、emissions_per_second_x64 +96、growth_global +112 */
288
+ function orcaRewards(b: Bytes): ClmmPoolState['rewards'] {
289
+ if (b.data.length < 269 + 3 * 128) {
290
+ return []
291
+ }
292
+ const lastUpdated = b.u64(261)
293
+ return [0, 1, 2].flatMap((index) => {
294
+ const o = 269 + index * 128
295
+ const mint = b.key(o)
296
+ return mint === ZERO_ADDRESS
297
+ ? []
298
+ : [{ index, mint, emission: { initialized: true, openTime: 0n, endTime: U64_MAX_VALUE, lastUpdated, emissionsPerSecondX64: b.u128(o + 96), growthGlobalX64: b.u128(o + 112) } }]
299
+ })
234
300
  }
235
301
 
236
302
  function raydiumPool(b: Bytes): ClmmPoolState {
@@ -246,6 +312,8 @@ function raydiumPool(b: Bytes): ClmmPoolState {
246
312
  global0: b.u128(277),
247
313
  global1: b.u128(293),
248
314
  fee: null,
315
+ liquidity: b.u128(237),
316
+ rewards: raydiumRewards(b),
249
317
  }
250
318
  }
251
319
 
@@ -262,6 +330,8 @@ function orcaPool(b: Bytes): ClmmPoolState {
262
330
  global1: b.u128(245),
263
331
  decimals0: null,
264
332
  decimals1: null,
333
+ liquidity: b.u128(49),
334
+ rewards: orcaRewards(b),
265
335
  }
266
336
  }
267
337
 
@@ -346,7 +416,9 @@ export function orcaTickArray(pool: string, tick: number, spacing: number): { ad
346
416
  return { address: findProgramAddress([utf8('tick_array'), decodeAddress(pool), utf8(String(start))], ORCA_WHIRLPOOL_PROGRAM_ID), start }
347
417
  }
348
418
 
349
- function raydiumTick(account: AccountInfo, start: number, tick: number, spacing: number): TickFeeData | null {
419
+ const rewardOutside = (b: Bytes, offset: number) => [b.u128(offset), b.u128(offset + 16), b.u128(offset + 32)]
420
+
421
+ export function raydiumTick(account: AccountInfo, start: number, tick: number, spacing: number): TickFeeData | null {
350
422
  raydiumDiscriminator ??= discriminator('TickArrayState')
351
423
  const offset = 44 + ((tick - start) / spacing) * RAYDIUM_TICK_SIZE
352
424
  // 先认判别符、查长度再读字段(PDA 上可能是别的账户,如被转过 SOL 的系统账户)
@@ -358,7 +430,7 @@ function raydiumTick(account: AccountInfo, start: number, tick: number, spacing:
358
430
  return null
359
431
  }
360
432
  // Raydium 不区分是否初始化:没用过的 tick 各项都是 0,按程序的公式算出来就是对的
361
- return { initialized: true, feeGrowthOutside0: b.u128(offset + 36), feeGrowthOutside1: b.u128(offset + 52) }
433
+ return { initialized: true, feeGrowthOutside0: b.u128(offset + 36), feeGrowthOutside1: b.u128(offset + 52), rewardGrowthsOutside: rewardOutside(b, offset + 68) }
362
434
  }
363
435
 
364
436
  /** Anchor 账户判别符:sha256("account:<名称>") 的前 8 字节 */
@@ -367,7 +439,7 @@ let orcaDiscriminators: { fixed: Uint8Array; dynamic: Uint8Array } | undefined
367
439
  let raydiumDiscriminator: Uint8Array | undefined
368
440
  const startsWith = (data: Uint8Array, prefix: Uint8Array) => data.length >= prefix.length && prefix.every((byte, i) => data[i] === byte)
369
441
 
370
- function orcaTick(account: AccountInfo, start: number, tick: number, spacing: number): TickFeeData | null {
442
+ export function orcaTick(account: AccountInfo, start: number, tick: number, spacing: number): TickFeeData | null {
371
443
  orcaDiscriminators ??= { fixed: discriminator('TickArray'), dynamic: discriminator('DynamicTickArray') }
372
444
  if (account.data.length < 60) {
373
445
  return null // 两种格式的头部(判别符 + start + …)都放不下
@@ -380,7 +452,7 @@ function orcaTick(account: AccountInfo, start: number, tick: number, spacing: nu
380
452
  if (b.i32(8) !== start || offset + ORCA_TICK_SIZE > account.data.length) {
381
453
  return null
382
454
  }
383
- return { initialized: b.u8(offset) === 1, feeGrowthOutside0: b.u128(offset + 33), feeGrowthOutside1: b.u128(offset + 49) }
455
+ return { initialized: b.u8(offset) === 1, feeGrowthOutside0: b.u128(offset + 33), feeGrowthOutside1: b.u128(offset + 49), rewardGrowthsOutside: rewardOutside(b, offset + 65) }
384
456
  }
385
457
  if (startsWith(account.data, orcaDiscriminators.dynamic)) {
386
458
  // 可变大小:start i32 | whirlpool | tick_bitmap u128 | ticks(未初始化 1 字节 0;初始化 1 字节 1 + 112 字节数据)
@@ -403,7 +475,7 @@ function orcaTick(account: AccountInfo, start: number, tick: number, spacing: nu
403
475
  if (offset + 113 > account.data.length) {
404
476
  return null
405
477
  }
406
- return { initialized: true, feeGrowthOutside0: b.u128(offset + 33), feeGrowthOutside1: b.u128(offset + 49) }
478
+ return { initialized: true, feeGrowthOutside0: b.u128(offset + 33), feeGrowthOutside1: b.u128(offset + 49), rewardGrowthsOutside: rewardOutside(b, offset + 65) }
407
479
  }
408
480
  return null
409
481
  }
@@ -426,6 +498,8 @@ interface RawClmm {
426
498
  amount1: bigint
427
499
  fees0: bigint | null
428
500
  fees1: bigint | null
501
+ /** rewards 选项开启时:每个已初始化的奖励的未领取数量(读不到 tick 时为 null) */
502
+ rewards?: Array<{ index: number; mint: string; amount: bigint | null }>
429
503
  }
430
504
 
431
505
  interface RawAmm {
@@ -446,7 +520,7 @@ interface RawAmm {
446
520
  */
447
521
  export async function lpPositions(client: SolanaClient, owner: string, options: LpPositionsOptions = {}): Promise<SolanaLpPosition[]> {
448
522
  if (!isAddress(owner)) {
449
- throw new Error(`Invalid owner address: ${owner}`)
523
+ throw new InvalidAddressError(`Invalid owner address: ${owner}`, owner)
450
524
  }
451
525
  const protocols = new Set<SolanaLpProtocol>(options.protocols ?? (Object.keys(SOLANA_LP_PROTOCOLS) as SolanaLpProtocol[]))
452
526
  for (const p of protocols) {
@@ -456,7 +530,7 @@ export async function lpPositions(client: SolanaClient, owner: string, options:
456
530
  }
457
531
  for (const address of [...(options.mints ?? []), ...(options.pools ?? [])]) {
458
532
  if (!isAddress(address)) {
459
- throw new Error(`Invalid address: ${address}`)
533
+ throw new InvalidAddressError(`Invalid address: ${address}`, address)
460
534
  }
461
535
  }
462
536
  const { signal } = options
@@ -498,11 +572,9 @@ export async function lpPositions(client: SolanaClient, owner: string, options:
498
572
  if (!account) {
499
573
  return
500
574
  }
501
- const program = SOLANA_LP_PROTOCOLS[c.protocol].programId
502
- if (c.protocol === 'raydium-clmm' && account.owner === program && account.data.length === RAYDIUM_POSITION_SIZE) {
503
- positions.push(raydiumPosition(c.address, new Bytes(account.data)))
504
- } else if (c.protocol === 'orca-whirlpool' && account.owner === program && account.data.length === ORCA_POSITION_SIZE) {
505
- positions.push(orcaPosition(c.address, new Bytes(account.data)))
575
+ const parsed = parseClmmPosition(c.protocol, c.address, account)
576
+ if (parsed) {
577
+ positions.push(parsed)
506
578
  }
507
579
  })
508
580
  // LP mint 的权限是 AMM v4 / CPMM 的固定权限地址时才去找池子
@@ -525,13 +597,30 @@ export async function lpPositions(client: SolanaClient, owner: string, options:
525
597
  }
526
598
  })
527
599
 
528
- // 3. 池子:集中流动性的池子账户;LP 代币按 LP mint 找池子
600
+ // 3. 池子:集中流动性的池子账户;LP 代币按 LP mint 找池子(之前找到过的直接读池子账户,和集中流动性的池子一起读)
529
601
  const poolAddresses = [...new Set(positions.map((p) => p.pool))]
602
+ const cached = lookups.flatMap((l) => {
603
+ const address = ammPoolByLpMint.get(lpPoolKey(l.protocol, l.lpMint))
604
+ return address ? [{ ...l, address }] : []
605
+ })
606
+ const uncached = lookups.filter((l) => !ammPoolByLpMint.has(lpPoolKey(l.protocol, l.lpMint)))
530
607
  const [poolAccounts, found] = await Promise.all([
531
- client.accounts(poolAddresses, { signal }),
532
- Promise.all(lookups.map((l) => findAmmPool(client, l.protocol, l.lpMint, signal))),
608
+ client.accounts([...poolAddresses, ...cached.map((c) => c.address)], { signal }),
609
+ findAmmPools(client, uncached, signal),
533
610
  ])
534
- for (const pool of found) {
611
+ // 缓存的池子核对一下(地址不对、账户读不到时重新找)
612
+ const stale: typeof lookups = []
613
+ cached.forEach((c, i) => {
614
+ const account = poolAccounts[poolAddresses.length + i]
615
+ const pool = account ? parseAmmPool(account) : null
616
+ if (pool && pool.protocol === c.protocol && pool.lpMint === c.lpMint) {
617
+ poolByLp.set(pool.lpMint, pool)
618
+ } else {
619
+ ammPoolByLpMint.delete(lpPoolKey(c.protocol, c.lpMint))
620
+ stale.push({ protocol: c.protocol, lpMint: c.lpMint })
621
+ }
622
+ })
623
+ for (const pool of [...found, ...(stale.length ? await findAmmPools(client, stale, signal) : [])]) {
535
624
  if (pool) {
536
625
  poolByLp.set(pool.lpMint, pool)
537
626
  }
@@ -605,10 +694,32 @@ export async function lpPositions(client: SolanaClient, owner: string, options:
605
694
  fees1 = null
606
695
  }
607
696
  }
608
- if (!options.includeClosed && position.liquidity === 0n && fees0 === 0n && fees1 === 0n) {
697
+ // 未领取的挖矿奖励:没有流动性时只有已记账的;有流动性时先把池子的奖励增长结算到 now,再按区间内的增长算新增
698
+ let rewards: RawClmm['rewards']
699
+ if (options.rewards) {
700
+ const now = BigInt(Math.floor(options.now ?? Date.now() / 1000))
701
+ const ticks = (() => {
702
+ if (!arrays) return null
703
+ const read = position.protocol === 'raydium-clmm' ? raydiumTick : orcaTick
704
+ const lowerAccount = byAddress.get(arrays.lower.address)
705
+ const upperAccount = byAddress.get(arrays.upper.address)
706
+ const lower = lowerAccount ? read(lowerAccount, arrays.lower.start, position.tickLower, pool.tickSpacing) : null
707
+ const upper = upperAccount ? read(upperAccount, arrays.upper.start, position.tickUpper, pool.tickSpacing) : null
708
+ return lower && upper ? { lower, upper } : null
709
+ })()
710
+ rewards = pool.rewards.map(({ index, mint, emission }) => {
711
+ const state = position.rewards[index] as { growthLast: bigint; owed: bigint }
712
+ if (!arrays) return { index, mint, amount: state.owed }
713
+ if (!ticks) return { index, mint, amount: null }
714
+ const global = rewardGrowthGlobal(pool.liquidity, emission, now)
715
+ const inside = getRewardGrowthInside(pool.tick, position.tickLower, ticks.lower, position.tickUpper, ticks.upper, index, global)
716
+ return { index, mint, amount: getFeesOwed(state.owed, position.liquidity, inside, state.growthLast) }
717
+ })
718
+ }
719
+ if (!options.includeClosed && position.liquidity === 0n && fees0 === 0n && fees1 === 0n && !rewards?.some((x) => x.amount !== 0n)) {
609
720
  continue
610
721
  }
611
- raw.push({ type: 'clmm', position, pool: { ...pool, fee: pool.fee ?? (pool.ammConfig ? (feeRates.get(pool.ammConfig) ?? null) : null) }, amount0, amount1, fees0, fees1 })
722
+ raw.push({ type: 'clmm', position, pool: { ...pool, fee: pool.fee ?? (pool.ammConfig ? (feeRates.get(pool.ammConfig) ?? null) : null) }, amount0, amount1, fees0, fees1, rewards })
612
723
  }
613
724
  for (const pool of ammHeld) {
614
725
  const balance = held.get(pool.lpMint)?.amount ?? 0n
@@ -633,6 +744,19 @@ export async function lpPositions(client: SolanaClient, owner: string, options:
633
744
  return finalize(client, raw, options)
634
745
  }
635
746
 
747
+ /**
748
+ * 方法不可用(见 methodUnavailable)时把错误换成说明怎么绕过的 Error(cause 为原错误,hint 拿到原错误的 message);
749
+ * 取消、限频、超时等原样抛出(保留错误类型)。/lp、/approvals、资产总值共用
750
+ */
751
+ export function withMethodHint<T>(promise: Promise<T>, signal: AbortSignal | undefined, hint: (detail: string) => string): Promise<T> {
752
+ return promise.catch((err: unknown) => {
753
+ if (signal?.aborted || !methodUnavailable(err)) {
754
+ throw err
755
+ }
756
+ throw new Error(hint(err instanceof Error ? err.message : String(err)), { cause: err })
757
+ })
758
+ }
759
+
636
760
  /**
637
761
  * 节点不提供这个方法(方法不存在、被拦截、需要付费 / Token、403 等):这时报错里说明怎么绕过。
638
762
  * 多节点时所有节点都是这个原因才算;限频、超时、网络不对等原样抛出(资产总值 portfolio 也用)
@@ -661,43 +785,63 @@ async function listHoldings(client: SolanaClient, owner: string, mints: readonly
661
785
  const balances = list.length ? await client.balances(owner, list, { signal }) : []
662
786
  return balances.filter((b) => b.success && !b.native && b.balance !== '0').map((b) => ({ mint: b.token, amount: BigInt(b.balance), decimals: b.decimals }))
663
787
  }
664
- const tokens = await client.ownerTokens(owner, { signal, metadata: false, includeNative: false, includeNfts: true }).catch((err: unknown) => {
665
- if (signal?.aborted || !methodUnavailable(err)) {
666
- throw err // 取消、限频、超时等原样抛出(保留错误类型)
667
- }
668
- throw new Error(
669
- `Scanning the token accounts of ${owner} failed (${err instanceof Error ? err.message : String(err)}). ` +
670
- 'Scanning needs getTokenAccountsByOwner (most free public nodes do not support it): pass the position NFT / LP mints in `mints`, or use a node that supports it',
671
- { cause: err },
672
- )
673
- })
788
+ const tokens = await withMethodHint(
789
+ client.ownerTokens(owner, { signal, metadata: false, includeNative: false, includeNfts: true }),
790
+ signal,
791
+ (detail) =>
792
+ `Scanning the token accounts of ${owner} failed (${detail}). ` +
793
+ 'Scanning needs getTokenAccountsByOwner (most free public nodes do not support it): pass the position NFT / LP mints in `mints`, or use a node that supports it',
794
+ )
674
795
  return tokens.map((t) => ({ mint: t.token, amount: BigInt(t.balance), decimals: t.decimals }))
675
796
  }
676
797
 
677
- /** 按 LP mint 找 AMM v4 / CPMM 的池子(getProgramAccounts + memcmp),找不到时为 null */
798
+ /**
799
+ * LP mint → 池子地址(AMM v4 / CPMM):池子地址不会变,找到后永久缓存(条目数有上限,超出淘汰最早写入的),
800
+ * 之后不再 getProgramAccounts,用时读池子账户核对
801
+ */
802
+ const ammPoolByLpMint = new Map<string, string>()
803
+ const MAX_CACHED_LP_POOLS = 10_000
804
+ /** 同时进行的 getProgramAccounts 数(每个都要节点扫描整个程序的账户,很重) */
805
+ const MAX_PARALLEL_POOL_LOOKUPS = 4
806
+
807
+ function lpPoolKey(protocol: 'raydium-amm' | 'raydium-cpmm', lpMint: string): string {
808
+ return `${protocol}:${lpMint}`
809
+ }
810
+
811
+ /** 测试用:清空 LP mint → 池子地址的缓存 */
812
+ export function resetLpPoolCache(): void {
813
+ ammPoolByLpMint.clear()
814
+ }
815
+
816
+ /** 按 LP mint 找池子,限制并发;结果与 lookups 一一对应 */
817
+ function findAmmPools(
818
+ client: SolanaClient,
819
+ lookups: ReadonlyArray<{ protocol: 'raydium-amm' | 'raydium-cpmm'; lpMint: string }>,
820
+ signal: AbortSignal | undefined,
821
+ ): Promise<Array<AmmPoolState | null>> {
822
+ return mapPool(lookups, MAX_PARALLEL_POOL_LOOKUPS, (l) => findAmmPool(client, l.protocol, l.lpMint, signal))
823
+ }
824
+
825
+ /** 按 LP mint 找 AMM v4 / CPMM 的池子(getProgramAccounts + memcmp),找不到时为 null;找到的池子地址写入缓存 */
678
826
  async function findAmmPool(client: SolanaClient, protocol: 'raydium-amm' | 'raydium-cpmm', lpMint: string, signal: AbortSignal | undefined): Promise<AmmPoolState | null> {
679
827
  const [programId, size, offset] = protocol === 'raydium-amm' ? [RAYDIUM_AMM_PROGRAM_ID, AMM_POOL_SIZE, 464] : [RAYDIUM_CPMM_PROGRAM_ID, CPMM_POOL_SIZE, 136]
680
- const res = await client
681
- .request<{ pubkey: string; account: { data: [string, string]; owner: string } }[]>(
828
+ // 免费公共节点大多不支持 getProgramAccounts:说明怎么绕过
829
+ const res = await withMethodHint(
830
+ client.request<{ pubkey: string; account: { data: [string, string]; owner: string } }[]>(
682
831
  'getProgramAccounts',
683
832
  [programId, { encoding: 'base64', commitment: client.commitment, filters: [{ dataSize: size }, { memcmp: { offset, bytes: lpMint } }] }],
684
833
  { signal },
685
- )
686
- .catch((err: unknown) => {
687
- if (signal?.aborted || !methodUnavailable(err)) {
688
- throw err
689
- }
690
- // 免费公共节点大多不支持 getProgramAccounts:说明怎么绕过
691
- throw new Error(
692
- `Finding the ${SOLANA_LP_PROTOCOLS[protocol].name} pool of LP token ${lpMint} uses getProgramAccounts, which failed (${err instanceof Error ? err.message : String(err)}; most free public nodes do not support it). ` +
693
- "Pass the pool address in `pools`, use a node that supports getProgramAccounts, or skip LP tokens with `protocols: ['raydium-clmm', 'orca-whirlpool']`",
694
- { cause: err },
695
- )
696
- })
834
+ ),
835
+ signal,
836
+ (detail) =>
837
+ `Finding the ${SOLANA_LP_PROTOCOLS[protocol].name} pool of LP token ${lpMint} uses getProgramAccounts, which failed (${detail}; most free public nodes do not support it). ` +
838
+ "Pass the pool address in `pools`, use a node that supports getProgramAccounts, or skip LP tokens with `protocols: ['raydium-clmm', 'orca-whirlpool']`",
839
+ )
697
840
  for (const item of res ?? []) {
698
841
  const data = base64.decode(item.account.data[0])
699
842
  const pool = parseAmmPool({ address: item.pubkey, owner: item.account.owner, data, lamports: 0n, executable: false })
700
843
  if (pool?.lpMint === lpMint) {
844
+ boundedSet(ammPoolByLpMint, lpPoolKey(protocol, lpMint), pool.address, MAX_CACHED_LP_POOLS)
701
845
  return pool
702
846
  }
703
847
  }
@@ -706,7 +850,7 @@ async function findAmmPool(client: SolanaClient, protocol: 'raydium-amm' | 'rayd
706
850
 
707
851
  async function finalize(client: SolanaClient, raw: readonly (RawClmm | RawAmm)[], options: LpPositionsOptions): Promise<SolanaLpPosition[]> {
708
852
  const { signal } = options
709
- const mints = [...new Set(raw.flatMap((r) => [r.pool.mint0, r.pool.mint1]))]
853
+ const mints = [...new Set(raw.flatMap((r) => [r.pool.mint0, r.pool.mint1, ...(r.type === 'clmm' ? (r.rewards ?? []).map((x) => x.mint) : [])]))]
710
854
  const priceSource = options.prices === true ? defillamaPrices() : options.prices || null
711
855
  const fetchFn = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
712
856
  // 价格源出错(服务不可用、被拦截、同步抛错等)时照样返回仓位,只是不带价格;取消查询时照常 reject
@@ -784,6 +928,11 @@ async function finalize(client: SolanaClient, raw: readonly (RawClmm | RawAmm)[]
784
928
  const d1 = token1.decimals
785
929
  const fees0Formatted = r.fees0 === null ? null : fmt(r.fees0, token0)
786
930
  const fees1Formatted = r.fees1 === null ? null : fmt(r.fees1, token1)
931
+ const rewards = r.rewards?.map((x): SolanaLpReward => {
932
+ const t = token(x.mint, null)
933
+ const formatted = x.amount === null ? null : fmt(x.amount, t)
934
+ return { index: x.index, mint: x.mint, symbol: t.symbol, decimals: t.decimals, amount: x.amount === null ? null : x.amount.toString(), formatted, ...(priceSource ? { usd: x.amount === 0n ? 0 : usd(t, formatted) } : {}) }
935
+ })
787
936
  return {
788
937
  ...base,
789
938
  type: 'clmm',
@@ -804,6 +953,8 @@ async function finalize(client: SolanaClient, raw: readonly (RawClmm | RawAmm)[]
804
953
  fees0Formatted,
805
954
  fees1Formatted,
806
955
  ...(priceSource ? { feesUsd: r.fees0 === null || r.fees1 === null ? null : sum(usd(token0, fees0Formatted), usd(token1, fees1Formatted), r.fees0, r.fees1) } : {}),
956
+ ...(rewards ? { rewards } : {}),
957
+ ...(rewards && priceSource ? { rewardsUsd: rewards.some((x) => x.usd === null || x.usd === undefined) ? null : rewards.reduce((acc, x) => acc + (x.usd ?? 0), 0) } : {}),
807
958
  }
808
959
  })
809
960
  if (priceSource) {
package/src/lpMath.ts CHANGED
@@ -153,6 +153,8 @@ export interface TickFeeData {
153
153
  initialized: boolean
154
154
  feeGrowthOutside0: bigint
155
155
  feeGrowthOutside1: bigint
156
+ /** 三个挖矿奖励的 tick 外增长(读 tick 时带上;只算手续费时可以不给) */
157
+ rewardGrowthsOutside?: readonly bigint[]
156
158
  }
157
159
 
158
160
  const wrap = (x: bigint) => ((x % (1n << 128n)) + (1n << 128n)) % (1n << 128n)
@@ -192,6 +194,42 @@ export function getFeesOwed(owed: bigint, liquidity: bigint, growthInside: bigin
192
194
  return owed + (delta > U64_MAX ? 0n : delta)
193
195
  }
194
196
 
197
+ /** 池子里一个挖矿奖励的状态(两个程序的 reward_infos 里的同名字段) */
198
+ export interface RewardEmission {
199
+ /** 已初始化:奖励的 mint 不是默认地址 */
200
+ initialized: boolean
201
+ emissionsPerSecondX64: bigint
202
+ growthGlobalX64: bigint
203
+ /** 上次更新的时间(秒)。Raydium 每个奖励自己记;Orca 是池子的 reward_last_updated_timestamp(三个奖励相同) */
204
+ lastUpdated: bigint
205
+ /** 开始 / 结束时间(秒):Raydium 的 open_time / end_time;Orca 没有,传 0n / 2^64 − 1 */
206
+ openTime: bigint
207
+ endTime: bigint
208
+ }
209
+
210
+ /**
211
+ * 池子在 now 时刻的奖励全局增长:程序每次操作池子(交易、增减流动性、领取)前先把排放按时间摊给当前的流动性,
212
+ * Q64.64:growth += Δt · emissions_per_second / liquidity(向下取整,u128 回绕)。
213
+ * Raydium:now ≤ open_time 不动,Δt 到 end_time 为止;Orca:池子流动性为 0 或 Δt 为 0 不动
214
+ */
215
+ export function rewardGrowthGlobal(liquidity: bigint, reward: RewardEmission, now: bigint): bigint {
216
+ if (!reward.initialized || liquidity === 0n || now <= reward.openTime) {
217
+ return reward.growthGlobalX64
218
+ }
219
+ const latest = now < reward.endTime ? now : reward.endTime
220
+ const delta = latest > reward.lastUpdated ? latest - reward.lastUpdated : 0n
221
+ return wrap(reward.growthGlobalX64 + (delta * reward.emissionsPerSecondX64) / liquidity)
222
+ }
223
+
224
+ /** 区间内某个奖励的增长(u128 回绕),tick 未初始化的处理同 getFeeGrowthInside */
225
+ export function getRewardGrowthInside(tick: number, tickLower: number, lower: TickFeeData, tickUpper: number, upper: TickFeeData, index: number, global: bigint): bigint {
226
+ const lowerOutside = lower.rewardGrowthsOutside?.[index] ?? 0n
227
+ const upperOutside = upper.rewardGrowthsOutside?.[index] ?? 0n
228
+ const below = !lower.initialized ? global : tick < tickLower ? wrap(global - lowerOutside) : lowerOutside
229
+ const above = !upper.initialized ? 0n : tick < tickUpper ? upperOutside : wrap(global - upperOutside)
230
+ return wrap(global - below - above)
231
+ }
232
+
195
233
  /** Q64.64 sqrt 价格 → token1 / token0 的价格(按精度换算,浮点数,只用于展示) */
196
234
  export function sqrtPriceToPrice(sqrtPrice: bigint, decimals0: number, decimals1: number): number {
197
235
  // 先取高位再转浮点,避免 2^128 级别的整数转换丢精度后再平方
package/src/portfolio.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * 资产总值(子路径 `@w3lib/solana-batch-call/price`):代币余额 × 价格,可选加上 LP 仓位的价值
3
3
  */
4
4
  import type { RequestOptions, SolanaClient } from './client.js'
5
- import { lpPositions, methodUnavailable, type LpPositionsOptions, type SolanaLpPosition } from './lp.js'
5
+ import { lpPositions, withMethodHint, type LpPositionsOptions, type SolanaLpPosition } from './lp.js'
6
6
  import { combinePrices, defillamaPrices, onchainPrices, tokenPrices, type SolanaPriceSource } from './price.js'
7
7
 
8
8
  export interface PortfolioOptions extends RequestOptions {
@@ -110,15 +110,12 @@ async function holdings(client: SolanaClient, owner: string, tokens: readonly st
110
110
  const balances = tokens.length ? await client.balances(owner, tokens, { symbol: true, signal }) : []
111
111
  return balances.filter((b) => b.success).map((b) => ({ token: b.token, native: b.native, symbol: b.symbol ?? null, balance: b.balance, decimals: b.decimals, formatted: b.formatted }))
112
112
  }
113
- const owned = await client.ownerTokens(owner, { signal }).catch((err: unknown) => {
114
- if (signal?.aborted || !methodUnavailable(err)) {
115
- throw err // 取消、限频、超时等原样抛出(保留错误类型)
116
- }
117
- throw new Error(
118
- `Listing the tokens of ${owner} failed (${err instanceof Error ? err.message : String(err)}). ` +
119
- 'Listing needs getTokenAccountsByOwner (most free public nodes do not support it): pass the mints in `tokens`, or use a node that supports it',
120
- { cause: err },
121
- )
122
- })
113
+ const owned = await withMethodHint(
114
+ client.ownerTokens(owner, { signal }),
115
+ signal,
116
+ (detail) =>
117
+ `Listing the tokens of ${owner} failed (${detail}). ` +
118
+ 'Listing needs getTokenAccountsByOwner (most free public nodes do not support it): pass the mints in `tokens`, or use a node that supports it',
119
+ )
123
120
  return owned.map((t) => ({ token: t.token, native: t.native, symbol: t.symbol ?? null, balance: t.balance, decimals: t.decimals, formatted: t.formatted }))
124
121
  }