@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,504 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react'
2
+ import {
3
+ scanForPayments,
4
+ claimStealthPayment,
5
+ } from '@sip-protocol/sdk'
6
+ import type {
7
+ SolanaScanParams,
8
+ SolanaScanResult,
9
+ SolanaClaimParams,
10
+ SolanaClaimResult,
11
+ } from '@sip-protocol/sdk'
12
+ import type { HexString } from '@sip-protocol/types'
13
+
14
+ /**
15
+ * Scan status states
16
+ */
17
+ export type ScanStatus = 'idle' | 'scanning' | 'claiming' | 'success' | 'error'
18
+
19
+ /**
20
+ * Parameters for useScanPayments hook
21
+ */
22
+ export interface UseScanPaymentsParams {
23
+ /** Solana RPC connection */
24
+ connection: SolanaScanParams['connection']
25
+ /** Recipient's viewing private key (hex) */
26
+ viewingPrivateKey: HexString
27
+ /** Recipient's spending public key (hex) */
28
+ spendingPublicKey: HexString
29
+ /** Optional: Auto-scan interval in milliseconds (0 = disabled) */
30
+ scanInterval?: number
31
+ /** Optional: Initial from slot for scanning */
32
+ fromSlot?: number
33
+ /** Optional: Limit number of results per scan */
34
+ limit?: number
35
+ }
36
+
37
+ /**
38
+ * Payment with claim status
39
+ */
40
+ export interface PaymentWithStatus extends SolanaScanResult {
41
+ /** Whether this payment has been claimed */
42
+ claimed: boolean
43
+ /** Claim result if claimed */
44
+ claimResult?: SolanaClaimResult
45
+ }
46
+
47
+ /**
48
+ * Return type for useScanPayments hook
49
+ */
50
+ export interface UseScanPaymentsReturn {
51
+ /** Current scan/claim status */
52
+ status: ScanStatus
53
+ /** Whether scanning is in progress */
54
+ isScanning: boolean
55
+ /** Whether claiming is in progress */
56
+ isClaiming: boolean
57
+ /** Error message if scan/claim failed */
58
+ error: Error | null
59
+ /** Detected payments */
60
+ payments: PaymentWithStatus[]
61
+ /** Total unclaimed balance across all payments */
62
+ totalUnclaimed: bigint
63
+ /** Timestamp of last scan */
64
+ lastScannedAt: Date | null
65
+ /** Trigger a manual scan */
66
+ scan: (options?: { fromSlot?: number; limit?: number }) => Promise<SolanaScanResult[]>
67
+ /** Claim a specific payment */
68
+ claim: (payment: SolanaScanResult, params: ClaimParams) => Promise<SolanaClaimResult | null>
69
+ /** Claim all unclaimed payments */
70
+ claimAll: (params: ClaimAllParams) => Promise<ClaimAllResult>
71
+ /** Start auto-scanning */
72
+ startAutoScan: (intervalMs?: number) => void
73
+ /** Stop auto-scanning */
74
+ stopAutoScan: () => void
75
+ /** Clear all payments and reset state */
76
+ reset: () => void
77
+ /** Clear error */
78
+ clearError: () => void
79
+ }
80
+
81
+ /**
82
+ * Parameters for claiming a payment
83
+ */
84
+ export interface ClaimParams {
85
+ /** Recipient's spending private key (hex) */
86
+ spendingPrivateKey: HexString
87
+ /** Destination address to send claimed funds (base58) */
88
+ destinationAddress: string
89
+ /** SPL token mint address */
90
+ mint: SolanaClaimParams['mint']
91
+ }
92
+
93
+ /**
94
+ * Mint resolver function type
95
+ * Converts a mint address string to a PublicKey-like object for claiming
96
+ */
97
+ export type MintResolver = (mintAddress: string) => SolanaClaimParams['mint']
98
+
99
+ /**
100
+ * Parameters for claiming all payments
101
+ */
102
+ export interface ClaimAllParams {
103
+ /** Recipient's spending private key (hex) */
104
+ spendingPrivateKey: HexString
105
+ /** Destination address to send claimed funds (base58) */
106
+ destinationAddress: string
107
+ /**
108
+ * Function to convert mint address string to PublicKey object
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * import { PublicKey } from '@solana/web3.js'
113
+ *
114
+ * const mintResolver = (mint: string) => new PublicKey(mint)
115
+ * await claimAll({ spendingPrivateKey, destinationAddress, mintResolver })
116
+ * ```
117
+ */
118
+ mintResolver: MintResolver
119
+ }
120
+
121
+ /**
122
+ * Result from claimAll operation
123
+ */
124
+ export interface ClaimAllResult {
125
+ /** Successful claim results */
126
+ succeeded: SolanaClaimResult[]
127
+ /** Failed claims with error info */
128
+ failed: Array<{
129
+ payment: PaymentWithStatus
130
+ error: Error
131
+ }>
132
+ /** Total number of unclaimed payments attempted */
133
+ totalAttempted: number
134
+ }
135
+
136
+ /**
137
+ * useScanPayments - Scan for and claim incoming stealth payments on Solana
138
+ *
139
+ * @remarks
140
+ * This hook provides a React-friendly interface for scanning the Solana blockchain
141
+ * for incoming stealth payments and claiming them. It supports both manual and
142
+ * automatic scanning with configurable intervals.
143
+ *
144
+ * Features:
145
+ * - Scan for incoming stealth payments
146
+ * - Auto-scanning with configurable interval
147
+ * - Claim individual or all payments
148
+ * - Track claimed vs unclaimed payments
149
+ * - Error handling and recovery
150
+ *
151
+ * @param params - Hook configuration parameters
152
+ *
153
+ * @example
154
+ * ```tsx
155
+ * import { useScanPayments } from '@sip-protocol/react'
156
+ * import { useConnection } from '@solana/wallet-adapter-react'
157
+ * import { PublicKey } from '@solana/web3.js'
158
+ *
159
+ * function ReceivePayments() {
160
+ * const { connection } = useConnection()
161
+ *
162
+ * const {
163
+ * payments,
164
+ * isScanning,
165
+ * scan,
166
+ * claim,
167
+ * totalUnclaimed,
168
+ * lastScannedAt,
169
+ * } = useScanPayments({
170
+ * connection,
171
+ * viewingPrivateKey: '0x...',
172
+ * spendingPublicKey: '0x...',
173
+ * scanInterval: 30000, // Auto-scan every 30 seconds
174
+ * })
175
+ *
176
+ * const handleClaim = async (payment: SolanaScanResult) => {
177
+ * await claim(payment, {
178
+ * spendingPrivateKey: '0x...',
179
+ * destinationAddress: 'myWallet...',
180
+ * mint: new PublicKey(payment.mint),
181
+ * })
182
+ * }
183
+ *
184
+ * return (
185
+ * <div>
186
+ * <button onClick={() => scan()} disabled={isScanning}>
187
+ * {isScanning ? 'Scanning...' : 'Scan for Payments'}
188
+ * </button>
189
+ *
190
+ * <p>Total unclaimed: {totalUnclaimed.toString()}</p>
191
+ * <p>Last scan: {lastScannedAt?.toLocaleString()}</p>
192
+ *
193
+ * {payments.map((payment) => (
194
+ * <div key={payment.txSignature}>
195
+ * <p>{payment.amount.toString()} {payment.tokenSymbol}</p>
196
+ * {!payment.claimed && (
197
+ * <button onClick={() => handleClaim(payment)}>Claim</button>
198
+ * )}
199
+ * </div>
200
+ * ))}
201
+ * </div>
202
+ * )
203
+ * }
204
+ * ```
205
+ */
206
+ export function useScanPayments(params: UseScanPaymentsParams): UseScanPaymentsReturn {
207
+ const {
208
+ connection,
209
+ viewingPrivateKey,
210
+ spendingPublicKey,
211
+ scanInterval = 0,
212
+ fromSlot: initialFromSlot,
213
+ limit: defaultLimit = 100,
214
+ } = params
215
+
216
+ const [status, setStatus] = useState<ScanStatus>('idle')
217
+ const [error, setError] = useState<Error | null>(null)
218
+ const [payments, setPayments] = useState<PaymentWithStatus[]>([])
219
+ const [lastScannedAt, setLastScannedAt] = useState<Date | null>(null)
220
+ const [lastScannedSlot, setLastScannedSlot] = useState<number | undefined>(initialFromSlot)
221
+
222
+ const autoScanIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
223
+ const isAutoScanningRef = useRef(false)
224
+
225
+ const isScanning = status === 'scanning'
226
+ const isClaiming = status === 'claiming'
227
+
228
+ // Calculate total unclaimed balance
229
+ const totalUnclaimed = payments
230
+ .filter((p) => !p.claimed)
231
+ .reduce((sum, p) => sum + p.amount, 0n)
232
+
233
+ /**
234
+ * Scan for payments
235
+ */
236
+ const scan = useCallback(
237
+ async (options?: { fromSlot?: number; limit?: number }): Promise<SolanaScanResult[]> => {
238
+ setStatus('scanning')
239
+ setError(null)
240
+
241
+ try {
242
+ const scanParams: SolanaScanParams = {
243
+ connection,
244
+ viewingPrivateKey,
245
+ spendingPublicKey,
246
+ fromSlot: options?.fromSlot ?? lastScannedSlot,
247
+ limit: options?.limit ?? defaultLimit,
248
+ }
249
+
250
+ const results = await scanForPayments(scanParams)
251
+
252
+ // Merge with existing payments, avoiding duplicates
253
+ setPayments((prev) => {
254
+ const existingTxs = new Set(prev.map((p) => p.txSignature))
255
+ const newPayments = results
256
+ .filter((r) => !existingTxs.has(r.txSignature))
257
+ .map((r) => ({ ...r, claimed: false }))
258
+
259
+ return [...prev, ...newPayments]
260
+ })
261
+
262
+ // Update last scanned slot to the highest slot we've seen
263
+ if (results.length > 0) {
264
+ const maxSlot = Math.max(...results.map((r) => r.slot))
265
+ setLastScannedSlot(maxSlot + 1) // Start from next slot on subsequent scans
266
+ }
267
+
268
+ setLastScannedAt(new Date())
269
+ setStatus('idle')
270
+
271
+ return results
272
+ } catch (err) {
273
+ const error = err instanceof Error ? err : new Error('Scan failed')
274
+ setError(error)
275
+ setStatus('error')
276
+ throw error
277
+ }
278
+ },
279
+ [connection, viewingPrivateKey, spendingPublicKey, lastScannedSlot, defaultLimit]
280
+ )
281
+
282
+ /**
283
+ * Claim a specific payment
284
+ */
285
+ const claim = useCallback(
286
+ async (payment: SolanaScanResult, claimParams: ClaimParams): Promise<SolanaClaimResult | null> => {
287
+ setStatus('claiming')
288
+ setError(null)
289
+
290
+ try {
291
+ const sdkParams: SolanaClaimParams = {
292
+ connection,
293
+ stealthAddress: payment.stealthAddress,
294
+ ephemeralPublicKey: payment.ephemeralPublicKey,
295
+ viewingPrivateKey,
296
+ spendingPrivateKey: claimParams.spendingPrivateKey,
297
+ destinationAddress: claimParams.destinationAddress,
298
+ mint: claimParams.mint,
299
+ }
300
+
301
+ const result = await claimStealthPayment(sdkParams)
302
+
303
+ // Update payment status
304
+ setPayments((prev) =>
305
+ prev.map((p) =>
306
+ p.txSignature === payment.txSignature
307
+ ? { ...p, claimed: true, claimResult: result }
308
+ : p
309
+ )
310
+ )
311
+
312
+ setStatus('success')
313
+ return result
314
+ } catch (err) {
315
+ const error = err instanceof Error ? err : new Error('Claim failed')
316
+ setError(error)
317
+ setStatus('error')
318
+ return null
319
+ }
320
+ },
321
+ [connection, viewingPrivateKey]
322
+ )
323
+
324
+ /**
325
+ * Claim all unclaimed payments
326
+ *
327
+ * @remarks
328
+ * Iterates through all unclaimed payments and attempts to claim each one.
329
+ * Uses the provided mintResolver to convert mint address strings to PublicKey objects.
330
+ * Handles partial failures gracefully - continues with remaining payments if one fails.
331
+ *
332
+ * @example
333
+ * ```typescript
334
+ * import { PublicKey } from '@solana/web3.js'
335
+ *
336
+ * const { claimAll } = useScanPayments({ ... })
337
+ *
338
+ * const result = await claimAll({
339
+ * spendingPrivateKey: '0x...',
340
+ * destinationAddress: 'myWallet...',
341
+ * mintResolver: (mint) => new PublicKey(mint),
342
+ * })
343
+ *
344
+ * console.log(`Claimed ${result.succeeded.length} of ${result.totalAttempted}`)
345
+ * if (result.failed.length > 0) {
346
+ * console.error('Failed claims:', result.failed)
347
+ * }
348
+ * ```
349
+ */
350
+ const claimAll = useCallback(
351
+ async (claimAllParams: ClaimAllParams): Promise<ClaimAllResult> => {
352
+ const { spendingPrivateKey, destinationAddress, mintResolver } = claimAllParams
353
+ const unclaimed = payments.filter((p) => !p.claimed)
354
+
355
+ if (unclaimed.length === 0) {
356
+ return { succeeded: [], failed: [], totalAttempted: 0 }
357
+ }
358
+
359
+ setStatus('claiming')
360
+ setError(null)
361
+
362
+ const succeeded: SolanaClaimResult[] = []
363
+ const failed: Array<{ payment: PaymentWithStatus; error: Error }> = []
364
+
365
+ // Process claims sequentially to avoid overwhelming the RPC
366
+ for (const payment of unclaimed) {
367
+ try {
368
+ const mint = mintResolver(payment.mint)
369
+ const result = await claimStealthPayment({
370
+ connection,
371
+ stealthAddress: payment.stealthAddress,
372
+ ephemeralPublicKey: payment.ephemeralPublicKey,
373
+ viewingPrivateKey,
374
+ spendingPrivateKey,
375
+ destinationAddress,
376
+ mint,
377
+ })
378
+
379
+ // Update payment status immediately
380
+ setPayments((prev) =>
381
+ prev.map((p) =>
382
+ p.txSignature === payment.txSignature
383
+ ? { ...p, claimed: true, claimResult: result }
384
+ : p
385
+ )
386
+ )
387
+
388
+ succeeded.push(result)
389
+ } catch (err) {
390
+ const error = err instanceof Error ? err : new Error('Claim failed')
391
+ failed.push({ payment, error })
392
+ }
393
+ }
394
+
395
+ // Set final status based on results
396
+ if (failed.length > 0 && succeeded.length === 0) {
397
+ setStatus('error')
398
+ setError(failed[0].error)
399
+ } else if (failed.length > 0) {
400
+ // Partial success - still set success but keep error for reference
401
+ setStatus('success')
402
+ setError(new Error(`${failed.length} of ${unclaimed.length} claims failed`))
403
+ } else {
404
+ setStatus('success')
405
+ }
406
+
407
+ return {
408
+ succeeded,
409
+ failed,
410
+ totalAttempted: unclaimed.length,
411
+ }
412
+ },
413
+ [payments, connection, viewingPrivateKey]
414
+ )
415
+
416
+ /**
417
+ * Start auto-scanning
418
+ */
419
+ const startAutoScan = useCallback(
420
+ (intervalMs?: number) => {
421
+ const interval = intervalMs ?? scanInterval
422
+ if (interval <= 0) return
423
+
424
+ // Stop existing auto-scan
425
+ if (autoScanIntervalRef.current) {
426
+ clearInterval(autoScanIntervalRef.current)
427
+ }
428
+
429
+ isAutoScanningRef.current = true
430
+
431
+ // Initial scan
432
+ scan().catch(console.error)
433
+
434
+ // Set up interval
435
+ autoScanIntervalRef.current = setInterval(() => {
436
+ if (isAutoScanningRef.current && status !== 'scanning') {
437
+ scan().catch(console.error)
438
+ }
439
+ }, interval)
440
+ },
441
+ [scan, scanInterval, status]
442
+ )
443
+
444
+ /**
445
+ * Stop auto-scanning
446
+ */
447
+ const stopAutoScan = useCallback(() => {
448
+ isAutoScanningRef.current = false
449
+ if (autoScanIntervalRef.current) {
450
+ clearInterval(autoScanIntervalRef.current)
451
+ autoScanIntervalRef.current = null
452
+ }
453
+ }, [])
454
+
455
+ /**
456
+ * Reset all state
457
+ */
458
+ const reset = useCallback(() => {
459
+ stopAutoScan()
460
+ setStatus('idle')
461
+ setError(null)
462
+ setPayments([])
463
+ setLastScannedAt(null)
464
+ setLastScannedSlot(initialFromSlot)
465
+ }, [stopAutoScan, initialFromSlot])
466
+
467
+ /**
468
+ * Clear error state
469
+ */
470
+ const clearError = useCallback(() => {
471
+ setError(null)
472
+ if (status === 'error') {
473
+ setStatus('idle')
474
+ }
475
+ }, [status])
476
+
477
+ // Auto-start scanning if interval is provided (runs only on mount/unmount)
478
+ useEffect(() => {
479
+ if (scanInterval > 0) {
480
+ startAutoScan(scanInterval)
481
+ }
482
+
483
+ return () => {
484
+ stopAutoScan()
485
+ }
486
+ }, [])
487
+
488
+ return {
489
+ status,
490
+ isScanning,
491
+ isClaiming,
492
+ error,
493
+ payments,
494
+ totalUnclaimed,
495
+ lastScannedAt,
496
+ scan,
497
+ claim,
498
+ claimAll,
499
+ startAutoScan,
500
+ stopAutoScan,
501
+ reset,
502
+ clearError,
503
+ }
504
+ }
@@ -54,12 +54,15 @@ export function useStealthAddress(chain: ChainId): {
54
54
  metaAddress: string | null
55
55
  stealthAddress: string | null
56
56
  isGenerating: boolean
57
+ error: Error | null
57
58
  regenerate: () => void
58
59
  copyToClipboard: () => Promise<void>
60
+ clearError: () => void
59
61
  } {
60
62
  const [metaAddress, setMetaAddress] = useState<string | null>(null)
61
63
  const [stealthAddress, setStealthAddress] = useState<string | null>(null)
62
64
  const [isGenerating, setIsGenerating] = useState<boolean>(false)
65
+ const [error, setError] = useState<Error | null>(null)
63
66
 
64
67
  // Generate meta-address on mount
65
68
  useEffect(() => {
@@ -90,9 +93,11 @@ export function useStealthAddress(chain: ChainId): {
90
93
 
91
94
  if (cancelled) return
92
95
  setStealthAddress(stealthData.stealthAddress.address)
93
- } catch (error) {
94
- console.error('Failed to generate stealth addresses:', error)
96
+ setError(null) // Clear any previous error on success
97
+ } catch (err) {
95
98
  if (cancelled) return
99
+ const error = err instanceof Error ? err : new Error('Failed to generate stealth addresses')
100
+ setError(error)
96
101
  setMetaAddress(null)
97
102
  setStealthAddress(null)
98
103
  } finally {
@@ -139,8 +144,10 @@ export function useStealthAddress(chain: ChainId): {
139
144
  : generateStealthAddress(metaAddressObj)
140
145
 
141
146
  setStealthAddress(stealthData.stealthAddress.address)
142
- } catch (error) {
143
- console.error('Failed to regenerate stealth address:', error)
147
+ setError(null) // Clear any previous error on success
148
+ } catch (err) {
149
+ const error = err instanceof Error ? err : new Error('Failed to regenerate stealth address')
150
+ setError(error)
144
151
  } finally {
145
152
  setIsGenerating(false)
146
153
  }
@@ -155,8 +162,8 @@ export function useStealthAddress(chain: ChainId): {
155
162
 
156
163
  try {
157
164
  await navigator.clipboard.writeText(stealthAddress)
158
- } catch (error) {
159
- console.error('Failed to copy to clipboard:', error)
165
+ setError(null) // Clear any previous error on success
166
+ } catch (clipboardErr) {
160
167
  // Fallback for older browsers
161
168
  const textArea = document.createElement('textarea')
162
169
  textArea.value = stealthAddress
@@ -166,19 +173,28 @@ export function useStealthAddress(chain: ChainId): {
166
173
  textArea.select()
167
174
  try {
168
175
  document.execCommand('copy')
176
+ setError(null) // Clear any previous error on success
169
177
  } catch (err) {
170
- console.error('Fallback copy failed:', err)
178
+ const error = err instanceof Error ? err : new Error('Failed to copy to clipboard')
179
+ setError(error)
171
180
  } finally {
172
181
  document.body.removeChild(textArea)
173
182
  }
174
183
  }
175
184
  }, [stealthAddress])
176
185
 
186
+ // Clear error state
187
+ const clearError = useCallback(() => {
188
+ setError(null)
189
+ }, [])
190
+
177
191
  return {
178
192
  metaAddress,
179
193
  stealthAddress,
180
194
  isGenerating,
195
+ error,
181
196
  regenerate,
182
197
  copyToClipboard,
198
+ clearError,
183
199
  }
184
200
  }