@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,1187 @@
1
+ /**
2
+ * Ethereum Privacy Transaction History
3
+ *
4
+ * Component for displaying history of Ethereum privacy transactions.
5
+ *
6
+ * @module components/ethereum/transaction-history
7
+ */
8
+
9
+ import React, { useState, useCallback, useMemo, useEffect } from 'react'
10
+ import { ETHEREUM_NETWORKS, type EthereumNetwork } from './transaction-tracker'
11
+
12
+ /**
13
+ * Transaction direction
14
+ */
15
+ export type TransactionDirection = 'sent' | 'received' | 'claimed'
16
+
17
+ /**
18
+ * Transaction type
19
+ */
20
+ export type TransactionType = 'stealth_transfer' | 'standard_transfer' | 'claim'
21
+
22
+ /**
23
+ * Privacy transaction history item
24
+ */
25
+ export interface PrivacyTransactionHistoryItem {
26
+ /** Transaction hash */
27
+ hash: string
28
+ /** Transaction direction */
29
+ direction: TransactionDirection
30
+ /** Transaction type */
31
+ type: TransactionType
32
+ /** Timestamp in milliseconds */
33
+ timestamp: number
34
+ /** Block number */
35
+ blockNumber?: number
36
+ /** From address */
37
+ from: string
38
+ /** To address (may be stealth address) */
39
+ to: string
40
+ /** Whether to address is stealth */
41
+ isStealthAddress: boolean
42
+ /** Amount in token units (string for precision) */
43
+ amount: string
44
+ /** Token symbol */
45
+ tokenSymbol: string
46
+ /** Token decimals */
47
+ tokenDecimals: number
48
+ /** USD value at time of transaction */
49
+ usdValue?: string
50
+ /** Current USD value */
51
+ currentUsdValue?: string
52
+ /** Gas used */
53
+ gasUsed?: string
54
+ /** Gas price in gwei */
55
+ gasPrice?: string
56
+ /** Transaction fee in ETH */
57
+ fee?: string
58
+ /** Status */
59
+ status: 'pending' | 'confirmed' | 'failed'
60
+ /** Ephemeral public key for stealth transactions */
61
+ ephemeralPublicKey?: string
62
+ /** View tag for efficient scanning */
63
+ viewTag?: number
64
+ /** Claim key (only for received/claimed) */
65
+ claimKey?: string
66
+ }
67
+
68
+ /**
69
+ * Filter options for transaction history
70
+ */
71
+ export interface TransactionHistoryFilter {
72
+ direction?: TransactionDirection | 'all'
73
+ type?: TransactionType | 'all'
74
+ status?: 'pending' | 'confirmed' | 'failed' | 'all'
75
+ tokenSymbol?: string
76
+ fromDate?: Date
77
+ toDate?: Date
78
+ minAmount?: string
79
+ maxAmount?: string
80
+ }
81
+
82
+ /**
83
+ * Sort options for transaction history
84
+ */
85
+ export interface TransactionHistorySort {
86
+ field: 'timestamp' | 'amount' | 'usdValue'
87
+ direction: 'asc' | 'desc'
88
+ }
89
+
90
+ /**
91
+ * TransactionHistory component props
92
+ */
93
+ export interface TransactionHistoryProps {
94
+ /** List of transactions */
95
+ transactions: PrivacyTransactionHistoryItem[]
96
+ /** Loading state */
97
+ isLoading?: boolean
98
+ /** Error message */
99
+ error?: string | null
100
+ /** Filter options */
101
+ filter?: TransactionHistoryFilter
102
+ /** Sort options */
103
+ sort?: TransactionHistorySort
104
+ /** Callback when filter changes */
105
+ onFilterChange?: (filter: TransactionHistoryFilter) => void
106
+ /** Callback when sort changes */
107
+ onSortChange?: (sort: TransactionHistorySort) => void
108
+ /** Callback to load more transactions */
109
+ onLoadMore?: () => void
110
+ /** Whether more transactions are available */
111
+ hasMore?: boolean
112
+ /** Callback to export transactions */
113
+ onExport?: (format: 'csv' | 'json') => void
114
+ /** Callback when transaction is selected */
115
+ onTransactionSelect?: (tx: PrivacyTransactionHistoryItem) => void
116
+ /** Network configuration */
117
+ network?: string | EthereumNetwork
118
+ /** Items per page for pagination */
119
+ pageSize?: number
120
+ /** Custom class name */
121
+ className?: string
122
+ /** Size variant */
123
+ size?: 'sm' | 'md' | 'lg'
124
+ /** Show USD values */
125
+ showUsdValues?: boolean
126
+ /** Show filters */
127
+ showFilters?: boolean
128
+ /** Show export button */
129
+ showExport?: boolean
130
+ }
131
+
132
+ /**
133
+ * CSS styles for the component
134
+ */
135
+ const styles = `
136
+ .sip-tx-history {
137
+ font-family: system-ui, -apple-system, sans-serif;
138
+ background: #ffffff;
139
+ border: 1px solid #e5e7eb;
140
+ border-radius: 12px;
141
+ overflow: hidden;
142
+ }
143
+
144
+ .sip-tx-history[data-size="sm"] {
145
+ border-radius: 8px;
146
+ }
147
+
148
+ .sip-tx-history[data-size="lg"] {
149
+ border-radius: 16px;
150
+ }
151
+
152
+ /* Header */
153
+ .sip-tx-history-header {
154
+ display: flex;
155
+ align-items: center;
156
+ justify-content: space-between;
157
+ padding: 16px 20px;
158
+ background: #f9fafb;
159
+ border-bottom: 1px solid #e5e7eb;
160
+ }
161
+
162
+ .sip-tx-history[data-size="sm"] .sip-tx-history-header {
163
+ padding: 12px 16px;
164
+ }
165
+
166
+ .sip-tx-history-title {
167
+ display: flex;
168
+ align-items: center;
169
+ gap: 10px;
170
+ }
171
+
172
+ .sip-tx-history-title svg {
173
+ width: 20px;
174
+ height: 20px;
175
+ color: #6b7280;
176
+ }
177
+
178
+ .sip-tx-history-title h2 {
179
+ font-size: 16px;
180
+ font-weight: 600;
181
+ color: #111827;
182
+ margin: 0;
183
+ }
184
+
185
+ .sip-tx-history-actions {
186
+ display: flex;
187
+ gap: 8px;
188
+ }
189
+
190
+ /* Filter bar */
191
+ .sip-tx-history-filters {
192
+ display: flex;
193
+ align-items: center;
194
+ gap: 12px;
195
+ padding: 12px 20px;
196
+ background: #f9fafb;
197
+ border-bottom: 1px solid #e5e7eb;
198
+ flex-wrap: wrap;
199
+ }
200
+
201
+ .sip-tx-history-filter-group {
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 6px;
205
+ }
206
+
207
+ .sip-tx-history-filter-label {
208
+ font-size: 12px;
209
+ font-weight: 500;
210
+ color: #6b7280;
211
+ }
212
+
213
+ .sip-tx-history-select {
214
+ padding: 6px 10px;
215
+ border: 1px solid #d1d5db;
216
+ border-radius: 6px;
217
+ font-size: 13px;
218
+ color: #374151;
219
+ background: white;
220
+ cursor: pointer;
221
+ }
222
+
223
+ .sip-tx-history-select:focus {
224
+ outline: none;
225
+ border-color: #6366f1;
226
+ }
227
+
228
+ /* Buttons */
229
+ .sip-tx-history-btn {
230
+ display: inline-flex;
231
+ align-items: center;
232
+ justify-content: center;
233
+ gap: 6px;
234
+ padding: 8px 14px;
235
+ border: none;
236
+ border-radius: 6px;
237
+ font-size: 13px;
238
+ font-weight: 500;
239
+ cursor: pointer;
240
+ transition: all 0.2s;
241
+ }
242
+
243
+ .sip-tx-history-btn svg {
244
+ width: 16px;
245
+ height: 16px;
246
+ }
247
+
248
+ .sip-tx-history-btn-secondary {
249
+ background: #f3f4f6;
250
+ color: #374151;
251
+ }
252
+
253
+ .sip-tx-history-btn-secondary:hover {
254
+ background: #e5e7eb;
255
+ }
256
+
257
+ .sip-tx-history-btn-primary {
258
+ background: #6366f1;
259
+ color: white;
260
+ }
261
+
262
+ .sip-tx-history-btn-primary:hover {
263
+ background: #4f46e5;
264
+ }
265
+
266
+ .sip-tx-history-btn:disabled {
267
+ opacity: 0.5;
268
+ cursor: not-allowed;
269
+ }
270
+
271
+ /* List */
272
+ .sip-tx-history-list {
273
+ padding: 0;
274
+ margin: 0;
275
+ list-style: none;
276
+ }
277
+
278
+ .sip-tx-history-empty {
279
+ padding: 48px 20px;
280
+ text-align: center;
281
+ color: #6b7280;
282
+ }
283
+
284
+ .sip-tx-history-empty svg {
285
+ width: 48px;
286
+ height: 48px;
287
+ margin-bottom: 12px;
288
+ opacity: 0.4;
289
+ }
290
+
291
+ .sip-tx-history-empty p {
292
+ margin: 0;
293
+ font-size: 14px;
294
+ }
295
+
296
+ /* Item */
297
+ .sip-tx-history-item {
298
+ display: flex;
299
+ align-items: center;
300
+ justify-content: space-between;
301
+ padding: 16px 20px;
302
+ border-bottom: 1px solid #e5e7eb;
303
+ cursor: pointer;
304
+ transition: background 0.2s;
305
+ }
306
+
307
+ .sip-tx-history-item:last-child {
308
+ border-bottom: none;
309
+ }
310
+
311
+ .sip-tx-history-item:hover {
312
+ background: #f9fafb;
313
+ }
314
+
315
+ .sip-tx-history[data-size="sm"] .sip-tx-history-item {
316
+ padding: 12px 16px;
317
+ }
318
+
319
+ .sip-tx-history-item-left {
320
+ display: flex;
321
+ align-items: center;
322
+ gap: 12px;
323
+ flex: 1;
324
+ min-width: 0;
325
+ }
326
+
327
+ .sip-tx-history-item-icon {
328
+ display: flex;
329
+ align-items: center;
330
+ justify-content: center;
331
+ width: 40px;
332
+ height: 40px;
333
+ border-radius: 10px;
334
+ flex-shrink: 0;
335
+ }
336
+
337
+ .sip-tx-history-item-icon svg {
338
+ width: 20px;
339
+ height: 20px;
340
+ }
341
+
342
+ .sip-tx-history-item-icon[data-direction="sent"] {
343
+ background: #fee2e2;
344
+ color: #dc2626;
345
+ }
346
+
347
+ .sip-tx-history-item-icon[data-direction="received"] {
348
+ background: #d1fae5;
349
+ color: #059669;
350
+ }
351
+
352
+ .sip-tx-history-item-icon[data-direction="claimed"] {
353
+ background: #dbeafe;
354
+ color: #2563eb;
355
+ }
356
+
357
+ .sip-tx-history-item-details {
358
+ flex: 1;
359
+ min-width: 0;
360
+ }
361
+
362
+ .sip-tx-history-item-title {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: 8px;
366
+ font-size: 14px;
367
+ font-weight: 600;
368
+ color: #111827;
369
+ margin-bottom: 2px;
370
+ }
371
+
372
+ .sip-tx-history-item-stealth-badge {
373
+ display: inline-flex;
374
+ align-items: center;
375
+ gap: 4px;
376
+ padding: 2px 6px;
377
+ font-size: 10px;
378
+ font-weight: 600;
379
+ text-transform: uppercase;
380
+ letter-spacing: 0.05em;
381
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
382
+ color: white;
383
+ border-radius: 4px;
384
+ }
385
+
386
+ .sip-tx-history-item-stealth-badge svg {
387
+ width: 10px;
388
+ height: 10px;
389
+ }
390
+
391
+ .sip-tx-history-item-address {
392
+ font-family: 'SF Mono', Monaco, monospace;
393
+ font-size: 12px;
394
+ color: #6b7280;
395
+ overflow: hidden;
396
+ text-overflow: ellipsis;
397
+ white-space: nowrap;
398
+ }
399
+
400
+ .sip-tx-history-item-meta {
401
+ display: flex;
402
+ align-items: center;
403
+ gap: 8px;
404
+ margin-top: 4px;
405
+ font-size: 11px;
406
+ color: #9ca3af;
407
+ }
408
+
409
+ .sip-tx-history-item-status {
410
+ display: inline-flex;
411
+ padding: 1px 6px;
412
+ border-radius: 3px;
413
+ font-weight: 500;
414
+ }
415
+
416
+ .sip-tx-history-item-status[data-status="pending"] {
417
+ background: #fef3c7;
418
+ color: #d97706;
419
+ }
420
+
421
+ .sip-tx-history-item-status[data-status="confirmed"] {
422
+ background: #d1fae5;
423
+ color: #059669;
424
+ }
425
+
426
+ .sip-tx-history-item-status[data-status="failed"] {
427
+ background: #fee2e2;
428
+ color: #dc2626;
429
+ }
430
+
431
+ .sip-tx-history-item-right {
432
+ display: flex;
433
+ flex-direction: column;
434
+ align-items: flex-end;
435
+ gap: 2px;
436
+ }
437
+
438
+ .sip-tx-history-item-amount {
439
+ font-size: 14px;
440
+ font-weight: 600;
441
+ }
442
+
443
+ .sip-tx-history-item-amount[data-direction="sent"] {
444
+ color: #dc2626;
445
+ }
446
+
447
+ .sip-tx-history-item-amount[data-direction="received"],
448
+ .sip-tx-history-item-amount[data-direction="claimed"] {
449
+ color: #059669;
450
+ }
451
+
452
+ .sip-tx-history-item-usd {
453
+ font-size: 12px;
454
+ color: #6b7280;
455
+ }
456
+
457
+ /* Load more */
458
+ .sip-tx-history-load-more {
459
+ padding: 16px 20px;
460
+ text-align: center;
461
+ border-top: 1px solid #e5e7eb;
462
+ background: #f9fafb;
463
+ }
464
+
465
+ /* Loading */
466
+ .sip-tx-history-loading {
467
+ display: flex;
468
+ align-items: center;
469
+ justify-content: center;
470
+ gap: 8px;
471
+ padding: 32px 20px;
472
+ color: #6b7280;
473
+ font-size: 14px;
474
+ }
475
+
476
+ .sip-tx-history-loading-spinner {
477
+ width: 20px;
478
+ height: 20px;
479
+ border: 2px solid #e5e7eb;
480
+ border-top-color: #6366f1;
481
+ border-radius: 50%;
482
+ animation: sip-tx-history-spin 1s linear infinite;
483
+ }
484
+
485
+ @keyframes sip-tx-history-spin {
486
+ to { transform: rotate(360deg); }
487
+ }
488
+
489
+ /* Error */
490
+ .sip-tx-history-error {
491
+ display: flex;
492
+ align-items: center;
493
+ justify-content: center;
494
+ gap: 8px;
495
+ padding: 24px 20px;
496
+ background: #fee2e2;
497
+ color: #dc2626;
498
+ font-size: 14px;
499
+ }
500
+
501
+ .sip-tx-history-error svg {
502
+ width: 20px;
503
+ height: 20px;
504
+ }
505
+
506
+ /* Dark mode */
507
+ @media (prefers-color-scheme: dark) {
508
+ .sip-tx-history {
509
+ background: #1f2937;
510
+ border-color: #374151;
511
+ }
512
+
513
+ .sip-tx-history-header {
514
+ background: #111827;
515
+ border-color: #374151;
516
+ }
517
+
518
+ .sip-tx-history-title h2 {
519
+ color: #f9fafb;
520
+ }
521
+
522
+ .sip-tx-history-filters {
523
+ background: #111827;
524
+ border-color: #374151;
525
+ }
526
+
527
+ .sip-tx-history-select {
528
+ background: #374151;
529
+ border-color: #4b5563;
530
+ color: #e5e7eb;
531
+ }
532
+
533
+ .sip-tx-history-item:hover {
534
+ background: #111827;
535
+ }
536
+
537
+ .sip-tx-history-item-title {
538
+ color: #f9fafb;
539
+ }
540
+
541
+ .sip-tx-history-item-address {
542
+ color: #9ca3af;
543
+ }
544
+
545
+ .sip-tx-history-btn-secondary {
546
+ background: #374151;
547
+ color: #e5e7eb;
548
+ }
549
+
550
+ .sip-tx-history-btn-secondary:hover {
551
+ background: #4b5563;
552
+ }
553
+
554
+ .sip-tx-history-empty {
555
+ color: #9ca3af;
556
+ }
557
+
558
+ .sip-tx-history-load-more {
559
+ background: #111827;
560
+ border-color: #374151;
561
+ }
562
+ }
563
+ `
564
+
565
+ /**
566
+ * Icons
567
+ */
568
+ const HistoryIcon = () => (
569
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
570
+ <circle cx="12" cy="12" r="10" />
571
+ <polyline points="12 6 12 12 16 14" />
572
+ </svg>
573
+ )
574
+
575
+ const ArrowUpIcon = () => (
576
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
577
+ <line x1="12" y1="19" x2="12" y2="5" />
578
+ <polyline points="5 12 12 5 19 12" />
579
+ </svg>
580
+ )
581
+
582
+ const ArrowDownIcon = () => (
583
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
584
+ <line x1="12" y1="5" x2="12" y2="19" />
585
+ <polyline points="19 12 12 19 5 12" />
586
+ </svg>
587
+ )
588
+
589
+ const CheckCircleIcon = () => (
590
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
591
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
592
+ <polyline points="22 4 12 14.01 9 11.01" />
593
+ </svg>
594
+ )
595
+
596
+ const ShieldIcon = () => (
597
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
598
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
599
+ </svg>
600
+ )
601
+
602
+ const DownloadIcon = () => (
603
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
604
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
605
+ <polyline points="7 10 12 15 17 10" />
606
+ <line x1="12" y1="15" x2="12" y2="3" />
607
+ </svg>
608
+ )
609
+
610
+ const AlertIcon = () => (
611
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
612
+ <circle cx="12" cy="12" r="10" />
613
+ <line x1="12" y1="8" x2="12" y2="12" />
614
+ <line x1="12" y1="16" x2="12.01" y2="16" />
615
+ </svg>
616
+ )
617
+
618
+ /**
619
+ * Format address for display
620
+ */
621
+ function formatAddress(address: string): string {
622
+ if (address.length <= 14) return address
623
+ return `${address.slice(0, 8)}...${address.slice(-6)}`
624
+ }
625
+
626
+ /**
627
+ * Format timestamp
628
+ */
629
+ function formatTimestamp(timestamp: number): string {
630
+ const date = new Date(timestamp)
631
+ const now = new Date()
632
+ const diffMs = now.getTime() - date.getTime()
633
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
634
+
635
+ if (diffDays === 0) {
636
+ return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
637
+ }
638
+ if (diffDays === 1) {
639
+ return 'Yesterday'
640
+ }
641
+ if (diffDays < 7) {
642
+ return `${diffDays} days ago`
643
+ }
644
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
645
+ }
646
+
647
+ /**
648
+ * Format amount
649
+ */
650
+ function formatAmount(amount: string, decimals: number, symbol: string): string {
651
+ const value = parseFloat(amount) / Math.pow(10, decimals)
652
+ const formatted = value.toLocaleString(undefined, {
653
+ minimumFractionDigits: 0,
654
+ maximumFractionDigits: 6,
655
+ })
656
+ return `${formatted} ${symbol}`
657
+ }
658
+
659
+ /**
660
+ * Get direction label
661
+ */
662
+ function getDirectionLabel(direction: TransactionDirection): string {
663
+ switch (direction) {
664
+ case 'sent':
665
+ return 'Sent'
666
+ case 'received':
667
+ return 'Received'
668
+ case 'claimed':
669
+ return 'Claimed'
670
+ }
671
+ }
672
+
673
+ /**
674
+ * TransactionHistory - Display privacy transaction history
675
+ *
676
+ * @example Basic usage
677
+ * ```tsx
678
+ * import { TransactionHistory } from '@sip-protocol/react'
679
+ *
680
+ * function HistoryView() {
681
+ * const [transactions, setTransactions] = useState([])
682
+ *
683
+ * return (
684
+ * <TransactionHistory
685
+ * transactions={transactions}
686
+ * onTransactionSelect={(tx) => console.log('Selected:', tx)}
687
+ * network="mainnet"
688
+ * showFilters
689
+ * showExport
690
+ * />
691
+ * )
692
+ * }
693
+ * ```
694
+ */
695
+ export function TransactionHistory({
696
+ transactions,
697
+ isLoading = false,
698
+ error = null,
699
+ filter = { direction: 'all' },
700
+ sort = { field: 'timestamp', direction: 'desc' },
701
+ onFilterChange,
702
+ onSortChange,
703
+ onLoadMore,
704
+ hasMore = false,
705
+ onExport,
706
+ onTransactionSelect,
707
+ network = 'mainnet',
708
+ pageSize = 20,
709
+ className = '',
710
+ size = 'md',
711
+ showUsdValues = true,
712
+ showFilters = true,
713
+ showExport = true,
714
+ }: TransactionHistoryProps) {
715
+ const [currentPage, setCurrentPage] = useState(1)
716
+
717
+ // Resolve network config
718
+ const networkConfig = useMemo((): EthereumNetwork => {
719
+ if (typeof network === 'object') return network
720
+ return ETHEREUM_NETWORKS[network] ?? ETHEREUM_NETWORKS.mainnet
721
+ }, [network])
722
+
723
+ // Filter and sort transactions
724
+ const filteredTransactions = useMemo(() => {
725
+ let result = [...transactions]
726
+
727
+ // Apply filters
728
+ if (filter.direction && filter.direction !== 'all') {
729
+ result = result.filter((tx) => tx.direction === filter.direction)
730
+ }
731
+ if (filter.type && filter.type !== 'all') {
732
+ result = result.filter((tx) => tx.type === filter.type)
733
+ }
734
+ if (filter.status && filter.status !== 'all') {
735
+ result = result.filter((tx) => tx.status === filter.status)
736
+ }
737
+ if (filter.tokenSymbol) {
738
+ result = result.filter((tx) => tx.tokenSymbol === filter.tokenSymbol)
739
+ }
740
+ if (filter.fromDate) {
741
+ result = result.filter((tx) => tx.timestamp >= filter.fromDate!.getTime())
742
+ }
743
+ if (filter.toDate) {
744
+ result = result.filter((tx) => tx.timestamp <= filter.toDate!.getTime())
745
+ }
746
+
747
+ // Apply sort
748
+ result.sort((a, b) => {
749
+ let cmp = 0
750
+ switch (sort.field) {
751
+ case 'timestamp':
752
+ cmp = a.timestamp - b.timestamp
753
+ break
754
+ case 'amount':
755
+ cmp = parseFloat(a.amount) - parseFloat(b.amount)
756
+ break
757
+ case 'usdValue':
758
+ cmp = parseFloat(a.usdValue || '0') - parseFloat(b.usdValue || '0')
759
+ break
760
+ }
761
+ return sort.direction === 'asc' ? cmp : -cmp
762
+ })
763
+
764
+ return result
765
+ }, [transactions, filter, sort])
766
+
767
+ // Paginated transactions
768
+ const paginatedTransactions = useMemo(() => {
769
+ return filteredTransactions.slice(0, currentPage * pageSize)
770
+ }, [filteredTransactions, currentPage, pageSize])
771
+
772
+ // Handle filter change
773
+ const handleFilterChange = useCallback(
774
+ (key: keyof TransactionHistoryFilter, value: string) => {
775
+ const newFilter = { ...filter, [key]: value }
776
+ onFilterChange?.(newFilter)
777
+ setCurrentPage(1)
778
+ },
779
+ [filter, onFilterChange]
780
+ )
781
+
782
+ // Handle load more
783
+ const handleLoadMore = useCallback(() => {
784
+ if (paginatedTransactions.length < filteredTransactions.length) {
785
+ setCurrentPage((p) => p + 1)
786
+ } else {
787
+ onLoadMore?.()
788
+ }
789
+ }, [paginatedTransactions, filteredTransactions, onLoadMore])
790
+
791
+ // Can load more
792
+ const canLoadMore = useMemo(() => {
793
+ return paginatedTransactions.length < filteredTransactions.length || hasMore
794
+ }, [paginatedTransactions, filteredTransactions, hasMore])
795
+
796
+ // Get explorer URL for transaction
797
+ const getExplorerUrl = useCallback(
798
+ (hash: string) => `${networkConfig.explorerUrl}/tx/${hash}`,
799
+ [networkConfig]
800
+ )
801
+
802
+ return (
803
+ <>
804
+ <style>{styles}</style>
805
+
806
+ <div className={`sip-tx-history ${className}`} data-size={size}>
807
+ {/* Header */}
808
+ <div className="sip-tx-history-header">
809
+ <div className="sip-tx-history-title">
810
+ <HistoryIcon />
811
+ <h2>Transaction History</h2>
812
+ </div>
813
+ <div className="sip-tx-history-actions">
814
+ {showExport && onExport && (
815
+ <button
816
+ type="button"
817
+ className="sip-tx-history-btn sip-tx-history-btn-secondary"
818
+ onClick={() => onExport('csv')}
819
+ disabled={transactions.length === 0}
820
+ aria-label="Export as CSV"
821
+ >
822
+ <DownloadIcon />
823
+ Export
824
+ </button>
825
+ )}
826
+ </div>
827
+ </div>
828
+
829
+ {/* Filters */}
830
+ {showFilters && (
831
+ <div className="sip-tx-history-filters">
832
+ <div className="sip-tx-history-filter-group">
833
+ <span className="sip-tx-history-filter-label">Direction:</span>
834
+ <select
835
+ className="sip-tx-history-select"
836
+ value={filter.direction || 'all'}
837
+ onChange={(e) => handleFilterChange('direction', e.target.value)}
838
+ >
839
+ <option value="all">All</option>
840
+ <option value="sent">Sent</option>
841
+ <option value="received">Received</option>
842
+ <option value="claimed">Claimed</option>
843
+ </select>
844
+ </div>
845
+
846
+ <div className="sip-tx-history-filter-group">
847
+ <span className="sip-tx-history-filter-label">Status:</span>
848
+ <select
849
+ className="sip-tx-history-select"
850
+ value={filter.status || 'all'}
851
+ onChange={(e) => handleFilterChange('status', e.target.value)}
852
+ >
853
+ <option value="all">All</option>
854
+ <option value="pending">Pending</option>
855
+ <option value="confirmed">Confirmed</option>
856
+ <option value="failed">Failed</option>
857
+ </select>
858
+ </div>
859
+
860
+ <div className="sip-tx-history-filter-group">
861
+ <span className="sip-tx-history-filter-label">Sort:</span>
862
+ <select
863
+ className="sip-tx-history-select"
864
+ value={`${sort.field}-${sort.direction}`}
865
+ onChange={(e) => {
866
+ const [field, direction] = e.target.value.split('-') as [
867
+ TransactionHistorySort['field'],
868
+ TransactionHistorySort['direction']
869
+ ]
870
+ onSortChange?.({ field, direction })
871
+ }}
872
+ >
873
+ <option value="timestamp-desc">Newest First</option>
874
+ <option value="timestamp-asc">Oldest First</option>
875
+ <option value="amount-desc">Amount (High to Low)</option>
876
+ <option value="amount-asc">Amount (Low to High)</option>
877
+ </select>
878
+ </div>
879
+ </div>
880
+ )}
881
+
882
+ {/* Error */}
883
+ {error && (
884
+ <div className="sip-tx-history-error" data-testid="error">
885
+ <AlertIcon />
886
+ {error}
887
+ </div>
888
+ )}
889
+
890
+ {/* Loading */}
891
+ {isLoading && transactions.length === 0 && (
892
+ <div className="sip-tx-history-loading" data-testid="loading">
893
+ <div className="sip-tx-history-loading-spinner" />
894
+ Loading transactions...
895
+ </div>
896
+ )}
897
+
898
+ {/* Empty state */}
899
+ {!isLoading && transactions.length === 0 && !error && (
900
+ <div className="sip-tx-history-empty" data-testid="empty">
901
+ <HistoryIcon />
902
+ <p>No transactions yet</p>
903
+ </div>
904
+ )}
905
+
906
+ {/* Transaction list */}
907
+ {paginatedTransactions.length > 0 && (
908
+ <ul className="sip-tx-history-list" data-testid="transaction-list">
909
+ {paginatedTransactions.map((tx) => (
910
+ <li
911
+ key={tx.hash}
912
+ className="sip-tx-history-item"
913
+ onClick={() => onTransactionSelect?.(tx)}
914
+ data-testid={`tx-item-${tx.hash.slice(0, 8)}`}
915
+ >
916
+ <div className="sip-tx-history-item-left">
917
+ <div
918
+ className="sip-tx-history-item-icon"
919
+ data-direction={tx.direction}
920
+ >
921
+ {tx.direction === 'sent' ? (
922
+ <ArrowUpIcon />
923
+ ) : tx.direction === 'claimed' ? (
924
+ <CheckCircleIcon />
925
+ ) : (
926
+ <ArrowDownIcon />
927
+ )}
928
+ </div>
929
+ <div className="sip-tx-history-item-details">
930
+ <div className="sip-tx-history-item-title">
931
+ {getDirectionLabel(tx.direction)}
932
+ {tx.isStealthAddress && (
933
+ <span className="sip-tx-history-item-stealth-badge">
934
+ <ShieldIcon />
935
+ Stealth
936
+ </span>
937
+ )}
938
+ </div>
939
+ <div className="sip-tx-history-item-address">
940
+ {tx.direction === 'sent' ? 'To: ' : 'From: '}
941
+ <a
942
+ href={`${networkConfig.explorerUrl}/address/${tx.direction === 'sent' ? tx.to : tx.from}`}
943
+ target="_blank"
944
+ rel="noopener noreferrer"
945
+ onClick={(e) => e.stopPropagation()}
946
+ style={{ color: 'inherit', textDecoration: 'none' }}
947
+ >
948
+ {formatAddress(tx.direction === 'sent' ? tx.to : tx.from)}
949
+ </a>
950
+ </div>
951
+ <div className="sip-tx-history-item-meta">
952
+ <span className="sip-tx-history-item-status" data-status={tx.status}>
953
+ {tx.status}
954
+ </span>
955
+ <span>{formatTimestamp(tx.timestamp)}</span>
956
+ <a
957
+ href={getExplorerUrl(tx.hash)}
958
+ target="_blank"
959
+ rel="noopener noreferrer"
960
+ onClick={(e) => e.stopPropagation()}
961
+ style={{ color: '#6366f1', textDecoration: 'none' }}
962
+ >
963
+ View on Explorer
964
+ </a>
965
+ </div>
966
+ </div>
967
+ </div>
968
+ <div className="sip-tx-history-item-right">
969
+ <span
970
+ className="sip-tx-history-item-amount"
971
+ data-direction={tx.direction}
972
+ >
973
+ {tx.direction === 'sent' ? '-' : '+'}
974
+ {formatAmount(tx.amount, tx.tokenDecimals, tx.tokenSymbol)}
975
+ </span>
976
+ {showUsdValues && tx.usdValue && (
977
+ <span className="sip-tx-history-item-usd">
978
+ ${parseFloat(tx.usdValue).toLocaleString(undefined, {
979
+ minimumFractionDigits: 2,
980
+ maximumFractionDigits: 2,
981
+ })}
982
+ </span>
983
+ )}
984
+ </div>
985
+ </li>
986
+ ))}
987
+ </ul>
988
+ )}
989
+
990
+ {/* Load more */}
991
+ {canLoadMore && !isLoading && (
992
+ <div className="sip-tx-history-load-more">
993
+ <button
994
+ type="button"
995
+ className="sip-tx-history-btn sip-tx-history-btn-secondary"
996
+ onClick={handleLoadMore}
997
+ >
998
+ Load More
999
+ </button>
1000
+ </div>
1001
+ )}
1002
+ </div>
1003
+ </>
1004
+ )
1005
+ }
1006
+
1007
+ /**
1008
+ * Hook for managing transaction history
1009
+ *
1010
+ * @example
1011
+ * ```tsx
1012
+ * const {
1013
+ * transactions,
1014
+ * isLoading,
1015
+ * filter,
1016
+ * setFilter,
1017
+ * loadMore,
1018
+ * refresh,
1019
+ * exportHistory,
1020
+ * } = useTransactionHistory({
1021
+ * viewingPrivateKey,
1022
+ * spendingPublicKey,
1023
+ * network: 'mainnet',
1024
+ * })
1025
+ * ```
1026
+ */
1027
+ export function useTransactionHistory(options: {
1028
+ initialTransactions?: PrivacyTransactionHistoryItem[]
1029
+ fetchTransactions?: () => Promise<PrivacyTransactionHistoryItem[]>
1030
+ pageSize?: number
1031
+ } = {}) {
1032
+ const {
1033
+ initialTransactions = [],
1034
+ fetchTransactions,
1035
+ pageSize = 20,
1036
+ } = options
1037
+
1038
+ const [transactions, setTransactions] = useState<PrivacyTransactionHistoryItem[]>(
1039
+ initialTransactions
1040
+ )
1041
+ const [isLoading, setIsLoading] = useState(false)
1042
+ const [error, setError] = useState<string | null>(null)
1043
+ const [filter, setFilter] = useState<TransactionHistoryFilter>({ direction: 'all' })
1044
+ const [sort, setSort] = useState<TransactionHistorySort>({
1045
+ field: 'timestamp',
1046
+ direction: 'desc',
1047
+ })
1048
+ const [hasMore, setHasMore] = useState(true)
1049
+
1050
+ // Load transactions
1051
+ const loadTransactions = useCallback(async () => {
1052
+ if (!fetchTransactions) return
1053
+
1054
+ setIsLoading(true)
1055
+ setError(null)
1056
+
1057
+ try {
1058
+ const result = await fetchTransactions()
1059
+ setTransactions(result)
1060
+ setHasMore(result.length >= pageSize)
1061
+ } catch (err) {
1062
+ setError(err instanceof Error ? err.message : 'Failed to load transactions')
1063
+ } finally {
1064
+ setIsLoading(false)
1065
+ }
1066
+ }, [fetchTransactions, pageSize])
1067
+
1068
+ // Load on mount
1069
+ useEffect(() => {
1070
+ if (fetchTransactions && initialTransactions.length === 0) {
1071
+ loadTransactions()
1072
+ }
1073
+ }, [fetchTransactions, initialTransactions.length, loadTransactions])
1074
+
1075
+ // Add transaction
1076
+ const addTransaction = useCallback((tx: PrivacyTransactionHistoryItem) => {
1077
+ setTransactions((prev) => [tx, ...prev])
1078
+ }, [])
1079
+
1080
+ // Update transaction
1081
+ const updateTransaction = useCallback(
1082
+ (hash: string, updates: Partial<PrivacyTransactionHistoryItem>) => {
1083
+ setTransactions((prev) =>
1084
+ prev.map((tx) => (tx.hash === hash ? { ...tx, ...updates } : tx))
1085
+ )
1086
+ },
1087
+ []
1088
+ )
1089
+
1090
+ // Export history
1091
+ const exportHistory = useCallback(
1092
+ (format: 'csv' | 'json') => {
1093
+ if (format === 'json') {
1094
+ const blob = new Blob([JSON.stringify(transactions, null, 2)], {
1095
+ type: 'application/json',
1096
+ })
1097
+ const url = URL.createObjectURL(blob)
1098
+ const a = document.createElement('a')
1099
+ a.href = url
1100
+ a.download = `sip-transactions-${Date.now()}.json`
1101
+ document.body.appendChild(a)
1102
+ a.click()
1103
+ document.body.removeChild(a)
1104
+ URL.revokeObjectURL(url)
1105
+ } else {
1106
+ // CSV export
1107
+ const headers = [
1108
+ 'Hash',
1109
+ 'Direction',
1110
+ 'Type',
1111
+ 'Timestamp',
1112
+ 'From',
1113
+ 'To',
1114
+ 'Amount',
1115
+ 'Token',
1116
+ 'USD Value',
1117
+ 'Status',
1118
+ 'Stealth',
1119
+ ]
1120
+ const rows = transactions.map((tx) => [
1121
+ tx.hash,
1122
+ tx.direction,
1123
+ tx.type,
1124
+ new Date(tx.timestamp).toISOString(),
1125
+ tx.from,
1126
+ tx.to,
1127
+ (parseFloat(tx.amount) / Math.pow(10, tx.tokenDecimals)).toString(),
1128
+ tx.tokenSymbol,
1129
+ tx.usdValue || '',
1130
+ tx.status,
1131
+ tx.isStealthAddress ? 'Yes' : 'No',
1132
+ ])
1133
+
1134
+ const csv = [headers, ...rows].map((row) => row.join(',')).join('\n')
1135
+ const blob = new Blob([csv], { type: 'text/csv' })
1136
+ const url = URL.createObjectURL(blob)
1137
+ const a = document.createElement('a')
1138
+ a.href = url
1139
+ a.download = `sip-transactions-${Date.now()}.csv`
1140
+ document.body.appendChild(a)
1141
+ a.click()
1142
+ document.body.removeChild(a)
1143
+ URL.revokeObjectURL(url)
1144
+ }
1145
+ },
1146
+ [transactions]
1147
+ )
1148
+
1149
+ // Get summary statistics
1150
+ const summary = useMemo(() => {
1151
+ const sent = transactions.filter((tx) => tx.direction === 'sent')
1152
+ const received = transactions.filter((tx) => tx.direction === 'received')
1153
+ const claimed = transactions.filter((tx) => tx.direction === 'claimed')
1154
+ const stealth = transactions.filter((tx) => tx.isStealthAddress)
1155
+
1156
+ return {
1157
+ total: transactions.length,
1158
+ sent: sent.length,
1159
+ received: received.length,
1160
+ claimed: claimed.length,
1161
+ stealthCount: stealth.length,
1162
+ stealthPercentage: transactions.length > 0
1163
+ ? Math.round((stealth.length / transactions.length) * 100)
1164
+ : 0,
1165
+ }
1166
+ }, [transactions])
1167
+
1168
+ return {
1169
+ transactions,
1170
+ setTransactions,
1171
+ isLoading,
1172
+ error,
1173
+ filter,
1174
+ setFilter,
1175
+ sort,
1176
+ setSort,
1177
+ hasMore,
1178
+ setHasMore,
1179
+ loadTransactions,
1180
+ addTransaction,
1181
+ updateTransaction,
1182
+ exportHistory,
1183
+ summary,
1184
+ }
1185
+ }
1186
+
1187
+ export default TransactionHistory