@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
package/src/simulate.ts CHANGED
@@ -9,7 +9,7 @@ import type { RequestOptions, SolanaClient } from './client.js'
9
9
  import { NATIVE_MINT, SOL_DECIMALS, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from './constants.js'
10
10
  import { decodeInstruction, type DecodedInstruction } from './decode.js'
11
11
  import { classify, describe, type AssetDelta, type TxKind } from './describe.js'
12
- import { formatUnits, parseMint, type AccountInfo } from './layout.js'
12
+ import { computeBudgetOf, formatUnits, parseMint, parseTokenAccountState, type AccountInfo } from './layout.js'
13
13
  import { BPF_LOADER_UPGRADEABLE, COMPUTE_BUDGET, PROGRAMS, programErrorName } from './programs.js'
14
14
  import type { SolanaPriceSource } from './price.js'
15
15
  import {
@@ -222,18 +222,7 @@ interface TokenState {
222
222
  function tokenState(snap: Snap | null): TokenState | null {
223
223
  const d = snap?.data
224
224
  if (!snap || !d || (snap.owner !== TOKEN_PROGRAM_ID && snap.owner !== TOKEN_2022_PROGRAM_ID)) return null
225
- if (d.length !== 165 && !(d.length > 165 && d[165] === 2)) return null
226
- const view = new DataView(d.buffer, d.byteOffset, d.byteLength)
227
- const key = (o: number) => encodeAddress(d.slice(o, o + 32))
228
- if (d[108] === 0) return null
229
- return {
230
- mint: key(0),
231
- owner: key(32),
232
- amount: view.getBigUint64(64, true),
233
- delegate: view.getUint32(72, true) === 1 ? key(76) : null,
234
- delegatedAmount: view.getBigUint64(121, true),
235
- closeAuthority: view.getUint32(129, true) === 1 ? key(133) : null,
236
- }
225
+ return parseTokenAccountState(d)
237
226
  }
238
227
 
239
228
  function mintAuthorities(snap: Snap | null): { mintAuthority: string | null; freezeAuthority: string | null } | null {
@@ -338,14 +327,7 @@ export async function simulate(client: SolanaClient, input: SimulateInput, optio
338
327
  }
339
328
 
340
329
  // 手续费:签名费 + 计算单元上限 × 单价
341
- let limit: bigint | null = null
342
- let price = 0n
343
- for (const ix of tx.instructions) {
344
- if (ix.programId !== COMPUTE_BUDGET) continue
345
- const view = new DataView(ix.data.buffer, ix.data.byteOffset, ix.data.byteLength)
346
- if (ix.data[0] === 2 && ix.data.length >= 5) limit = BigInt(view.getUint32(1, true))
347
- if (ix.data[0] === 3 && ix.data.length >= 9) price = view.getBigUint64(1, true)
348
- }
330
+ const { limit, price } = computeBudgetOf(tx.instructions)
349
331
  const nonBudget = tx.instructions.filter((ix) => ix.programId !== COMPUTE_BUDGET).length
350
332
  const units = BigInt(Math.min(1_400_000, limit !== null ? Number(limit) : 200_000 * nonBudget))
351
333
  const fee = 5000n * BigInt(tx.signers.length) + (price * units + 999_999n) / 1_000_000n
package/src/source.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { DEFAULT_RPC_URLS, GENESIS_HASHES, type Cluster } from './constants.js'
2
- import { FallbackRpc, HttpRpc, type FallbackOptions, type HttpRpcOptions, type RpcTransport } from './rpc.js'
2
+ import { FallbackRpc, HttpRpc, type FallbackOptions, type HttpRpcOptions, type RpcRequestOptions, type RpcTransport } from './rpc.js'
3
3
 
4
4
  /**
5
5
  * 节点来源(也可以传数组:主节点 + 备用节点):
6
6
  * - RPC URL 字符串
7
7
  * - @solana/web3.js 的 Connection(使用其 rpcEndpoint)
8
- * - 自定义传输:实现 `request(method, params)` 的对象(如包一层 @solana/kit 的 rpc)
8
+ * - 自定义传输:实现 `request(method, params)` 的对象(如包一层 @solana/kit 的 rpc);可选的第三个参数 `{ signal }` 用于取消
9
9
  */
10
10
  export type RpcSource = string | RpcTransport | { rpcEndpoint: string }
11
11
 
@@ -84,8 +84,8 @@ export class NetworkCheckedRpc implements RpcTransport {
84
84
  }
85
85
  }
86
86
 
87
- async request<T = unknown>(method: string, params?: readonly unknown[]): Promise<T> {
88
- const [checked, result] = await Promise.allSettled([this.#check(), this.inner.request<T>(method, params)])
87
+ async request<T = unknown>(method: string, params?: readonly unknown[], options?: RpcRequestOptions): Promise<T> {
88
+ const [checked, result] = await Promise.allSettled([this.#check(), options ? this.inner.request<T>(method, params, options) : this.inner.request<T>(method, params)])
89
89
  if (checked.status === 'rejected') {
90
90
  throw checked.reason
91
91
  }
@@ -102,10 +102,16 @@ export interface ResolvedSource {
102
102
  genesis: () => Promise<string>
103
103
  }
104
104
 
105
+ export { clientSourceConfigs, type ClientSourceConfig } from './clientConfig.js'
106
+
107
+ /**
108
+ * @param expectedGenesis 期望的创世哈希(如 sendRpc 要和 client 在同一个网络):传了时总是校验节点所在网络,不一致时报 NetworkMismatchError
109
+ */
105
110
  export function resolveSource(
106
111
  source: RpcSource | readonly RpcSource[] | undefined,
107
112
  cluster: Cluster | undefined,
108
113
  options: SourceOptions = {},
114
+ expectedGenesis?: () => Promise<string>,
109
115
  ): ResolvedSource {
110
116
  const isDefault = source === undefined
111
117
  const list: readonly RpcSource[] = isDefault ? DEFAULT_RPC_URLS[cluster ?? 'mainnet'] : Array.isArray(source) ? source : [source as RpcSource]
@@ -118,6 +124,9 @@ export function resolveSource(
118
124
 
119
125
  let detecting: Promise<string> | null = null
120
126
  const genesis = (): Promise<string> => {
127
+ if (expectedGenesis) {
128
+ return expectedGenesis()
129
+ }
121
130
  if (cluster) {
122
131
  return Promise.resolve(GENESIS_HASHES[cluster])
123
132
  }
@@ -142,7 +151,7 @@ export function resolveSource(
142
151
  }
143
152
 
144
153
  // 内置节点本来就是按 cluster 选的,不校验;单个节点且没指定 cluster 时,期望值就是它自己,也不需要校验
145
- const check = !isDefault && (multiple || cluster !== undefined)
154
+ const check = !isDefault && (multiple || cluster !== undefined || expectedGenesis !== undefined)
146
155
  const nodes = check ? raw.map((node) => new NetworkCheckedRpc(node, genesis)) : raw
147
156
  // 单个节点也包一层 FallbackRpc:统一 onRequest 事件和健康状态(单节点失败时抛原始错误)
148
157
  return { transport: new FallbackRpc(nodes, options), genesis }
@@ -10,7 +10,7 @@
10
10
  * await closeTokenAccounts(client, key, list.filter((a) => a.amount === '0'))
11
11
  * ```
12
12
  */
13
- import { approvals, type ApprovalsQuery, type TokenAccountApproval } from '../approvals.js'
13
+ import { approvals, type ApprovalsOptions, type TokenAccountApproval } from '../approvals.js'
14
14
  import { resolve, type ShortcutOptions } from '../shortcuts.js'
15
15
 
16
16
  export {
@@ -22,6 +22,7 @@ export {
22
22
  revokeApprovals,
23
23
  type ApprovalCall,
24
24
  type ApprovalResult,
25
+ type ApprovalsOptions,
25
26
  type ApprovalsQuery,
26
27
  type BuildClosesOptions,
27
28
  type CloseAccountsOptions,
@@ -30,7 +31,7 @@ export {
30
31
  } from '../approvals.js'
31
32
  export { closeAccount, harvestWithheldTokensToMint, revoke } from '../write/instructions.js'
32
33
 
33
- export interface GetApprovalsOptions extends ApprovalsQuery, ShortcutOptions {}
34
+ export interface GetApprovalsOptions extends ApprovalsOptions, ShortcutOptions {}
34
35
 
35
36
  /**
36
37
  * 查地址的代币账户给出的授权和可以关闭的空账户,节点参数与 getBalances 相同。
@@ -11,7 +11,7 @@
11
11
  * await claimAll(client, key, c.positions) // 批量领取
12
12
  * ```
13
13
  */
14
- import { claimable, type Claimable, type ClaimQuery } from '../claim.js'
14
+ import { claimable, type Claimable, type ClaimableOptions } from '../claim.js'
15
15
  import { resolve, type ShortcutOptions } from '../shortcuts.js'
16
16
 
17
17
  export {
@@ -25,6 +25,7 @@ export {
25
25
  type ClaimOptions,
26
26
  type ClaimPlan,
27
27
  type ClaimProtocol,
28
+ type ClaimableOptions,
28
29
  type ClaimQuery,
29
30
  type ClaimResult,
30
31
  type ClaimSimulation,
@@ -35,7 +36,7 @@ export {
35
36
  } from '../claim.js'
36
37
  export type { PlanSimulation } from '../batchPlan.js'
37
38
 
38
- export interface GetClaimableOptions extends ClaimQuery, ShortcutOptions {}
39
+ export interface GetClaimableOptions extends ClaimableOptions, ShortcutOptions {}
39
40
 
40
41
  /**
41
42
  * 预览一个地址能领取的手续费和挖矿奖励(只读取),节点参数与 getBalances 相同。默认列出全部代币账户(需要支持 getTokenAccountsByOwner 的节点),
@@ -11,7 +11,7 @@
11
11
  * const { simulation, results } = await reclaim(client, key, r.items.filter((i) => i.selected))
12
12
  * ```
13
13
  */
14
- import { reclaimable, type Reclaimable, type ReclaimQuery } from '../reclaim.js'
14
+ import { reclaimable, type Reclaimable, type ReclaimableOptions } from '../reclaim.js'
15
15
  import { resolve, type ShortcutOptions } from '../shortcuts.js'
16
16
 
17
17
  export {
@@ -28,6 +28,7 @@ export {
28
28
  type ReclaimKind,
29
29
  type ReclaimOptions,
30
30
  type ReclaimPlan,
31
+ type ReclaimableOptions,
31
32
  type ReclaimQuery,
32
33
  type ReclaimResult,
33
34
  type ReclaimSimulation,
@@ -37,7 +38,7 @@ export {
37
38
  export type { PlanSimulation } from '../batchPlan.js'
38
39
  export { burnChecked, closeAccount, harvestWithheldTokensToMint } from '../write/instructions.js'
39
40
 
40
- export interface GetReclaimableOptions extends ReclaimQuery, ShortcutOptions {}
41
+ export interface GetReclaimableOptions extends ReclaimableOptions, ShortcutOptions {}
41
42
 
42
43
  /**
43
44
  * 扫描钱包里能回收租金的账户,节点参数与 getBalances 相同。默认列出全部代币账户(需要支持 getTokenAccountsByOwner 的节点),
package/src/swap/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * (直接兑换,或经过 SOL / USDC / USDT 的两跳),按链上状态在本地报价(照搬各程序的计算),选输出最多的;
4
4
  * 按滑点算最少收到的数量,构建指令(SOL 自动包装 / 解包、缺少的代币账户自动创建),发送同 /write
5
5
  */
6
- import { getAssociatedTokenAddress, isAddress } from '../address.js'
6
+ import { getAssociatedTokenAddress, InvalidAddressError, isAddress } from '../address.js'
7
7
  import type { SolanaClient } from '../client.js'
8
8
  import { NATIVE_MINT, TOKEN_PROGRAM_ID } from '../constants.js'
9
9
  import { formatUnits, parseTokenAccount, type AccountInfo } from '../layout.js'
@@ -180,7 +180,7 @@ export async function buildSwap(client: SolanaClient, owner: string, params: Sol
180
180
  const tokenIn = nativeIn ? NATIVE_MINT : params.tokenIn
181
181
  const tokenOut = nativeOut ? NATIVE_MINT : params.tokenOut
182
182
  for (const mint of [tokenIn, tokenOut]) {
183
- if (!isAddress(mint)) throw new Error(`Invalid mint address: ${mint}`)
183
+ if (!isAddress(mint)) throw new InvalidAddressError(`Invalid mint address: ${mint}`, mint)
184
184
  }
185
185
  if (tokenIn === tokenOut) throw new Error(nativeIn || nativeOut ? 'Wrapping / unwrapping SOL is not a swap' : 'tokenIn and tokenOut are the same')
186
186
  const protocols = new Set(options.protocols ?? SWAP_PROTOCOLS)
@@ -192,7 +192,7 @@ export async function buildSwap(client: SolanaClient, owner: string, params: Sol
192
192
  if (!isAddress(m)) throw new Error(`Invalid intermediate mint: ${m}`)
193
193
  }
194
194
  for (const p of options.pools ?? []) {
195
- if (!isAddress(p)) throw new Error(`Invalid pool address: ${p}`)
195
+ if (!isAddress(p)) throw new InvalidAddressError(`Invalid pool address: ${p}`, p)
196
196
  }
197
197
 
198
198
  // 1. 一次读:候选池子(PDA 推导 + 传入的)、Clock、各代币的 mint
@@ -0,0 +1,240 @@
1
+ // 代币详情(tokens)、NFT 元数据(nfts)、NFT 持有人(nftOwners)
2
+
3
+ import type { AccountInfo, TransferFeeInfo } from './layout.js'
4
+ import { getMetadataAddress, isAddress } from './address.js'
5
+ import type { RequestOptions, TokensOptions } from './client.js'
6
+ import { SOL_DECIMALS, TOKEN_2022_PROGRAM_ID } from './constants.js'
7
+ import { formatUnits, parseTokenAccount, parseTransferFeeConfig } from './layout.js'
8
+ import { RpcError } from './rpc.js'
9
+ import type { ReadContext } from './balances.js'
10
+ import type { QueryContext } from './accountReader.js'
11
+ import { getMeta, learnMeta, safeParseMetaplex, safeParseMint } from './tokenMeta.js'
12
+ import {
13
+ DEFAULT_TOKEN_FIELDS,
14
+ type FailureReason,
15
+ type NftDetails,
16
+ type NftOwner,
17
+ type TokenDetails,
18
+ type TokenField,
19
+ type TokenStandard,
20
+ type TransferFee,
21
+ type TransferFeeConfig,
22
+ } from './types.js'
23
+
24
+ const TOKEN_STANDARDS: readonly TokenStandard[] = [
25
+ 'NonFungible',
26
+ 'FungibleAsset',
27
+ 'Fungible',
28
+ 'NonFungibleEdition',
29
+ 'ProgrammableNonFungible',
30
+ 'ProgrammableNonFungibleEdition',
31
+ ]
32
+
33
+ export async function tokensOf<F extends TokenField>(rc: ReadContext, mints: readonly string[], options: TokensOptions<F>): Promise<TokenDetails<F>[]> {
34
+ const fields = (options.fields ?? DEFAULT_TOKEN_FIELDS) as readonly F[]
35
+ const wanted = new Set<TokenField>(fields)
36
+ const scope = rc.scope
37
+ const ctx: QueryContext = { signal: options.signal }
38
+ const needsNames = wanted.has('name') || wanted.has('symbol') || wanted.has('uri')
39
+ const addresses: string[] = []
40
+ const plan = mints.map((mint) => {
41
+ if (rc.nativeMints.has(mint) || !isAddress(mint)) {
42
+ return { mint, mintIndex: -1, metadataIndex: -1 }
43
+ }
44
+ const meta = getMeta(scope, mint)
45
+ // uri / supply / 权限 / 手续费不缓存,需要时总要读 mint;name / symbol 命中缓存时可以省掉元数据账户
46
+ const cachedOnly =
47
+ !wanted.has('uri') &&
48
+ !wanted.has('supply') &&
49
+ !wanted.has('mintAuthority') &&
50
+ !wanted.has('freezeAuthority') &&
51
+ !wanted.has('transferFee') &&
52
+ (!wanted.has('decimals') || meta.decimals !== undefined) &&
53
+ (!wanted.has('tokenProgram') || meta.tokenProgram !== undefined) &&
54
+ (!wanted.has('name') || meta.name !== undefined) &&
55
+ (!wanted.has('symbol') || meta.symbol !== undefined)
56
+ if (cachedOnly) {
57
+ return { mint, mintIndex: -1, metadataIndex: -1 }
58
+ }
59
+ const namesCached = (!wanted.has('name') || meta.name !== undefined) && (!wanted.has('symbol') || meta.symbol !== undefined) && !wanted.has('uri')
60
+ return {
61
+ mint,
62
+ mintIndex: addresses.push(mint) - 1,
63
+ metadataIndex: needsNames && !namesCached ? addresses.push(getMetadataAddress(mint)) - 1 : -1,
64
+ }
65
+ })
66
+ const [accounts, epoch] = await Promise.all([
67
+ addresses.length ? rc.reader.accounts(addresses, ctx) : Promise.resolve([] as (AccountInfo | null)[]),
68
+ // 手续费按当前 epoch 选新 / 旧配置;和账户读取在同一个批量请求里
69
+ wanted.has('transferFee') && addresses.length
70
+ ? rc.reader.call<{ epoch: number | string }>(ctx, 'getEpochInfo', [{ commitment: rc.commitment }]).then((res) => BigInt(res.epoch))
71
+ : Promise.resolve(null),
72
+ ])
73
+
74
+ return plan.map(({ mint, mintIndex, metadataIndex }) => {
75
+ const native = rc.nativeMints.has(mint)
76
+ const values: Record<TokenField, unknown> & { exists: boolean } = native
77
+ ? { exists: true, name: 'Solana', symbol: 'SOL', uri: null, decimals: SOL_DECIMALS, supply: null, tokenProgram: null, mintAuthority: null, freezeAuthority: null, transferFee: null }
78
+ : tokenValues(
79
+ rc,
80
+ mint,
81
+ mintIndex === -1 ? null : (accounts[mintIndex] ?? null),
82
+ metadataIndex === -1 ? null : (accounts[metadataIndex] ?? null),
83
+ mintIndex !== -1,
84
+ mintIndex !== -1 && metadataIndex !== -1,
85
+ epoch,
86
+ )
87
+ const out: Record<string, unknown> = { address: mint, native }
88
+ let missing: F | undefined
89
+ for (const field of fields) {
90
+ out[field] = values[field] ?? null
91
+ // 权限为 null 表示没有权限、手续费为 null 表示没有手续费,都是有效值;主币没有 uri / supply / tokenProgram
92
+ const nullable =
93
+ field === 'mintAuthority' || field === 'freezeAuthority' || field === 'transferFee' || (native && (field === 'uri' || field === 'supply' || field === 'tokenProgram'))
94
+ if (out[field] === null && !nullable) {
95
+ missing ??= field
96
+ }
97
+ }
98
+ if (wanted.has('supply')) {
99
+ const supply = values.supply as string | null
100
+ const decimals = values.decimals as number | null
101
+ out.supplyFormatted = supply === null || decimals === null ? null : formatUnits(BigInt(supply), decimals)
102
+ }
103
+ const error: FailureReason | undefined = native
104
+ ? undefined
105
+ : !isAddress(mint)
106
+ ? 'invalid-address'
107
+ : !values.exists
108
+ ? accounts[mintIndex]
109
+ ? 'not-token'
110
+ : 'not-found'
111
+ : missing
112
+ ? 'missing-field'
113
+ : undefined
114
+ out.success = error === undefined
115
+ if (error) {
116
+ out.error = error
117
+ if (error === 'missing-field') {
118
+ out.errorField = missing
119
+ }
120
+ }
121
+ return out as TokenDetails<F>
122
+ })
123
+ }
124
+
125
+ function tokenValues(
126
+ rc: ReadContext,
127
+ mint: string,
128
+ mintAccount: AccountInfo | null,
129
+ metadataAccount: AccountInfo | null,
130
+ fetchedMint: boolean,
131
+ lookedForNames: boolean,
132
+ epoch: bigint | null,
133
+ ): Record<TokenField, unknown> & { exists: boolean } {
134
+ const scope = rc.scope
135
+ const meta = learnMeta(scope, mint, mintAccount, metadataAccount, lookedForNames)
136
+ const info = mintAccount ? safeParseMint(mintAccount) : null
137
+ const uri = info?.metadata?.uri ?? (metadataAccount ? (safeParseMetaplex(metadataAccount)?.uri ?? null) : null)
138
+ const feeConfig = info && mintAccount?.owner === TOKEN_2022_PROGRAM_ID ? parseTransferFeeConfig(mintAccount.data) : null
139
+ return {
140
+ // 读了 mint 账户就以它为准(不存在则 false);没读说明请求的字段全部命中缓存
141
+ exists: fetchedMint ? info !== null : true,
142
+ name: meta.name ?? null,
143
+ symbol: meta.symbol ?? null,
144
+ uri,
145
+ decimals: meta.decimals ?? null,
146
+ supply: info ? info.supply.toString() : null,
147
+ tokenProgram: meta.tokenProgram ?? null,
148
+ mintAuthority: info?.mintAuthority ?? null,
149
+ freezeAuthority: info?.freezeAuthority ?? null,
150
+ transferFee: feeConfig ? toTransferFeeConfig(feeConfig.older, feeConfig.newer, epoch) : null,
151
+ }
152
+ }
153
+
154
+ function toTransferFee(fee: TransferFeeInfo): TransferFee {
155
+ return { basisPoints: fee.basisPoints, maximumFee: fee.maximumFee.toString(), epoch: fee.epoch.toString() }
156
+ }
157
+
158
+ /** 当前生效的配置:epoch >= newer.epoch 用 newer,否则 older;不知道当前 epoch 时按 newer */
159
+ function toTransferFeeConfig(older: TransferFeeInfo, newer: TransferFeeInfo, epoch: bigint | null): TransferFeeConfig {
160
+ const current = epoch === null || epoch >= newer.epoch ? newer : older
161
+ return { ...toTransferFee(current), older: toTransferFee(older), newer: toTransferFee(newer) }
162
+ }
163
+
164
+ /** 批量查 NFT 元数据(Metaplex;Token-2022 NFT 取 TokenMetadata 扩展),一次 getMultipleAccounts */
165
+ export async function nftsOf(rc: ReadContext, mints: readonly string[], options: RequestOptions): Promise<NftDetails[]> {
166
+ const scope = rc.scope
167
+ const addresses: string[] = []
168
+ const plan = mints.map((mint) =>
169
+ isAddress(mint) ? { mint, metadataIndex: addresses.push(getMetadataAddress(mint)) - 1, mintIndex: addresses.push(mint) - 1 } : { mint, metadataIndex: -1, mintIndex: -1 },
170
+ )
171
+ const accounts = addresses.length ? await rc.reader.accounts(addresses, { signal: options.signal }) : []
172
+ return plan.map(({ mint, metadataIndex, mintIndex }): NftDetails => {
173
+ const metadataAccount = metadataIndex === -1 ? null : (accounts[metadataIndex] ?? null)
174
+ const mintAccount = mintIndex === -1 ? null : (accounts[mintIndex] ?? null)
175
+ learnMeta(scope, mint, mintAccount, metadataAccount, mintIndex !== -1)
176
+ const metaplex = metadataAccount ? safeParseMetaplex(metadataAccount) : null
177
+ const ext = mintAccount ? (safeParseMint(mintAccount)?.metadata ?? null) : null
178
+ if (!metaplex && !ext) {
179
+ const error: FailureReason = mintIndex === -1 ? 'invalid-address' : !mintAccount ? 'not-found' : !safeParseMint(mintAccount) ? 'not-token' : 'no-metadata'
180
+ return { mint, name: null, symbol: null, uri: null, collection: null, creators: [], sellerFeeBasisPoints: null, tokenStandard: null, isMutable: null, updateAuthority: null, success: false, error }
181
+ }
182
+ return {
183
+ mint,
184
+ name: ext?.name ?? metaplex?.name ?? null,
185
+ symbol: ext?.symbol ?? metaplex?.symbol ?? null,
186
+ uri: ext?.uri ?? metaplex?.uri ?? null,
187
+ collection: metaplex?.collection ?? null,
188
+ creators: metaplex?.creators ?? [],
189
+ sellerFeeBasisPoints: metaplex?.sellerFeeBasisPoints ?? null,
190
+ tokenStandard: metaplex?.tokenStandard == null ? null : (TOKEN_STANDARDS[metaplex.tokenStandard] ?? null),
191
+ isMutable: metaplex?.isMutable ?? null,
192
+ updateAuthority: metaplex?.updateAuthority ?? null,
193
+ success: true,
194
+ }
195
+ })
196
+ }
197
+
198
+ /**
199
+ * 批量查 NFT 持有人:getTokenLargestAccounts(合并成一个批量请求)+ 一次 getMultipleAccounts。
200
+ * 节点拒绝的 mint(不存在 / 不是代币)在同一次 getMultipleAccounts 里读 mint 账户区分原因
201
+ */
202
+ export async function nftOwnersOf(rc: ReadContext, mints: readonly string[], options: RequestOptions): Promise<NftOwner[]> {
203
+ const ctx: QueryContext = { signal: options.signal }
204
+ const largest = await Promise.all(
205
+ mints.map((mint): Promise<{ tokenAccount: string | null; rejected: boolean }> =>
206
+ isAddress(mint)
207
+ ? rc.reader.call<{ value: Array<{ address: string; amount: string }> }>(ctx, 'getTokenLargestAccounts', [mint, { commitment: rc.commitment }])
208
+ .then((res) => ({ tokenAccount: res.value.find((a) => a.amount !== '0')?.address ?? null, rejected: false }))
209
+ .catch((err: unknown) => {
210
+ // mint 不存在 / 不是代币:该项失败;节点问题照常抛出
211
+ if (err instanceof RpcError && !err.nodeFault) return { tokenAccount: null, rejected: true }
212
+ throw err
213
+ })
214
+ : Promise.resolve({ tokenAccount: null, rejected: false }),
215
+ ),
216
+ )
217
+ const toRead = largest.flatMap(({ tokenAccount, rejected }, i) => (tokenAccount ? [tokenAccount] : rejected ? [mints[i] as string] : []))
218
+ const accounts = toRead.length ? await rc.reader.accounts(toRead, ctx) : []
219
+ const byAddress = new Map(toRead.map((a, i) => [a, accounts[i] ?? null]))
220
+ return mints.map((mint, i): NftOwner => {
221
+ const { tokenAccount, rejected } = largest[i] ?? { tokenAccount: null, rejected: false }
222
+ if (!isAddress(mint)) {
223
+ return { mint, owner: null, tokenAccount: null, success: false, error: 'invalid-address' }
224
+ }
225
+ if (rejected) {
226
+ return { mint, owner: null, tokenAccount: null, success: false, error: byAddress.get(mint) ? 'not-token' : 'not-found' }
227
+ }
228
+ const account = tokenAccount ? byAddress.get(tokenAccount) : null
229
+ let owner: string | null = null
230
+ if (account) {
231
+ try {
232
+ owner = parseTokenAccount(account.data).owner
233
+ } catch {
234
+ owner = null
235
+ }
236
+ }
237
+ // 没有余额大于 0 的代币账户(供应量为 0 / 已销毁),或代币账户刚被关闭
238
+ return owner ? { mint, owner, tokenAccount, success: true } : { mint, owner: null, tokenAccount: null, success: false, error: 'no-holder' }
239
+ })
240
+ }