@w3lib/solana-batch-call 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +16 -8
  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 +2 -2
  20. package/dist/batchPlan.d.ts.map +1 -1
  21. package/dist/batchPlan.js +5 -26
  22. package/dist/batchPlan.js.map +1 -1
  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 +5 -3
  28. package/dist/claim.d.ts.map +1 -1
  29. package/dist/claim.js +10 -10
  30. package/dist/claim.js.map +1 -1
  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.map +1 -1
  40. package/dist/decode.js +2 -29
  41. package/dist/decode.js.map +1 -1
  42. package/dist/fees.d.ts.map +1 -1
  43. package/dist/fees.js +2 -12
  44. package/dist/fees.js.map +1 -1
  45. package/dist/history.d.ts.map +1 -1
  46. package/dist/history.js +4 -15
  47. package/dist/history.js.map +1 -1
  48. package/dist/index.d.ts +3 -3
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/layout.d.ts +21 -0
  53. package/dist/layout.d.ts.map +1 -1
  54. package/dist/layout.js +50 -69
  55. package/dist/layout.js.map +1 -1
  56. package/dist/liquidity/common.js +2 -2
  57. package/dist/liquidity/common.js.map +1 -1
  58. package/dist/lp.d.ts +9 -13
  59. package/dist/lp.d.ts.map +1 -1
  60. package/dist/lp.js +67 -55
  61. package/dist/lp.js.map +1 -1
  62. package/dist/portfolio.d.ts.map +1 -1
  63. package/dist/portfolio.js +3 -8
  64. package/dist/portfolio.js.map +1 -1
  65. package/dist/reclaim.d.ts +4 -2
  66. package/dist/reclaim.d.ts.map +1 -1
  67. package/dist/reclaim.js +16 -16
  68. package/dist/reclaim.js.map +1 -1
  69. package/dist/risk.js +2 -2
  70. package/dist/risk.js.map +1 -1
  71. package/dist/rpc.d.ts +30 -7
  72. package/dist/rpc.d.ts.map +1 -1
  73. package/dist/rpc.js +400 -109
  74. package/dist/rpc.js.map +1 -1
  75. package/dist/simulate.d.ts.map +1 -1
  76. package/dist/simulate.js +3 -26
  77. package/dist/simulate.js.map +1 -1
  78. package/dist/source.d.ts +8 -4
  79. package/dist/source.d.ts.map +1 -1
  80. package/dist/source.js +11 -4
  81. package/dist/source.js.map +1 -1
  82. package/dist/subpaths/approvals.d.ts +3 -3
  83. package/dist/subpaths/approvals.d.ts.map +1 -1
  84. package/dist/subpaths/approvals.js.map +1 -1
  85. package/dist/subpaths/claim.d.ts +3 -3
  86. package/dist/subpaths/claim.d.ts.map +1 -1
  87. package/dist/subpaths/claim.js.map +1 -1
  88. package/dist/subpaths/reclaim.d.ts +3 -3
  89. package/dist/subpaths/reclaim.d.ts.map +1 -1
  90. package/dist/subpaths/reclaim.js.map +1 -1
  91. package/dist/swap/index.js +3 -3
  92. package/dist/swap/index.js.map +1 -1
  93. package/dist/tokenDetails.d.ts +12 -0
  94. package/dist/tokenDetails.d.ts.map +1 -0
  95. package/dist/tokenDetails.js +196 -0
  96. package/dist/tokenDetails.js.map +1 -0
  97. package/dist/tokenMeta.d.ts +63 -0
  98. package/dist/tokenMeta.d.ts.map +1 -0
  99. package/dist/tokenMeta.js +226 -0
  100. package/dist/tokenMeta.js.map +1 -0
  101. package/dist/tradeCheck.js +3 -3
  102. package/dist/tradeCheck.js.map +1 -1
  103. package/dist/util.d.ts +22 -0
  104. package/dist/util.d.ts.map +1 -0
  105. package/dist/util.js +107 -0
  106. package/dist/util.js.map +1 -0
  107. package/dist/write/chain.d.ts +51 -0
  108. package/dist/write/chain.d.ts.map +1 -0
  109. package/dist/write/chain.js +184 -0
  110. package/dist/write/chain.js.map +1 -0
  111. package/dist/write/collect.js +2 -2
  112. package/dist/write/collect.js.map +1 -1
  113. package/dist/write/errors.d.ts +10 -0
  114. package/dist/write/errors.d.ts.map +1 -0
  115. package/dist/write/errors.js +77 -0
  116. package/dist/write/errors.js.map +1 -0
  117. package/dist/write/pack.d.ts +28 -0
  118. package/dist/write/pack.d.ts.map +1 -0
  119. package/dist/write/pack.js +125 -0
  120. package/dist/write/pack.js.map +1 -0
  121. package/dist/write/send.d.ts +23 -9
  122. package/dist/write/send.d.ts.map +1 -1
  123. package/dist/write/send.js +53 -351
  124. package/dist/write/send.js.map +1 -1
  125. package/dist/write/tracker.d.ts +24 -0
  126. package/dist/write/tracker.d.ts.map +1 -0
  127. package/dist/write/tracker.js +129 -0
  128. package/dist/write/tracker.js.map +1 -0
  129. package/package.json +1 -1
  130. package/src/accountReader.ts +187 -0
  131. package/src/address.ts +41 -6
  132. package/src/approvals.ts +36 -44
  133. package/src/balances.ts +678 -0
  134. package/src/batchPlan.ts +6 -26
  135. package/src/bytes.ts +89 -0
  136. package/src/claim.ts +15 -11
  137. package/src/client.ts +59 -1239
  138. package/src/clientConfig.ts +12 -0
  139. package/src/decode.ts +4 -30
  140. package/src/fees.ts +2 -9
  141. package/src/history.ts +4 -15
  142. package/src/index.ts +13 -1
  143. package/src/layout.ts +63 -82
  144. package/src/liquidity/common.ts +2 -2
  145. package/src/lp.ts +86 -60
  146. package/src/portfolio.ts +8 -11
  147. package/src/reclaim.ts +20 -17
  148. package/src/risk.ts +2 -2
  149. package/src/rpc.ts +472 -116
  150. package/src/simulate.ts +3 -21
  151. package/src/source.ts +14 -5
  152. package/src/subpaths/approvals.ts +3 -2
  153. package/src/subpaths/claim.ts +3 -2
  154. package/src/subpaths/reclaim.ts +3 -2
  155. package/src/swap/index.ts +3 -3
  156. package/src/tokenDetails.ts +240 -0
  157. package/src/tokenMeta.ts +270 -0
  158. package/src/tradeCheck.ts +3 -3
  159. package/src/util.ts +110 -0
  160. package/src/write/chain.ts +218 -0
  161. package/src/write/collect.ts +2 -2
  162. package/src/write/errors.ts +82 -0
  163. package/src/write/pack.ts +142 -0
  164. package/src/write/send.ts +74 -407
  165. package/src/write/tracker.ts +152 -0
@@ -0,0 +1,12 @@
1
+ import type { Cluster } from './constants.js'
2
+ import type { SourceOptions } from './source.js'
3
+
4
+ /** SolanaClient 的节点配置(网络、节点选项、期望的创世哈希),按客户端登记:写操作的 sendRpc 按同样的配置解析、校验网络 */
5
+ export interface ClientSourceConfig {
6
+ cluster: Cluster | undefined
7
+ options: SourceOptions
8
+ genesis: () => Promise<string>
9
+ }
10
+
11
+ /** 单独成模块:/write 只需查表,不用静态带上 source.ts(sendRpc 解析时才按需加载) */
12
+ export const clientSourceConfigs = new WeakMap<object, ClientSourceConfig>()
package/src/decode.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * 指令解码(/simulate 和 /history 共用):System、SPL Token / Token-2022、关联代币账户、计算预算、Memo、升级程序加载器,
3
3
  * 以及 programs.ts 里认得的 DEX / 聚合器(协议 + 指令名 + 大类)。其他程序只给程序名(认得时)和大类 contract / unknown
4
4
  */
5
- import { encodeAddress } from './address.js'
5
+ import { Bytes } from './bytes.js'
6
6
  import { ASSOCIATED_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from './constants.js'
7
7
  import { BPF_LOADER_UPGRADEABLE, COMPUTE_BUDGET, MEMO_V1, MEMO_V2, PROGRAMS, knownMethod, type IxType } from './programs.js'
8
8
 
@@ -28,32 +28,6 @@ export const AUTHORITY_TYPES = [
28
28
 
29
29
  const short = (a: string | undefined) => (a ? `${a.slice(0, 4)}…${a.slice(-4)}` : '?')
30
30
 
31
- class Data {
32
- readonly view: DataView
33
- offset = 0
34
- constructor(readonly bytes: Uint8Array) {
35
- this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
36
- }
37
- u8() {
38
- return this.view.getUint8(this.offset++)
39
- }
40
- u32() {
41
- const v = this.view.getUint32(this.offset, true)
42
- this.offset += 4
43
- return v
44
- }
45
- u64() {
46
- const v = this.view.getBigUint64(this.offset, true)
47
- this.offset += 8
48
- return v
49
- }
50
- key() {
51
- const v = encodeAddress(this.bytes.slice(this.offset, this.offset + 32))
52
- this.offset += 32
53
- return v
54
- }
55
- }
56
-
57
31
  /**
58
32
  * 解码一条指令。accounts 是指令用到的账户地址(已按指令里的下标解析,包含地址查找表里的),data 是指令数据。
59
33
  * 数据格式不对时不抛错,按“不认识的指令”返回
@@ -78,7 +52,7 @@ export function decodeInstruction(index: number, programId: string, accounts: re
78
52
  type Partial_ = Pick<DecodedInstruction, 'type' | 'summary'> & Partial<Pick<DecodedInstruction, 'method' | 'args' | 'protocol'>>
79
53
 
80
54
  function decodeKnown(programId: string, a: readonly string[], data: Uint8Array): Partial_ | null {
81
- const d = new Data(data)
55
+ const d = new Bytes(data)
82
56
  switch (programId) {
83
57
  case SYSTEM_PROGRAM_ID:
84
58
  return decodeSystem(d, a)
@@ -108,7 +82,7 @@ function decodeKnown(programId: string, a: readonly string[], data: Uint8Array):
108
82
  }
109
83
  }
110
84
 
111
- function decodeSystem(d: Data, a: readonly string[]): Partial_ {
85
+ function decodeSystem(d: Bytes, a: readonly string[]): Partial_ {
112
86
  const tag = d.u32()
113
87
  switch (tag) {
114
88
  case 0: {
@@ -139,7 +113,7 @@ function decodeSystem(d: Data, a: readonly string[]): Partial_ {
139
113
  }
140
114
  }
141
115
 
142
- function decodeToken(d: Data, a: readonly string[]): Partial_ {
116
+ function decodeToken(d: Bytes, a: readonly string[]): Partial_ {
143
117
  const tag = d.u8()
144
118
  const amountArgs = (extra: Record<string, unknown>, amount: bigint) => ({ ...extra, amount: amount.toString() })
145
119
  switch (tag) {
package/src/fees.ts CHANGED
@@ -7,7 +7,7 @@ import { base64 } from '@scure/base'
7
7
  import { encodeAddress } from './address.js'
8
8
  import type { RequestOptions, SolanaClient } from './client.js'
9
9
  import { SOL_DECIMALS, SYSTEM_PROGRAM_ID } from './constants.js'
10
- import { formatUnits } from './layout.js'
10
+ import { computeBudgetOf, formatUnits } from './layout.js'
11
11
  import type { InstructionInput } from './write/transaction.js'
12
12
 
13
13
  export type FeeLevel = 'slow' | 'normal' | 'fast'
@@ -156,14 +156,7 @@ export async function estimateCost(client: SolanaClient, input: CostInput, optio
156
156
  raw = base64.encode(bytes)
157
157
  signatures = message.signatures
158
158
  writable = [...message.writable, ...(await lookupWritable(client, message.lookups, signal))]
159
- let limit: number | null = null
160
- let price = 0n
161
- for (const ix of message.instructions) {
162
- if (ix.programId !== COMPUTE_BUDGET_PROGRAM_ID) continue
163
- const view = new DataView(ix.data.buffer, ix.data.byteOffset, ix.data.byteLength)
164
- if (ix.data[0] === 2 && ix.data.length >= 5) limit = view.getUint32(1, true)
165
- if (ix.data[0] === 3 && ix.data.length >= 9) price = view.getBigUint64(1, true)
166
- }
159
+ const { limit, price } = computeBudgetOf(message.instructions)
167
160
  const others = message.instructions.filter((ix) => ix.programId !== COMPUTE_BUDGET_PROGRAM_ID).length
168
161
  budget = { limit: Math.min(MAX_COMPUTE_UNITS, limit ?? DEFAULT_UNITS_PER_INSTRUCTION * others), price }
169
162
  } else {
package/src/history.ts CHANGED
@@ -5,12 +5,13 @@
5
5
  */
6
6
  import { base58 } from '@scure/base'
7
7
 
8
- import { isAddress } from './address.js'
8
+ import { InvalidAddressError, isAddress } from './address.js'
9
9
  import type { RequestOptions, SolanaClient } from './client.js'
10
10
  import { NATIVE_MINT, SOL_DECIMALS, SYSTEM_PROGRAM_ID } from './constants.js'
11
11
  import { decodeInstruction, type DecodedInstruction } from './decode.js'
12
12
  import { classify, describe, type AssetDelta, type Classified } from './describe.js'
13
13
  import { formatUnits } from './layout.js'
14
+ import { mapPool } from './util.js'
14
15
 
15
16
  export type ActivityType = 'transfer' | 'swap' | 'approve' | 'liquidity' | 'mint' | 'contract' | 'other'
16
17
 
@@ -95,7 +96,7 @@ const short = (a: string) => `${a.slice(0, 4)}…${a.slice(-4)}`
95
96
  * ```
96
97
  */
97
98
  export async function history(client: SolanaClient, address: string, options: HistoryOptions = {}): Promise<HistoryPage> {
98
- if (!isAddress(address)) throw new Error(`Invalid address: ${address}`)
99
+ if (!isAddress(address)) throw new InvalidAddressError(`Invalid address: ${address}`, address)
99
100
  const limit = options.limit ?? 20
100
101
  if (!Number.isInteger(limit) || limit < 1 || limit > MAX_HISTORY_LIMIT) throw new RangeError(`limit must be an integer between 1 and ${MAX_HISTORY_LIMIT}`)
101
102
  const sources = options.source === undefined ? [rpcHistory()] : Array.isArray(options.source) ? options.source : [options.source as HistorySource]
@@ -185,18 +186,6 @@ interface SignatureInfo {
185
186
  blockTime: number | null
186
187
  }
187
188
 
188
- async function pool<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
189
- const results = new Array<R>(items.length)
190
- let next = 0
191
- const worker = async () => {
192
- while (next < items.length) {
193
- const i = next++
194
- results[i] = await fn(items[i] as T)
195
- }
196
- }
197
- await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker))
198
- return results
199
- }
200
189
 
201
190
  /**
202
191
  * 默认数据源:getSignaturesForAddress(用 before 翻页)+ getTransaction(逐笔,json 编码、支持 v0)。
@@ -228,7 +217,7 @@ export function rpcHistory(options: RpcHistoryOptions = {}): HistorySource {
228
217
  let i = 0
229
218
  while (i < rows.length && items.length < limit && scanned < maxScan) {
230
219
  const chunk = rows.slice(i, i + Math.max(1, Math.min(limit - items.length, maxScan - scanned)))
231
- const txs = await pool(chunk, concurrency, (row) => client.request<RawTransaction | null>('getTransaction', [row.signature, { encoding: 'json', maxSupportedTransactionVersion: MAX_TX_VERSION, commitment }], { signal }))
220
+ const txs = await mapPool(chunk, concurrency, (row) => client.request<RawTransaction | null>('getTransaction', [row.signature, { encoding: 'json', maxSupportedTransactionVersion: MAX_TX_VERSION, commitment }], { signal }))
232
221
  for (const [j, tx] of txs.entries()) {
233
222
  i++
234
223
  scanned++
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export {
6
6
  type AccountsOptions,
7
7
  type BalanceQuery,
8
8
  type BalancesOptions,
9
+ type BalancesQueryOptions,
9
10
  type ClientConfig,
10
11
  type OwnerTokensOptions,
11
12
  type PersistTokenMetaOptions,
@@ -64,7 +65,17 @@ export {
64
65
  clusterOfGenesis,
65
66
  type Cluster,
66
67
  } from './constants.js'
67
- export { createProgramAddress, decodeAddress, encodeAddress, findProgramAddress, getAssociatedTokenAddress, getMetadataAddress, isAddress, isOnCurve } from './address.js'
68
+ export {
69
+ InvalidAddressError,
70
+ createProgramAddress,
71
+ decodeAddress,
72
+ encodeAddress,
73
+ findProgramAddress,
74
+ getAssociatedTokenAddress,
75
+ getMetadataAddress,
76
+ isAddress,
77
+ isOnCurve,
78
+ } from './address.js'
68
79
  export {
69
80
  formatAmount,
70
81
  formatUnits,
@@ -94,6 +105,7 @@ export {
94
105
  type FallbackOptions,
95
106
  type HttpRpcOptions,
96
107
  type RequestEvent,
108
+ type RpcRequestOptions,
97
109
  type RpcTransport,
98
110
  } from './rpc.js'
99
111
  export { NetworkCheckedRpc, NetworkMismatchError, type RpcSource, type SourceOptions } from './source.js'
package/src/layout.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { encodeAddress } from './address.js'
1
+ import { Bytes } from './bytes.js'
2
2
 
3
3
  /** getMultipleAccounts 解析后的账户 */
4
4
  export interface AccountInfo {
@@ -10,80 +10,6 @@ export interface AccountInfo {
10
10
  executable: boolean
11
11
  }
12
12
 
13
- class Reader {
14
- #offset: number
15
- readonly #view: DataView
16
-
17
- constructor(
18
- readonly bytes: Uint8Array,
19
- offset = 0,
20
- ) {
21
- this.#offset = offset
22
- this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
23
- }
24
-
25
- get offset(): number {
26
- return this.#offset
27
- }
28
-
29
- #need(n: number): void {
30
- if (this.#offset + n > this.bytes.length) {
31
- throw new RangeError('Account data is shorter than expected')
32
- }
33
- }
34
-
35
- skip(n: number): void {
36
- this.#need(n)
37
- this.#offset += n
38
- }
39
-
40
- u8(): number {
41
- this.#need(1)
42
- return this.bytes[this.#offset++] as number
43
- }
44
-
45
- u16(): number {
46
- this.#need(2)
47
- const v = this.#view.getUint16(this.#offset, true)
48
- this.#offset += 2
49
- return v
50
- }
51
-
52
- u32(): number {
53
- this.#need(4)
54
- const v = this.#view.getUint32(this.#offset, true)
55
- this.#offset += 4
56
- return v
57
- }
58
-
59
- u64(): bigint {
60
- this.#need(8)
61
- const v = this.#view.getBigUint64(this.#offset, true)
62
- this.#offset += 8
63
- return v
64
- }
65
-
66
- pubkey(): string {
67
- this.#need(32)
68
- const v = encodeAddress(this.bytes.slice(this.#offset, this.#offset + 32))
69
- this.#offset += 32
70
- return v
71
- }
72
-
73
- /** borsh string:u32 长度 + UTF-8;去掉 Metaplex 用来补齐长度的 \0 */
74
- string(): string {
75
- const len = this.u32()
76
- this.#need(len)
77
- const v = new TextDecoder().decode(this.bytes.slice(this.#offset, this.#offset + len))
78
- this.#offset += len
79
- return v.replace(/\0+$/, '').trim()
80
- }
81
-
82
- option<T>(read: () => T): T | null {
83
- return this.u8() === 1 ? read() : null
84
- }
85
- }
86
-
87
13
  export interface MintInfo {
88
14
  mintAuthority: string | null
89
15
  supply: bigint
@@ -116,7 +42,7 @@ export function parseMint(data: Uint8Array): MintInfo {
116
42
  if (data.length !== MINT_SIZE && !(data.length > ACCOUNT_TYPE_OFFSET && data[ACCOUNT_TYPE_OFFSET] === ACCOUNT_TYPE_MINT)) {
117
43
  throw new RangeError('Not a mint account')
118
44
  }
119
- const r = new Reader(data)
45
+ const r = new Bytes(data)
120
46
  const hasMintAuthority = r.u32() === 1
121
47
  const mintAuthority = r.pubkey()
122
48
  const supply = r.u64()
@@ -140,7 +66,7 @@ export function findExtension(data: Uint8Array, extensionType: number): Uint8Arr
140
66
  return null
141
67
  }
142
68
  try {
143
- const r = new Reader(data, EXTENSIONS_OFFSET)
69
+ const r = new Bytes(data, EXTENSIONS_OFFSET)
144
70
  while (r.offset + 4 <= data.length) {
145
71
  const type = r.u16()
146
72
  const length = r.u16()
@@ -167,7 +93,7 @@ function parseTokenMetadataExtension(data: Uint8Array): TokenMetadata | null {
167
93
  return null
168
94
  }
169
95
  try {
170
- const m = new Reader(ext)
96
+ const m = new Bytes(ext)
171
97
  m.pubkey() // updateAuthority
172
98
  m.pubkey() // mint
173
99
  return { name: m.string(), symbol: m.string(), uri: m.string() }
@@ -212,7 +138,7 @@ export function parseTransferFeeConfig(data: Uint8Array): TransferFeeConfigInfo
212
138
  return null
213
139
  }
214
140
  try {
215
- const r = new Reader(ext)
141
+ const r = new Bytes(ext)
216
142
  // OptionalNonZeroPubkey:全 0 表示没有
217
143
  const configAuthority = r.pubkey()
218
144
  const withdrawAuthority = r.pubkey()
@@ -238,15 +164,70 @@ export interface TokenAccountInfo {
238
164
  amount: bigint
239
165
  }
240
166
 
167
+ function isTokenAccountData(data: Uint8Array): boolean {
168
+ return data.length === ACCOUNT_SIZE || (data.length > ACCOUNT_TYPE_OFFSET && data[ACCOUNT_TYPE_OFFSET] === ACCOUNT_TYPE_ACCOUNT)
169
+ }
170
+
241
171
  /** 解析代币账户(SPL Token / Token-2022 前 72 字节相同) */
242
172
  export function parseTokenAccount(data: Uint8Array): TokenAccountInfo {
243
- if (data.length !== ACCOUNT_SIZE && !(data.length > ACCOUNT_TYPE_OFFSET && data[ACCOUNT_TYPE_OFFSET] === ACCOUNT_TYPE_ACCOUNT)) {
173
+ if (!isTokenAccountData(data)) {
244
174
  throw new RangeError('Not a token account')
245
175
  }
246
- const r = new Reader(data)
176
+ const r = new Bytes(data)
247
177
  return { mint: r.pubkey(), owner: r.pubkey(), amount: r.u64() }
248
178
  }
249
179
 
180
+ /** 代币账户的完整基础布局(前 165 字节,SPL Token / Token-2022 相同) */
181
+ export interface TokenAccountState extends TokenAccountInfo {
182
+ delegate: string | null
183
+ /** 0 未初始化、1 正常、2 冻结 */
184
+ state: number
185
+ frozen: boolean
186
+ /** Wrapped SOL 账户(is_native 有值) */
187
+ native: boolean
188
+ delegatedAmount: bigint
189
+ closeAuthority: string | null
190
+ }
191
+
192
+ /** 解析代币账户的完整基础布局;不是代币账户或未初始化时为 null(包内使用:/approvals、/simulate) */
193
+ export function parseTokenAccountState(data: Uint8Array): TokenAccountState | null {
194
+ if (!isTokenAccountData(data)) {
195
+ return null
196
+ }
197
+ const b = new Bytes(data)
198
+ const state = b.u8(108)
199
+ if (state === 0) {
200
+ return null
201
+ }
202
+ // COption:u32 标记(1 有、0 没有)+ 值
203
+ return {
204
+ mint: b.key(0),
205
+ owner: b.key(32),
206
+ amount: b.u64(64),
207
+ delegate: b.u32(72) === 1 ? b.key(76) : null,
208
+ state,
209
+ frozen: state === 2,
210
+ native: b.u32(109) === 1,
211
+ delegatedAmount: b.u64(121),
212
+ closeAuthority: b.u32(129) === 1 ? b.key(133) : null,
213
+ }
214
+ }
215
+
216
+ /** 计算预算指令里的计算单元上限(SetComputeUnitLimit)和单价(SetComputeUnitPrice,micro-lamports);没有时 limit 为 null、price 为 0 */
217
+ export function computeBudgetOf(instructions: Iterable<{ programId: string; data: Uint8Array }>): { limit: number | null; price: bigint } {
218
+ let limit: number | null = null
219
+ let price = 0n
220
+ for (const ix of instructions) {
221
+ if (ix.programId !== COMPUTE_BUDGET_PROGRAM) continue
222
+ const b = new Bytes(ix.data)
223
+ if (ix.data[0] === 2 && ix.data.length >= 5) limit = b.u32(1)
224
+ if (ix.data[0] === 3 && ix.data.length >= 9) price = b.u64(1)
225
+ }
226
+ return { limit, price }
227
+ }
228
+
229
+ const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111'
230
+
250
231
  export interface MetaplexMetadata extends TokenMetadata {
251
232
  updateAuthority: string
252
233
  mint: string
@@ -261,7 +242,7 @@ export interface MetaplexMetadata extends TokenMetadata {
261
242
 
262
243
  /** 解析 Metaplex Token Metadata 账户;老版本账户缺少后面的字段时,缺的部分为默认值 */
263
244
  export function parseMetaplexMetadata(data: Uint8Array): MetaplexMetadata {
264
- const r = new Reader(data)
245
+ const r = new Bytes(data)
265
246
  if (r.u8() !== 4) {
266
247
  throw new RangeError('Not a Metaplex metadata account') // Key::MetadataV1 = 4
267
248
  }
@@ -1,6 +1,6 @@
1
1
  import { sha256 } from '@noble/hashes/sha2.js'
2
2
 
3
- import { encodeAddress, getAssociatedTokenAddress, isAddress } from '../address.js'
3
+ import { encodeAddress, getAssociatedTokenAddress, InvalidAddressError, isAddress } from '../address.js'
4
4
  import type { SolanaClient } from '../client.js'
5
5
  import { NATIVE_MINT, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'
6
6
  import { formatUnits, parseMint, parseTokenAccount, parseTransferFeeConfig, parseUnits, type AccountInfo } from '../layout.js'
@@ -25,7 +25,7 @@ export interface Ctx {
25
25
  export function createCtx(client: SolanaClient, owner: string, options: SolanaLiquidityOptions): Ctx {
26
26
  for (const address of [owner, options.recipient ?? owner]) {
27
27
  if (!isAddress(address)) {
28
- throw new Error(`Invalid address: ${address}`)
28
+ throw new InvalidAddressError(`Invalid address: ${address}`, address)
29
29
  }
30
30
  }
31
31
  const slippage = options.slippage ?? 0.005
package/src/lp.ts CHANGED
@@ -1,10 +1,12 @@
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 { boundedSet, mapPool } from './util.js'
9
+ import { Bytes } from './bytes.js'
8
10
  import {
9
11
  getAmountsForLiquidity,
10
12
  getFeeGrowthInside,
@@ -168,34 +170,8 @@ export interface LpPositionsOptions extends RequestOptions {
168
170
  // 账户布局(各程序的 Anchor / 原生账户,偏移量含 8 字节判别符)
169
171
  // ---------------------------------------------------------------------------
170
172
 
171
- /** 小端读取账户数据(添加 / 移除流动性也用) */
172
- export class Bytes {
173
- readonly #view: DataView
174
- constructor(readonly data: Uint8Array) {
175
- this.#view = new DataView(data.buffer, data.byteOffset, data.byteLength)
176
- }
177
- u8(o: number): number {
178
- return this.#view.getUint8(o)
179
- }
180
- u16(o: number): number {
181
- return this.#view.getUint16(o, true)
182
- }
183
- u32(o: number): number {
184
- return this.#view.getUint32(o, true)
185
- }
186
- i32(o: number): number {
187
- return this.#view.getInt32(o, true)
188
- }
189
- u64(o: number): bigint {
190
- return this.#view.getBigUint64(o, true)
191
- }
192
- u128(o: number): bigint {
193
- return (this.#view.getBigUint64(o + 8, true) << 64n) | this.#view.getBigUint64(o, true)
194
- }
195
- key(o: number): string {
196
- return encodeAddress(this.data.subarray(o, o + 32))
197
- }
198
- }
173
+ // 小端读取账户数据:公共的 Bytes(添加 / 移除流动性也从这里引用)
174
+ export { Bytes }
199
175
 
200
176
  const ZERO_ADDRESS = '11111111111111111111111111111111'
201
177
  const RAYDIUM_POSITION_SIZE = 281
@@ -544,7 +520,7 @@ interface RawAmm {
544
520
  */
545
521
  export async function lpPositions(client: SolanaClient, owner: string, options: LpPositionsOptions = {}): Promise<SolanaLpPosition[]> {
546
522
  if (!isAddress(owner)) {
547
- throw new Error(`Invalid owner address: ${owner}`)
523
+ throw new InvalidAddressError(`Invalid owner address: ${owner}`, owner)
548
524
  }
549
525
  const protocols = new Set<SolanaLpProtocol>(options.protocols ?? (Object.keys(SOLANA_LP_PROTOCOLS) as SolanaLpProtocol[]))
550
526
  for (const p of protocols) {
@@ -554,7 +530,7 @@ export async function lpPositions(client: SolanaClient, owner: string, options:
554
530
  }
555
531
  for (const address of [...(options.mints ?? []), ...(options.pools ?? [])]) {
556
532
  if (!isAddress(address)) {
557
- throw new Error(`Invalid address: ${address}`)
533
+ throw new InvalidAddressError(`Invalid address: ${address}`, address)
558
534
  }
559
535
  }
560
536
  const { signal } = options
@@ -621,13 +597,30 @@ export async function lpPositions(client: SolanaClient, owner: string, options:
621
597
  }
622
598
  })
623
599
 
624
- // 3. 池子:集中流动性的池子账户;LP 代币按 LP mint 找池子
600
+ // 3. 池子:集中流动性的池子账户;LP 代币按 LP mint 找池子(之前找到过的直接读池子账户,和集中流动性的池子一起读)
625
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)))
626
607
  const [poolAccounts, found] = await Promise.all([
627
- client.accounts(poolAddresses, { signal }),
628
- 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),
629
610
  ])
630
- 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) : [])]) {
631
624
  if (pool) {
632
625
  poolByLp.set(pool.lpMint, pool)
633
626
  }
@@ -751,6 +744,19 @@ export async function lpPositions(client: SolanaClient, owner: string, options:
751
744
  return finalize(client, raw, options)
752
745
  }
753
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
+
754
760
  /**
755
761
  * 节点不提供这个方法(方法不存在、被拦截、需要付费 / Token、403 等):这时报错里说明怎么绕过。
756
762
  * 多节点时所有节点都是这个原因才算;限频、超时、网络不对等原样抛出(资产总值 portfolio 也用)
@@ -779,43 +785,63 @@ async function listHoldings(client: SolanaClient, owner: string, mints: readonly
779
785
  const balances = list.length ? await client.balances(owner, list, { signal }) : []
780
786
  return balances.filter((b) => b.success && !b.native && b.balance !== '0').map((b) => ({ mint: b.token, amount: BigInt(b.balance), decimals: b.decimals }))
781
787
  }
782
- const tokens = await client.ownerTokens(owner, { signal, metadata: false, includeNative: false, includeNfts: true }).catch((err: unknown) => {
783
- if (signal?.aborted || !methodUnavailable(err)) {
784
- throw err // 取消、限频、超时等原样抛出(保留错误类型)
785
- }
786
- throw new Error(
787
- `Scanning the token accounts of ${owner} failed (${err instanceof Error ? err.message : String(err)}). ` +
788
- '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',
789
- { cause: err },
790
- )
791
- })
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
+ )
792
795
  return tokens.map((t) => ({ mint: t.token, amount: BigInt(t.balance), decimals: t.decimals }))
793
796
  }
794
797
 
795
- /** 按 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;找到的池子地址写入缓存 */
796
826
  async function findAmmPool(client: SolanaClient, protocol: 'raydium-amm' | 'raydium-cpmm', lpMint: string, signal: AbortSignal | undefined): Promise<AmmPoolState | null> {
797
827
  const [programId, size, offset] = protocol === 'raydium-amm' ? [RAYDIUM_AMM_PROGRAM_ID, AMM_POOL_SIZE, 464] : [RAYDIUM_CPMM_PROGRAM_ID, CPMM_POOL_SIZE, 136]
798
- const res = await client
799
- .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 } }[]>(
800
831
  'getProgramAccounts',
801
832
  [programId, { encoding: 'base64', commitment: client.commitment, filters: [{ dataSize: size }, { memcmp: { offset, bytes: lpMint } }] }],
802
833
  { signal },
803
- )
804
- .catch((err: unknown) => {
805
- if (signal?.aborted || !methodUnavailable(err)) {
806
- throw err
807
- }
808
- // 免费公共节点大多不支持 getProgramAccounts:说明怎么绕过
809
- throw new Error(
810
- `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). ` +
811
- "Pass the pool address in `pools`, use a node that supports getProgramAccounts, or skip LP tokens with `protocols: ['raydium-clmm', 'orca-whirlpool']`",
812
- { cause: err },
813
- )
814
- })
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
+ )
815
840
  for (const item of res ?? []) {
816
841
  const data = base64.decode(item.account.data[0])
817
842
  const pool = parseAmmPool({ address: item.pubkey, owner: item.account.owner, data, lamports: 0n, executable: false })
818
843
  if (pool?.lpMint === lpMint) {
844
+ boundedSet(ammPoolByLpMint, lpPoolKey(protocol, lpMint), pool.address, MAX_CACHED_LP_POOLS)
819
845
  return pool
820
846
  }
821
847
  }
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
  }