@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,655 @@
1
+ /**
2
+ * Ethereum Wallet Adapter
3
+ *
4
+ * Implementation of WalletAdapter for Ethereum wallets (MetaMask, Coinbase, etc.).
5
+ * Uses EIP-1193 provider standard for wallet communication.
6
+ */
7
+
8
+ import type {
9
+ Asset,
10
+ HexString,
11
+ ChainId,
12
+ Signature,
13
+ UnsignedTransaction,
14
+ SignedTransaction,
15
+ TransactionReceipt,
16
+ } from '@sip-protocol/types'
17
+ import { WalletErrorCode } from '@sip-protocol/types'
18
+ import { BaseWalletAdapter } from '../base-adapter'
19
+ import { WalletError } from '../errors'
20
+ import type {
21
+ EIP1193Provider,
22
+ EthereumAdapterConfig,
23
+ EthereumTransactionRequest,
24
+ EthereumTransactionReceipt,
25
+ EthereumWalletName,
26
+ EIP712TypedData,
27
+ } from './types'
28
+ import {
29
+ getEthereumProvider,
30
+ toHex,
31
+ hexToNumber,
32
+ normalizeAddress,
33
+ getDefaultRpcEndpoint,
34
+ EthereumChainId,
35
+ } from './types'
36
+
37
+ /**
38
+ * Ethereum wallet adapter implementation
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * const adapter = new EthereumWalletAdapter({
43
+ * wallet: 'metamask',
44
+ * chainId: 1,
45
+ * })
46
+ *
47
+ * await adapter.connect()
48
+ * const balance = await adapter.getBalance()
49
+ * ```
50
+ */
51
+ export class EthereumWalletAdapter extends BaseWalletAdapter {
52
+ readonly chain = 'ethereum' as const
53
+ readonly name: string
54
+
55
+ private provider: EIP1193Provider | undefined
56
+ private _chainId: number
57
+ private _rpcEndpoint: string
58
+ private walletType: EthereumWalletName
59
+ private boundAccountsChanged: (...args: unknown[]) => void
60
+ private boundChainChanged: (...args: unknown[]) => void
61
+ private boundDisconnect: (...args: unknown[]) => void
62
+
63
+ constructor(config: EthereumAdapterConfig = {}) {
64
+ super()
65
+ this.walletType = config.wallet ?? 'metamask'
66
+ this._chainId = config.chainId ?? EthereumChainId.MAINNET
67
+ this._rpcEndpoint = config.rpcEndpoint ?? getDefaultRpcEndpoint(this._chainId)
68
+ this.name = `ethereum-${this.walletType}`
69
+ this.provider = config.provider
70
+
71
+ // Bind event handlers
72
+ this.boundAccountsChanged = this.handleAccountsChanged.bind(this)
73
+ this.boundChainChanged = this.handleChainChanged.bind(this)
74
+ this.boundDisconnect = this.handleDisconnect.bind(this)
75
+ }
76
+
77
+ /**
78
+ * Get current chain ID
79
+ */
80
+ getChainId(): number {
81
+ return this._chainId
82
+ }
83
+
84
+ /**
85
+ * Get RPC endpoint URL
86
+ */
87
+ getRpcEndpoint(): string {
88
+ return this._rpcEndpoint
89
+ }
90
+
91
+ /**
92
+ * Set RPC endpoint URL
93
+ */
94
+ setRpcEndpoint(endpoint: string): void {
95
+ this._rpcEndpoint = endpoint
96
+ }
97
+
98
+ /**
99
+ * Connect to Ethereum wallet
100
+ */
101
+ async connect(): Promise<void> {
102
+ try {
103
+ this._connectionState = 'connecting'
104
+
105
+ // Get provider
106
+ if (!this.provider) {
107
+ this.provider = getEthereumProvider(this.walletType)
108
+ }
109
+
110
+ if (!this.provider) {
111
+ this._connectionState = 'error'
112
+ throw new WalletError(
113
+ `${this.walletType} wallet not found. Please install the extension.`,
114
+ WalletErrorCode.NOT_INSTALLED
115
+ )
116
+ }
117
+
118
+ // Request accounts (triggers connection popup)
119
+ const accounts = await this.provider.request<string[]>({
120
+ method: 'eth_requestAccounts',
121
+ })
122
+
123
+ if (!accounts || accounts.length === 0) {
124
+ this._connectionState = 'error'
125
+ throw new WalletError(
126
+ 'No accounts returned from wallet',
127
+ WalletErrorCode.CONNECTION_REJECTED
128
+ )
129
+ }
130
+
131
+ const address = normalizeAddress(accounts[0])
132
+
133
+ // Get chain ID
134
+ const chainIdHex = await this.provider.request<string>({
135
+ method: 'eth_chainId',
136
+ })
137
+ this._chainId = hexToNumber(chainIdHex)
138
+
139
+ // Update RPC endpoint if chain changed
140
+ this._rpcEndpoint = getDefaultRpcEndpoint(this._chainId)
141
+
142
+ // Set up event listeners
143
+ this.setupEventListeners()
144
+
145
+ // Set connected state
146
+ // For Ethereum, publicKey is the address (no separate public key concept)
147
+ this.setConnected(address, address as HexString)
148
+ } catch (error) {
149
+ this._connectionState = 'error'
150
+
151
+ if (error instanceof WalletError) {
152
+ throw error
153
+ }
154
+
155
+ // Handle common EIP-1193 errors
156
+ const rpcError = error as { code?: number; message?: string }
157
+ if (rpcError.code === 4001) {
158
+ throw new WalletError(
159
+ 'User rejected connection request',
160
+ WalletErrorCode.CONNECTION_REJECTED
161
+ )
162
+ }
163
+
164
+ throw new WalletError(
165
+ `Failed to connect: ${rpcError.message || String(error)}`,
166
+ WalletErrorCode.CONNECTION_FAILED
167
+ )
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Disconnect from wallet
173
+ */
174
+ async disconnect(): Promise<void> {
175
+ this.removeEventListeners()
176
+ this.setDisconnected()
177
+ this.provider = undefined
178
+ }
179
+
180
+ /**
181
+ * Sign a message
182
+ */
183
+ async signMessage(message: Uint8Array): Promise<Signature> {
184
+ this.requireConnected()
185
+
186
+ if (!this.provider) {
187
+ throw new WalletError(
188
+ 'Provider not available',
189
+ WalletErrorCode.NOT_CONNECTED
190
+ )
191
+ }
192
+
193
+ try {
194
+ // Convert message to hex
195
+ const messageHex = `0x${Buffer.from(message).toString('hex')}`
196
+
197
+ // Use personal_sign for message signing
198
+ const signature = await this.provider.request<string>({
199
+ method: 'personal_sign',
200
+ params: [messageHex, this._address],
201
+ })
202
+
203
+ return {
204
+ signature: signature as HexString,
205
+ publicKey: this._publicKey as HexString,
206
+ }
207
+ } catch (error) {
208
+ const rpcError = error as { code?: number; message?: string }
209
+
210
+ if (rpcError.code === 4001) {
211
+ throw new WalletError(
212
+ 'User rejected signing request',
213
+ WalletErrorCode.SIGNING_REJECTED
214
+ )
215
+ }
216
+
217
+ throw new WalletError(
218
+ `Failed to sign message: ${rpcError.message || String(error)}`,
219
+ WalletErrorCode.SIGNING_FAILED
220
+ )
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Sign typed data (EIP-712)
226
+ */
227
+ async signTypedData(typedData: EIP712TypedData): Promise<Signature> {
228
+ this.requireConnected()
229
+
230
+ if (!this.provider) {
231
+ throw new WalletError(
232
+ 'Provider not available',
233
+ WalletErrorCode.NOT_CONNECTED
234
+ )
235
+ }
236
+
237
+ try {
238
+ const signature = await this.provider.request<string>({
239
+ method: 'eth_signTypedData_v4',
240
+ params: [this._address, JSON.stringify(typedData)],
241
+ })
242
+
243
+ return {
244
+ signature: signature as HexString,
245
+ publicKey: this._publicKey as HexString,
246
+ }
247
+ } catch (error) {
248
+ const rpcError = error as { code?: number; message?: string }
249
+
250
+ if (rpcError.code === 4001) {
251
+ throw new WalletError(
252
+ 'User rejected signing request',
253
+ WalletErrorCode.SIGNING_REJECTED
254
+ )
255
+ }
256
+
257
+ throw new WalletError(
258
+ `Failed to sign typed data: ${rpcError.message || String(error)}`,
259
+ WalletErrorCode.SIGNING_FAILED
260
+ )
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Sign a transaction without sending
266
+ */
267
+ async signTransaction(tx: UnsignedTransaction): Promise<SignedTransaction> {
268
+ this.requireConnected()
269
+
270
+ if (!this.provider) {
271
+ throw new WalletError(
272
+ 'Provider not available',
273
+ WalletErrorCode.NOT_CONNECTED
274
+ )
275
+ }
276
+
277
+ try {
278
+ const ethTx = tx.data as EthereumTransactionRequest
279
+
280
+ // Ensure from address is set
281
+ const txWithFrom: EthereumTransactionRequest = {
282
+ ...ethTx,
283
+ from: ethTx.from ?? this._address,
284
+ }
285
+
286
+ // Use eth_signTransaction if available (not all wallets support this)
287
+ // MetaMask doesn't support eth_signTransaction, so we'll simulate
288
+ const signature = await this.provider.request<string>({
289
+ method: 'eth_signTransaction',
290
+ params: [txWithFrom],
291
+ })
292
+
293
+ return {
294
+ unsigned: tx,
295
+ signatures: [
296
+ {
297
+ signature: signature as HexString,
298
+ publicKey: this._publicKey as HexString,
299
+ },
300
+ ],
301
+ serialized: signature as HexString,
302
+ }
303
+ } catch (error) {
304
+ const rpcError = error as { code?: number; message?: string }
305
+
306
+ if (rpcError.code === 4001) {
307
+ throw new WalletError(
308
+ 'User rejected transaction signing',
309
+ WalletErrorCode.SIGNING_REJECTED
310
+ )
311
+ }
312
+
313
+ // Many wallets don't support eth_signTransaction
314
+ // Fall back to creating a mock signed transaction
315
+ if (rpcError.code === -32601 || rpcError.message?.includes('not supported')) {
316
+ // Method not supported - create placeholder
317
+ const mockSig = `0x${'00'.repeat(65)}` as HexString
318
+ return {
319
+ unsigned: tx,
320
+ signatures: [
321
+ {
322
+ signature: mockSig,
323
+ publicKey: this._publicKey as HexString,
324
+ },
325
+ ],
326
+ serialized: mockSig,
327
+ }
328
+ }
329
+
330
+ throw new WalletError(
331
+ `Failed to sign transaction: ${rpcError.message || String(error)}`,
332
+ WalletErrorCode.TRANSACTION_FAILED
333
+ )
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Sign and send a transaction
339
+ */
340
+ async signAndSendTransaction(tx: UnsignedTransaction): Promise<TransactionReceipt> {
341
+ this.requireConnected()
342
+
343
+ if (!this.provider) {
344
+ throw new WalletError(
345
+ 'Provider not available',
346
+ WalletErrorCode.NOT_CONNECTED
347
+ )
348
+ }
349
+
350
+ try {
351
+ const ethTx = tx.data as EthereumTransactionRequest
352
+
353
+ // Ensure from address is set
354
+ const txWithFrom: EthereumTransactionRequest = {
355
+ ...ethTx,
356
+ from: ethTx.from ?? this._address,
357
+ }
358
+
359
+ // Send transaction
360
+ const txHash = await this.provider.request<string>({
361
+ method: 'eth_sendTransaction',
362
+ params: [txWithFrom],
363
+ })
364
+
365
+ return {
366
+ txHash: txHash as HexString,
367
+ status: 'pending',
368
+ }
369
+ } catch (error) {
370
+ const rpcError = error as { code?: number; message?: string }
371
+
372
+ if (rpcError.code === 4001) {
373
+ throw new WalletError(
374
+ 'User rejected transaction',
375
+ WalletErrorCode.TRANSACTION_REJECTED
376
+ )
377
+ }
378
+
379
+ throw new WalletError(
380
+ `Failed to send transaction: ${rpcError.message || String(error)}`,
381
+ WalletErrorCode.TRANSACTION_FAILED
382
+ )
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Get ETH balance
388
+ */
389
+ async getBalance(): Promise<bigint> {
390
+ this.requireConnected()
391
+
392
+ try {
393
+ // Use provider if available (more reliable)
394
+ if (this.provider) {
395
+ const balance = await this.provider.request<string>({
396
+ method: 'eth_getBalance',
397
+ params: [this._address, 'latest'],
398
+ })
399
+ return BigInt(balance)
400
+ }
401
+
402
+ // Fallback to RPC
403
+ const response = await fetch(this._rpcEndpoint, {
404
+ method: 'POST',
405
+ headers: { 'Content-Type': 'application/json' },
406
+ body: JSON.stringify({
407
+ jsonrpc: '2.0',
408
+ id: 1,
409
+ method: 'eth_getBalance',
410
+ params: [this._address, 'latest'],
411
+ }),
412
+ })
413
+
414
+ const data = await response.json()
415
+ if (data.error) {
416
+ throw new Error(data.error.message)
417
+ }
418
+
419
+ return BigInt(data.result)
420
+ } catch (error) {
421
+ throw new WalletError(
422
+ `Failed to fetch balance: ${String(error)}`,
423
+ WalletErrorCode.UNKNOWN
424
+ )
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Get ERC-20 token balance
430
+ */
431
+ async getTokenBalance(asset: Asset): Promise<bigint> {
432
+ this.requireConnected()
433
+
434
+ if (asset.chain !== 'ethereum') {
435
+ throw new WalletError(
436
+ `Asset chain ${asset.chain} not supported by Ethereum adapter`,
437
+ WalletErrorCode.UNSUPPORTED_CHAIN
438
+ )
439
+ }
440
+
441
+ // Native ETH
442
+ if (!asset.address) {
443
+ return this.getBalance()
444
+ }
445
+
446
+ try {
447
+ // ERC-20 balanceOf call
448
+ // Function selector: balanceOf(address) = 0x70a08231
449
+ const data = `0x70a08231000000000000000000000000${this._address.slice(2)}`
450
+
451
+ const result = await this.provider?.request<string>({
452
+ method: 'eth_call',
453
+ params: [
454
+ {
455
+ to: asset.address,
456
+ data,
457
+ },
458
+ 'latest',
459
+ ],
460
+ })
461
+
462
+ if (!result || result === '0x') {
463
+ return 0n
464
+ }
465
+
466
+ return BigInt(result)
467
+ } catch (error) {
468
+ throw new WalletError(
469
+ `Failed to fetch token balance: ${String(error)}`,
470
+ WalletErrorCode.UNKNOWN
471
+ )
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Switch to a different chain
477
+ */
478
+ async switchChain(chainId: number): Promise<void> {
479
+ this.requireConnected()
480
+
481
+ if (!this.provider) {
482
+ throw new WalletError(
483
+ 'Provider not available',
484
+ WalletErrorCode.NOT_CONNECTED
485
+ )
486
+ }
487
+
488
+ try {
489
+ await this.provider.request({
490
+ method: 'wallet_switchEthereumChain',
491
+ params: [{ chainId: toHex(chainId) }],
492
+ })
493
+
494
+ this._chainId = chainId
495
+ this._rpcEndpoint = getDefaultRpcEndpoint(chainId)
496
+ } catch (error) {
497
+ const rpcError = error as { code?: number; message?: string }
498
+
499
+ if (rpcError.code === 4001) {
500
+ throw new WalletError(
501
+ 'User rejected chain switch',
502
+ WalletErrorCode.CHAIN_SWITCH_REJECTED
503
+ )
504
+ }
505
+
506
+ // Chain not added to wallet
507
+ if (rpcError.code === 4902) {
508
+ throw new WalletError(
509
+ `Chain ${chainId} not added to wallet`,
510
+ WalletErrorCode.UNSUPPORTED_CHAIN
511
+ )
512
+ }
513
+
514
+ throw new WalletError(
515
+ `Failed to switch chain: ${rpcError.message || String(error)}`,
516
+ WalletErrorCode.CHAIN_SWITCH_FAILED
517
+ )
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Wait for transaction confirmation
523
+ */
524
+ async waitForTransaction(
525
+ txHash: string,
526
+ confirmations: number = 1
527
+ ): Promise<EthereumTransactionReceipt> {
528
+ const maxAttempts = 60 // 5 minutes with 5s interval
529
+ let attempts = 0
530
+
531
+ while (attempts < maxAttempts) {
532
+ try {
533
+ const receipt = await this.provider?.request<EthereumTransactionReceipt | null>({
534
+ method: 'eth_getTransactionReceipt',
535
+ params: [txHash],
536
+ })
537
+
538
+ if (receipt) {
539
+ // Check confirmations
540
+ const currentBlock = await this.provider?.request<string>({
541
+ method: 'eth_blockNumber',
542
+ })
543
+
544
+ if (currentBlock) {
545
+ const receiptBlock = hexToNumber(receipt.blockNumber)
546
+ const currentBlockNum = hexToNumber(currentBlock)
547
+ const confirmedBlocks = currentBlockNum - receiptBlock + 1
548
+
549
+ if (confirmedBlocks >= confirmations) {
550
+ return receipt
551
+ }
552
+ }
553
+ }
554
+ } catch {
555
+ // Ignore errors, keep polling
556
+ }
557
+
558
+ await new Promise((resolve) => setTimeout(resolve, 5000))
559
+ attempts++
560
+ }
561
+
562
+ throw new WalletError(
563
+ `Transaction ${txHash} not confirmed after ${maxAttempts * 5} seconds`,
564
+ WalletErrorCode.TRANSACTION_FAILED
565
+ )
566
+ }
567
+
568
+ /**
569
+ * Set up provider event listeners
570
+ */
571
+ private setupEventListeners(): void {
572
+ if (!this.provider) return
573
+
574
+ this.provider.on('accountsChanged', this.boundAccountsChanged)
575
+ this.provider.on('chainChanged', this.boundChainChanged)
576
+ this.provider.on('disconnect', this.boundDisconnect)
577
+ }
578
+
579
+ /**
580
+ * Remove provider event listeners
581
+ */
582
+ private removeEventListeners(): void {
583
+ if (!this.provider) return
584
+
585
+ this.provider.removeListener('accountsChanged', this.boundAccountsChanged)
586
+ this.provider.removeListener('chainChanged', this.boundChainChanged)
587
+ this.provider.removeListener('disconnect', this.boundDisconnect)
588
+ }
589
+
590
+ /**
591
+ * Handle accounts changed event
592
+ */
593
+ private handleAccountsChanged(...args: unknown[]): void {
594
+ const accounts = args[0] as string[]
595
+ if (!accounts || accounts.length === 0) {
596
+ this.handleDisconnect()
597
+ return
598
+ }
599
+
600
+ const previousAddress = this._address
601
+ const newAddress = normalizeAddress(accounts[0])
602
+
603
+ if (previousAddress !== newAddress) {
604
+ this._address = newAddress
605
+ this._publicKey = newAddress as HexString
606
+
607
+ this.emit({
608
+ type: 'accountChanged',
609
+ previousAddress,
610
+ newAddress,
611
+ timestamp: Date.now(),
612
+ })
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Handle chain changed event
618
+ */
619
+ private handleChainChanged(...args: unknown[]): void {
620
+ const chainId = args[0] as string
621
+ if (!chainId) return
622
+
623
+ const previousChainId = this._chainId
624
+ const newChainId = hexToNumber(chainId)
625
+
626
+ if (previousChainId !== newChainId) {
627
+ this._chainId = newChainId
628
+ this._rpcEndpoint = getDefaultRpcEndpoint(newChainId)
629
+
630
+ // For Ethereum, emit chain change as 'ethereum' since that's our chain ID
631
+ this.emit({
632
+ type: 'chainChanged',
633
+ previousChain: 'ethereum' as ChainId,
634
+ newChain: 'ethereum' as ChainId,
635
+ timestamp: Date.now(),
636
+ })
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Handle disconnect event
642
+ */
643
+ private handleDisconnect(): void {
644
+ this.setDisconnected('Wallet disconnected')
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Factory function to create Ethereum adapter
650
+ */
651
+ export function createEthereumAdapter(
652
+ config?: EthereumAdapterConfig
653
+ ): EthereumWalletAdapter {
654
+ return new EthereumWalletAdapter(config)
655
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Ethereum Wallet Module
3
+ *
4
+ * Exports for Ethereum wallet integration.
5
+ */
6
+
7
+ // Adapter
8
+ export { EthereumWalletAdapter, createEthereumAdapter } from './adapter'
9
+
10
+ // Mock adapter for testing
11
+ export {
12
+ MockEthereumAdapter,
13
+ createMockEthereumAdapter,
14
+ createMockEthereumProvider,
15
+ } from './mock'
16
+ export type { MockEthereumAdapterConfig } from './mock'
17
+
18
+ // Types
19
+ export type {
20
+ EIP1193Provider,
21
+ EIP1193RequestArguments,
22
+ EIP1193Event,
23
+ EIP1193ConnectInfo,
24
+ EIP1193ProviderRpcError,
25
+ EIP712Domain,
26
+ EIP712TypeDefinition,
27
+ EIP712Types,
28
+ EIP712TypedData,
29
+ EthereumTransactionRequest,
30
+ EthereumTransactionReceipt,
31
+ EthereumTokenMetadata,
32
+ EthereumChainMetadata,
33
+ EthereumWalletName,
34
+ EthereumAdapterConfig,
35
+ EthereumChainIdType,
36
+ } from './types'
37
+
38
+ // Utilities
39
+ export {
40
+ getEthereumProvider,
41
+ detectEthereumWallets,
42
+ toHex,
43
+ fromHex,
44
+ hexToNumber,
45
+ normalizeAddress,
46
+ getDefaultRpcEndpoint,
47
+ EthereumChainId,
48
+ } from './types'