@sip-protocol/react 0.1.0 → 0.1.1

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,1050 @@
1
+ import React, { useState, useCallback, useMemo } from 'react'
2
+
3
+ /**
4
+ * EthereumStealthAddressDisplay Component
5
+ *
6
+ * Displays EIP-5564 stealth addresses with Ethereum-specific features including
7
+ * network support for mainnet and L2s, Etherscan integration, and EIP-5564 validation.
8
+ *
9
+ * @module components/ethereum/stealth-address-display
10
+ */
11
+
12
+ /**
13
+ * Ownership status for stealth addresses
14
+ */
15
+ export type EthereumOwnershipStatus = 'yours' | 'others' | 'unknown'
16
+
17
+ /**
18
+ * Ethereum network IDs
19
+ */
20
+ export type EthereumStealthNetworkId =
21
+ | 'mainnet'
22
+ | 'arbitrum'
23
+ | 'optimism'
24
+ | 'base'
25
+ | 'polygon'
26
+ | 'sepolia'
27
+
28
+ /**
29
+ * Network configuration for explorer links
30
+ */
31
+ export interface EthereumStealthNetworkConfig {
32
+ name: string
33
+ chainId: number
34
+ explorerUrl: string
35
+ explorerName: string
36
+ isL2: boolean
37
+ color: string
38
+ }
39
+
40
+ /**
41
+ * Default Ethereum network configurations
42
+ */
43
+ export const ETHEREUM_STEALTH_NETWORKS: Record<
44
+ EthereumStealthNetworkId,
45
+ EthereumStealthNetworkConfig
46
+ > = {
47
+ mainnet: {
48
+ name: 'Ethereum',
49
+ chainId: 1,
50
+ explorerUrl: 'https://etherscan.io/address',
51
+ explorerName: 'Etherscan',
52
+ isL2: false,
53
+ color: '#627EEA',
54
+ },
55
+ arbitrum: {
56
+ name: 'Arbitrum',
57
+ chainId: 42161,
58
+ explorerUrl: 'https://arbiscan.io/address',
59
+ explorerName: 'Arbiscan',
60
+ isL2: true,
61
+ color: '#28A0F0',
62
+ },
63
+ optimism: {
64
+ name: 'Optimism',
65
+ chainId: 10,
66
+ explorerUrl: 'https://optimistic.etherscan.io/address',
67
+ explorerName: 'Optimism Explorer',
68
+ isL2: true,
69
+ color: '#FF0420',
70
+ },
71
+ base: {
72
+ name: 'Base',
73
+ chainId: 8453,
74
+ explorerUrl: 'https://basescan.org/address',
75
+ explorerName: 'BaseScan',
76
+ isL2: true,
77
+ color: '#0052FF',
78
+ },
79
+ polygon: {
80
+ name: 'Polygon',
81
+ chainId: 137,
82
+ explorerUrl: 'https://polygonscan.com/address',
83
+ explorerName: 'PolygonScan',
84
+ isL2: true,
85
+ color: '#8247E5',
86
+ },
87
+ sepolia: {
88
+ name: 'Sepolia',
89
+ chainId: 11155111,
90
+ explorerUrl: 'https://sepolia.etherscan.io/address',
91
+ explorerName: 'Sepolia Etherscan',
92
+ isL2: false,
93
+ color: '#CFB5F0',
94
+ },
95
+ }
96
+
97
+ /**
98
+ * EthereumStealthAddressDisplay component props
99
+ */
100
+ export interface EthereumStealthAddressDisplayProps {
101
+ /** The stealth address to display (0x prefixed, 42 chars) */
102
+ address: string
103
+ /** Optional stealth meta-address for QR code (full sip: format) */
104
+ metaAddress?: string
105
+ /** Optional ephemeral public key (EIP-5564) */
106
+ ephemeralPublicKey?: string
107
+ /** View tag for the stealth address (EIP-5564) */
108
+ viewTag?: number
109
+ /** Ownership status of the address */
110
+ ownership?: EthereumOwnershipStatus
111
+ /** Whether the address is validated */
112
+ isValid?: boolean
113
+ /** Network for explorer links */
114
+ network?: EthereumStealthNetworkId
115
+ /** Custom network configuration */
116
+ networkConfig?: EthereumStealthNetworkConfig
117
+ /** Whether to show the QR code button */
118
+ showQrCode?: boolean
119
+ /** Whether to show the explorer link */
120
+ showExplorerLink?: boolean
121
+ /** Whether to show the copy button */
122
+ showCopyButton?: boolean
123
+ /** Whether to show the ownership badge */
124
+ showOwnership?: boolean
125
+ /** Whether to show the validation indicator */
126
+ showValidation?: boolean
127
+ /** Whether to show the network badge */
128
+ showNetworkBadge?: boolean
129
+ /** Whether to show the EIP-5564 badge */
130
+ showEipBadge?: boolean
131
+ /** Whether to show the view tag */
132
+ showViewTag?: boolean
133
+ /** Custom class name */
134
+ className?: string
135
+ /** Size variant */
136
+ size?: 'sm' | 'md' | 'lg'
137
+ /** Callback when address is copied */
138
+ onCopy?: (address: string) => void
139
+ /** Callback when QR code is shown */
140
+ onShowQr?: (address: string) => void
141
+ /** Callback when ephemeral key is copied */
142
+ onCopyEphemeralKey?: (key: string) => void
143
+ }
144
+
145
+ /**
146
+ * Validates an Ethereum address format
147
+ */
148
+ export function isValidEthereumAddress(address: string): boolean {
149
+ // Ethereum addresses are 42 chars with 0x prefix
150
+ const ethRegex = /^0x[0-9a-fA-F]{40}$/
151
+ return ethRegex.test(address)
152
+ }
153
+
154
+ /**
155
+ * Validates an EIP-5564 stealth address
156
+ * Stealth addresses are valid Ethereum addresses generated from EIP-5564
157
+ */
158
+ export function isValidEthereumStealthAddress(address: string): boolean {
159
+ return isValidEthereumAddress(address)
160
+ }
161
+
162
+ /**
163
+ * Validates an ephemeral public key (compressed secp256k1)
164
+ */
165
+ export function isValidEphemeralPublicKey(key: string): boolean {
166
+ // Compressed public key: 66 chars (02 or 03 prefix + 64 hex)
167
+ const compressedRegex = /^0x0[23][0-9a-fA-F]{64}$/
168
+ // Uncompressed public key: 130 chars (04 prefix + 128 hex)
169
+ const uncompressedRegex = /^0x04[0-9a-fA-F]{128}$/
170
+ return compressedRegex.test(key) || uncompressedRegex.test(key)
171
+ }
172
+
173
+ /**
174
+ * Truncates an address for display
175
+ */
176
+ export function truncateEthereumAddress(
177
+ address: string,
178
+ startChars = 6,
179
+ endChars = 4
180
+ ): string {
181
+ if (address.length <= startChars + endChars + 3) {
182
+ return address
183
+ }
184
+ return `${address.slice(0, startChars)}...${address.slice(-endChars)}`
185
+ }
186
+
187
+ /**
188
+ * CSS styles for the component
189
+ */
190
+ const styles = `
191
+ .sip-eth-stealth-address {
192
+ display: inline-flex;
193
+ flex-direction: column;
194
+ gap: 8px;
195
+ font-family: system-ui, -apple-system, sans-serif;
196
+ }
197
+
198
+ .sip-eth-stealth-container {
199
+ display: inline-flex;
200
+ align-items: center;
201
+ gap: 8px;
202
+ padding: 8px 12px;
203
+ background: linear-gradient(135deg, #1a1a2e 0%, #0d1421 100%);
204
+ border: 1px solid #2d3748;
205
+ border-radius: 8px;
206
+ position: relative;
207
+ }
208
+
209
+ .sip-eth-stealth-container[data-size="sm"] {
210
+ padding: 4px 8px;
211
+ gap: 6px;
212
+ border-radius: 6px;
213
+ }
214
+
215
+ .sip-eth-stealth-container[data-size="lg"] {
216
+ padding: 12px 16px;
217
+ gap: 12px;
218
+ border-radius: 12px;
219
+ }
220
+
221
+ .sip-eth-stealth-icon {
222
+ display: flex;
223
+ align-items: center;
224
+ justify-content: center;
225
+ width: 24px;
226
+ height: 24px;
227
+ background: linear-gradient(135deg, #627EEA 0%, #8A92B2 100%);
228
+ border-radius: 6px;
229
+ color: white;
230
+ flex-shrink: 0;
231
+ }
232
+
233
+ .sip-eth-stealth-icon svg {
234
+ width: 14px;
235
+ height: 14px;
236
+ }
237
+
238
+ .sip-eth-stealth-container[data-size="sm"] .sip-eth-stealth-icon {
239
+ width: 20px;
240
+ height: 20px;
241
+ border-radius: 4px;
242
+ }
243
+
244
+ .sip-eth-stealth-container[data-size="sm"] .sip-eth-stealth-icon svg {
245
+ width: 12px;
246
+ height: 12px;
247
+ }
248
+
249
+ .sip-eth-stealth-container[data-size="lg"] .sip-eth-stealth-icon {
250
+ width: 32px;
251
+ height: 32px;
252
+ border-radius: 8px;
253
+ }
254
+
255
+ .sip-eth-stealth-container[data-size="lg"] .sip-eth-stealth-icon svg {
256
+ width: 18px;
257
+ height: 18px;
258
+ }
259
+
260
+ .sip-eth-stealth-address-text {
261
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', monospace;
262
+ font-size: 13px;
263
+ color: #e2e8f0;
264
+ cursor: default;
265
+ position: relative;
266
+ }
267
+
268
+ .sip-eth-stealth-container[data-size="sm"] .sip-eth-stealth-address-text {
269
+ font-size: 11px;
270
+ }
271
+
272
+ .sip-eth-stealth-container[data-size="lg"] .sip-eth-stealth-address-text {
273
+ font-size: 15px;
274
+ }
275
+
276
+ .sip-eth-stealth-address-full {
277
+ position: absolute;
278
+ bottom: calc(100% + 8px);
279
+ left: 50%;
280
+ transform: translateX(-50%);
281
+ padding: 8px 12px;
282
+ background: #1f2937;
283
+ color: #e2e8f0;
284
+ font-size: 11px;
285
+ font-family: 'SF Mono', Monaco, monospace;
286
+ border-radius: 6px;
287
+ white-space: nowrap;
288
+ opacity: 0;
289
+ visibility: hidden;
290
+ transition: opacity 0.2s, visibility 0.2s;
291
+ z-index: 10;
292
+ pointer-events: none;
293
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
294
+ }
295
+
296
+ .sip-eth-stealth-address-full::after {
297
+ content: '';
298
+ position: absolute;
299
+ top: 100%;
300
+ left: 50%;
301
+ transform: translateX(-50%);
302
+ border: 6px solid transparent;
303
+ border-top-color: #1f2937;
304
+ }
305
+
306
+ .sip-eth-stealth-address-text:hover .sip-eth-stealth-address-full {
307
+ opacity: 1;
308
+ visibility: visible;
309
+ }
310
+
311
+ .sip-eth-stealth-actions {
312
+ display: flex;
313
+ align-items: center;
314
+ gap: 4px;
315
+ margin-left: 4px;
316
+ }
317
+
318
+ .sip-eth-stealth-action-btn {
319
+ display: flex;
320
+ align-items: center;
321
+ justify-content: center;
322
+ width: 28px;
323
+ height: 28px;
324
+ padding: 0;
325
+ border: none;
326
+ background: transparent;
327
+ color: #94a3b8;
328
+ cursor: pointer;
329
+ border-radius: 4px;
330
+ transition: all 0.2s;
331
+ }
332
+
333
+ .sip-eth-stealth-action-btn:hover {
334
+ background: rgba(255, 255, 255, 0.1);
335
+ color: #e2e8f0;
336
+ }
337
+
338
+ .sip-eth-stealth-action-btn:active {
339
+ transform: scale(0.95);
340
+ }
341
+
342
+ .sip-eth-stealth-action-btn svg {
343
+ width: 16px;
344
+ height: 16px;
345
+ }
346
+
347
+ .sip-eth-stealth-container[data-size="sm"] .sip-eth-stealth-action-btn {
348
+ width: 24px;
349
+ height: 24px;
350
+ }
351
+
352
+ .sip-eth-stealth-container[data-size="sm"] .sip-eth-stealth-action-btn svg {
353
+ width: 14px;
354
+ height: 14px;
355
+ }
356
+
357
+ .sip-eth-stealth-container[data-size="lg"] .sip-eth-stealth-action-btn {
358
+ width: 32px;
359
+ height: 32px;
360
+ }
361
+
362
+ .sip-eth-stealth-container[data-size="lg"] .sip-eth-stealth-action-btn svg {
363
+ width: 18px;
364
+ height: 18px;
365
+ }
366
+
367
+ .sip-eth-stealth-badge {
368
+ display: inline-flex;
369
+ align-items: center;
370
+ gap: 4px;
371
+ padding: 2px 8px;
372
+ font-size: 10px;
373
+ font-weight: 600;
374
+ text-transform: uppercase;
375
+ letter-spacing: 0.05em;
376
+ border-radius: 4px;
377
+ }
378
+
379
+ .sip-eth-stealth-badge[data-ownership="yours"] {
380
+ background: rgba(34, 197, 94, 0.2);
381
+ color: #22c55e;
382
+ border: 1px solid rgba(34, 197, 94, 0.3);
383
+ }
384
+
385
+ .sip-eth-stealth-badge[data-ownership="others"] {
386
+ background: rgba(59, 130, 246, 0.2);
387
+ color: #3b82f6;
388
+ border: 1px solid rgba(59, 130, 246, 0.3);
389
+ }
390
+
391
+ .sip-eth-stealth-badge[data-ownership="unknown"] {
392
+ background: rgba(156, 163, 175, 0.2);
393
+ color: #9ca3af;
394
+ border: 1px solid rgba(156, 163, 175, 0.3);
395
+ }
396
+
397
+ .sip-eth-stealth-network-badge {
398
+ display: inline-flex;
399
+ align-items: center;
400
+ gap: 4px;
401
+ padding: 2px 8px;
402
+ font-size: 10px;
403
+ font-weight: 600;
404
+ border-radius: 4px;
405
+ background: rgba(98, 126, 234, 0.2);
406
+ color: #627EEA;
407
+ border: 1px solid rgba(98, 126, 234, 0.3);
408
+ }
409
+
410
+ .sip-eth-stealth-network-badge[data-l2="true"] {
411
+ background: rgba(40, 160, 240, 0.2);
412
+ color: #28A0F0;
413
+ border: 1px solid rgba(40, 160, 240, 0.3);
414
+ }
415
+
416
+ .sip-eth-stealth-eip-badge {
417
+ display: inline-flex;
418
+ align-items: center;
419
+ gap: 4px;
420
+ padding: 2px 8px;
421
+ font-size: 9px;
422
+ font-weight: 600;
423
+ border-radius: 4px;
424
+ background: rgba(139, 92, 246, 0.2);
425
+ color: #8b5cf6;
426
+ border: 1px solid rgba(139, 92, 246, 0.3);
427
+ font-family: 'SF Mono', Monaco, monospace;
428
+ }
429
+
430
+ .sip-eth-stealth-view-tag {
431
+ display: inline-flex;
432
+ align-items: center;
433
+ gap: 4px;
434
+ padding: 2px 8px;
435
+ font-size: 10px;
436
+ font-weight: 500;
437
+ border-radius: 4px;
438
+ background: rgba(251, 191, 36, 0.2);
439
+ color: #fbbf24;
440
+ border: 1px solid rgba(251, 191, 36, 0.3);
441
+ font-family: 'SF Mono', Monaco, monospace;
442
+ }
443
+
444
+ .sip-eth-stealth-validation {
445
+ display: flex;
446
+ align-items: center;
447
+ gap: 4px;
448
+ font-size: 11px;
449
+ }
450
+
451
+ .sip-eth-stealth-validation[data-valid="true"] {
452
+ color: #22c55e;
453
+ }
454
+
455
+ .sip-eth-stealth-validation[data-valid="false"] {
456
+ color: #ef4444;
457
+ }
458
+
459
+ .sip-eth-stealth-validation svg {
460
+ width: 14px;
461
+ height: 14px;
462
+ }
463
+
464
+ .sip-eth-stealth-meta {
465
+ display: flex;
466
+ flex-wrap: wrap;
467
+ gap: 6px;
468
+ align-items: center;
469
+ }
470
+
471
+ .sip-eth-stealth-ephemeral {
472
+ display: flex;
473
+ align-items: center;
474
+ gap: 6px;
475
+ padding: 4px 8px;
476
+ background: rgba(99, 102, 241, 0.1);
477
+ border: 1px solid rgba(99, 102, 241, 0.2);
478
+ border-radius: 6px;
479
+ font-size: 10px;
480
+ color: #a5b4fc;
481
+ }
482
+
483
+ .sip-eth-stealth-ephemeral-label {
484
+ font-weight: 600;
485
+ text-transform: uppercase;
486
+ letter-spacing: 0.05em;
487
+ }
488
+
489
+ .sip-eth-stealth-ephemeral-key {
490
+ font-family: 'SF Mono', Monaco, monospace;
491
+ color: #c4b5fd;
492
+ }
493
+
494
+ .sip-eth-stealth-qr-modal {
495
+ position: fixed;
496
+ top: 0;
497
+ left: 0;
498
+ right: 0;
499
+ bottom: 0;
500
+ background: rgba(0, 0, 0, 0.7);
501
+ display: flex;
502
+ align-items: center;
503
+ justify-content: center;
504
+ z-index: 1000;
505
+ }
506
+
507
+ .sip-eth-stealth-qr-content {
508
+ background: white;
509
+ padding: 24px;
510
+ border-radius: 16px;
511
+ text-align: center;
512
+ max-width: 320px;
513
+ width: 90%;
514
+ }
515
+
516
+ .sip-eth-stealth-qr-title {
517
+ font-size: 16px;
518
+ font-weight: 600;
519
+ color: #111827;
520
+ margin-bottom: 16px;
521
+ }
522
+
523
+ .sip-eth-stealth-qr-code {
524
+ display: flex;
525
+ align-items: center;
526
+ justify-content: center;
527
+ width: 200px;
528
+ height: 200px;
529
+ margin: 0 auto 16px;
530
+ background: #f9fafb;
531
+ border-radius: 8px;
532
+ border: 1px solid #e5e7eb;
533
+ }
534
+
535
+ .sip-eth-stealth-qr-address {
536
+ font-family: 'SF Mono', Monaco, monospace;
537
+ font-size: 11px;
538
+ color: #6b7280;
539
+ word-break: break-all;
540
+ margin-bottom: 16px;
541
+ }
542
+
543
+ .sip-eth-stealth-qr-close {
544
+ padding: 8px 24px;
545
+ background: #111827;
546
+ color: white;
547
+ border: none;
548
+ border-radius: 8px;
549
+ font-size: 14px;
550
+ font-weight: 500;
551
+ cursor: pointer;
552
+ transition: background 0.2s;
553
+ }
554
+
555
+ .sip-eth-stealth-qr-close:hover {
556
+ background: #374151;
557
+ }
558
+
559
+ .sip-eth-stealth-copy-success {
560
+ position: absolute;
561
+ top: -32px;
562
+ left: 50%;
563
+ transform: translateX(-50%);
564
+ padding: 4px 12px;
565
+ background: #22c55e;
566
+ color: white;
567
+ font-size: 12px;
568
+ font-weight: 500;
569
+ border-radius: 4px;
570
+ opacity: 0;
571
+ visibility: hidden;
572
+ transition: opacity 0.2s, visibility 0.2s;
573
+ }
574
+
575
+ .sip-eth-stealth-copy-success.visible {
576
+ opacity: 1;
577
+ visibility: visible;
578
+ }
579
+
580
+ /* Dark mode already active by default, light mode override */
581
+ @media (prefers-color-scheme: light) {
582
+ .sip-eth-stealth-container {
583
+ background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
584
+ border-color: #e2e8f0;
585
+ }
586
+
587
+ .sip-eth-stealth-address-text {
588
+ color: #1e293b;
589
+ }
590
+
591
+ .sip-eth-stealth-action-btn {
592
+ color: #64748b;
593
+ }
594
+
595
+ .sip-eth-stealth-action-btn:hover {
596
+ background: rgba(0, 0, 0, 0.05);
597
+ color: #1e293b;
598
+ }
599
+ }
600
+ `
601
+
602
+ /**
603
+ * Ethereum diamond icon for stealth address indicator
604
+ */
605
+ const EthereumIcon = () => (
606
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
607
+ <path d="M12 2L4 12l8 10 8-10L12 2z" />
608
+ <path d="M4 12l8 3.5L20 12" />
609
+ <path d="M12 2v13.5" />
610
+ </svg>
611
+ )
612
+
613
+ /**
614
+ * Copy icon
615
+ */
616
+ const CopyIcon = () => (
617
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
618
+ <rect x="9" y="9" width="13" height="13" rx="2" />
619
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
620
+ </svg>
621
+ )
622
+
623
+ /**
624
+ * External link icon
625
+ */
626
+ const ExternalLinkIcon = () => (
627
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
628
+ <path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" />
629
+ <polyline points="15 3 21 3 21 9" />
630
+ <line x1="10" y1="14" x2="21" y2="3" />
631
+ </svg>
632
+ )
633
+
634
+ /**
635
+ * QR code icon
636
+ */
637
+ const QrCodeIcon = () => (
638
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
639
+ <rect x="3" y="3" width="7" height="7" rx="1" />
640
+ <rect x="14" y="3" width="7" height="7" rx="1" />
641
+ <rect x="3" y="14" width="7" height="7" rx="1" />
642
+ <rect x="14" y="14" width="3" height="3" />
643
+ <rect x="18" y="14" width="3" height="3" />
644
+ <rect x="14" y="18" width="3" height="3" />
645
+ <rect x="18" y="18" width="3" height="3" />
646
+ </svg>
647
+ )
648
+
649
+ /**
650
+ * Check icon
651
+ */
652
+ const CheckIcon = () => (
653
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
654
+ <polyline points="20 6 9 17 4 12" />
655
+ </svg>
656
+ )
657
+
658
+ /**
659
+ * X icon
660
+ */
661
+ const XIcon = () => (
662
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
663
+ <line x1="18" y1="6" x2="6" y2="18" />
664
+ <line x1="6" y1="6" x2="18" y2="18" />
665
+ </svg>
666
+ )
667
+
668
+ /**
669
+ * EthereumStealthAddressDisplay - Component for displaying EIP-5564 stealth addresses
670
+ *
671
+ * Displays stealth addresses with Ethereum-specific visual styling,
672
+ * including network support, Etherscan links, and EIP-5564 metadata.
673
+ *
674
+ * @example Basic usage
675
+ * ```tsx
676
+ * import { EthereumStealthAddressDisplay } from '@sip-protocol/react'
677
+ *
678
+ * function WalletView() {
679
+ * return (
680
+ * <EthereumStealthAddressDisplay
681
+ * address="0x742d35Cc6634C0532925a3b844Bc9e7595f2..."
682
+ * ownership="yours"
683
+ * />
684
+ * )
685
+ * }
686
+ * ```
687
+ *
688
+ * @example With EIP-5564 metadata
689
+ * ```tsx
690
+ * <EthereumStealthAddressDisplay
691
+ * address="0x742d35Cc6634C0532925a3b844Bc9e7595f2..."
692
+ * ephemeralPublicKey="0x02abc...123"
693
+ * viewTag={42}
694
+ * network="arbitrum"
695
+ * showEipBadge
696
+ * showViewTag
697
+ * />
698
+ * ```
699
+ */
700
+ export function EthereumStealthAddressDisplay({
701
+ address,
702
+ metaAddress,
703
+ ephemeralPublicKey,
704
+ viewTag,
705
+ ownership = 'unknown',
706
+ isValid,
707
+ network = 'mainnet',
708
+ networkConfig,
709
+ showQrCode = true,
710
+ showExplorerLink = true,
711
+ showCopyButton = true,
712
+ showOwnership = true,
713
+ showValidation = true,
714
+ showNetworkBadge = true,
715
+ showEipBadge = true,
716
+ showViewTag = true,
717
+ className = '',
718
+ size = 'md',
719
+ onCopy,
720
+ onShowQr,
721
+ onCopyEphemeralKey,
722
+ }: EthereumStealthAddressDisplayProps) {
723
+ const [showCopySuccess, setShowCopySuccess] = useState(false)
724
+ const [showQrModal, setShowQrModal] = useState(false)
725
+
726
+ // Determine validation status
727
+ const validationStatus = useMemo(() => {
728
+ if (isValid !== undefined) return isValid
729
+ return isValidEthereumStealthAddress(address)
730
+ }, [address, isValid])
731
+
732
+ // Get network config
733
+ const config = useMemo(() => {
734
+ return networkConfig ?? ETHEREUM_STEALTH_NETWORKS[network]
735
+ }, [network, networkConfig])
736
+
737
+ // Truncate address for display based on size
738
+ const displayAddress = useMemo(() => {
739
+ const startChars = size === 'sm' ? 6 : size === 'lg' ? 10 : 8
740
+ const endChars = size === 'sm' ? 4 : size === 'lg' ? 6 : 4
741
+ return truncateEthereumAddress(address, startChars, endChars)
742
+ }, [address, size])
743
+
744
+ // Handle copy to clipboard
745
+ const handleCopy = useCallback(async () => {
746
+ try {
747
+ await navigator.clipboard.writeText(address)
748
+ setShowCopySuccess(true)
749
+ onCopy?.(address)
750
+ setTimeout(() => setShowCopySuccess(false), 2000)
751
+ } catch {
752
+ // Fallback for older browsers
753
+ const textArea = document.createElement('textarea')
754
+ textArea.value = address
755
+ document.body.appendChild(textArea)
756
+ textArea.select()
757
+ document.execCommand('copy')
758
+ document.body.removeChild(textArea)
759
+ setShowCopySuccess(true)
760
+ onCopy?.(address)
761
+ setTimeout(() => setShowCopySuccess(false), 2000)
762
+ }
763
+ }, [address, onCopy])
764
+
765
+ // Handle copy ephemeral key
766
+ const handleCopyEphemeralKey = useCallback(async () => {
767
+ if (!ephemeralPublicKey) return
768
+ try {
769
+ await navigator.clipboard.writeText(ephemeralPublicKey)
770
+ onCopyEphemeralKey?.(ephemeralPublicKey)
771
+ } catch {
772
+ const textArea = document.createElement('textarea')
773
+ textArea.value = ephemeralPublicKey
774
+ document.body.appendChild(textArea)
775
+ textArea.select()
776
+ document.execCommand('copy')
777
+ document.body.removeChild(textArea)
778
+ onCopyEphemeralKey?.(ephemeralPublicKey)
779
+ }
780
+ }, [ephemeralPublicKey, onCopyEphemeralKey])
781
+
782
+ // Handle show QR code
783
+ const handleShowQr = useCallback(() => {
784
+ setShowQrModal(true)
785
+ onShowQr?.(metaAddress ?? address)
786
+ }, [address, metaAddress, onShowQr])
787
+
788
+ // Handle close QR modal
789
+ const handleCloseQr = useCallback(() => {
790
+ setShowQrModal(false)
791
+ }, [])
792
+
793
+ // Get ownership label
794
+ const ownershipLabel = useMemo(() => {
795
+ switch (ownership) {
796
+ case 'yours':
797
+ return 'Your Address'
798
+ case 'others':
799
+ return "Recipient's Address"
800
+ case 'unknown':
801
+ default:
802
+ return 'Unknown'
803
+ }
804
+ }, [ownership])
805
+
806
+ // Explorer URL
807
+ const explorerUrl = useMemo(() => {
808
+ return `${config.explorerUrl}/${address}`
809
+ }, [config.explorerUrl, address])
810
+
811
+ // Truncate ephemeral key for display
812
+ const displayEphemeralKey = useMemo(() => {
813
+ if (!ephemeralPublicKey) return ''
814
+ return truncateEthereumAddress(ephemeralPublicKey, 8, 6)
815
+ }, [ephemeralPublicKey])
816
+
817
+ return (
818
+ <>
819
+ <style>{styles}</style>
820
+
821
+ <div
822
+ className={`sip-eth-stealth-address ${className}`}
823
+ data-testid="eth-stealth-address"
824
+ >
825
+ <div
826
+ className="sip-eth-stealth-container"
827
+ data-size={size}
828
+ data-ownership={ownership}
829
+ >
830
+ {/* Copy success message */}
831
+ <span
832
+ className={`sip-eth-stealth-copy-success ${showCopySuccess ? 'visible' : ''}`}
833
+ >
834
+ Copied!
835
+ </span>
836
+
837
+ {/* Ethereum icon badge */}
838
+ <div className="sip-eth-stealth-icon" title="EIP-5564 Stealth Address">
839
+ <EthereumIcon />
840
+ </div>
841
+
842
+ {/* Address text with full address tooltip */}
843
+ <span className="sip-eth-stealth-address-text" data-testid="address-text">
844
+ {displayAddress}
845
+ <span className="sip-eth-stealth-address-full">{address}</span>
846
+ </span>
847
+
848
+ {/* Action buttons */}
849
+ <div className="sip-eth-stealth-actions">
850
+ {showCopyButton && (
851
+ <button
852
+ type="button"
853
+ className="sip-eth-stealth-action-btn"
854
+ onClick={handleCopy}
855
+ title="Copy address"
856
+ aria-label="Copy address to clipboard"
857
+ >
858
+ <CopyIcon />
859
+ </button>
860
+ )}
861
+
862
+ {showExplorerLink && (
863
+ <a
864
+ href={explorerUrl}
865
+ target="_blank"
866
+ rel="noopener noreferrer"
867
+ className="sip-eth-stealth-action-btn"
868
+ title={`View on ${config.explorerName}`}
869
+ aria-label={`View on ${config.explorerName}`}
870
+ >
871
+ <ExternalLinkIcon />
872
+ </a>
873
+ )}
874
+
875
+ {showQrCode && (
876
+ <button
877
+ type="button"
878
+ className="sip-eth-stealth-action-btn"
879
+ onClick={handleShowQr}
880
+ title="Show QR code"
881
+ aria-label="Show QR code"
882
+ >
883
+ <QrCodeIcon />
884
+ </button>
885
+ )}
886
+ </div>
887
+
888
+ {/* Network badge */}
889
+ {showNetworkBadge && (
890
+ <span
891
+ className="sip-eth-stealth-network-badge"
892
+ data-l2={config.isL2.toString()}
893
+ style={{
894
+ borderColor: `${config.color}40`,
895
+ color: config.color,
896
+ background: `${config.color}20`,
897
+ }}
898
+ >
899
+ {config.name}
900
+ </span>
901
+ )}
902
+
903
+ {/* Ownership badge */}
904
+ {showOwnership && (
905
+ <span className="sip-eth-stealth-badge" data-ownership={ownership}>
906
+ {ownershipLabel}
907
+ </span>
908
+ )}
909
+ </div>
910
+
911
+ {/* Meta information row */}
912
+ <div className="sip-eth-stealth-meta">
913
+ {/* EIP-5564 badge */}
914
+ {showEipBadge && (
915
+ <span className="sip-eth-stealth-eip-badge">EIP-5564</span>
916
+ )}
917
+
918
+ {/* View tag */}
919
+ {showViewTag && viewTag !== undefined && (
920
+ <span className="sip-eth-stealth-view-tag">View Tag: {viewTag}</span>
921
+ )}
922
+
923
+ {/* Validation indicator */}
924
+ {showValidation && (
925
+ <div
926
+ className="sip-eth-stealth-validation"
927
+ data-valid={validationStatus}
928
+ aria-label={
929
+ validationStatus
930
+ ? 'Valid EIP-5564 stealth address'
931
+ : 'Invalid address format'
932
+ }
933
+ >
934
+ {validationStatus ? <CheckIcon /> : <XIcon />}
935
+ <span>
936
+ {validationStatus ? 'Valid stealth address' : 'Invalid format'}
937
+ </span>
938
+ </div>
939
+ )}
940
+ </div>
941
+
942
+ {/* Ephemeral public key (if provided) */}
943
+ {ephemeralPublicKey && (
944
+ <div className="sip-eth-stealth-ephemeral">
945
+ <span className="sip-eth-stealth-ephemeral-label">Ephemeral Key:</span>
946
+ <span className="sip-eth-stealth-ephemeral-key">
947
+ {displayEphemeralKey}
948
+ </span>
949
+ <button
950
+ type="button"
951
+ className="sip-eth-stealth-action-btn"
952
+ onClick={handleCopyEphemeralKey}
953
+ title="Copy ephemeral key"
954
+ aria-label="Copy ephemeral public key"
955
+ style={{ width: 20, height: 20 }}
956
+ >
957
+ <CopyIcon />
958
+ </button>
959
+ </div>
960
+ )}
961
+ </div>
962
+
963
+ {/* QR Code Modal */}
964
+ {showQrModal && (
965
+ <div
966
+ className="sip-eth-stealth-qr-modal"
967
+ onClick={handleCloseQr}
968
+ role="dialog"
969
+ aria-modal="true"
970
+ aria-labelledby="eth-qr-modal-title"
971
+ >
972
+ <div
973
+ className="sip-eth-stealth-qr-content"
974
+ onClick={(e) => e.stopPropagation()}
975
+ >
976
+ <h3 id="eth-qr-modal-title" className="sip-eth-stealth-qr-title">
977
+ Scan to Receive ({config.name})
978
+ </h3>
979
+ <div className="sip-eth-stealth-qr-code" data-testid="qr-code-container">
980
+ {/* QR Code placeholder - can be enhanced with actual QR library */}
981
+ <QrCodeIcon />
982
+ </div>
983
+ <p className="sip-eth-stealth-qr-address">{metaAddress ?? address}</p>
984
+ <button
985
+ type="button"
986
+ className="sip-eth-stealth-qr-close"
987
+ onClick={handleCloseQr}
988
+ >
989
+ Close
990
+ </button>
991
+ </div>
992
+ </div>
993
+ )}
994
+ </>
995
+ )
996
+ }
997
+
998
+ /**
999
+ * Hook to manage Ethereum stealth address display state
1000
+ */
1001
+ export function useEthereumStealthAddressDisplay(
1002
+ address: string,
1003
+ options: {
1004
+ checkOwnership?: (address: string) => EthereumOwnershipStatus
1005
+ validateAddress?: (address: string) => boolean
1006
+ network?: EthereumStealthNetworkId
1007
+ } = {}
1008
+ ) {
1009
+ const { checkOwnership, validateAddress, network = 'mainnet' } = options
1010
+
1011
+ const ownership = useMemo(() => {
1012
+ if (checkOwnership) {
1013
+ return checkOwnership(address)
1014
+ }
1015
+ return 'unknown' as EthereumOwnershipStatus
1016
+ }, [address, checkOwnership])
1017
+
1018
+ const isValid = useMemo(() => {
1019
+ if (validateAddress) {
1020
+ return validateAddress(address)
1021
+ }
1022
+ return isValidEthereumStealthAddress(address)
1023
+ }, [address, validateAddress])
1024
+
1025
+ const truncated = useMemo(() => truncateEthereumAddress(address), [address])
1026
+
1027
+ const networkConfig = useMemo(
1028
+ () => ETHEREUM_STEALTH_NETWORKS[network],
1029
+ [network]
1030
+ )
1031
+
1032
+ const explorerUrl = useMemo(
1033
+ () => `${networkConfig.explorerUrl}/${address}`,
1034
+ [networkConfig.explorerUrl, address]
1035
+ )
1036
+
1037
+ return {
1038
+ address,
1039
+ truncated,
1040
+ ownership,
1041
+ isValid,
1042
+ isStealth: isValidEthereumStealthAddress(address),
1043
+ network,
1044
+ networkConfig,
1045
+ explorerUrl,
1046
+ isL2: networkConfig.isL2,
1047
+ }
1048
+ }
1049
+
1050
+ export default EthereumStealthAddressDisplay