@w3lib/solana-batch-call 0.2.2 → 0.4.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 (123) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +205 -2
  3. package/dist/address.d.ts +2 -0
  4. package/dist/address.d.ts.map +1 -1
  5. package/dist/address.js +8 -0
  6. package/dist/address.js.map +1 -1
  7. package/dist/client.d.ts +4 -0
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +8 -0
  10. package/dist/client.js.map +1 -1
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/layout.d.ts +5 -0
  16. package/dist/layout.d.ts.map +1 -1
  17. package/dist/layout.js +16 -0
  18. package/dist/layout.js.map +1 -1
  19. package/dist/liquidity/amm.d.ts +7 -0
  20. package/dist/liquidity/amm.d.ts.map +1 -0
  21. package/dist/liquidity/amm.js +307 -0
  22. package/dist/liquidity/amm.js.map +1 -0
  23. package/dist/liquidity/clmm.d.ts +18 -0
  24. package/dist/liquidity/clmm.d.ts.map +1 -0
  25. package/dist/liquidity/clmm.js +85 -0
  26. package/dist/liquidity/clmm.js.map +1 -0
  27. package/dist/liquidity/common.d.ts +94 -0
  28. package/dist/liquidity/common.d.ts.map +1 -0
  29. package/dist/liquidity/common.js +228 -0
  30. package/dist/liquidity/common.js.map +1 -0
  31. package/dist/liquidity/index.d.ts +34 -0
  32. package/dist/liquidity/index.d.ts.map +1 -0
  33. package/dist/liquidity/index.js +157 -0
  34. package/dist/liquidity/index.js.map +1 -0
  35. package/dist/liquidity/orca.d.ts +6 -0
  36. package/dist/liquidity/orca.d.ts.map +1 -0
  37. package/dist/liquidity/orca.js +303 -0
  38. package/dist/liquidity/orca.js.map +1 -0
  39. package/dist/liquidity/raydiumClmm.d.ts +6 -0
  40. package/dist/liquidity/raydiumClmm.d.ts.map +1 -0
  41. package/dist/liquidity/raydiumClmm.js +307 -0
  42. package/dist/liquidity/raydiumClmm.js.map +1 -0
  43. package/dist/liquidity/types.d.ts +150 -0
  44. package/dist/liquidity/types.d.ts.map +1 -0
  45. package/dist/liquidity/types.js +2 -0
  46. package/dist/liquidity/types.js.map +1 -0
  47. package/dist/lp.d.ts +168 -0
  48. package/dist/lp.d.ts.map +1 -0
  49. package/dist/lp.js +632 -0
  50. package/dist/lp.js.map +1 -0
  51. package/dist/lpMath.d.ts +82 -0
  52. package/dist/lpMath.d.ts.map +1 -0
  53. package/dist/lpMath.js +365 -0
  54. package/dist/lpMath.js.map +1 -0
  55. package/dist/shortcuts.d.ts +8 -0
  56. package/dist/shortcuts.d.ts.map +1 -1
  57. package/dist/shortcuts.js +1 -1
  58. package/dist/shortcuts.js.map +1 -1
  59. package/dist/subpaths/liquidity.d.ts +23 -0
  60. package/dist/subpaths/liquidity.d.ts.map +1 -0
  61. package/dist/subpaths/liquidity.js +19 -0
  62. package/dist/subpaths/liquidity.js.map +1 -0
  63. package/dist/subpaths/lp.d.ts +31 -0
  64. package/dist/subpaths/lp.d.ts.map +1 -0
  65. package/dist/subpaths/lp.js +33 -0
  66. package/dist/subpaths/lp.js.map +1 -0
  67. package/dist/subpaths/write.d.ts +22 -0
  68. package/dist/subpaths/write.d.ts.map +1 -0
  69. package/dist/subpaths/write.js +19 -0
  70. package/dist/subpaths/write.js.map +1 -0
  71. package/dist/write/batch.d.ts +41 -0
  72. package/dist/write/batch.d.ts.map +1 -0
  73. package/dist/write/batch.js +46 -0
  74. package/dist/write/batch.js.map +1 -0
  75. package/dist/write/collect.d.ts +50 -0
  76. package/dist/write/collect.d.ts.map +1 -0
  77. package/dist/write/collect.js +220 -0
  78. package/dist/write/collect.js.map +1 -0
  79. package/dist/write/instructions.d.ts +16 -0
  80. package/dist/write/instructions.d.ts.map +1 -0
  81. package/dist/write/instructions.js +89 -0
  82. package/dist/write/instructions.js.map +1 -0
  83. package/dist/write/send.d.ts +115 -0
  84. package/dist/write/send.d.ts.map +1 -0
  85. package/dist/write/send.js +720 -0
  86. package/dist/write/send.js.map +1 -0
  87. package/dist/write/signer.d.ts +94 -0
  88. package/dist/write/signer.d.ts.map +1 -0
  89. package/dist/write/signer.js +197 -0
  90. package/dist/write/signer.js.map +1 -0
  91. package/dist/write/transaction.d.ts +75 -0
  92. package/dist/write/transaction.d.ts.map +1 -0
  93. package/dist/write/transaction.js +133 -0
  94. package/dist/write/transaction.js.map +1 -0
  95. package/dist/write/transfer.d.ts +72 -0
  96. package/dist/write/transfer.d.ts.map +1 -0
  97. package/dist/write/transfer.js +127 -0
  98. package/dist/write/transfer.js.map +1 -0
  99. package/package.json +23 -5
  100. package/src/address.ts +9 -0
  101. package/src/client.ts +10 -0
  102. package/src/index.ts +2 -1
  103. package/src/layout.ts +17 -0
  104. package/src/liquidity/amm.ts +353 -0
  105. package/src/liquidity/clmm.ts +108 -0
  106. package/src/liquidity/common.ts +285 -0
  107. package/src/liquidity/index.ts +177 -0
  108. package/src/liquidity/orca.ts +344 -0
  109. package/src/liquidity/raydiumClmm.ts +348 -0
  110. package/src/liquidity/types.ts +158 -0
  111. package/src/lp.ts +850 -0
  112. package/src/lpMath.ts +420 -0
  113. package/src/shortcuts.ts +1 -1
  114. package/src/subpaths/liquidity.ts +45 -0
  115. package/src/subpaths/lp.ts +71 -0
  116. package/src/subpaths/write.ts +29 -0
  117. package/src/write/batch.ts +78 -0
  118. package/src/write/collect.ts +286 -0
  119. package/src/write/instructions.ts +98 -0
  120. package/src/write/send.ts +876 -0
  121. package/src/write/signer.ts +314 -0
  122. package/src/write/transaction.ts +181 -0
  123. package/src/write/transfer.ts +189 -0
@@ -0,0 +1,314 @@
1
+ import { base58, base64urlnopad } from '@scure/base'
2
+
3
+ import { encodeAddress } from '../address.js'
4
+ import { messageSigners, serializeTransaction, splitTransaction } from './transaction.js'
5
+
6
+ /**
7
+ * 自定义签名器:对交易消息做 ed25519 签名,返回 64 字节签名。用于 KMS / HSM / 硬件钱包,
8
+ * 或运行环境的 WebCrypto 不支持 Ed25519 时(如 React Native)自己用 @noble/curves 签名:
9
+ * `{ address, sign: async (msg) => ed25519.sign(msg, seed) }`
10
+ */
11
+ export interface WriteSigner {
12
+ address: string
13
+ sign(message: Uint8Array): Promise<Uint8Array>
14
+ }
15
+
16
+ /** Wallet Standard 钱包(Phantom / Solflare / Backpack / OKX 等浏览器钱包都实现了):`getWallets().get()` 拿到的对象 */
17
+ export interface StandardWalletLike {
18
+ readonly accounts: ReadonlyArray<{ readonly address: string }>
19
+ readonly features: Readonly<Record<string, unknown>>
20
+ }
21
+
22
+ /** @solana/wallet-adapter 的 adapter(`useWallet().wallet.adapter`),或 `useWallet()` 的返回值 */
23
+ export interface WalletAdapterLike {
24
+ readonly publicKey: { toBase58(): string } | null
25
+ readonly wallet?: StandardWalletLike | { readonly adapter?: unknown } | null
26
+ }
27
+
28
+ /** @solana/kit 里的交易(消息字节 + 各签名者的签名) */
29
+ export interface KitTransactionLike {
30
+ readonly messageBytes: Uint8Array
31
+ readonly signatures: Readonly<Record<string, Uint8Array | null>>
32
+ }
33
+
34
+ /**
35
+ * @solana/kit 的 TransactionSigner:TransactionPartialSigner(signTransactions)或
36
+ * TransactionModifyingSigner(modifyAndSignTransactions,如 @solana/react 的 useWalletAccountTransactionSigner)。
37
+ * 也可以用它把不支持 Wallet Standard 的钱包接进来,见 README
38
+ */
39
+ export interface KitSignerLike {
40
+ readonly address: string
41
+ signTransactions?(transactions: readonly KitTransactionLike[]): Promise<ReadonlyArray<Readonly<Record<string, Uint8Array>>>>
42
+ modifyAndSignTransactions?(transactions: readonly KitTransactionLike[]): Promise<readonly KitTransactionLike[]>
43
+ }
44
+
45
+ /**
46
+ * 发交易的钱包,以下都可以:
47
+ * - 私钥字符串:base58(Phantom 等钱包导出的 64 字节格式,或 32 字节种子),或 solana-keygen 生成的 JSON 数组 `"[12,34,…]"`
48
+ * - 私钥字节:Uint8Array(64 字节 secretKey 或 32 字节种子)
49
+ * - @solana/web3.js 的 Keypair(取 secretKey)
50
+ * - @solana/kit 的 KeyPairSigner(`{ address, keyPair }`,用它的 CryptoKey 签名)或其他 TransactionSigner
51
+ * - 自定义签名器 WriteSigner
52
+ * - 浏览器钱包:Wallet Standard 钱包对象(用第一个已连接的账户;指定账户传 `{ wallet, account }`)、
53
+ * @solana/wallet-adapter 的 adapter 或 useWallet() 的返回值(钱包需支持 Wallet Standard,主流钱包都支持)。
54
+ * 同一批交易先全部模拟、再让钱包一次确认全部签名,然后发送
55
+ *
56
+ * 私钥只在本地用 WebCrypto 的 Ed25519 签名,错误信息里不会带出私钥内容
57
+ */
58
+ export type SignerInput =
59
+ | string
60
+ | Uint8Array
61
+ | { secretKey: Uint8Array }
62
+ | { address: string; keyPair: CryptoKeyPair }
63
+ | WriteSigner
64
+ | StandardWalletLike
65
+ | { wallet: StandardWalletLike; account: { readonly address: string } | string }
66
+ | WalletAdapterLike
67
+ | KitSignerLike
68
+
69
+ /** 本地签名器:直接对消息签名 */
70
+ export interface LocalSigner {
71
+ kind: 'local'
72
+ address: string
73
+ sign(message: Uint8Array): Promise<Uint8Array>
74
+ }
75
+
76
+ /** 要外部钱包签的交易:消息 + 已有的签名(按 signers 顺序,没签的为 null) */
77
+ export interface SignRequest {
78
+ message: Uint8Array
79
+ signers: readonly string[]
80
+ signatures: ReadonlyArray<Uint8Array | null>
81
+ }
82
+
83
+ /** 外部钱包签好的交易:钱包可能改了消息(如加了指令),signatures 按新消息的签名者顺序 */
84
+ export interface SignedTransaction {
85
+ message: Uint8Array
86
+ signers: string[]
87
+ signatures: Array<Uint8Array | null>
88
+ }
89
+
90
+ /** 外部钱包:一次签多笔交易(只弹一次确认) */
91
+ export interface WalletSigner {
92
+ kind: 'wallet'
93
+ address: string
94
+ /** chain:Wallet Standard 的链标识,如 'solana:mainnet'(网络未知时不传) */
95
+ signTransactions(requests: readonly SignRequest[], chain?: string): Promise<SignedTransaction[]>
96
+ }
97
+
98
+ /** 归一化后的签名器 */
99
+ export type ResolvedSigner = LocalSigner | WalletSigner
100
+
101
+ // PKCS#8 封装 ed25519 种子的固定前缀(RFC 8410)
102
+ const PKCS8_PREFIX = Uint8Array.from([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20])
103
+
104
+ /** WebCrypto 的类型要求 ArrayBuffer 背书的视图 */
105
+ const view = (bytes: Uint8Array) => bytes as Uint8Array<ArrayBuffer>
106
+
107
+ function subtle(): SubtleCrypto {
108
+ const s = globalThis.crypto?.subtle
109
+ if (!s) {
110
+ throw new Error('WebCrypto is not available in this runtime; pass a signer with sign(message) instead of a private key')
111
+ }
112
+ return s
113
+ }
114
+
115
+ /** 用 32 字节种子生成签名器:私钥导入 WebCrypto 后不可导出 */
116
+ async function fromSeed(seed: Uint8Array, expected?: Uint8Array): Promise<ResolvedSigner> {
117
+ // 没有 WebCrypto 时直接给出这个原因(放在 try 外面,不被下面统一改写成“私钥不对”)
118
+ const crypto = subtle()
119
+ const pkcs8 = new Uint8Array(48)
120
+ pkcs8.set(PKCS8_PREFIX)
121
+ pkcs8.set(seed, 16)
122
+ let address: string
123
+ let key: CryptoKey
124
+ try {
125
+ // 先以可导出方式导入一次取公钥(JWK 的 x),再以不可导出方式导入用于签名
126
+ const extractable = await crypto.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign'])
127
+ const jwk = await crypto.exportKey('jwk', extractable)
128
+ const publicKey = base64urlnopad.decode(jwk.x as string)
129
+ address = encodeAddress(publicKey)
130
+ if (expected && encodeAddress(expected) !== address) {
131
+ throw new Error('mismatch')
132
+ }
133
+ key = await crypto.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign'])
134
+ } catch (err) {
135
+ if ((err as Error).message === 'mismatch') {
136
+ throw new Error('Invalid private key: the public key part does not match')
137
+ }
138
+ if ((err as Error).name === 'NotSupportedError' || /algorithm|not supported|unrecognized/i.test((err as Error).message)) {
139
+ throw new Error('Ed25519 is not supported by this runtime’s WebCrypto; pass a signer with sign(message) instead of a private key')
140
+ }
141
+ throw new Error('Invalid private key')
142
+ } finally {
143
+ pkcs8.fill(0)
144
+ }
145
+ return { kind: 'local', address, sign: async (message) => new Uint8Array(await subtle().sign('Ed25519', key, view(message))) }
146
+ }
147
+
148
+ function fromBytes(bytes: Uint8Array): Promise<ResolvedSigner> {
149
+ if (bytes.length === 64) {
150
+ return fromSeed(bytes.slice(0, 32), bytes.slice(32))
151
+ }
152
+ if (bytes.length === 32) {
153
+ return fromSeed(bytes.slice())
154
+ }
155
+ throw new Error('Invalid private key: expected 64 or 32 bytes')
156
+ }
157
+
158
+ function parseKeyString(value: string): Uint8Array {
159
+ const text = value.trim()
160
+ if (text.startsWith('[')) {
161
+ try {
162
+ const parsed = JSON.parse(text) as unknown
163
+ if (Array.isArray(parsed) && parsed.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)) {
164
+ return Uint8Array.from(parsed as number[])
165
+ }
166
+ } catch {
167
+ // 下面统一报错
168
+ }
169
+ throw new Error('Invalid private key: expected a JSON array of bytes')
170
+ }
171
+ let bytes: Uint8Array
172
+ try {
173
+ bytes = base58.decode(text)
174
+ } catch {
175
+ throw new Error('Invalid private key: expected base58 or a JSON array of bytes')
176
+ }
177
+ // 32 字节的 base58 字符串和地址长得一样:传错成地址时会悄悄变成另一个钱包,这里不接受(32 字节种子请传 Uint8Array)
178
+ if (bytes.length === 32) {
179
+ throw new Error('Invalid private key: a 32-byte base58 string looks like an address; pass the 64-byte secret key (or the seed as a Uint8Array)')
180
+ }
181
+ return bytes
182
+ }
183
+
184
+ async function toSigner(input: SignerInput): Promise<ResolvedSigner> {
185
+ if (typeof input === 'string') {
186
+ return fromBytes(parseKeyString(input))
187
+ }
188
+ if (input instanceof Uint8Array) {
189
+ return fromBytes(input)
190
+ }
191
+ if (!input || typeof input !== 'object') {
192
+ throw new Error(INVALID_SIGNER)
193
+ }
194
+ const o = input as Record<string, unknown>
195
+ if (o.secretKey instanceof Uint8Array) {
196
+ return fromBytes(o.secretKey)
197
+ }
198
+ const keyPair = o.keyPair as CryptoKeyPair | undefined
199
+ if (keyPair?.privateKey && typeof o.address === 'string') {
200
+ const key = keyPair.privateKey
201
+ return { kind: 'local', address: o.address, sign: async (message) => new Uint8Array(await subtle().sign('Ed25519', key, view(message))) }
202
+ }
203
+ if (typeof o.sign === 'function' && typeof o.address === 'string') {
204
+ const signer = input as WriteSigner
205
+ return { kind: 'local', address: signer.address, sign: (message) => signer.sign(message) }
206
+ }
207
+ // Wallet Standard 钱包
208
+ if (isStandardWallet(input)) {
209
+ return fromStandardWallet(input, undefined)
210
+ }
211
+ // { wallet, account }
212
+ if (isStandardWallet(o.wallet) && (typeof o.account === 'string' || typeof (o.account as { address?: unknown } | undefined)?.address === 'string')) {
213
+ return fromStandardWallet(o.wallet, typeof o.account === 'string' ? o.account : (o.account as { address: string }).address)
214
+ }
215
+ // wallet-adapter:adapter.wallet 是 Wallet Standard 钱包;useWallet() 的返回值是 { wallet: { adapter }, publicKey }
216
+ if ('publicKey' in o) {
217
+ const publicKey = (o.publicKey as { toBase58?: () => string } | null)?.toBase58?.()
218
+ const adapter = (o.wallet as { adapter?: Record<string, unknown> } | null | undefined)?.adapter ?? o
219
+ const standard = adapter.wallet
220
+ if (!publicKey) {
221
+ throw new Error('Wallet is not connected')
222
+ }
223
+ if (isStandardWallet(standard)) {
224
+ return fromStandardWallet(standard, publicKey)
225
+ }
226
+ throw new Error("This wallet adapter doesn't support Wallet Standard; wrap it as { address, signTransactions } (see README)")
227
+ }
228
+ // @solana/kit 的 TransactionSigner
229
+ if (typeof o.address === 'string' && (typeof o.signTransactions === 'function' || typeof o.modifyAndSignTransactions === 'function')) {
230
+ return fromKitSigner(input as KitSignerLike)
231
+ }
232
+ throw new Error(INVALID_SIGNER)
233
+ }
234
+
235
+ const INVALID_SIGNER = 'Invalid signer: expected a private key, a Keypair, a KeyPairSigner, { address, sign }, a browser wallet or a TransactionSigner'
236
+
237
+ const SIGN_FEATURE = 'solana:signTransaction'
238
+
239
+ function isStandardWallet(value: unknown): value is StandardWalletLike {
240
+ const features = (value as { features?: Record<string, unknown> } | null | undefined)?.features
241
+ return Boolean(features && typeof features === 'object' && SIGN_FEATURE in features && Array.isArray((value as { accounts?: unknown }).accounts))
242
+ }
243
+
244
+ /** 同一个钱包的签名请求排队:钱包同时只能处理一个确认窗口 */
245
+ const queues = new WeakMap<object, Promise<unknown>>()
246
+
247
+ function queued<T>(owner: object, task: () => Promise<T>): Promise<T> {
248
+ const previous = queues.get(owner) ?? Promise.resolve()
249
+ const run = previous.then(task, task)
250
+ queues.set(
251
+ owner,
252
+ run.catch(() => {}),
253
+ )
254
+ return run
255
+ }
256
+
257
+ interface StandardSignFeature {
258
+ signTransaction(...inputs: Array<{ account: unknown; transaction: Uint8Array; chain?: string }>): Promise<ReadonlyArray<{ signedTransaction: Uint8Array }>>
259
+ }
260
+
261
+ function fromStandardWallet(wallet: StandardWalletLike, address: string | undefined): WalletSigner {
262
+ const account = address === undefined ? wallet.accounts[0] : wallet.accounts.find((a) => a.address === address)
263
+ if (!account) {
264
+ throw new Error(address === undefined ? 'Wallet is not connected (no accounts)' : `Wallet account ${address} is not connected`)
265
+ }
266
+ const feature = wallet.features[SIGN_FEATURE] as StandardSignFeature
267
+ return {
268
+ kind: 'wallet',
269
+ address: account.address,
270
+ signTransactions: (requests, chain) =>
271
+ queued(wallet, async () => {
272
+ const outputs = await feature.signTransaction(
273
+ ...requests.map((r) => ({ account, transaction: serializeTransaction({ bytes: r.message, signers: [...r.signers] }, r.signatures), ...(chain ? { chain } : {}) })),
274
+ )
275
+ if (outputs.length !== requests.length) {
276
+ throw new Error('Wallet returned a different number of transactions')
277
+ }
278
+ return outputs.map((o) => {
279
+ const { signatures, message } = splitTransaction(o.signedTransaction)
280
+ return { message, signers: messageSigners(message), signatures: signatures.map((sig) => (sig.some((b) => b !== 0) ? sig : null)) }
281
+ })
282
+ }),
283
+ }
284
+ }
285
+
286
+ function fromKitSigner(signer: KitSignerLike): WalletSigner {
287
+ const toKit = (r: SignRequest): KitTransactionLike => ({ messageBytes: r.message, signatures: Object.fromEntries(r.signers.map((s, i) => [s, r.signatures[i] ?? null])) })
288
+ return {
289
+ kind: 'wallet',
290
+ address: signer.address,
291
+ signTransactions: (requests) =>
292
+ queued(signer, async () => {
293
+ if (typeof signer.modifyAndSignTransactions === 'function') {
294
+ const signed = await signer.modifyAndSignTransactions(requests.map(toKit))
295
+ return signed.map((t) => {
296
+ const signers = messageSigners(t.messageBytes)
297
+ return { message: t.messageBytes, signers, signatures: signers.map((s) => t.signatures[s] ?? null) }
298
+ })
299
+ }
300
+ const dictionaries = await (signer.signTransactions as NonNullable<KitSignerLike['signTransactions']>)(requests.map(toKit))
301
+ return requests.map((r, k) => ({ message: r.message, signers: [...r.signers], signatures: r.signers.map((s, i) => dictionaries[k]?.[s] ?? r.signatures[i] ?? null) }))
302
+ }),
303
+ }
304
+ }
305
+
306
+ /** 归一化钱包;同一个输入只解析一次(cache 由调用方按批次传入) */
307
+ export function resolveSigner(input: SignerInput, cache: Map<unknown, Promise<ResolvedSigner>> = new Map()): Promise<ResolvedSigner> {
308
+ let resolved = cache.get(input)
309
+ if (!resolved) {
310
+ resolved = toSigner(input)
311
+ cache.set(input, resolved)
312
+ }
313
+ return resolved
314
+ }
@@ -0,0 +1,181 @@
1
+ import { decodeAddress, encodeAddress } from '../address.js'
2
+
3
+ /** 交易里的一个账户 */
4
+ export interface AccountMeta {
5
+ pubkey: string
6
+ isSigner: boolean
7
+ isWritable: boolean
8
+ }
9
+
10
+ /** 一条指令(本库内部格式) */
11
+ export interface Instruction {
12
+ programId: string
13
+ keys: AccountMeta[]
14
+ data: Uint8Array
15
+ }
16
+
17
+ type AddressLike = string | { toBase58(): string }
18
+
19
+ /**
20
+ * 可以传入的指令格式:
21
+ * - @solana/web3.js 的 TransactionInstruction(`{ programId: PublicKey, keys: [{ pubkey, isSigner, isWritable }], data: Buffer }`)
22
+ * - @solana/kit 的 Instruction(`{ programAddress, accounts: [{ address, role }], data }`)
23
+ * - 本库的 Instruction(地址都是 base58 字符串)
24
+ */
25
+ export type InstructionInput =
26
+ | { programId: AddressLike; keys: ReadonlyArray<{ pubkey: AddressLike; isSigner: boolean; isWritable: boolean }>; data?: Uint8Array }
27
+ | { programAddress: string; accounts?: ReadonlyArray<{ address: string; role: number }>; data?: Uint8Array }
28
+
29
+ /** 单笔交易序列化后的大小上限(字节) */
30
+ export const PACKET_DATA_SIZE = 1232
31
+
32
+ /** 单笔交易最多锁定的账户数 */
33
+ export const MAX_ACCOUNT_LOCKS = 64
34
+
35
+ function toAddress(value: AddressLike): string {
36
+ return typeof value === 'string' ? value : value.toBase58()
37
+ }
38
+
39
+ /** 统一成本库的指令格式;地址非法时抛错 */
40
+ export function normalizeInstruction(input: InstructionInput): Instruction {
41
+ if ('programAddress' in input) {
42
+ // @solana/kit 的 AccountRole:0 只读、1 可写、2 只读签名、3 可写签名
43
+ const keys = (input.accounts ?? []).map((a) => ({ pubkey: a.address, isSigner: a.role >= 2, isWritable: (a.role & 1) === 1 }))
44
+ return checked({ programId: input.programAddress, keys, data: input.data ?? new Uint8Array() })
45
+ }
46
+ const keys = input.keys.map((k) => ({ pubkey: toAddress(k.pubkey), isSigner: k.isSigner, isWritable: k.isWritable }))
47
+ return checked({ programId: toAddress(input.programId), keys, data: input.data ? new Uint8Array(input.data) : new Uint8Array() })
48
+ }
49
+
50
+ function checked(ix: Instruction): Instruction {
51
+ decodeAddress(ix.programId)
52
+ ix.keys.forEach((k) => decodeAddress(k.pubkey))
53
+ return ix
54
+ }
55
+
56
+ function compactU16(n: number): number[] {
57
+ const out: number[] = []
58
+ let rest = n
59
+ for (;;) {
60
+ const byte = rest & 0x7f
61
+ rest >>= 7
62
+ if (rest === 0) {
63
+ out.push(byte)
64
+ return out
65
+ }
66
+ out.push(byte | 0x80)
67
+ }
68
+ }
69
+
70
+ /** 编译好的消息(legacy 格式) */
71
+ export interface CompiledMessage {
72
+ /** 要签名的消息字节 */
73
+ bytes: Uint8Array
74
+ /** 需要签名的地址,按交易里的签名顺序(第一个是手续费支付者) */
75
+ signers: string[]
76
+ /** 所有账户(去重后) */
77
+ accountKeys: string[]
78
+ /** 可写账户(查优先费用时用) */
79
+ writable: string[]
80
+ }
81
+
82
+ /**
83
+ * 编译 legacy 消息:账户按 [可写签名, 只读签名, 可写非签名, 只读非签名] 排序,手续费支付者排第一;
84
+ * 同一账户在多条指令里出现时,签名 / 可写取并集
85
+ */
86
+ export function compileMessage(payer: string, instructions: readonly Instruction[], blockhash: string): CompiledMessage {
87
+ const metas = new Map<string, { signer: boolean; writable: boolean; order: number }>()
88
+ const add = (pubkey: string, signer: boolean, writable: boolean) => {
89
+ const meta = metas.get(pubkey)
90
+ if (meta) {
91
+ meta.signer ||= signer
92
+ meta.writable ||= writable
93
+ } else {
94
+ metas.set(pubkey, { signer, writable, order: metas.size })
95
+ }
96
+ }
97
+ add(payer, true, true)
98
+ for (const ix of instructions) {
99
+ ix.keys.forEach((k) => add(k.pubkey, k.isSigner, k.isWritable))
100
+ add(ix.programId, false, false)
101
+ }
102
+ const rank = (m: { signer: boolean; writable: boolean }) => (m.signer ? (m.writable ? 0 : 1) : m.writable ? 2 : 3)
103
+ const keys = [...metas.entries()].sort(([a, x], [b, y]) => (a === payer ? -1 : b === payer ? 1 : rank(x) - rank(y) || x.order - y.order)).map(([key]) => key)
104
+ const index = new Map(keys.map((k, i) => [k, i]))
105
+ const count = (r: number) => keys.filter((k) => rank(metas.get(k) as { signer: boolean; writable: boolean }) === r).length
106
+ const signers = keys.filter((k) => metas.get(k)?.signer)
107
+
108
+ const out: number[] = [signers.length, count(1), count(3)]
109
+ out.push(...compactU16(keys.length))
110
+ keys.forEach((k) => out.push(...decodeAddress(k)))
111
+ out.push(...decodeAddress(blockhash))
112
+ out.push(...compactU16(instructions.length))
113
+ for (const ix of instructions) {
114
+ out.push(index.get(ix.programId) as number)
115
+ out.push(...compactU16(ix.keys.length))
116
+ ix.keys.forEach((k) => out.push(index.get(k.pubkey) as number))
117
+ out.push(...compactU16(ix.data.length))
118
+ out.push(...ix.data)
119
+ }
120
+ return { bytes: new Uint8Array(out), signers, accountKeys: keys, writable: keys.filter((k) => (metas.get(k) as { writable: boolean }).writable) }
121
+ }
122
+
123
+ /** 签名后的交易字节数(签名数 + 每个签名 64 字节 + 消息) */
124
+ export function transactionSize(message: CompiledMessage): number {
125
+ return compactU16(message.signers.length).length + 64 * message.signers.length + message.bytes.length
126
+ }
127
+
128
+ /** 拼成完整的交易:signatures 与 message.signers 顺序一致;没有签名的位置填 0(模拟时用) */
129
+ export function serializeTransaction(message: Pick<CompiledMessage, 'bytes' | 'signers'>, signatures: ReadonlyArray<Uint8Array | null>): Uint8Array {
130
+ const prefix = compactU16(message.signers.length)
131
+ const out = new Uint8Array(prefix.length + 64 * message.signers.length + message.bytes.length)
132
+ out.set(prefix, 0)
133
+ signatures.forEach((sig, i) => sig && out.set(sig, prefix.length + 64 * i))
134
+ out.set(message.bytes, prefix.length + 64 * message.signers.length)
135
+ return out
136
+ }
137
+
138
+ function readCompactU16(bytes: Uint8Array, offset: number): [value: number, next: number] {
139
+ let value = 0
140
+ let o = offset
141
+ for (let shift = 0; shift < 21; shift += 7) {
142
+ const b = bytes[o++]
143
+ if (b === undefined) throw new Error('Invalid transaction: truncated')
144
+ value |= (b & 0x7f) << shift
145
+ if (!(b & 0x80)) return [value, o]
146
+ }
147
+ throw new Error('Invalid transaction: bad length prefix')
148
+ }
149
+
150
+ /** 拆开完整交易:签名列表 + 消息字节 */
151
+ export function splitTransaction(bytes: Uint8Array): { signatures: Uint8Array[]; message: Uint8Array } {
152
+ const [count, start] = readCompactU16(bytes, 0)
153
+ const signatures: Uint8Array[] = []
154
+ for (let i = 0; i < count; i++) {
155
+ signatures.push(bytes.slice(start + 64 * i, start + 64 * (i + 1)))
156
+ }
157
+ const message = bytes.slice(start + 64 * count)
158
+ if (signatures.some((s) => s.length !== 64) || message.length < 3) throw new Error('Invalid transaction: truncated')
159
+ return { signatures, message }
160
+ }
161
+
162
+ /** 消息里需要签名的地址(前 numRequiredSignatures 个账户),legacy 和 v0 消息都支持 */
163
+ export function messageSigners(message: Uint8Array): string[] {
164
+ const versioned = ((message[0] as number) & 0x80) !== 0
165
+ const header = versioned ? 1 : 0
166
+ const required = message[header] as number
167
+ const [, keysStart] = readCompactU16(message, header + 3)
168
+ return Array.from({ length: required }, (_, i) => encodeAddress(message.slice(keysStart + 32 * i, keysStart + 32 * (i + 1))))
169
+ }
170
+
171
+ export function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
172
+ return a.length === b.length && a.every((v, i) => v === b[i])
173
+ }
174
+
175
+ /** 消息里的 recent blockhash(legacy 和 v0 消息都支持) */
176
+ export function messageBlockhash(message: Uint8Array): string {
177
+ const header = ((message[0] as number) & 0x80) !== 0 ? 1 : 0
178
+ const [keys, keysStart] = readCompactU16(message, header + 3)
179
+ const start = keysStart + 32 * keys
180
+ return encodeAddress(message.slice(start, start + 32))
181
+ }
@@ -0,0 +1,189 @@
1
+ import { getAssociatedTokenAddress, isAddress } from '../address.js'
2
+ import type { SolanaClient } from '../client.js'
3
+ import { NATIVE_MINT, SOL_DECIMALS } from '../constants.js'
4
+ import { formatUnits, parseUnits } from '../layout.js'
5
+ import type { TokenBalance } from '../types.js'
6
+ import { createAssociatedTokenAccountIdempotent, systemTransfer, transferChecked } from './instructions.js'
7
+ import { emptyOutcome, sendUnits, toWriteResult, type SendOptions, type TxOutcome, type Unit, type WriteResult } from './send.js'
8
+ import { resolveSigner, type ResolvedSigner, type SignerInput } from './signer.js'
9
+ import type { Instruction } from './transaction.js'
10
+
11
+ /** 一个收款项:amount(最小单位)和 formatted(按精度换算后,如 '1.5')二选一 */
12
+ export interface TransferItem {
13
+ /** 收款钱包地址(代币转到它的关联代币账户 ATA,没有时自动创建) */
14
+ to: string
15
+ /** 数量,最小单位(lamports / 代币最小单位),bigint 或十进制字符串 */
16
+ amount?: bigint | number | string
17
+ /** 按精度换算后的数量,如 '1.5';小数位超过精度时报错(不四舍五入) */
18
+ formatted?: string
19
+ /** 这一项转的代币 mint,默认 options.token */
20
+ token?: string
21
+ }
22
+
23
+ /**
24
+ * - 'pack'(默认):同一笔交易里打包多个收款人(Solana 原生支持一笔交易多条转账指令,不需要 Disperse 这样的合约),
25
+ * 按交易大小上限自动分笔;模拟失败的收款项(如收款账户被冻结)单独标记失败,其余照常发送
26
+ * - 'tx':每个收款人一笔交易
27
+ */
28
+ export type TransferMode = 'pack' | 'tx'
29
+
30
+ export interface BatchTransferOptions extends SendOptions {
31
+ /** 默认转的代币 mint,默认原生 SOL(NATIVE_MINT);每项也可以单独指定 token */
32
+ token?: string
33
+ mode?: TransferMode
34
+ /** 'pack' 时每笔交易最多几个收款人,默认不限(按交易大小,SOL 约 20 个一笔,代币约 10 个) */
35
+ batchSize?: number
36
+ /** 发送前检查余额够不够(按代币汇总),不够时抛错、什么都不发。默认 true(SOL 不含手续费和新建账户的租金) */
37
+ checkBalance?: boolean
38
+ /** 收款人没有该代币的 ATA 时自动创建(租金约 0.00204 SOL,由手续费支付者付)。默认 true;false 时这些收款项失败 */
39
+ createAccounts?: boolean
40
+ }
41
+
42
+ /** 单个收款项的结果 */
43
+ export interface TransferResult extends WriteResult {
44
+ /** 收款钱包 */
45
+ to: string
46
+ token: string
47
+ native: boolean
48
+ /** 数量(最小单位) */
49
+ amount: string
50
+ /** 按精度换算后的数量 */
51
+ formatted: string
52
+ /** 是否为收款人新建了代币账户(这一项额外付了租金) */
53
+ createdAccount: boolean
54
+ }
55
+
56
+ /** 解析后的收款项 */
57
+ interface Planned {
58
+ index: number
59
+ to: string
60
+ token: string
61
+ native: boolean
62
+ amount: bigint
63
+ decimals: number
64
+ tokenProgram: string | null
65
+ }
66
+
67
+ /**
68
+ * 一对多批量转账(SOL / SPL Token / Token-2022,可混合)。结果与 recipients 一一对应。
69
+ *
70
+ * ```ts
71
+ * await batchTransfer(client, privateKey, [
72
+ * { to: a, formatted: '0.5' }, // 默认代币(options.token,默认 SOL)
73
+ * { to: b, token: USDC, formatted: '20' }, // 代币:收款人没有 ATA 时自动创建
74
+ * ])
75
+ * ```
76
+ */
77
+ export async function batchTransfer(client: SolanaClient, from: SignerInput, recipients: readonly TransferItem[], options: BatchTransferOptions = {}): Promise<TransferResult[]> {
78
+ const cache = new Map()
79
+ const signer = await resolveSigner(from, cache)
80
+ const payer = options.feePayer === undefined ? signer : await resolveSigner(options.feePayer, cache)
81
+ const planned = await plan(client, signer.address, recipients, options)
82
+
83
+ // 收款人的 ATA 是否存在:一次 getMultipleAccounts
84
+ const atas = planned.map((p) => (p.native ? null : getAssociatedTokenAddress(p.to, p.token, p.tokenProgram as string)))
85
+ const needed = [...new Set(atas.filter((a): a is string => a !== null))]
86
+ const existing = new Set<string>()
87
+ if (needed.length) {
88
+ const accounts = await client.accounts(needed, { signal: options.signal })
89
+ needed.forEach((a, i) => accounts[i] && existing.add(a))
90
+ }
91
+
92
+ const results = planned.map((p) => toTransferResult(p, toWriteResult(p.index, signer.address, emptyOutcome('skipped')), false))
93
+ const units: Unit[] = []
94
+ /** 已经安排创建的 ATA:同一个收款人同一种代币有多项时只在第一项创建(后面的项在同一组里按顺序发送,模拟失败时会等前面的确认) */
95
+ const creating = new Set<string>()
96
+ planned.forEach((p, i) => {
97
+ const ata = atas[i] ?? null
98
+ const create = ata !== null && !existing.has(ata) && !creating.has(ata)
99
+ if (create) creating.add(ata)
100
+ if (create && options.createAccounts === false) {
101
+ results[i] = toTransferResult(p, toWriteResult(p.index, signer.address, emptyOutcome('failed', 'recipient has no token account (createAccounts is false)')), false)
102
+ return
103
+ }
104
+ units.push({
105
+ instructions: transferInstructions(signer, payer, p, ata, create),
106
+ payer,
107
+ signers: payer.address === signer.address ? [] : [signer],
108
+ continueOnError: true,
109
+ onUpdate: (outcome: TxOutcome) => {
110
+ results[i] = toTransferResult(p, toWriteResult(p.index, signer.address, outcome), create && outcome.status === 'confirmed')
111
+ options.onUpdate?.(results[i] as TransferResult)
112
+ },
113
+ })
114
+ })
115
+ await sendUnits(client, units, options, { batchSize: (options.mode ?? 'pack') === 'tx' ? 1 : options.batchSize })
116
+ return results
117
+ }
118
+
119
+ /** 转账指令:SOL 直接转;代币按需先创建收款人的 ATA,再 TransferChecked */
120
+ export function transferInstructions(signer: ResolvedSigner, payer: ResolvedSigner, p: Pick<Planned, 'to' | 'token' | 'native' | 'amount' | 'tokenProgram'> & { decimals: number | null }, ata: string | null, create: boolean): Instruction[] {
121
+ if (p.native) {
122
+ return [systemTransfer(signer.address, p.to, p.amount)]
123
+ }
124
+ const program = p.tokenProgram as string
125
+ const source = getAssociatedTokenAddress(signer.address, p.token, program)
126
+ const destination = ata ?? getAssociatedTokenAddress(p.to, p.token, program)
127
+ return [
128
+ ...(create ? [createAssociatedTokenAccountIdempotent(payer.address, destination, p.to, p.token, program)] : []),
129
+ transferChecked(source, p.token, destination, signer.address, p.amount, p.decimals as number, program),
130
+ ]
131
+ }
132
+
133
+ /** 解析数量(formatted 按精度换算)、代币程序,并检查余额 */
134
+ async function plan(client: SolanaClient, owner: string, recipients: readonly TransferItem[], options: BatchTransferOptions): Promise<Planned[]> {
135
+ const defaultToken = options.token ?? NATIVE_MINT
136
+ const tokens = [...new Set(recipients.map((r) => r.token ?? defaultToken))]
137
+ const balances = tokens.length ? await client.balances(owner, tokens, { signal: options.signal }) : []
138
+ const info = new Map(balances.map((b, i) => [tokens[i] as string, b]))
139
+ const planned = recipients.map((item, index): Planned => {
140
+ const token = item.token ?? defaultToken
141
+ const balance = info.get(token)
142
+ if (!isAddress(item.to)) {
143
+ throw new Error(`recipients[${index}]: invalid address ${item.to}`)
144
+ }
145
+ if ((item.amount === undefined) === (item.formatted === undefined)) {
146
+ throw new Error(`recipients[${index}]: pass exactly one of amount / formatted`)
147
+ }
148
+ const native = client.isNativeMint(token)
149
+ if (!native && (!balance?.success || !balance.tokenProgram)) {
150
+ throw new Error(`recipients[${index}]: cannot read token ${token} (${balance?.error ?? 'unknown error'})`)
151
+ }
152
+ // 原生 SOL 的精度固定;代币读不到时上面已经报错
153
+ const decimals = native ? SOL_DECIMALS : (balance as TokenBalance).decimals
154
+ let amount: bigint
155
+ if (item.formatted !== undefined) {
156
+ amount = parseUnits(item.formatted, decimals)
157
+ } else {
158
+ amount = BigInt(item.amount as bigint | number | string)
159
+ }
160
+ if (amount < 0n || amount >= 2n ** 64n) {
161
+ throw new Error(`recipients[${index}]: amount out of range`)
162
+ }
163
+ return { index, to: item.to, token, native, amount, decimals, tokenProgram: native ? null : (balance?.tokenProgram ?? null) }
164
+ })
165
+ if (options.checkBalance ?? true) {
166
+ const totals = new Map<string, bigint>()
167
+ planned.forEach((p) => totals.set(p.token, (totals.get(p.token) ?? 0n) + p.amount))
168
+ for (const [token, total] of totals) {
169
+ // 读到这里时余额都读成功了(代币读不到时上面已经报错,原生 SOL 总能读到)
170
+ const balance = info.get(token) as TokenBalance
171
+ if (BigInt(balance.balance) < total) {
172
+ throw new Error(`Insufficient balance of ${token}: need ${total}, have ${balance.balance}`)
173
+ }
174
+ }
175
+ }
176
+ return planned
177
+ }
178
+
179
+ function toTransferResult(p: Planned, result: WriteResult, createdAccount: boolean): TransferResult {
180
+ return {
181
+ ...result,
182
+ to: p.to,
183
+ token: p.token,
184
+ native: p.native,
185
+ amount: p.amount.toString(),
186
+ formatted: formatUnits(p.amount, p.decimals),
187
+ createdAccount,
188
+ }
189
+ }