@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,1079 @@
1
+ import React, { useState, useEffect, useCallback, useMemo } from 'react'
2
+
3
+ /**
4
+ * Transaction status types
5
+ */
6
+ export type TransactionStatus =
7
+ | 'pending'
8
+ | 'processing'
9
+ | 'confirmed'
10
+ | 'finalized'
11
+ | 'failed'
12
+ | 'cancelled'
13
+
14
+ /**
15
+ * Privacy-specific verification status
16
+ */
17
+ export type PrivacyVerificationStatus = 'pending' | 'verified' | 'failed' | 'not_applicable'
18
+
19
+ /**
20
+ * Transaction action types
21
+ */
22
+ export type TransactionActionType =
23
+ | 'transfer'
24
+ | 'stealth_transfer'
25
+ | 'function_call'
26
+ | 'create_account'
27
+ | 'stake'
28
+ | 'unstake'
29
+
30
+ /**
31
+ * Transaction action
32
+ */
33
+ export interface TransactionAction {
34
+ type: TransactionActionType
35
+ receiver: string
36
+ amount?: string
37
+ methodName?: string
38
+ args?: Record<string, unknown>
39
+ }
40
+
41
+ /**
42
+ * Privacy verification details
43
+ */
44
+ export interface PrivacyVerification {
45
+ stealthAddressResolved: PrivacyVerificationStatus
46
+ commitmentVerified: PrivacyVerificationStatus
47
+ viewingKeyGenerated: PrivacyVerificationStatus
48
+ }
49
+
50
+ /**
51
+ * Transaction details
52
+ */
53
+ export interface PrivacyTransaction {
54
+ /** Transaction hash */
55
+ hash: string
56
+ /** Transaction status */
57
+ status: TransactionStatus
58
+ /** Block number (null if pending) */
59
+ blockHeight?: number
60
+ /** Number of confirmations */
61
+ confirmations: number
62
+ /** Required confirmations for finality */
63
+ requiredConfirmations: number
64
+ /** Timestamp of transaction (ms) */
65
+ timestamp: number
66
+ /** Sender account */
67
+ sender: string
68
+ /** Receiver account or stealth address */
69
+ receiver: string
70
+ /** Whether receiver is a stealth address */
71
+ isStealthReceiver: boolean
72
+ /** Amount transferred (in native token units) */
73
+ amount?: string
74
+ /** Gas used */
75
+ gasUsed?: string
76
+ /** Transaction fee */
77
+ fee?: string
78
+ /** Transaction actions */
79
+ actions: TransactionAction[]
80
+ /** Privacy verification status */
81
+ privacyVerification?: PrivacyVerification
82
+ /** Error message if failed */
83
+ errorMessage?: string
84
+ }
85
+
86
+ /**
87
+ * TransactionTracker component props
88
+ */
89
+ export interface TransactionTrackerProps {
90
+ /** Transaction data */
91
+ transaction: PrivacyTransaction
92
+ /** Callback to refresh transaction status */
93
+ onRefresh?: () => void
94
+ /** Callback to retry failed transaction */
95
+ onRetry?: () => void
96
+ /** Callback to cancel pending transaction */
97
+ onCancel?: () => void
98
+ /** Whether to show expanded details by default */
99
+ defaultExpanded?: boolean
100
+ /** Polling interval in ms (0 to disable) */
101
+ pollingInterval?: number
102
+ /** Network name for display */
103
+ networkName?: string
104
+ /** Explorer URL template (use {hash} placeholder) */
105
+ explorerUrlTemplate?: string
106
+ /** Custom class name */
107
+ className?: string
108
+ /** Size variant */
109
+ size?: 'sm' | 'md' | 'lg'
110
+ /** Whether to show privacy verification status */
111
+ showPrivacyStatus?: boolean
112
+ }
113
+
114
+ /**
115
+ * CSS styles for the component
116
+ */
117
+ const styles = `
118
+ .sip-tx-tracker {
119
+ font-family: system-ui, -apple-system, sans-serif;
120
+ border-radius: 12px;
121
+ overflow: hidden;
122
+ background: #ffffff;
123
+ border: 1px solid #e5e7eb;
124
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
125
+ }
126
+
127
+ .sip-tx-tracker[data-size="sm"] {
128
+ border-radius: 8px;
129
+ }
130
+
131
+ .sip-tx-tracker[data-size="lg"] {
132
+ border-radius: 16px;
133
+ }
134
+
135
+ /* Header */
136
+ .sip-tx-header {
137
+ display: flex;
138
+ align-items: center;
139
+ justify-content: space-between;
140
+ padding: 16px;
141
+ background: #f9fafb;
142
+ border-bottom: 1px solid #e5e7eb;
143
+ }
144
+
145
+ .sip-tx-tracker[data-size="sm"] .sip-tx-header {
146
+ padding: 12px;
147
+ }
148
+
149
+ .sip-tx-tracker[data-size="lg"] .sip-tx-header {
150
+ padding: 20px;
151
+ }
152
+
153
+ .sip-tx-header-left {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 12px;
157
+ }
158
+
159
+ .sip-tx-status-icon {
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ width: 40px;
164
+ height: 40px;
165
+ border-radius: 50%;
166
+ flex-shrink: 0;
167
+ }
168
+
169
+ .sip-tx-tracker[data-size="sm"] .sip-tx-status-icon {
170
+ width: 32px;
171
+ height: 32px;
172
+ }
173
+
174
+ .sip-tx-tracker[data-size="lg"] .sip-tx-status-icon {
175
+ width: 48px;
176
+ height: 48px;
177
+ }
178
+
179
+ .sip-tx-status-icon svg {
180
+ width: 20px;
181
+ height: 20px;
182
+ }
183
+
184
+ .sip-tx-tracker[data-size="sm"] .sip-tx-status-icon svg {
185
+ width: 16px;
186
+ height: 16px;
187
+ }
188
+
189
+ .sip-tx-tracker[data-size="lg"] .sip-tx-status-icon svg {
190
+ width: 24px;
191
+ height: 24px;
192
+ }
193
+
194
+ .sip-tx-status-icon[data-status="pending"] {
195
+ background: #fef3c7;
196
+ color: #d97706;
197
+ }
198
+
199
+ .sip-tx-status-icon[data-status="processing"] {
200
+ background: #dbeafe;
201
+ color: #2563eb;
202
+ }
203
+
204
+ .sip-tx-status-icon[data-status="confirmed"],
205
+ .sip-tx-status-icon[data-status="finalized"] {
206
+ background: #d1fae5;
207
+ color: #059669;
208
+ }
209
+
210
+ .sip-tx-status-icon[data-status="failed"],
211
+ .sip-tx-status-icon[data-status="cancelled"] {
212
+ background: #fee2e2;
213
+ color: #dc2626;
214
+ }
215
+
216
+ .sip-tx-status-info {
217
+ display: flex;
218
+ flex-direction: column;
219
+ gap: 2px;
220
+ }
221
+
222
+ .sip-tx-status-title {
223
+ font-size: 14px;
224
+ font-weight: 600;
225
+ color: #111827;
226
+ }
227
+
228
+ .sip-tx-tracker[data-size="sm"] .sip-tx-status-title {
229
+ font-size: 13px;
230
+ }
231
+
232
+ .sip-tx-tracker[data-size="lg"] .sip-tx-status-title {
233
+ font-size: 16px;
234
+ }
235
+
236
+ .sip-tx-status-subtitle {
237
+ font-size: 12px;
238
+ color: #6b7280;
239
+ }
240
+
241
+ .sip-tx-header-actions {
242
+ display: flex;
243
+ align-items: center;
244
+ gap: 8px;
245
+ }
246
+
247
+ .sip-tx-action-btn {
248
+ display: flex;
249
+ align-items: center;
250
+ justify-content: center;
251
+ padding: 8px 12px;
252
+ border: none;
253
+ border-radius: 6px;
254
+ font-size: 13px;
255
+ font-weight: 500;
256
+ cursor: pointer;
257
+ transition: all 0.2s;
258
+ }
259
+
260
+ .sip-tx-action-btn-primary {
261
+ background: #3b82f6;
262
+ color: white;
263
+ }
264
+
265
+ .sip-tx-action-btn-primary:hover {
266
+ background: #2563eb;
267
+ }
268
+
269
+ .sip-tx-action-btn-secondary {
270
+ background: #f3f4f6;
271
+ color: #374151;
272
+ }
273
+
274
+ .sip-tx-action-btn-secondary:hover {
275
+ background: #e5e7eb;
276
+ }
277
+
278
+ .sip-tx-action-btn-danger {
279
+ background: #fee2e2;
280
+ color: #dc2626;
281
+ }
282
+
283
+ .sip-tx-action-btn-danger:hover {
284
+ background: #fecaca;
285
+ }
286
+
287
+ /* Progress bar */
288
+ .sip-tx-progress {
289
+ padding: 0 16px 16px;
290
+ }
291
+
292
+ .sip-tx-tracker[data-size="sm"] .sip-tx-progress {
293
+ padding: 0 12px 12px;
294
+ }
295
+
296
+ .sip-tx-tracker[data-size="lg"] .sip-tx-progress {
297
+ padding: 0 20px 20px;
298
+ }
299
+
300
+ .sip-tx-progress-bar {
301
+ height: 6px;
302
+ background: #e5e7eb;
303
+ border-radius: 3px;
304
+ overflow: hidden;
305
+ margin-bottom: 8px;
306
+ }
307
+
308
+ .sip-tx-progress-fill {
309
+ height: 100%;
310
+ border-radius: 3px;
311
+ transition: width 0.3s ease;
312
+ }
313
+
314
+ .sip-tx-progress-fill[data-status="pending"] {
315
+ background: #fbbf24;
316
+ animation: sip-tx-pulse 1.5s infinite;
317
+ }
318
+
319
+ .sip-tx-progress-fill[data-status="processing"] {
320
+ background: #3b82f6;
321
+ animation: sip-tx-pulse 1.5s infinite;
322
+ }
323
+
324
+ .sip-tx-progress-fill[data-status="confirmed"] {
325
+ background: #10b981;
326
+ }
327
+
328
+ .sip-tx-progress-fill[data-status="finalized"] {
329
+ background: #059669;
330
+ }
331
+
332
+ .sip-tx-progress-fill[data-status="failed"],
333
+ .sip-tx-progress-fill[data-status="cancelled"] {
334
+ background: #ef4444;
335
+ }
336
+
337
+ @keyframes sip-tx-pulse {
338
+ 0%, 100% { opacity: 1; }
339
+ 50% { opacity: 0.7; }
340
+ }
341
+
342
+ .sip-tx-progress-info {
343
+ display: flex;
344
+ justify-content: space-between;
345
+ font-size: 11px;
346
+ color: #6b7280;
347
+ }
348
+
349
+ /* Privacy status */
350
+ .sip-tx-privacy {
351
+ padding: 12px 16px;
352
+ background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
353
+ border-bottom: 1px solid #4338ca;
354
+ }
355
+
356
+ .sip-tx-tracker[data-size="sm"] .sip-tx-privacy {
357
+ padding: 10px 12px;
358
+ }
359
+
360
+ .sip-tx-tracker[data-size="lg"] .sip-tx-privacy {
361
+ padding: 16px 20px;
362
+ }
363
+
364
+ .sip-tx-privacy-title {
365
+ display: flex;
366
+ align-items: center;
367
+ gap: 8px;
368
+ font-size: 12px;
369
+ font-weight: 600;
370
+ color: #c7d2fe;
371
+ text-transform: uppercase;
372
+ letter-spacing: 0.05em;
373
+ margin-bottom: 10px;
374
+ }
375
+
376
+ .sip-tx-privacy-title svg {
377
+ width: 14px;
378
+ height: 14px;
379
+ }
380
+
381
+ .sip-tx-privacy-items {
382
+ display: flex;
383
+ flex-wrap: wrap;
384
+ gap: 8px;
385
+ }
386
+
387
+ .sip-tx-privacy-item {
388
+ display: flex;
389
+ align-items: center;
390
+ gap: 6px;
391
+ padding: 6px 10px;
392
+ background: rgba(255, 255, 255, 0.1);
393
+ border-radius: 6px;
394
+ font-size: 12px;
395
+ color: #e0e7ff;
396
+ }
397
+
398
+ .sip-tx-privacy-item svg {
399
+ width: 14px;
400
+ height: 14px;
401
+ }
402
+
403
+ .sip-tx-privacy-item[data-status="verified"] svg {
404
+ color: #34d399;
405
+ }
406
+
407
+ .sip-tx-privacy-item[data-status="pending"] svg {
408
+ color: #fbbf24;
409
+ }
410
+
411
+ .sip-tx-privacy-item[data-status="failed"] svg {
412
+ color: #f87171;
413
+ }
414
+
415
+ /* Details section */
416
+ .sip-tx-details-toggle {
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: center;
420
+ gap: 8px;
421
+ width: 100%;
422
+ padding: 12px;
423
+ border: none;
424
+ background: transparent;
425
+ color: #6b7280;
426
+ font-size: 13px;
427
+ font-weight: 500;
428
+ cursor: pointer;
429
+ transition: all 0.2s;
430
+ border-top: 1px solid #e5e7eb;
431
+ }
432
+
433
+ .sip-tx-details-toggle:hover {
434
+ background: #f9fafb;
435
+ color: #374151;
436
+ }
437
+
438
+ .sip-tx-details-toggle svg {
439
+ width: 16px;
440
+ height: 16px;
441
+ transition: transform 0.2s;
442
+ }
443
+
444
+ .sip-tx-details-toggle[aria-expanded="true"] svg {
445
+ transform: rotate(180deg);
446
+ }
447
+
448
+ .sip-tx-details {
449
+ padding: 16px;
450
+ background: #f9fafb;
451
+ border-top: 1px solid #e5e7eb;
452
+ }
453
+
454
+ .sip-tx-tracker[data-size="sm"] .sip-tx-details {
455
+ padding: 12px;
456
+ }
457
+
458
+ .sip-tx-tracker[data-size="lg"] .sip-tx-details {
459
+ padding: 20px;
460
+ }
461
+
462
+ .sip-tx-detail-row {
463
+ display: flex;
464
+ justify-content: space-between;
465
+ align-items: flex-start;
466
+ padding: 8px 0;
467
+ border-bottom: 1px solid #e5e7eb;
468
+ }
469
+
470
+ .sip-tx-detail-row:last-child {
471
+ border-bottom: none;
472
+ }
473
+
474
+ .sip-tx-detail-label {
475
+ font-size: 12px;
476
+ color: #6b7280;
477
+ font-weight: 500;
478
+ }
479
+
480
+ .sip-tx-detail-value {
481
+ font-size: 12px;
482
+ color: #111827;
483
+ font-family: 'SF Mono', Monaco, monospace;
484
+ text-align: right;
485
+ max-width: 60%;
486
+ word-break: break-all;
487
+ }
488
+
489
+ .sip-tx-detail-value a {
490
+ color: #3b82f6;
491
+ text-decoration: none;
492
+ }
493
+
494
+ .sip-tx-detail-value a:hover {
495
+ text-decoration: underline;
496
+ }
497
+
498
+ /* Error message */
499
+ .sip-tx-error {
500
+ padding: 12px 16px;
501
+ background: #fee2e2;
502
+ border-top: 1px solid #fecaca;
503
+ font-size: 13px;
504
+ color: #dc2626;
505
+ }
506
+
507
+ /* ETA */
508
+ .sip-tx-eta {
509
+ display: flex;
510
+ align-items: center;
511
+ gap: 6px;
512
+ padding: 12px 16px;
513
+ background: #eff6ff;
514
+ border-top: 1px solid #bfdbfe;
515
+ font-size: 12px;
516
+ color: #1d4ed8;
517
+ }
518
+
519
+ .sip-tx-eta svg {
520
+ width: 14px;
521
+ height: 14px;
522
+ }
523
+
524
+ /* Dark mode */
525
+ @media (prefers-color-scheme: dark) {
526
+ .sip-tx-tracker {
527
+ background: #1f2937;
528
+ border-color: #374151;
529
+ }
530
+
531
+ .sip-tx-header {
532
+ background: #111827;
533
+ border-color: #374151;
534
+ }
535
+
536
+ .sip-tx-status-title {
537
+ color: #f9fafb;
538
+ }
539
+
540
+ .sip-tx-status-subtitle {
541
+ color: #9ca3af;
542
+ }
543
+
544
+ .sip-tx-progress-bar {
545
+ background: #374151;
546
+ }
547
+
548
+ .sip-tx-progress-info {
549
+ color: #9ca3af;
550
+ }
551
+
552
+ .sip-tx-details-toggle {
553
+ border-color: #374151;
554
+ color: #9ca3af;
555
+ }
556
+
557
+ .sip-tx-details-toggle:hover {
558
+ background: #111827;
559
+ color: #e5e7eb;
560
+ }
561
+
562
+ .sip-tx-details {
563
+ background: #111827;
564
+ border-color: #374151;
565
+ }
566
+
567
+ .sip-tx-detail-row {
568
+ border-color: #374151;
569
+ }
570
+
571
+ .sip-tx-detail-label {
572
+ color: #9ca3af;
573
+ }
574
+
575
+ .sip-tx-detail-value {
576
+ color: #f9fafb;
577
+ }
578
+
579
+ .sip-tx-action-btn-secondary {
580
+ background: #374151;
581
+ color: #e5e7eb;
582
+ }
583
+
584
+ .sip-tx-action-btn-secondary:hover {
585
+ background: #4b5563;
586
+ }
587
+ }
588
+ `
589
+
590
+ /**
591
+ * Status icons
592
+ */
593
+ const StatusIcons: Record<TransactionStatus, React.ReactNode> = {
594
+ pending: (
595
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
596
+ <circle cx="12" cy="12" r="10" />
597
+ <path d="M12 6v6l4 2" />
598
+ </svg>
599
+ ),
600
+ processing: (
601
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
602
+ <path d="M21 12a9 9 0 11-6.219-8.56" />
603
+ </svg>
604
+ ),
605
+ confirmed: (
606
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
607
+ <polyline points="20 6 9 17 4 12" />
608
+ </svg>
609
+ ),
610
+ finalized: (
611
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
612
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
613
+ <polyline points="9 12 11 14 15 10" />
614
+ </svg>
615
+ ),
616
+ failed: (
617
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
618
+ <circle cx="12" cy="12" r="10" />
619
+ <line x1="15" y1="9" x2="9" y2="15" />
620
+ <line x1="9" y1="9" x2="15" y2="15" />
621
+ </svg>
622
+ ),
623
+ cancelled: (
624
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
625
+ <circle cx="12" cy="12" r="10" />
626
+ <line x1="8" y1="12" x2="16" y2="12" />
627
+ </svg>
628
+ ),
629
+ }
630
+
631
+ /**
632
+ * Privacy status icons
633
+ */
634
+ const PrivacyStatusIcons: Record<PrivacyVerificationStatus, React.ReactNode> = {
635
+ verified: (
636
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
637
+ <polyline points="20 6 9 17 4 12" />
638
+ </svg>
639
+ ),
640
+ pending: (
641
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
642
+ <circle cx="12" cy="12" r="10" />
643
+ <path d="M12 6v6l4 2" />
644
+ </svg>
645
+ ),
646
+ failed: (
647
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
648
+ <line x1="18" y1="6" x2="6" y2="18" />
649
+ <line x1="6" y1="6" x2="18" y2="18" />
650
+ </svg>
651
+ ),
652
+ not_applicable: (
653
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
654
+ <line x1="5" y1="12" x2="19" y2="12" />
655
+ </svg>
656
+ ),
657
+ }
658
+
659
+ /**
660
+ * Chevron icon
661
+ */
662
+ const ChevronIcon = () => (
663
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
664
+ <polyline points="6 9 12 15 18 9" />
665
+ </svg>
666
+ )
667
+
668
+ /**
669
+ * Shield icon
670
+ */
671
+ const ShieldIcon = () => (
672
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
673
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
674
+ </svg>
675
+ )
676
+
677
+ /**
678
+ * Clock icon
679
+ */
680
+ const ClockIcon = () => (
681
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
682
+ <circle cx="12" cy="12" r="10" />
683
+ <polyline points="12 6 12 12 16 14" />
684
+ </svg>
685
+ )
686
+
687
+ /**
688
+ * Status titles
689
+ */
690
+ const STATUS_TITLES: Record<TransactionStatus, string> = {
691
+ pending: 'Transaction Pending',
692
+ processing: 'Processing Transaction',
693
+ confirmed: 'Transaction Confirmed',
694
+ finalized: 'Transaction Finalized',
695
+ failed: 'Transaction Failed',
696
+ cancelled: 'Transaction Cancelled',
697
+ }
698
+
699
+ /**
700
+ * TransactionTracker - Component for tracking NEAR privacy transactions
701
+ *
702
+ * @example Basic usage
703
+ * ```tsx
704
+ * import { TransactionTracker } from '@sip-protocol/react'
705
+ *
706
+ * function TransactionView({ txHash }) {
707
+ * const [tx, setTx] = useState(null)
708
+ *
709
+ * return (
710
+ * <TransactionTracker
711
+ * transaction={tx}
712
+ * onRefresh={() => fetchTransaction(txHash)}
713
+ * />
714
+ * )
715
+ * }
716
+ * ```
717
+ */
718
+ export function TransactionTracker({
719
+ transaction,
720
+ onRefresh,
721
+ onRetry,
722
+ onCancel,
723
+ defaultExpanded = false,
724
+ pollingInterval = 0,
725
+ networkName = 'NEAR',
726
+ explorerUrlTemplate = 'https://nearblocks.io/txns/{hash}',
727
+ className = '',
728
+ size = 'md',
729
+ showPrivacyStatus = true,
730
+ }: TransactionTrackerProps) {
731
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded)
732
+
733
+ // Calculate progress percentage
734
+ const progressPercentage = useMemo(() => {
735
+ const { status, confirmations, requiredConfirmations } = transaction
736
+
737
+ if (status === 'failed' || status === 'cancelled') return 100
738
+ if (status === 'finalized') return 100
739
+ if (status === 'pending') return 10
740
+
741
+ const progress = Math.min(confirmations / requiredConfirmations, 1) * 100
742
+ return Math.max(progress, 20)
743
+ }, [transaction])
744
+
745
+ // Calculate ETA
746
+ const estimatedTimeLeft = useMemo(() => {
747
+ const { status, confirmations, requiredConfirmations } = transaction
748
+
749
+ if (status === 'finalized' || status === 'failed' || status === 'cancelled') {
750
+ return null
751
+ }
752
+
753
+ const remaining = requiredConfirmations - confirmations
754
+ // NEAR has ~1 second block time
755
+ const seconds = Math.max(remaining, 1)
756
+
757
+ if (seconds < 60) {
758
+ return `~${seconds}s to finality`
759
+ }
760
+ return `~${Math.ceil(seconds / 60)}m to finality`
761
+ }, [transaction])
762
+
763
+ // Format timestamp
764
+ const formattedTime = useMemo(() => {
765
+ const date = new Date(transaction.timestamp)
766
+ return date.toLocaleString()
767
+ }, [transaction.timestamp])
768
+
769
+ // Explorer URL
770
+ const explorerUrl = useMemo(() => {
771
+ return explorerUrlTemplate.replace('{hash}', transaction.hash)
772
+ }, [explorerUrlTemplate, transaction.hash])
773
+
774
+ // Truncate hash for display
775
+ const truncatedHash = useMemo(() => {
776
+ const hash = transaction.hash
777
+ return `${hash.slice(0, 8)}...${hash.slice(-8)}`
778
+ }, [transaction.hash])
779
+
780
+ // Polling effect
781
+ useEffect(() => {
782
+ if (
783
+ pollingInterval <= 0 ||
784
+ !onRefresh ||
785
+ transaction.status === 'finalized' ||
786
+ transaction.status === 'failed' ||
787
+ transaction.status === 'cancelled'
788
+ ) {
789
+ return
790
+ }
791
+
792
+ const interval = setInterval(onRefresh, pollingInterval)
793
+ return () => clearInterval(interval)
794
+ }, [pollingInterval, onRefresh, transaction.status])
795
+
796
+ // Toggle expanded state
797
+ const toggleExpanded = useCallback(() => {
798
+ setIsExpanded((prev) => !prev)
799
+ }, [])
800
+
801
+ // Can show retry button
802
+ const canRetry = transaction.status === 'failed' && onRetry
803
+
804
+ // Can show cancel button
805
+ const canCancel = transaction.status === 'pending' && onCancel
806
+
807
+ return (
808
+ <>
809
+ <style>{styles}</style>
810
+
811
+ <div
812
+ className={`sip-tx-tracker ${className}`}
813
+ data-size={size}
814
+ data-status={transaction.status}
815
+ >
816
+ {/* Header */}
817
+ <div className="sip-tx-header">
818
+ <div className="sip-tx-header-left">
819
+ <div
820
+ className="sip-tx-status-icon"
821
+ data-status={transaction.status}
822
+ data-testid="status-icon"
823
+ >
824
+ {StatusIcons[transaction.status]}
825
+ </div>
826
+ <div className="sip-tx-status-info">
827
+ <span className="sip-tx-status-title">{STATUS_TITLES[transaction.status]}</span>
828
+ <span className="sip-tx-status-subtitle">
829
+ {networkName} • {truncatedHash}
830
+ </span>
831
+ </div>
832
+ </div>
833
+
834
+ <div className="sip-tx-header-actions">
835
+ {onRefresh && transaction.status !== 'finalized' && (
836
+ <button
837
+ type="button"
838
+ className="sip-tx-action-btn sip-tx-action-btn-secondary"
839
+ onClick={onRefresh}
840
+ aria-label="Refresh status"
841
+ >
842
+ Refresh
843
+ </button>
844
+ )}
845
+ {canRetry && (
846
+ <button
847
+ type="button"
848
+ className="sip-tx-action-btn sip-tx-action-btn-primary"
849
+ onClick={onRetry}
850
+ aria-label="Retry transaction"
851
+ >
852
+ Retry
853
+ </button>
854
+ )}
855
+ {canCancel && (
856
+ <button
857
+ type="button"
858
+ className="sip-tx-action-btn sip-tx-action-btn-danger"
859
+ onClick={onCancel}
860
+ aria-label="Cancel transaction"
861
+ >
862
+ Cancel
863
+ </button>
864
+ )}
865
+ </div>
866
+ </div>
867
+
868
+ {/* Progress bar */}
869
+ {transaction.status !== 'cancelled' && (
870
+ <div className="sip-tx-progress">
871
+ <div className="sip-tx-progress-bar">
872
+ <div
873
+ className="sip-tx-progress-fill"
874
+ data-status={transaction.status}
875
+ style={{ width: `${progressPercentage}%` }}
876
+ role="progressbar"
877
+ aria-valuenow={progressPercentage}
878
+ aria-valuemin={0}
879
+ aria-valuemax={100}
880
+ data-testid="progress-bar"
881
+ />
882
+ </div>
883
+ <div className="sip-tx-progress-info">
884
+ <span>
885
+ {transaction.confirmations}/{transaction.requiredConfirmations} confirmations
886
+ </span>
887
+ <span>{progressPercentage.toFixed(0)}%</span>
888
+ </div>
889
+ </div>
890
+ )}
891
+
892
+ {/* Privacy verification status */}
893
+ {showPrivacyStatus && transaction.privacyVerification && (
894
+ <div className="sip-tx-privacy" data-testid="privacy-status">
895
+ <div className="sip-tx-privacy-title">
896
+ <ShieldIcon />
897
+ Privacy Verification
898
+ </div>
899
+ <div className="sip-tx-privacy-items">
900
+ <div
901
+ className="sip-tx-privacy-item"
902
+ data-status={transaction.privacyVerification.stealthAddressResolved}
903
+ >
904
+ {PrivacyStatusIcons[transaction.privacyVerification.stealthAddressResolved] ??
905
+ PrivacyStatusIcons.pending}
906
+ <span>Stealth Address</span>
907
+ </div>
908
+ <div
909
+ className="sip-tx-privacy-item"
910
+ data-status={transaction.privacyVerification.commitmentVerified}
911
+ >
912
+ {PrivacyStatusIcons[transaction.privacyVerification.commitmentVerified] ??
913
+ PrivacyStatusIcons.pending}
914
+ <span>Commitment</span>
915
+ </div>
916
+ <div
917
+ className="sip-tx-privacy-item"
918
+ data-status={transaction.privacyVerification.viewingKeyGenerated}
919
+ >
920
+ {PrivacyStatusIcons[transaction.privacyVerification.viewingKeyGenerated] ??
921
+ PrivacyStatusIcons.pending}
922
+ <span>Viewing Key</span>
923
+ </div>
924
+ </div>
925
+ </div>
926
+ )}
927
+
928
+ {/* ETA */}
929
+ {estimatedTimeLeft && (
930
+ <div className="sip-tx-eta" data-testid="eta">
931
+ <ClockIcon />
932
+ {estimatedTimeLeft}
933
+ </div>
934
+ )}
935
+
936
+ {/* Error message */}
937
+ {transaction.errorMessage && (
938
+ <div className="sip-tx-error" data-testid="error-message">
939
+ {transaction.errorMessage}
940
+ </div>
941
+ )}
942
+
943
+ {/* Details toggle */}
944
+ <button
945
+ type="button"
946
+ className="sip-tx-details-toggle"
947
+ onClick={toggleExpanded}
948
+ aria-expanded={isExpanded}
949
+ aria-controls="tx-details"
950
+ >
951
+ <span>{isExpanded ? 'Hide Details' : 'Show Details'}</span>
952
+ <ChevronIcon />
953
+ </button>
954
+
955
+ {/* Details section */}
956
+ {isExpanded && (
957
+ <div id="tx-details" className="sip-tx-details" data-testid="details-section">
958
+ <div className="sip-tx-detail-row">
959
+ <span className="sip-tx-detail-label">Transaction Hash</span>
960
+ <span className="sip-tx-detail-value">
961
+ <a href={explorerUrl} target="_blank" rel="noopener noreferrer">
962
+ {truncatedHash}
963
+ </a>
964
+ </span>
965
+ </div>
966
+
967
+ <div className="sip-tx-detail-row">
968
+ <span className="sip-tx-detail-label">Timestamp</span>
969
+ <span className="sip-tx-detail-value">{formattedTime}</span>
970
+ </div>
971
+
972
+ <div className="sip-tx-detail-row">
973
+ <span className="sip-tx-detail-label">From</span>
974
+ <span className="sip-tx-detail-value">{transaction.sender}</span>
975
+ </div>
976
+
977
+ <div className="sip-tx-detail-row">
978
+ <span className="sip-tx-detail-label">
979
+ To {transaction.isStealthReceiver && '(Stealth)'}
980
+ </span>
981
+ <span className="sip-tx-detail-value">{transaction.receiver}</span>
982
+ </div>
983
+
984
+ {transaction.amount && (
985
+ <div className="sip-tx-detail-row">
986
+ <span className="sip-tx-detail-label">Amount</span>
987
+ <span className="sip-tx-detail-value">{transaction.amount}</span>
988
+ </div>
989
+ )}
990
+
991
+ {transaction.gasUsed && (
992
+ <div className="sip-tx-detail-row">
993
+ <span className="sip-tx-detail-label">Gas Used</span>
994
+ <span className="sip-tx-detail-value">{transaction.gasUsed}</span>
995
+ </div>
996
+ )}
997
+
998
+ {transaction.fee && (
999
+ <div className="sip-tx-detail-row">
1000
+ <span className="sip-tx-detail-label">Fee</span>
1001
+ <span className="sip-tx-detail-value">{transaction.fee}</span>
1002
+ </div>
1003
+ )}
1004
+
1005
+ {transaction.blockHeight && (
1006
+ <div className="sip-tx-detail-row">
1007
+ <span className="sip-tx-detail-label">Block</span>
1008
+ <span className="sip-tx-detail-value">#{transaction.blockHeight}</span>
1009
+ </div>
1010
+ )}
1011
+ </div>
1012
+ )}
1013
+ </div>
1014
+ </>
1015
+ )
1016
+ }
1017
+
1018
+ /**
1019
+ * Hook to manage transaction tracking state
1020
+ */
1021
+ export function useTransactionTracker(
1022
+ initialTransaction?: PrivacyTransaction,
1023
+ options: {
1024
+ pollingInterval?: number
1025
+ onStatusChange?: (status: TransactionStatus) => void
1026
+ } = {}
1027
+ ) {
1028
+ const { pollingInterval = 0, onStatusChange } = options
1029
+ const [transaction, setTransaction] = useState<PrivacyTransaction | null>(
1030
+ initialTransaction ?? null
1031
+ )
1032
+ const [isPolling, setIsPolling] = useState(pollingInterval > 0)
1033
+
1034
+ // Update transaction
1035
+ const updateTransaction = useCallback(
1036
+ (updates: Partial<PrivacyTransaction>) => {
1037
+ setTransaction((prev) => {
1038
+ if (!prev) return prev
1039
+
1040
+ const newTx = { ...prev, ...updates }
1041
+
1042
+ if (updates.status && updates.status !== prev.status) {
1043
+ onStatusChange?.(updates.status)
1044
+ }
1045
+
1046
+ return newTx
1047
+ })
1048
+ },
1049
+ [onStatusChange]
1050
+ )
1051
+
1052
+ // Start polling
1053
+ const startPolling = useCallback(() => {
1054
+ setIsPolling(true)
1055
+ }, [])
1056
+
1057
+ // Stop polling
1058
+ const stopPolling = useCallback(() => {
1059
+ setIsPolling(false)
1060
+ }, [])
1061
+
1062
+ // Check if transaction is final
1063
+ const isFinal = useMemo(() => {
1064
+ if (!transaction) return false
1065
+ return ['finalized', 'failed', 'cancelled'].includes(transaction.status)
1066
+ }, [transaction])
1067
+
1068
+ return {
1069
+ transaction,
1070
+ setTransaction,
1071
+ updateTransaction,
1072
+ isPolling,
1073
+ startPolling,
1074
+ stopPolling,
1075
+ isFinal,
1076
+ }
1077
+ }
1078
+
1079
+ export default TransactionTracker