@sip-protocol/sdk 0.1.0 → 0.1.4

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,657 @@
1
+ /**
2
+ * Trezor Hardware Wallet Adapter
3
+ *
4
+ * Provides integration with Trezor hardware wallets (Model One, Model T, Safe 3/5)
5
+ * for secure transaction signing.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { TrezorWalletAdapter } from '@sip-protocol/sdk'
10
+ *
11
+ * const trezor = new TrezorWalletAdapter({
12
+ * chain: 'ethereum',
13
+ * accountIndex: 0,
14
+ * manifestEmail: 'dev@myapp.com',
15
+ * manifestAppName: 'My DApp',
16
+ * manifestUrl: 'https://myapp.com',
17
+ * })
18
+ *
19
+ * await trezor.connect()
20
+ * const signature = await trezor.signMessage(message)
21
+ * ```
22
+ *
23
+ * @remarks
24
+ * Uses Trezor Connect for device communication.
25
+ *
26
+ * External dependency (install separately):
27
+ * - @trezor/connect-web
28
+ */
29
+
30
+ import type {
31
+ ChainId,
32
+ HexString,
33
+ Asset,
34
+ Signature,
35
+ UnsignedTransaction,
36
+ SignedTransaction,
37
+ TransactionReceipt,
38
+ } from '@sip-protocol/types'
39
+ import { WalletErrorCode } from '@sip-protocol/types'
40
+ import { BaseWalletAdapter } from '../base-adapter'
41
+ import { WalletError } from '../errors'
42
+ import {
43
+ type TrezorConfig,
44
+ type HardwareDeviceInfo,
45
+ type HardwareAccount,
46
+ type HardwareSignature,
47
+ type HardwareEthereumTx,
48
+ HardwareErrorCode,
49
+ HardwareWalletError,
50
+ getDerivationPath,
51
+ } from './types'
52
+
53
+ /**
54
+ * Trezor wallet adapter
55
+ *
56
+ * Supports Ethereum chain via Trezor Connect.
57
+ */
58
+ export class TrezorWalletAdapter extends BaseWalletAdapter {
59
+ readonly chain: ChainId
60
+ readonly name: string = 'trezor'
61
+
62
+ private config: TrezorConfig
63
+ private trezorConnect: TrezorConnectType | null = null
64
+ private initialized: boolean = false
65
+ private _derivationPath: string
66
+ private _deviceInfo: HardwareDeviceInfo | null = null
67
+ private _account: HardwareAccount | null = null
68
+
69
+ constructor(config: TrezorConfig) {
70
+ super()
71
+ this.chain = config.chain
72
+ this.config = {
73
+ accountIndex: 0,
74
+ timeout: 30000,
75
+ popup: true,
76
+ manifestEmail: 'support@sip-protocol.org',
77
+ manifestAppName: 'SIP Protocol',
78
+ manifestUrl: 'https://sip-protocol.org',
79
+ ...config,
80
+ }
81
+ this._derivationPath = config.derivationPath ??
82
+ getDerivationPath(config.chain, config.accountIndex ?? 0)
83
+ }
84
+
85
+ /**
86
+ * Get device information
87
+ */
88
+ get deviceInfo(): HardwareDeviceInfo | null {
89
+ return this._deviceInfo
90
+ }
91
+
92
+ /**
93
+ * Get current derivation path
94
+ */
95
+ get derivationPath(): string {
96
+ return this._derivationPath
97
+ }
98
+
99
+ /**
100
+ * Get current account
101
+ */
102
+ get account(): HardwareAccount | null {
103
+ return this._account
104
+ }
105
+
106
+ /**
107
+ * Connect to Trezor device
108
+ */
109
+ async connect(): Promise<void> {
110
+ this._connectionState = 'connecting'
111
+
112
+ try {
113
+ // Load and initialize Trezor Connect
114
+ await this.initializeTrezorConnect()
115
+
116
+ // Get account from device
117
+ this._account = await this.getAccountFromDevice()
118
+
119
+ // Get device features
120
+ const features = await this.getDeviceFeatures()
121
+ this._deviceInfo = {
122
+ manufacturer: 'trezor',
123
+ model: features.model ?? 'unknown',
124
+ firmwareVersion: features.firmwareVersion,
125
+ isLocked: features.pinProtection && !features.pinCached,
126
+ label: features.label,
127
+ deviceId: features.deviceId,
128
+ }
129
+
130
+ this.setConnected(this._account.address, this._account.publicKey)
131
+ } catch (error) {
132
+ this._connectionState = 'error'
133
+ throw this.handleError(error)
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Disconnect from Trezor device
139
+ */
140
+ async disconnect(): Promise<void> {
141
+ if (this.trezorConnect) {
142
+ // Trezor Connect doesn't have explicit disconnect
143
+ // Just clear state
144
+ }
145
+ this._account = null
146
+ this._deviceInfo = null
147
+ this.setDisconnected()
148
+ }
149
+
150
+ /**
151
+ * Sign a message
152
+ */
153
+ async signMessage(message: Uint8Array): Promise<Signature> {
154
+ this.requireConnected()
155
+
156
+ if (!this.trezorConnect) {
157
+ throw new HardwareWalletError(
158
+ 'Trezor Connect not initialized',
159
+ HardwareErrorCode.TRANSPORT_ERROR,
160
+ 'trezor'
161
+ )
162
+ }
163
+
164
+ try {
165
+ const sig = await this.signMessageOnDevice(message)
166
+
167
+ return {
168
+ signature: sig.signature,
169
+ publicKey: this._account!.publicKey,
170
+ }
171
+ } catch (error) {
172
+ throw this.handleError(error)
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Sign a transaction
178
+ */
179
+ async signTransaction(tx: UnsignedTransaction): Promise<SignedTransaction> {
180
+ this.requireConnected()
181
+
182
+ if (!this.trezorConnect) {
183
+ throw new HardwareWalletError(
184
+ 'Trezor Connect not initialized',
185
+ HardwareErrorCode.TRANSPORT_ERROR,
186
+ 'trezor'
187
+ )
188
+ }
189
+
190
+ try {
191
+ const sig = await this.signTransactionOnDevice(tx)
192
+
193
+ return {
194
+ unsigned: tx,
195
+ signatures: [
196
+ {
197
+ signature: sig.signature,
198
+ publicKey: this._account!.publicKey,
199
+ },
200
+ ],
201
+ serialized: sig.signature,
202
+ }
203
+ } catch (error) {
204
+ throw this.handleError(error)
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Sign and send transaction
210
+ *
211
+ * Note: Hardware wallets can only sign, not send. This returns a signed
212
+ * transaction that must be broadcast separately.
213
+ */
214
+ async signAndSendTransaction(tx: UnsignedTransaction): Promise<TransactionReceipt> {
215
+ const signed = await this.signTransaction(tx)
216
+
217
+ return {
218
+ txHash: signed.serialized.slice(0, 66) as HexString,
219
+ status: 'pending',
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Get native token balance
225
+ *
226
+ * Note: Hardware wallets don't track balances - this requires RPC.
227
+ */
228
+ async getBalance(): Promise<bigint> {
229
+ throw new WalletError(
230
+ 'Hardware wallets do not track balances. Use an RPC provider.',
231
+ WalletErrorCode.UNSUPPORTED_OPERATION
232
+ )
233
+ }
234
+
235
+ /**
236
+ * Get token balance
237
+ *
238
+ * Note: Hardware wallets don't track balances - this requires RPC.
239
+ */
240
+ async getTokenBalance(_asset: Asset): Promise<bigint> {
241
+ throw new WalletError(
242
+ 'Hardware wallets do not track balances. Use an RPC provider.',
243
+ WalletErrorCode.UNSUPPORTED_OPERATION
244
+ )
245
+ }
246
+
247
+ // ─── Account Management ─────────────────────────────────────────────────────
248
+
249
+ /**
250
+ * Get multiple accounts from device
251
+ */
252
+ async getAccounts(startIndex: number = 0, count: number = 5): Promise<HardwareAccount[]> {
253
+ this.requireConnected()
254
+
255
+ const accounts: HardwareAccount[] = []
256
+
257
+ for (let i = startIndex; i < startIndex + count; i++) {
258
+ const path = getDerivationPath(this.chain, i)
259
+ const account = await this.getAccountAtPath(path, i)
260
+ accounts.push(account)
261
+ }
262
+
263
+ return accounts
264
+ }
265
+
266
+ /**
267
+ * Switch to different account index
268
+ */
269
+ async switchAccount(accountIndex: number): Promise<HardwareAccount> {
270
+ this.requireConnected()
271
+
272
+ this._derivationPath = getDerivationPath(this.chain, accountIndex)
273
+ this._account = await this.getAccountFromDevice()
274
+
275
+ const previousAddress = this._address
276
+ this.setConnected(this._account.address, this._account.publicKey)
277
+
278
+ if (previousAddress !== this._account.address) {
279
+ this.emitAccountChanged(previousAddress, this._account.address)
280
+ }
281
+
282
+ return this._account
283
+ }
284
+
285
+ // ─── Private Methods ────────────────────────────────────────────────────────
286
+
287
+ /**
288
+ * Initialize Trezor Connect
289
+ */
290
+ private async initializeTrezorConnect(): Promise<void> {
291
+ if (this.initialized) return
292
+
293
+ try {
294
+ // Dynamic import of Trezor Connect
295
+ // @ts-expect-error - Dynamic import
296
+ const TrezorConnect = await import('@trezor/connect-web')
297
+ this.trezorConnect = TrezorConnect.default
298
+
299
+ // Initialize with manifest
300
+ await this.trezorConnect!.init({
301
+ manifest: {
302
+ email: this.config.manifestEmail!,
303
+ appUrl: this.config.manifestUrl!,
304
+ },
305
+ popup: this.config.popup,
306
+ })
307
+
308
+ this.initialized = true
309
+ } catch (error) {
310
+ throw new HardwareWalletError(
311
+ 'Failed to load Trezor Connect. Install @trezor/connect-web',
312
+ HardwareErrorCode.TRANSPORT_ERROR,
313
+ 'trezor',
314
+ error
315
+ )
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Get device features
321
+ */
322
+ private async getDeviceFeatures(): Promise<TrezorFeatures> {
323
+ if (!this.trezorConnect) {
324
+ throw new HardwareWalletError(
325
+ 'Trezor Connect not initialized',
326
+ HardwareErrorCode.TRANSPORT_ERROR,
327
+ 'trezor'
328
+ )
329
+ }
330
+
331
+ const result = await this.trezorConnect.getFeatures()
332
+
333
+ if (!result.success) {
334
+ const errorPayload = result.payload as { error?: string }
335
+ throw new HardwareWalletError(
336
+ errorPayload.error ?? 'Failed to get device features',
337
+ HardwareErrorCode.TRANSPORT_ERROR,
338
+ 'trezor'
339
+ )
340
+ }
341
+
342
+ // Type assertion after success check - payload is now the features object
343
+ const features = result.payload as {
344
+ model: string
345
+ major_version: number
346
+ minor_version: number
347
+ patch_version: number
348
+ label?: string
349
+ device_id?: string
350
+ pin_protection?: boolean
351
+ pin_cached?: boolean
352
+ }
353
+
354
+ return {
355
+ model: features.model,
356
+ firmwareVersion: `${features.major_version}.${features.minor_version}.${features.patch_version}`,
357
+ label: features.label ?? undefined,
358
+ deviceId: features.device_id ?? undefined,
359
+ pinProtection: features.pin_protection ?? false,
360
+ pinCached: features.pin_cached ?? false,
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Get account from device at current derivation path
366
+ */
367
+ private async getAccountFromDevice(): Promise<HardwareAccount> {
368
+ return this.getAccountAtPath(this._derivationPath, this.config.accountIndex ?? 0)
369
+ }
370
+
371
+ /**
372
+ * Get account at specific derivation path
373
+ */
374
+ private async getAccountAtPath(path: string, index: number): Promise<HardwareAccount> {
375
+ if (!this.trezorConnect) {
376
+ throw new HardwareWalletError(
377
+ 'Trezor Connect not initialized',
378
+ HardwareErrorCode.TRANSPORT_ERROR,
379
+ 'trezor'
380
+ )
381
+ }
382
+
383
+ if (this.chain === 'ethereum') {
384
+ const result = await this.trezorConnect.ethereumGetAddress({
385
+ path,
386
+ showOnTrezor: false,
387
+ })
388
+
389
+ if (!result.success) {
390
+ const errorPayload = result.payload as { error?: string }
391
+ throw new HardwareWalletError(
392
+ errorPayload.error ?? 'Failed to get address',
393
+ HardwareErrorCode.TRANSPORT_ERROR,
394
+ 'trezor'
395
+ )
396
+ }
397
+
398
+ const addressPayload = result.payload as { address: string }
399
+ return {
400
+ address: addressPayload.address,
401
+ publicKey: addressPayload.address as HexString, // Trezor returns address, not public key for Ethereum
402
+ derivationPath: path,
403
+ index,
404
+ chain: this.chain,
405
+ }
406
+ }
407
+
408
+ throw new HardwareWalletError(
409
+ `Chain ${this.chain} not supported by Trezor adapter`,
410
+ HardwareErrorCode.UNSUPPORTED,
411
+ 'trezor'
412
+ )
413
+ }
414
+
415
+ /**
416
+ * Sign message on device
417
+ */
418
+ private async signMessageOnDevice(message: Uint8Array): Promise<HardwareSignature> {
419
+ if (!this.trezorConnect) {
420
+ throw new HardwareWalletError(
421
+ 'Trezor Connect not initialized',
422
+ HardwareErrorCode.TRANSPORT_ERROR,
423
+ 'trezor'
424
+ )
425
+ }
426
+
427
+ if (this.chain === 'ethereum') {
428
+ const messageHex = `0x${Buffer.from(message).toString('hex')}`
429
+
430
+ const result = await this.trezorConnect.ethereumSignMessage({
431
+ path: this._derivationPath,
432
+ message: messageHex,
433
+ hex: true,
434
+ })
435
+
436
+ if (!result.success) {
437
+ const errorPayload = result.payload as { error?: string }
438
+ throw new HardwareWalletError(
439
+ errorPayload.error ?? 'Failed to sign message',
440
+ HardwareErrorCode.USER_REJECTED,
441
+ 'trezor'
442
+ )
443
+ }
444
+
445
+ const signaturePayload = result.payload as { signature: string }
446
+ return {
447
+ r: '0x' as HexString,
448
+ s: '0x' as HexString,
449
+ v: 0,
450
+ signature: `0x${signaturePayload.signature}` as HexString,
451
+ }
452
+ }
453
+
454
+ throw new HardwareWalletError(
455
+ `Message signing not supported for ${this.chain}`,
456
+ HardwareErrorCode.UNSUPPORTED,
457
+ 'trezor'
458
+ )
459
+ }
460
+
461
+ /**
462
+ * Sign transaction on device
463
+ */
464
+ private async signTransactionOnDevice(tx: UnsignedTransaction): Promise<HardwareSignature> {
465
+ if (!this.trezorConnect) {
466
+ throw new HardwareWalletError(
467
+ 'Trezor Connect not initialized',
468
+ HardwareErrorCode.TRANSPORT_ERROR,
469
+ 'trezor'
470
+ )
471
+ }
472
+
473
+ if (this.chain === 'ethereum') {
474
+ const ethTx = tx.data as HardwareEthereumTx
475
+
476
+ const result = await this.trezorConnect.ethereumSignTransaction({
477
+ path: this._derivationPath,
478
+ transaction: {
479
+ to: ethTx.to,
480
+ value: ethTx.value,
481
+ gasLimit: ethTx.gasLimit,
482
+ gasPrice: ethTx.gasPrice,
483
+ nonce: ethTx.nonce,
484
+ data: ethTx.data ?? '0x',
485
+ chainId: ethTx.chainId,
486
+ },
487
+ })
488
+
489
+ if (!result.success) {
490
+ const errorPayload = result.payload as { error?: string }
491
+ throw new HardwareWalletError(
492
+ errorPayload.error ?? 'Failed to sign transaction',
493
+ HardwareErrorCode.USER_REJECTED,
494
+ 'trezor'
495
+ )
496
+ }
497
+
498
+ const txPayload = result.payload as { r: string; s: string; v: string }
499
+ return {
500
+ r: `0x${txPayload.r}` as HexString,
501
+ s: `0x${txPayload.s}` as HexString,
502
+ v: parseInt(txPayload.v, 16),
503
+ signature: `0x${txPayload.r}${txPayload.s}${txPayload.v}` as HexString,
504
+ }
505
+ }
506
+
507
+ throw new HardwareWalletError(
508
+ `Transaction signing not supported for ${this.chain}`,
509
+ HardwareErrorCode.UNSUPPORTED,
510
+ 'trezor'
511
+ )
512
+ }
513
+
514
+ /**
515
+ * Handle and transform errors
516
+ */
517
+ private handleError(error: unknown): Error {
518
+ if (error instanceof HardwareWalletError) {
519
+ return error
520
+ }
521
+
522
+ if (error instanceof WalletError) {
523
+ return error
524
+ }
525
+
526
+ const err = error as { code?: string; message?: string }
527
+
528
+ // Trezor Connect error codes
529
+ if (err.code) {
530
+ switch (err.code) {
531
+ case 'Failure_ActionCancelled':
532
+ case 'Method_Cancel':
533
+ return new HardwareWalletError(
534
+ 'Action cancelled on device',
535
+ HardwareErrorCode.USER_REJECTED,
536
+ 'trezor'
537
+ )
538
+ case 'Failure_PinCancelled':
539
+ return new HardwareWalletError(
540
+ 'PIN entry cancelled',
541
+ HardwareErrorCode.USER_REJECTED,
542
+ 'trezor'
543
+ )
544
+ case 'Device_CallInProgress':
545
+ return new HardwareWalletError(
546
+ 'Another operation in progress',
547
+ HardwareErrorCode.TRANSPORT_ERROR,
548
+ 'trezor'
549
+ )
550
+ }
551
+ }
552
+
553
+ if (err.message?.includes('cancelled') || err.message?.includes('Cancelled')) {
554
+ return new HardwareWalletError(
555
+ 'Operation cancelled',
556
+ HardwareErrorCode.USER_REJECTED,
557
+ 'trezor'
558
+ )
559
+ }
560
+
561
+ if (err.message?.includes('not found') || err.message?.includes('No device')) {
562
+ return new HardwareWalletError(
563
+ 'Trezor device not found',
564
+ HardwareErrorCode.DEVICE_NOT_FOUND,
565
+ 'trezor'
566
+ )
567
+ }
568
+
569
+ return new HardwareWalletError(
570
+ err.message ?? 'Unknown Trezor error',
571
+ HardwareErrorCode.TRANSPORT_ERROR,
572
+ 'trezor',
573
+ error
574
+ )
575
+ }
576
+ }
577
+
578
+ // ─── Type Stubs for Dynamic Imports ───────────────────────────────────────────
579
+
580
+ /**
581
+ * Trezor Connect type stub
582
+ */
583
+ interface TrezorConnectType {
584
+ init(params: {
585
+ manifest: { email: string; appUrl: string }
586
+ popup?: boolean
587
+ }): Promise<void>
588
+
589
+ getFeatures(): Promise<TrezorResponse<TrezorFeaturesPayload>>
590
+
591
+ ethereumGetAddress(params: {
592
+ path: string
593
+ showOnTrezor?: boolean
594
+ }): Promise<TrezorResponse<{ address: string }>>
595
+
596
+ ethereumSignMessage(params: {
597
+ path: string
598
+ message: string
599
+ hex?: boolean
600
+ }): Promise<TrezorResponse<{ signature: string }>>
601
+
602
+ ethereumSignTransaction(params: {
603
+ path: string
604
+ transaction: {
605
+ to: string
606
+ value: HexString
607
+ gasLimit: HexString
608
+ gasPrice?: HexString
609
+ nonce: HexString
610
+ data?: HexString
611
+ chainId: number
612
+ }
613
+ }): Promise<TrezorResponse<{ r: string; s: string; v: string }>>
614
+ }
615
+
616
+ /**
617
+ * Trezor response wrapper
618
+ */
619
+ interface TrezorResponse<T> {
620
+ success: boolean
621
+ payload: T | { error: string }
622
+ }
623
+
624
+ /**
625
+ * Trezor device features payload
626
+ */
627
+ interface TrezorFeaturesPayload {
628
+ model?: string
629
+ major_version?: number
630
+ minor_version?: number
631
+ patch_version?: number
632
+ label?: string
633
+ device_id?: string
634
+ pin_protection?: boolean
635
+ pin_cached?: boolean
636
+ }
637
+
638
+ /**
639
+ * Parsed Trezor features
640
+ */
641
+ interface TrezorFeatures {
642
+ model?: string
643
+ firmwareVersion?: string
644
+ label?: string
645
+ deviceId?: string
646
+ pinProtection: boolean
647
+ pinCached: boolean
648
+ }
649
+
650
+ // ─── Factory Function ─────────────────────────────────────────────────────────
651
+
652
+ /**
653
+ * Create a Trezor wallet adapter
654
+ */
655
+ export function createTrezorAdapter(config: TrezorConfig): TrezorWalletAdapter {
656
+ return new TrezorWalletAdapter(config)
657
+ }