@sip-protocol/sdk 0.1.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.
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Solana Wallet Adapter
3
+ *
4
+ * Implementation of WalletAdapter for Solana chain.
5
+ * Supports Phantom, Solflare, Backpack, and generic Solana wallets.
6
+ */
7
+
8
+ import type {
9
+ HexString,
10
+ Asset,
11
+ Signature,
12
+ UnsignedTransaction,
13
+ SignedTransaction,
14
+ TransactionReceipt,
15
+ } from '@sip-protocol/types'
16
+ import { WalletErrorCode } from '@sip-protocol/types'
17
+ import { BaseWalletAdapter } from '../base-adapter'
18
+ import { WalletError } from '../errors'
19
+ import type {
20
+ SolanaWalletProvider,
21
+ SolanaAdapterConfig,
22
+ SolanaWalletName,
23
+ SolanaCluster,
24
+ SolanaTransaction,
25
+ SolanaVersionedTransaction,
26
+ SolanaConnection,
27
+ SolanaSendOptions,
28
+ } from './types'
29
+ import {
30
+ getSolanaProvider,
31
+ solanaPublicKeyToHex,
32
+ } from './types'
33
+
34
+ /**
35
+ * Default RPC endpoints for Solana clusters
36
+ */
37
+ const DEFAULT_RPC_ENDPOINTS: Record<SolanaCluster, string> = {
38
+ 'mainnet-beta': 'https://api.mainnet-beta.solana.com',
39
+ 'testnet': 'https://api.testnet.solana.com',
40
+ 'devnet': 'https://api.devnet.solana.com',
41
+ 'localnet': 'http://localhost:8899',
42
+ }
43
+
44
+ /**
45
+ * Solana wallet adapter
46
+ *
47
+ * Provides SIP-compatible wallet interface for Solana.
48
+ * Works with Phantom, Solflare, Backpack, and other Solana wallets.
49
+ *
50
+ * @example Browser usage with Phantom
51
+ * ```typescript
52
+ * const wallet = new SolanaWalletAdapter({ wallet: 'phantom' })
53
+ * await wallet.connect()
54
+ *
55
+ * const balance = await wallet.getBalance()
56
+ * console.log(`Balance: ${balance} lamports`)
57
+ *
58
+ * // Sign a message
59
+ * const sig = await wallet.signMessage(new TextEncoder().encode('Hello'))
60
+ * ```
61
+ *
62
+ * @example With custom RPC endpoint
63
+ * ```typescript
64
+ * const wallet = new SolanaWalletAdapter({
65
+ * wallet: 'phantom',
66
+ * cluster: 'devnet',
67
+ * rpcEndpoint: 'https://my-rpc.example.com',
68
+ * })
69
+ * ```
70
+ */
71
+ export class SolanaWalletAdapter extends BaseWalletAdapter {
72
+ readonly chain = 'solana' as const
73
+ readonly name: string
74
+
75
+ private provider: SolanaWalletProvider | undefined
76
+ private connection: SolanaConnection | undefined
77
+ private walletName: SolanaWalletName
78
+ private cluster: SolanaCluster
79
+ private rpcEndpoint: string
80
+
81
+ // Event handler references for cleanup
82
+ private connectHandler?: () => void
83
+ private disconnectHandler?: () => void
84
+ private accountChangedHandler?: (pubkey: unknown) => void
85
+
86
+ constructor(config: SolanaAdapterConfig = {}) {
87
+ super()
88
+ this.walletName = config.wallet ?? 'phantom'
89
+ this.name = `solana-${this.walletName}`
90
+ this.cluster = config.cluster ?? 'mainnet-beta'
91
+ this.rpcEndpoint = config.rpcEndpoint ?? DEFAULT_RPC_ENDPOINTS[this.cluster]
92
+
93
+ // Allow injecting provider/connection for testing
94
+ if (config.provider) {
95
+ this.provider = config.provider
96
+ }
97
+ if (config.connection) {
98
+ this.connection = config.connection
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Get the current Solana cluster
104
+ */
105
+ getCluster(): SolanaCluster {
106
+ return this.cluster
107
+ }
108
+
109
+ /**
110
+ * Get the RPC endpoint
111
+ */
112
+ getRpcEndpoint(): string {
113
+ return this.rpcEndpoint
114
+ }
115
+
116
+ /**
117
+ * Set the RPC endpoint
118
+ */
119
+ setRpcEndpoint(endpoint: string): void {
120
+ this.rpcEndpoint = endpoint
121
+ // Clear connection so it's recreated with new endpoint
122
+ this.connection = undefined
123
+ }
124
+
125
+ /**
126
+ * Get or create a connection for RPC calls
127
+ */
128
+ private async getConnection(): Promise<SolanaConnection> {
129
+ if (this.connection) return this.connection
130
+
131
+ // Create a minimal fetch-based connection
132
+ this.connection = createMinimalConnection(this.rpcEndpoint)
133
+ return this.connection
134
+ }
135
+
136
+ /**
137
+ * Connect to the wallet
138
+ */
139
+ async connect(): Promise<void> {
140
+ this._connectionState = 'connecting'
141
+
142
+ try {
143
+ // Get provider if not already set
144
+ if (!this.provider) {
145
+ this.provider = getSolanaProvider(this.walletName)
146
+ }
147
+
148
+ if (!this.provider) {
149
+ this.setError(
150
+ WalletErrorCode.NOT_INSTALLED,
151
+ `${this.walletName} wallet is not installed`
152
+ )
153
+ throw new WalletError(
154
+ `${this.walletName} wallet is not installed`,
155
+ WalletErrorCode.NOT_INSTALLED
156
+ )
157
+ }
158
+
159
+ // Connect to wallet
160
+ const { publicKey } = await this.provider.connect()
161
+
162
+ if (!publicKey) {
163
+ throw new WalletError(
164
+ 'No public key returned from wallet',
165
+ WalletErrorCode.CONNECTION_FAILED
166
+ )
167
+ }
168
+
169
+ // Set up event handlers
170
+ this.setupEventHandlers()
171
+
172
+ // Update state
173
+ const address = publicKey.toBase58()
174
+ const hexPubKey = solanaPublicKeyToHex(publicKey)
175
+ this.setConnected(address, hexPubKey)
176
+ } catch (error) {
177
+ const message = error instanceof Error ? error.message : 'Connection failed'
178
+
179
+ // Check if user rejected
180
+ if (message.includes('User rejected') || message.includes('rejected')) {
181
+ this.setError(WalletErrorCode.CONNECTION_REJECTED, message)
182
+ throw new WalletError(message, WalletErrorCode.CONNECTION_REJECTED)
183
+ }
184
+
185
+ this.setError(WalletErrorCode.CONNECTION_FAILED, message)
186
+ throw error instanceof WalletError
187
+ ? error
188
+ : new WalletError(message, WalletErrorCode.CONNECTION_FAILED, { cause: error as Error })
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Disconnect from the wallet
194
+ */
195
+ async disconnect(): Promise<void> {
196
+ this.cleanupEventHandlers()
197
+
198
+ if (this.provider?.disconnect) {
199
+ try {
200
+ await this.provider.disconnect()
201
+ } catch {
202
+ // Ignore disconnect errors
203
+ }
204
+ }
205
+
206
+ this.setDisconnected('User disconnected')
207
+ this.provider = undefined
208
+ }
209
+
210
+ /**
211
+ * Sign a message
212
+ */
213
+ async signMessage(message: Uint8Array): Promise<Signature> {
214
+ this.requireConnected()
215
+
216
+ if (!this.provider) {
217
+ throw new WalletError('Provider not available', WalletErrorCode.NOT_CONNECTED)
218
+ }
219
+
220
+ try {
221
+ const { signature } = await this.provider.signMessage(message)
222
+
223
+ return {
224
+ signature: ('0x' + Buffer.from(signature).toString('hex')) as HexString,
225
+ publicKey: this._publicKey as HexString,
226
+ }
227
+ } catch (error) {
228
+ const message = error instanceof Error ? error.message : 'Signing failed'
229
+
230
+ if (message.includes('User rejected') || message.includes('rejected')) {
231
+ throw new WalletError(message, WalletErrorCode.SIGNING_REJECTED)
232
+ }
233
+
234
+ throw new WalletError(message, WalletErrorCode.SIGNING_FAILED, {
235
+ cause: error as Error,
236
+ })
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Sign a transaction
242
+ *
243
+ * The transaction data should be a SolanaTransaction or SolanaVersionedTransaction
244
+ */
245
+ async signTransaction(tx: UnsignedTransaction): Promise<SignedTransaction> {
246
+ this.requireConnected()
247
+
248
+ if (!this.provider) {
249
+ throw new WalletError('Provider not available', WalletErrorCode.NOT_CONNECTED)
250
+ }
251
+
252
+ try {
253
+ const solTx = tx.data as SolanaTransaction | SolanaVersionedTransaction
254
+ const signed = await this.provider.signTransaction(solTx)
255
+ const serialized = signed.serialize()
256
+
257
+ return {
258
+ unsigned: tx,
259
+ signatures: [
260
+ {
261
+ signature: ('0x' + Buffer.from(serialized).toString('hex')) as HexString,
262
+ publicKey: this._publicKey as HexString,
263
+ },
264
+ ],
265
+ serialized: ('0x' + Buffer.from(serialized).toString('hex')) as HexString,
266
+ }
267
+ } catch (error) {
268
+ const message = error instanceof Error ? error.message : 'Signing failed'
269
+
270
+ if (message.includes('User rejected') || message.includes('rejected')) {
271
+ throw new WalletError(message, WalletErrorCode.SIGNING_REJECTED)
272
+ }
273
+
274
+ throw new WalletError(message, WalletErrorCode.SIGNING_FAILED, {
275
+ cause: error as Error,
276
+ })
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Sign and send a transaction
282
+ */
283
+ async signAndSendTransaction(tx: UnsignedTransaction): Promise<TransactionReceipt> {
284
+ this.requireConnected()
285
+
286
+ if (!this.provider) {
287
+ throw new WalletError('Provider not available', WalletErrorCode.NOT_CONNECTED)
288
+ }
289
+
290
+ try {
291
+ const solTx = tx.data as SolanaTransaction | SolanaVersionedTransaction
292
+ const sendOptions = tx.metadata?.sendOptions as SolanaSendOptions | undefined
293
+
294
+ const { signature } = await this.provider.signAndSendTransaction(solTx, sendOptions)
295
+
296
+ return {
297
+ txHash: ('0x' + Buffer.from(signature).toString('hex')) as HexString,
298
+ status: 'pending', // Transaction is sent but not confirmed yet
299
+ timestamp: Date.now(),
300
+ }
301
+ } catch (error) {
302
+ const message = error instanceof Error ? error.message : 'Transaction failed'
303
+
304
+ if (message.includes('User rejected') || message.includes('rejected')) {
305
+ throw new WalletError(message, WalletErrorCode.TRANSACTION_REJECTED)
306
+ }
307
+
308
+ if (message.includes('insufficient') || message.includes('Insufficient')) {
309
+ throw new WalletError(message, WalletErrorCode.INSUFFICIENT_FUNDS)
310
+ }
311
+
312
+ throw new WalletError(message, WalletErrorCode.TRANSACTION_FAILED, {
313
+ cause: error as Error,
314
+ })
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Sign multiple transactions at once
320
+ *
321
+ * Solana-specific method for batch signing
322
+ */
323
+ async signAllTransactions<T extends SolanaTransaction | SolanaVersionedTransaction>(
324
+ transactions: T[]
325
+ ): Promise<T[]> {
326
+ this.requireConnected()
327
+
328
+ if (!this.provider) {
329
+ throw new WalletError('Provider not available', WalletErrorCode.NOT_CONNECTED)
330
+ }
331
+
332
+ try {
333
+ return await this.provider.signAllTransactions(transactions)
334
+ } catch (error) {
335
+ const message = error instanceof Error ? error.message : 'Signing failed'
336
+
337
+ if (message.includes('User rejected') || message.includes('rejected')) {
338
+ throw new WalletError(message, WalletErrorCode.SIGNING_REJECTED)
339
+ }
340
+
341
+ throw new WalletError(message, WalletErrorCode.SIGNING_FAILED, {
342
+ cause: error as Error,
343
+ })
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Get native SOL balance
349
+ */
350
+ async getBalance(): Promise<bigint> {
351
+ this.requireConnected()
352
+
353
+ try {
354
+ const connection = await this.getConnection()
355
+ const balance = await connection.getBalance({
356
+ toBase58: () => this._address,
357
+ toBytes: () => new Uint8Array(32),
358
+ toString: () => this._address,
359
+ })
360
+
361
+ return BigInt(balance)
362
+ } catch (error) {
363
+ throw new WalletError(
364
+ 'Failed to get balance',
365
+ WalletErrorCode.UNKNOWN,
366
+ { cause: error as Error }
367
+ )
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Get SPL token balance
373
+ */
374
+ async getTokenBalance(asset: Asset): Promise<bigint> {
375
+ this.requireConnected()
376
+
377
+ if (asset.chain !== 'solana') {
378
+ throw new WalletError(
379
+ `Asset chain ${asset.chain} not supported by Solana adapter`,
380
+ WalletErrorCode.UNSUPPORTED_CHAIN
381
+ )
382
+ }
383
+
384
+ // Native SOL
385
+ if (!asset.address) {
386
+ return this.getBalance()
387
+ }
388
+
389
+ try {
390
+ const connection = await this.getConnection()
391
+ // For SPL tokens, we need to find the associated token account
392
+ // This is a simplified implementation - real implementation would use
393
+ // getAssociatedTokenAddress and handle missing accounts
394
+ const result = await connection.getTokenAccountBalance({
395
+ toBase58: () => asset.address as string,
396
+ toBytes: () => new Uint8Array(32),
397
+ toString: () => asset.address as string,
398
+ })
399
+
400
+ return BigInt(result.value.amount)
401
+ } catch {
402
+ // Token account might not exist
403
+ return 0n
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Set up wallet event handlers
409
+ */
410
+ private setupEventHandlers(): void {
411
+ if (!this.provider) return
412
+
413
+ this.connectHandler = () => {
414
+ // Wallet reconnected
415
+ }
416
+
417
+ this.disconnectHandler = () => {
418
+ this.setDisconnected('Wallet disconnected')
419
+ }
420
+
421
+ this.accountChangedHandler = (pubkey: unknown) => {
422
+ if (pubkey && typeof (pubkey as { toBase58?: () => string }).toBase58 === 'function') {
423
+ const newAddress = (pubkey as { toBase58: () => string }).toBase58()
424
+ const previousAddress = this._address
425
+ this._address = newAddress
426
+ this._publicKey = solanaPublicKeyToHex(pubkey as { toBase58: () => string; toBytes: () => Uint8Array; toString: () => string })
427
+ this.emitAccountChanged(previousAddress, newAddress)
428
+ } else {
429
+ // Account changed to null = disconnected
430
+ this.setDisconnected('Account changed')
431
+ }
432
+ }
433
+
434
+ this.provider.on('connect', this.connectHandler)
435
+ this.provider.on('disconnect', this.disconnectHandler)
436
+ this.provider.on('accountChanged', this.accountChangedHandler)
437
+ }
438
+
439
+ /**
440
+ * Clean up wallet event handlers
441
+ */
442
+ private cleanupEventHandlers(): void {
443
+ if (!this.provider) return
444
+
445
+ if (this.connectHandler) {
446
+ this.provider.off('connect', this.connectHandler)
447
+ }
448
+ if (this.disconnectHandler) {
449
+ this.provider.off('disconnect', this.disconnectHandler)
450
+ }
451
+ if (this.accountChangedHandler) {
452
+ this.provider.off('accountChanged', this.accountChangedHandler)
453
+ }
454
+
455
+ this.connectHandler = undefined
456
+ this.disconnectHandler = undefined
457
+ this.accountChangedHandler = undefined
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Create a minimal Solana RPC connection using fetch
463
+ *
464
+ * This allows basic RPC calls without requiring @solana/web3.js
465
+ */
466
+ function createMinimalConnection(endpoint: string): SolanaConnection {
467
+ const rpc = async (method: string, params: unknown[]): Promise<unknown> => {
468
+ const response = await fetch(endpoint, {
469
+ method: 'POST',
470
+ headers: { 'Content-Type': 'application/json' },
471
+ body: JSON.stringify({
472
+ jsonrpc: '2.0',
473
+ id: Date.now(),
474
+ method,
475
+ params,
476
+ }),
477
+ })
478
+
479
+ const data = await response.json()
480
+
481
+ if (data.error) {
482
+ throw new Error(data.error.message || 'RPC error')
483
+ }
484
+
485
+ return data.result
486
+ }
487
+
488
+ return {
489
+ async getBalance(publicKey) {
490
+ const result = await rpc('getBalance', [publicKey.toBase58()])
491
+ return (result as { value: number }).value
492
+ },
493
+
494
+ async getTokenAccountBalance(publicKey) {
495
+ const result = await rpc('getTokenAccountBalance', [publicKey.toBase58()])
496
+ return result as { value: { amount: string; decimals: number } }
497
+ },
498
+
499
+ async getLatestBlockhash() {
500
+ const result = await rpc('getLatestBlockhash', [])
501
+ return result as { blockhash: string; lastValidBlockHeight: number }
502
+ },
503
+
504
+ async sendRawTransaction(rawTransaction, options) {
505
+ const base64Tx = Buffer.from(rawTransaction).toString('base64')
506
+ const result = await rpc('sendTransaction', [
507
+ base64Tx,
508
+ {
509
+ encoding: 'base64',
510
+ skipPreflight: options?.skipPreflight ?? false,
511
+ preflightCommitment: options?.preflightCommitment ?? 'confirmed',
512
+ maxRetries: options?.maxRetries,
513
+ },
514
+ ])
515
+ return result as string
516
+ },
517
+
518
+ async confirmTransaction(signature, commitment = 'confirmed') {
519
+ const result = await rpc('getSignatureStatuses', [[signature]])
520
+ const statuses = result as { value: Array<{ err: unknown } | null> }
521
+ return { value: { err: statuses.value[0]?.err ?? null } }
522
+ },
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Create a Solana wallet adapter with default configuration
528
+ */
529
+ export function createSolanaAdapter(
530
+ config: SolanaAdapterConfig = {}
531
+ ): SolanaWalletAdapter {
532
+ return new SolanaWalletAdapter(config)
533
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Solana Wallet Module
3
+ *
4
+ * Provides Solana-specific wallet adapter for SIP Protocol.
5
+ */
6
+
7
+ // Main adapter
8
+ export { SolanaWalletAdapter, createSolanaAdapter } from './adapter'
9
+
10
+ // Mock adapter for testing
11
+ export {
12
+ MockSolanaAdapter,
13
+ createMockSolanaAdapter,
14
+ createMockSolanaProvider,
15
+ createMockSolanaConnection,
16
+ } from './mock'
17
+
18
+ export type { MockSolanaAdapterConfig } from './mock'
19
+
20
+ // Types
21
+ export {
22
+ getSolanaProvider,
23
+ detectSolanaWallets,
24
+ solanaPublicKeyToHex,
25
+ base58ToHex,
26
+ } from './types'
27
+
28
+ export type {
29
+ SolanaPublicKey,
30
+ SolanaTransaction,
31
+ SolanaVersionedTransaction,
32
+ SolanaWalletProvider,
33
+ SolanaWalletName,
34
+ SolanaCluster,
35
+ SolanaAdapterConfig,
36
+ SolanaConnection,
37
+ SolanaSendOptions,
38
+ SolanaUnsignedTransaction,
39
+ SolanaSignature,
40
+ } from './types'