diff --git a/libs/ui-components/src/components/BridgeStatus/BridgeStatus.headless.tsx b/libs/ui-components/src/components/BridgeStatus/BridgeStatus.headless.tsx new file mode 100644 index 0000000..2be3eef --- /dev/null +++ b/libs/ui-components/src/components/BridgeStatus/BridgeStatus.headless.tsx @@ -0,0 +1,202 @@ +/** + * BridgeStatus Headless Component + * Provides transaction status logic without any styling + * Uses render props pattern for maximum flexibility + */ + +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; +import { useBridgeExecution } from '../../hooks/useBridgeExecution'; +import type { + BridgeStatusHeadlessProps, + BridgeStatusRenderProps, + BridgeTransactionStatus, +} from './types'; + +// Default explorer URL templates by chain +const DEFAULT_EXPLORER_TEMPLATES: Record = { + ethereum: 'https://etherscan.io/tx/{{txHash}}', + polygon: 'https://polygonscan.com/tx/{{txHash}}', + arbitrum: 'https://arbiscan.io/tx/{{txHash}}', + optimism: 'https://optimistic.etherscan.io/tx/{{txHash}}', + base: 'https://basescan.org/tx/{{txHash}}', + stellar: 'https://stellar.expert/explorer/public/tx/{{txHash}}', + solana: 'https://explorer.solana.com/tx/{{txHash}}', +}; + +// Get explorer URL +const getExplorerUrl = (txHash: string, chain: string, template?: string): string | null => { + if (template) { + return template.replace('{{txHash}}', txHash); + } + const defaultTemplate = DEFAULT_EXPLORER_TEMPLATES[chain.toLowerCase()]; + if (defaultTemplate) { + return defaultTemplate.replace('{{txHash}}', txHash); + } + return null; +}; + +// Format time remaining +const formatTimeRemaining = (seconds: number): string => { + if (seconds <= 0) return 'Completing...'; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins > 0) { + return `${mins}m ${secs}s remaining`; + } + return `${secs}s remaining`; +}; + +/** + * BridgeStatusHeadless Component + * Headless component that provides transaction status logic via render props + * + * @example + * ```tsx + * + * {({ state, isPending, isConfirmed, timeRemainingText }) => ( + *
+ * {isPending && Processing... {state.progress}%} + * {isConfirmed && Complete!} + *

{timeRemainingText}

+ *
+ * )} + *
+ * ``` + */ +export const BridgeStatusHeadless: React.FC = ({ + children, + txHash, + bridgeName, + sourceChain, + destinationChain, + amount, + onStatusChange, + onConfirmed, + onFailed, + onRetry, + token, + fee, + slippagePercent, + estimatedTimeSeconds, + slippageWarningThreshold = 1.0, + explorerUrlTemplate, +}) => { + // SSR hydration handling + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + setIsHydrated(true); + }, []); + + // Use the bridge execution hook + const { + status, + progress, + step, + error, + estimatedTimeRemaining, + confirmations, + requiredConfirmations, + isPending, + isConfirmed, + isFailed, + retry, + start, + } = useBridgeExecution({ + estimatedTimeSeconds, + onStatusChange, + onConfirmed, + onFailed, + autoStart: false, + }); + + // Start monitoring when component mounts + useEffect(() => { + if (isHydrated) { + start( + txHash, + bridgeName, + sourceChain, + destinationChain, + amount, + token, + fee, + slippagePercent + ); + } + }, [txHash, bridgeName, sourceChain, destinationChain, amount, token, fee, slippagePercent, isHydrated, start]); + + // Handle retry + const handleRetry = useCallback(() => { + if (onRetry) { + onRetry(); + } else { + retry(); + } + }, [onRetry, retry]); + + // Handle dismiss error + const dismissError = useCallback(() => { + // Error is cleared by the hook when status changes + }, []); + + // Check slippage warning + const showSlippageWarning = + slippagePercent !== undefined && slippagePercent > slippageWarningThreshold; + + // Get explorer URL + const explorerUrl = getExplorerUrl(txHash, sourceChain, explorerUrlTemplate); + + // Build state object + const state = { + status, + progress, + step, + error, + estimatedTimeRemaining, + confirmations, + requiredConfirmations, + showSlippageWarning, + isHydrated, + }; + + // Build props object for render + const renderProps: BridgeStatusRenderProps = { + state, + props: { + txHash, + bridgeName, + sourceChain, + destinationChain, + amount, + token, + fee, + slippagePercent, + onStatusChange, + onConfirmed, + onFailed, + onRetry, + slippageWarningThreshold, + explorerUrlTemplate, + }, + isPending, + isConfirmed, + isFailed, + timeRemainingText: formatTimeRemaining(estimatedTimeRemaining), + explorerUrl, + retry: handleRetry, + dismissError, + }; + + return <>{children(renderProps)}; +}; + +export default BridgeStatusHeadless; diff --git a/libs/ui-components/src/components/BridgeStatus/BridgeStatus.tsx b/libs/ui-components/src/components/BridgeStatus/BridgeStatus.tsx new file mode 100644 index 0000000..fc6f948 --- /dev/null +++ b/libs/ui-components/src/components/BridgeStatus/BridgeStatus.tsx @@ -0,0 +1,650 @@ +/** + * BridgeStatus Component + * Displays real-time transaction status for cross-chain bridge transfers + * Supports both Stellar and EVM transactions with theming and customization + */ + +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; +import { useBridgeExecution } from '../../hooks/useBridgeExecution'; +import type { + BridgeStatusProps, + BridgeStatusState, + BridgeTransactionStatus, + TransactionError, +} from './types'; + +// Default explorer URL templates by chain +const DEFAULT_EXPLORER_TEMPLATES: Record = { + ethereum: 'https://etherscan.io/tx/{{txHash}}', + polygon: 'https://polygonscan.com/tx/{{txHash}}', + arbitrum: 'https://arbiscan.io/tx/{{txHash}}', + optimism: 'https://optimistic.etherscan.io/tx/{{txHash}}', + base: 'https://basescan.org/tx/{{txHash}}', + stellar: 'https://stellar.expert/explorer/public/tx/{{txHash}}', + solana: 'https://explorer.solana.com/tx/{{txHash}}', +}; + +// Status badge colors using CSS variables +const STATUS_COLORS: Record = { + pending: { + bg: 'var(--bw-colors-status-pending-bg, #fef3c7)', + text: 'var(--bw-colors-status-pending-text, #d97706)', + border: 'var(--bw-colors-status-pending-border, #fbbf24)', + }, + confirmed: { + bg: 'var(--bw-colors-status-success-bg, #d1fae5)', + text: 'var(--bw-colors-status-success-text, #059669)', + border: 'var(--bw-colors-status-success-border, #34d399)', + }, + failed: { + bg: 'var(--bw-colors-status-error-bg, #fee2e2)', + text: 'var(--bw-colors-status-error-text, #dc2626)', + border: 'var(--bw-colors-status-error-border, #f87171)', + }, +}; + +// Status icons +const StatusIcon: React.FC<{ status: BridgeTransactionStatus; size?: number }> = ({ + status, + size = 20, +}) => { + const iconStyle = { width: size, height: size }; + + switch (status) { + case 'pending': + return ( + + + + + + ); + case 'confirmed': + return ( + + + + + ); + case 'failed': + return ( + + + + + ); + default: + return null; + } +}; + +// Format time remaining +const formatTimeRemaining = (seconds: number): string => { + if (seconds <= 0) return 'Completing...'; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins > 0) { + return `${mins}m ${secs}s remaining`; + } + return `${secs}s remaining`; +}; + +// Format amount with token +const formatAmount = (amount: number, token?: string): string => { + const formatted = amount.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 6, + }); + return token ? `${formatted} ${token}` : formatted; +}; + +// Format fee +const formatFee = (fee?: number): string => { + if (fee === undefined || fee === null) return '--'; + return `${fee.toFixed(4)}%`; +}; + +// Format slippage +const formatSlippage = (slippage?: number): string => { + if (slippage === undefined || slippage === null) return '--'; + return `${slippage.toFixed(2)}%`; +}; + +// Get explorer URL +const getExplorerUrl = (txHash: string, chain: string, template?: string): string | null => { + if (template) { + return template.replace('{{txHash}}', txHash); + } + const defaultTemplate = DEFAULT_EXPLORER_TEMPLATES[chain.toLowerCase()]; + if (defaultTemplate) { + return defaultTemplate.replace('{{txHash}}', txHash); + } + return null; +}; + +/** + * BridgeStatus Component + * + * @example + * ```tsx + * console.log('Status:', status)} + * /> + * ``` + */ +export const BridgeStatus: React.FC = ({ + txHash, + bridgeName, + sourceChain, + destinationChain, + amount, + token, + destinationToken, + fee, + slippagePercent, + estimatedTimeSeconds, + onStatusChange, + onConfirmed, + onFailed, + onRetry, + className = '', + style = {}, + detailed = false, + compact = false, + slippageWarningThreshold = 1.0, + explorerUrlTemplate, + disabled = false, + 'data-testid': dataTestId, +}) => { + // SSR hydration handling + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + setIsHydrated(true); + }, []); + + // Use the bridge execution hook + const { + status, + progress, + step, + error, + estimatedTimeRemaining, + confirmations, + requiredConfirmations, + isPending, + isConfirmed, + isFailed, + retry, + start, + details, + } = useBridgeExecution({ + estimatedTimeSeconds, + onStatusChange, + onConfirmed, + onFailed, + autoStart: false, + }); + + // Start monitoring when component mounts + useEffect(() => { + if (!disabled && isHydrated) { + start( + txHash, + bridgeName, + sourceChain, + destinationChain, + amount ?? 0, + token, + fee, + slippagePercent + ); + } + }, [txHash, bridgeName, sourceChain, destinationChain, amount, token, fee, slippagePercent, disabled, isHydrated, start]); + + // Handle retry + const handleRetry = useCallback(() => { + if (onRetry) { + onRetry(); + } else { + retry(); + } + }, [onRetry, retry]); + + // Check slippage warning + const showSlippageWarning = + slippagePercent !== undefined && slippagePercent > slippageWarningThreshold; + + // Get explorer URL + const explorerUrl = getExplorerUrl(txHash, sourceChain, explorerUrlTemplate); + + // Status colors + const statusColors = STATUS_COLORS[status]; + + // Compact view + if (compact) { + return ( +
+ + {status} +
+ ); + } + + // Full view + return ( +
+ {/* Header with status badge */} +
+
+ + + {isPending && 'Processing Transaction'} + {isConfirmed && 'Transaction Complete'} + {isFailed && 'Transaction Failed'} + +
+ + {status} + +
+ + {/* Bridge info */} +
+
+ + {sourceChain} + + + + + + {destinationChain} + +
+
+ via {bridgeName.charAt(0).toUpperCase() + bridgeName.slice(1)} Bridge +
+
+ + {/* Amount */} +
+ + {formatAmount(amount, token)} + + {destinationToken && destinationToken !== token && ( +
+ Receiving {destinationToken} +
+ )} +
+ + {/* Progress bar (only for pending) */} + {isPending && ( +
+
+
+
+
+ {step} + {progress}% +
+
+ )} + + {/* Confirmations (for EVM chains) */} + {isPending && requiredConfirmations > 1 && ( +
+ Confirmations: {confirmations} / {requiredConfirmations} +
+ )} + + {/* Estimated time */} + {isPending && ( +
+ + + + + {formatTimeRemaining(estimatedTimeRemaining)} +
+ )} + + {/* Fee and slippage summary (detailed view) */} + {detailed && ( +
+
+
+ Fee +
+
+ {formatFee(fee)} +
+
+
+
+ Slippage +
+
+ {formatSlippage(slippagePercent)} +
+
+
+ )} + + {/* Slippage warning */} + {showSlippageWarning && ( +
+ ⚠️ High Slippage Warning +
+ Current slippage ({formatSlippage(slippagePercent)}) exceeds recommended threshold ( + {slippageWarningThreshold}%). Consider increasing slippage tolerance or using a different route. +
+ )} + + {/* Error display */} + {isFailed && error && ( +
+
+ Error: {error.message} +
+ {error.suggestedAction && ( +
+ Suggested action: {error.suggestedAction.replace('_', ' ')} +
+ )} + {error.recoverable && ( + + )} +
+ )} + + {/* Explorer link */} + {explorerUrl && ( + + )} + + {/* Transaction hash (truncated) */} +
+ {txHash.slice(0, 10)}...{txHash.slice(-8)} +
+
+ ); +}; + +export default BridgeStatus; diff --git a/libs/ui-components/src/components/BridgeStatus/__tests__/types.spec.ts b/libs/ui-components/src/components/BridgeStatus/__tests__/types.spec.ts new file mode 100644 index 0000000..e9bc694 --- /dev/null +++ b/libs/ui-components/src/components/BridgeStatus/__tests__/types.spec.ts @@ -0,0 +1,149 @@ +/** + * BridgeStatus Type Validation Tests + * These tests validate TypeScript types compile correctly + */ + +import type { + BridgeStatusProps, + BridgeStatusState, + BridgeTransactionStatus, + TransactionStatusDetails, + TransactionError, + BridgeStatusHeadlessProps, + BridgeStatusRenderProps, +} from '../types'; +import type { UseBridgeExecutionOptions, UseBridgeExecutionReturn } from '../../../hooks/useBridgeExecution'; + +// Type assertion helper +function assertType(_value: T) {} + +// Validate BridgeStatusProps +const validProps: BridgeStatusProps = { + txHash: '0x1234567890abcdef', + bridgeName: 'hop', + sourceChain: 'ethereum', + destinationChain: 'polygon', + amount: 1000, + token: 'USDC', + fee: 0.5, + slippagePercent: 0.25, + estimatedTimeSeconds: 180, + detailed: true, + compact: false, + slippageWarningThreshold: 1.0, + disabled: false, +}; + +assertType(validProps); + +// Validate status types +const pendingStatus: BridgeTransactionStatus = 'pending'; +const confirmedStatus: BridgeTransactionStatus = 'confirmed'; +const failedStatus: BridgeTransactionStatus = 'failed'; + +assertType(pendingStatus); +assertType(confirmedStatus); +assertType(failedStatus); + +// Validate TransactionStatusDetails +const statusDetails: TransactionStatusDetails = { + txHash: '0x123', + status: 'pending', + bridgeName: 'hop', + sourceChain: 'ethereum', + destinationChain: 'polygon', + amount: 1000, + progress: 50, + confirmations: 5, + requiredConfirmations: 12, + timestamp: Date.now(), +}; + +assertType(statusDetails); + +// Validate TransactionError +const error: TransactionError = { + code: 'TRANSACTION_FAILED', + message: 'Transaction failed', + recoverable: true, + suggestedAction: 'retry', +}; + +assertType(error); + +// Validate BridgeStatusState +const state: BridgeStatusState = { + status: 'pending', + progress: 50, + step: 'Processing...', + error: null, + estimatedTimeRemaining: 120, + confirmations: 5, + requiredConfirmations: 12, + showSlippageWarning: false, + isHydrated: true, +}; + +assertType(state); + +// Validate UseBridgeExecutionOptions +const executionOptions: UseBridgeExecutionOptions = { + pollIntervalMs: 5000, + maxPollDurationMs: 600000, + requiredConfirmations: 12, + estimatedTimeSeconds: 180, + autoStart: false, +}; + +assertType(executionOptions); + +// Validate UseBridgeExecutionReturn +const executionReturn: UseBridgeExecutionReturn = { + status: 'pending', + progress: 50, + step: 'Processing', + error: null, + estimatedTimeRemaining: 120, + confirmations: 5, + requiredConfirmations: 12, + isPolling: true, + start: () => {}, + stop: () => {}, + retry: () => {}, + details: null, + isPending: true, + isConfirmed: false, + isFailed: false, +}; + +assertType(executionReturn); + +// Validate BridgeStatusHeadlessProps +const headlessProps: BridgeStatusHeadlessProps = { + children: () => null, + txHash: '0x123', + bridgeName: 'hop', + sourceChain: 'ethereum', + destinationChain: 'polygon', + amount: 1000, +}; + +assertType(headlessProps); + +// Validate BridgeStatusRenderProps +const renderProps: BridgeStatusRenderProps = { + state, + props: validProps, + isPending: true, + isConfirmed: false, + isFailed: false, + timeRemainingText: '2m remaining', + explorerUrl: 'https://etherscan.io/tx/0x123', + retry: () => {}, + dismissError: () => {}, +}; + +assertType(renderProps); + +// Export to make this a module +export {}; diff --git a/libs/ui-components/src/components/BridgeStatus/index.ts b/libs/ui-components/src/components/BridgeStatus/index.ts new file mode 100644 index 0000000..510f502 --- /dev/null +++ b/libs/ui-components/src/components/BridgeStatus/index.ts @@ -0,0 +1,22 @@ +/** + * BridgeStatus Component Exports + */ + +export { BridgeStatus } from './BridgeStatus'; +export { BridgeStatusHeadless } from './BridgeStatus.headless'; +export type { + BridgeStatusProps, + BridgeStatusState, + BridgeStatusHeadlessProps, + BridgeStatusRenderProps, + BridgeTransactionStatus, + TransactionStatusDetails, + TransactionError, + StatusBadgeProps, + ProgressIndicatorProps, + FeeSummaryProps, + EstimatedTimeProps, + ErrorDisplayProps, + ChainId, + BridgeProvider, +} from './types'; diff --git a/libs/ui-components/src/components/BridgeStatus/types.ts b/libs/ui-components/src/components/BridgeStatus/types.ts new file mode 100644 index 0000000..2b8ce9f --- /dev/null +++ b/libs/ui-components/src/components/BridgeStatus/types.ts @@ -0,0 +1,221 @@ +/** + * BridgeStatus Component Types + * Type definitions for the BridgeStatus transaction tracking component + */ + +import type { ReactNode } from 'react'; + +/** + * Transaction status states + */ +export type BridgeTransactionStatus = 'pending' | 'confirmed' | 'failed'; + +/** + * Chain identifier type + */ +export type ChainId = string; + +/** + * Bridge provider name type + */ +export type BridgeProvider = string; + +/** + * Props for the BridgeStatus component + */ +export interface BridgeStatusProps { + /** Transaction hash for tracking */ + txHash: string; + /** Name of the bridge provider (e.g., 'hop', 'layerzero', 'stellar') */ + bridgeName: BridgeProvider; + /** Source chain identifier */ + sourceChain: ChainId; + /** Destination chain identifier */ + destinationChain: ChainId; + /** Amount being transferred */ + amount: number; + /** Token symbol being transferred */ + token?: string; + /** Optional token symbol for destination (for swaps) */ + destinationToken?: string; + /** Optional transaction fee */ + fee?: number; + /** Optional slippage percentage */ + slippagePercent?: number; + /** Estimated completion time in seconds */ + estimatedTimeSeconds?: number; + /** Callback fired when status changes */ + onStatusChange?: (status: BridgeTransactionStatus, details?: TransactionStatusDetails) => void; + /** Callback fired when transaction is confirmed */ + onConfirmed?: (details: TransactionStatusDetails) => void; + /** Callback fired when transaction fails */ + onFailed?: (error: TransactionError) => void; + /** Callback for retry action */ + onRetry?: () => void; + /** Custom class name for styling overrides */ + className?: string; + /** Custom inline styles */ + style?: React.CSSProperties; + /** Whether to show detailed view with fees and slippage */ + detailed?: boolean; + /** Whether to show compact/minimal view */ + compact?: boolean; + /** Slippage threshold for warnings (default: 1.0 = 1%) */ + slippageWarningThreshold?: number; + /** Custom explorer URL template (use {{txHash}} placeholder) */ + explorerUrlTemplate?: string; + /** Whether component is disabled */ + disabled?: boolean; + /** Test ID for testing */ + 'data-testid'?: string; +} + +/** + * Transaction status details passed to callbacks + */ +export interface TransactionStatusDetails { + txHash: string; + status: BridgeTransactionStatus; + bridgeName: BridgeProvider; + sourceChain: ChainId; + destinationChain: ChainId; + amount: number; + token?: string; + fee?: number; + slippagePercent?: number; + progress: number; + estimatedTimeRemaining?: number; + confirmations?: number; + requiredConfirmations?: number; + timestamp: number; +} + +/** + * Transaction error details + */ +export interface TransactionError { + code: string; + message: string; + txHash?: string; + recoverable: boolean; + suggestedAction?: 'retry' | 'increase_slippage' | 'change_bridge' | 'contact_support'; +} + +/** + * Internal state for the BridgeStatus component + */ +export interface BridgeStatusState { + status: BridgeTransactionStatus; + progress: number; + step: string; + error: TransactionError | null; + estimatedTimeRemaining: number; + confirmations: number; + requiredConfirmations: number; + showSlippageWarning: boolean; + isHydrated: boolean; +} + +/** + * Props for status badge component + */ +export interface StatusBadgeProps { + status: BridgeTransactionStatus; + className?: string; + style?: React.CSSProperties; +} + +/** + * Props for progress indicator component + */ +export interface ProgressIndicatorProps { + progress: number; + status: BridgeTransactionStatus; + className?: string; + style?: React.CSSProperties; +} + +/** + * Props for fee summary component + */ +export interface FeeSummaryProps { + fee?: number; + slippagePercent?: number; + amount: number; + showWarning?: boolean; + warningThreshold?: number; + className?: string; + style?: React.CSSProperties; +} + +/** + * Props for estimated time display + */ +export interface EstimatedTimeProps { + secondsRemaining: number; + className?: string; + style?: React.CSSProperties; +} + +/** + * Props for error display component + */ +export interface ErrorDisplayProps { + error: TransactionError; + onRetry?: () => void; + className?: string; + style?: React.CSSProperties; +} + +/** + * Render props for headless BridgeStatus component + */ +export interface BridgeStatusRenderProps { + /** Current transaction state */ + state: BridgeStatusState; + /** Original props passed to component */ + props: BridgeStatusProps; + /** Whether transaction is pending */ + isPending: boolean; + /** Whether transaction is confirmed */ + isConfirmed: boolean; + /** Whether transaction failed */ + isFailed: boolean; + /** Formatted time remaining string */ + timeRemainingText: string; + /** Explorer URL for the transaction */ + explorerUrl: string | null; + /** Function to retry the transaction */ + retry: () => void; + /** Function to dismiss error */ + dismissError: () => void; +} + +/** + * Props for headless BridgeStatus component + */ +export interface BridgeStatusHeadlessProps { + /** Render function for custom UI */ + children: (props: BridgeStatusRenderProps) => ReactNode; + /** Transaction hash */ + txHash: string; + /** Bridge provider name */ + bridgeName: BridgeProvider; + /** Source chain */ + sourceChain: ChainId; + /** Destination chain */ + destinationChain: ChainId; + /** Amount being transferred */ + amount: number; + /** Optional callbacks and configuration */ + onStatusChange?: (status: BridgeTransactionStatus, details?: TransactionStatusDetails) => void; + onConfirmed?: (details: TransactionStatusDetails) => void; + onFailed?: (error: TransactionError) => void; + onRetry?: () => void; + token?: string; + fee?: number; + slippagePercent?: number; + estimatedTimeSeconds?: number; + slippageWarningThreshold?: number; + explorerUrlTemplate?: string; +} diff --git a/libs/ui-components/src/hooks/useBridgeExecution.ts b/libs/ui-components/src/hooks/useBridgeExecution.ts new file mode 100644 index 0000000..b9960e0 --- /dev/null +++ b/libs/ui-components/src/hooks/useBridgeExecution.ts @@ -0,0 +1,465 @@ +/** + * useBridgeExecution Hook + * Manages cross-chain bridge transaction execution with status tracking + * Supports both Stellar and EVM transactions + */ + +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { + BridgeTransactionStatus, + TransactionStatusDetails, + TransactionError, + BridgeProvider, + ChainId, +} from '../components/BridgeStatus/types'; + +/** + * Configuration options for useBridgeExecution + */ +export interface UseBridgeExecutionOptions { + /** Polling interval in milliseconds */ + pollIntervalMs?: number; + /** Maximum polling duration in milliseconds */ + maxPollDurationMs?: number; + /** Number of confirmations required for EVM chains */ + requiredConfirmations?: number; + /** Estimated time for transaction completion in seconds */ + estimatedTimeSeconds?: number; + /** Callback when status changes */ + onStatusChange?: (status: BridgeTransactionStatus, details?: TransactionStatusDetails) => void; + /** Callback when transaction is confirmed */ + onConfirmed?: (details: TransactionStatusDetails) => void; + /** Callback when transaction fails */ + onFailed?: (error: TransactionError) => void; + /** Whether to auto-start polling on mount */ + autoStart?: boolean; +} + +/** + * Return type for useBridgeExecution hook + */ +export interface UseBridgeExecutionReturn { + /** Current transaction status */ + status: BridgeTransactionStatus; + /** Progress percentage (0-100) */ + progress: number; + /** Current execution step description */ + step: string; + /** Error information if failed */ + error: TransactionError | null; + /** Estimated time remaining in seconds */ + estimatedTimeRemaining: number; + /** Number of confirmations received */ + confirmations: number; + /** Required confirmations for completion */ + requiredConfirmations: number; + /** Whether currently polling for status */ + isPolling: boolean; + /** Start transaction monitoring */ + start: ( + txHash: string, + provider: BridgeProvider, + sourceChain: ChainId, + destinationChain: ChainId, + amount?: number, + token?: string, + fee?: number, + slippagePercent?: number + ) => void; + /** Stop transaction monitoring */ + stop: () => void; + /** Retry failed transaction */ + retry: () => void; + /** Current transaction details */ + details: TransactionStatusDetails | null; + /** Whether transaction is pending */ + isPending: boolean; + /** Whether transaction is confirmed */ + isConfirmed: boolean; + /** Whether transaction failed */ + isFailed: boolean; +} + +// Default configuration +const DEFAULT_POLL_INTERVAL_MS = 3000; +const DEFAULT_MAX_POLL_DURATION_MS = 10 * 60 * 1000; // 10 minutes +const DEFAULT_REQUIRED_CONFIRMATIONS = 12; +const DEFAULT_ESTIMATED_TIME_SECONDS = 180; // 3 minutes + +// Chain-specific confirmation requirements +const CHAIN_CONFIRMATIONS: Record = { + ethereum: 12, + polygon: 20, + arbitrum: 10, + optimism: 10, + base: 10, + stellar: 1, // Stellar has instant finality + solana: 32, +}; + +// Chain-specific estimated times (in seconds) +const CHAIN_ESTIMATED_TIMES: Record = { + ethereum: 180, + polygon: 120, + arbitrum: 60, + optimism: 60, + base: 60, + stellar: 30, + solana: 30, +}; + +/** + * Mock function to simulate transaction status checking + * In production, this would call actual bridge APIs or RPC endpoints + */ +const checkTransactionStatus = async ( + txHash: string, + provider: BridgeProvider, + sourceChain: ChainId, + destinationChain: ChainId, +): Promise<{ + status: BridgeTransactionStatus; + progress: number; + confirmations: number; + step: string; +}> => { + // Simulate API call delay + await new Promise((resolve) => setTimeout(resolve, 500)); + + // This is a mock implementation + // In production, this would: + // 1. Call the bridge provider's API + // 2. Check source chain RPC for confirmations + // 3. Check destination chain for completion + // 4. Return actual status + + const random = Math.random(); + + // Simulate progressive status + if (random < 0.1) { + return { + status: 'pending', + progress: 10, + confirmations: 0, + step: 'Submitting to source chain...', + }; + } else if (random < 0.3) { + return { + status: 'pending', + progress: 30, + confirmations: 2, + step: 'Waiting for source confirmations...', + }; + } else if (random < 0.5) { + return { + status: 'pending', + progress: 50, + confirmations: 6, + step: 'Relaying to destination chain...', + }; + } else if (random < 0.7) { + return { + status: 'pending', + progress: 75, + confirmations: 10, + step: 'Finalizing on destination...', + }; + } else if (random < 0.9) { + return { + status: 'confirmed', + progress: 100, + confirmations: CHAIN_CONFIRMATIONS[destinationChain.toLowerCase()] || DEFAULT_REQUIRED_CONFIRMATIONS, + step: 'Transaction complete', + }; + } else { + // Simulate occasional failures + return { + status: 'failed', + progress: 0, + confirmations: 0, + step: 'Transaction failed', + }; + } +}; + +/** + * Hook for managing bridge transaction execution + * + * @example + * ```tsx + * const { + * status, + * progress, + * isPending, + * start, + * retry + * } = useBridgeExecution({ + * onStatusChange: (status) => console.log('Status:', status), + * onConfirmed: (details) => console.log('Confirmed:', details), + * }); + * + * // Start monitoring a transaction + * start('0x123...', 'hop', 'ethereum', 'polygon'); + * ``` + */ +export function useBridgeExecution( + options: UseBridgeExecutionOptions = {} +): UseBridgeExecutionReturn { + const { + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + maxPollDurationMs = DEFAULT_MAX_POLL_DURATION_MS, + requiredConfirmations: userConfirmations, + estimatedTimeSeconds: userEstimatedTime, + onStatusChange, + onConfirmed, + onFailed, + autoStart = false, + } = options; + + // State + const [status, setStatus] = useState('pending'); + const [progress, setProgress] = useState(0); + const [step, setStep] = useState('Initializing...'); + const [error, setError] = useState(null); + const [estimatedTimeRemaining, setEstimatedTimeRemaining] = useState( + userEstimatedTime || DEFAULT_ESTIMATED_TIME_SECONDS + ); + const [confirmations, setConfirmations] = useState(0); + const [isPolling, setIsPolling] = useState(false); + const [details, setDetails] = useState(null); + + // Refs for managing polling + const pollIntervalRef = useRef | null>(null); + const pollStartTimeRef = useRef(0); + const txInfoRef = useRef<{ + txHash: string; + provider: BridgeProvider; + sourceChain: ChainId; + destinationChain: ChainId; + amount: number; + token?: string; + fee?: number; + slippagePercent?: number; + } | null>(null); + + const requiredConfirmations = + userConfirmations || + (txInfoRef.current?.destinationChain + ? CHAIN_CONFIRMATIONS[txInfoRef.current.destinationChain.toLowerCase()] || DEFAULT_REQUIRED_CONFIRMATIONS + : DEFAULT_REQUIRED_CONFIRMATIONS); + + // Computed states + const isPending = status === 'pending'; + const isConfirmed = status === 'confirmed'; + const isFailed = status === 'failed'; + + // Clear polling interval + const clearPolling = useCallback(() => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + setIsPolling(false); + }, []); + + // Create transaction details object + const createDetails = useCallback((): TransactionStatusDetails | null => { + if (!txInfoRef.current) return null; + const { txHash, provider, sourceChain, destinationChain, amount, token, fee, slippagePercent } = + txInfoRef.current; + return { + txHash, + status, + bridgeName: provider, + sourceChain, + destinationChain, + amount, + token, + fee, + slippagePercent, + progress, + estimatedTimeRemaining, + confirmations, + requiredConfirmations, + timestamp: Date.now(), + }; + }, [status, progress, estimatedTimeRemaining, confirmations, requiredConfirmations]); + + // Update status with callbacks + const updateStatus = useCallback( + (newStatus: BridgeTransactionStatus, newDetails?: TransactionStatusDetails) => { + setStatus(newStatus); + const detailsToSend = newDetails || createDetails(); + + if (detailsToSend) { + setDetails(detailsToSend); + onStatusChange?.(newStatus, detailsToSend); + + if (newStatus === 'confirmed') { + onConfirmed?.(detailsToSend); + } else if (newStatus === 'failed') { + const txError: TransactionError = { + code: 'TRANSACTION_FAILED', + message: 'Transaction failed during execution', + txHash: detailsToSend.txHash, + recoverable: true, + suggestedAction: 'retry', + }; + setError(txError); + onFailed?.(txError); + } + } + }, + [createDetails, onStatusChange, onConfirmed, onFailed] + ); + + // Poll for transaction status + const pollStatus = useCallback(async () => { + if (!txInfoRef.current) return; + + const { txHash, provider, sourceChain, destinationChain } = txInfoRef.current; + + // Check for timeout + if (Date.now() - pollStartTimeRef.current > maxPollDurationMs) { + clearPolling(); + const timeoutError: TransactionError = { + code: 'POLLING_TIMEOUT', + message: 'Transaction monitoring timed out. Please check explorer for status.', + txHash, + recoverable: false, + suggestedAction: 'contact_support', + }; + setError(timeoutError); + updateStatus('failed', createDetails() || undefined); + onFailed?.(timeoutError); + return; + } + + try { + const result = await checkTransactionStatus(txHash, provider, sourceChain, destinationChain); + + setProgress(result.progress); + setStep(result.step); + setConfirmations(result.confirmations); + + // Update estimated time remaining + setEstimatedTimeRemaining((prev) => { + if (result.status === 'confirmed') return 0; + const elapsed = (Date.now() - pollStartTimeRef.current) / 1000; + const total = userEstimatedTime || CHAIN_ESTIMATED_TIMES[destinationChain.toLowerCase()] || DEFAULT_ESTIMATED_TIME_SECONDS; + return Math.max(0, Math.round(total - elapsed)); + }); + + if (result.status !== status) { + updateStatus(result.status, createDetails() || undefined); + + if (result.status === 'confirmed' || result.status === 'failed') { + clearPolling(); + } + } + } catch (err) { + console.error('Error polling transaction status:', err); + // Don't stop polling on error, just retry next interval + } + }, [ + status, + maxPollDurationMs, + userEstimatedTime, + clearPolling, + updateStatus, + createDetails, + onFailed, + ]); + + // Start monitoring a transaction + const start = useCallback( + ( + txHash: string, + provider: BridgeProvider, + sourceChain: ChainId, + destinationChain: ChainId, + amount: number = 0, + token?: string, + fee?: number, + slippagePercent?: number + ) => { + // Reset state + setStatus('pending'); + setProgress(0); + setStep('Initializing...'); + setError(null); + setConfirmations(0); + setEstimatedTimeRemaining( + userEstimatedTime || CHAIN_ESTIMATED_TIMES[destinationChain.toLowerCase()] || DEFAULT_ESTIMATED_TIME_SECONDS + ); + + // Store transaction info + txInfoRef.current = { + txHash, + provider, + sourceChain, + destinationChain, + amount, + token, + fee, + slippagePercent, + }; + + // Start polling + pollStartTimeRef.current = Date.now(); + setIsPolling(true); + + // Initial status check + void pollStatus(); + + // Set up polling interval + pollIntervalRef.current = setInterval(() => { + void pollStatus(); + }, pollIntervalMs); + }, + [pollIntervalMs, userEstimatedTime, pollStatus] + ); + + // Stop monitoring + const stop = useCallback(() => { + clearPolling(); + }, [clearPolling]); + + // Retry failed transaction + const retry = useCallback(() => { + if (txInfoRef.current) { + const { txHash, provider, sourceChain, destinationChain, amount, token, fee, slippagePercent } = + txInfoRef.current; + start(txHash, provider, sourceChain, destinationChain, amount, token, fee, slippagePercent); + } + }, [start]); + + // Cleanup on unmount + useEffect(() => { + return () => { + clearPolling(); + }; + }, [clearPolling]); + + return { + status, + progress, + step, + error, + estimatedTimeRemaining, + confirmations, + requiredConfirmations, + isPolling, + start, + stop, + retry, + details, + isPending, + isConfirmed, + isFailed, + }; +} + +export default useBridgeExecution; diff --git a/libs/ui-components/src/hooks/useFeeSlippageBenchmark.ts b/libs/ui-components/src/hooks/useFeeSlippageBenchmark.ts index 92ecb88..579ad07 100644 --- a/libs/ui-components/src/hooks/useFeeSlippageBenchmark.ts +++ b/libs/ui-components/src/hooks/useFeeSlippageBenchmark.ts @@ -1,3 +1,5 @@ +'use client' + import { useState, useEffect, useCallback } from 'react'; // Define the types locally to avoid import issues diff --git a/libs/ui-components/src/index.ts b/libs/ui-components/src/index.ts index 7c84a8e..2544251 100644 --- a/libs/ui-components/src/index.ts +++ b/libs/ui-components/src/index.ts @@ -33,21 +33,39 @@ export type { // Components export { TransactionHeartbeat, - BridgeStatus, + BridgeStatus as BridgeStatusLegacy, TransactionProvider, useTransaction, } from './components/TransactionHeartbeat'; export { BridgeHistory } from './components/BridgeHistory'; export { BridgeCompare } from './components/BridgeCompare'; +export { + BridgeStatus, + BridgeStatusHeadless, +} from './components/BridgeStatus'; export type { TransactionState } from './components/TransactionHeartbeat'; export type { BridgeHistoryProps } from './components/BridgeHistory'; +export type { + BridgeStatusProps, + BridgeStatusState, + BridgeStatusHeadlessProps, + BridgeStatusRenderProps, + BridgeTransactionStatus as BridgeStatusTransactionStatus, + TransactionStatusDetails, + TransactionError, +} from './components/BridgeStatus'; // Hooks export { useFeeSlippageBenchmark } from './hooks/useFeeSlippageBenchmark'; export { useTransactionHistory } from './hooks/useTransactionHistory'; export { useBridgeLiquidity } from './hooks/useBridgeLiquidity'; +export { useBridgeExecution } from './hooks/useBridgeExecution'; export type { FeeSlippageBenchmarkHookProps, FeeSlippageBenchmarkHookReturn } from './hooks/useFeeSlippageBenchmark'; +export type { + UseBridgeExecutionOptions, + UseBridgeExecutionReturn, +} from './hooks/useBridgeExecution'; // Transaction history export { createHttpTransactionHistoryBackend } from './transaction-history/storage'; @@ -70,3 +88,31 @@ export type { BridgeLiquidityMonitorConfig, } from './liquidity/types'; export type { UseBridgeLiquidityOptions, UseBridgeLiquidityResult } from './hooks/useBridgeLiquidity'; + +// Wallet Integration +export { + MetaMaskAdapter, + WalletConnectAdapter, + StellarAdapter, + useWallet, + WalletProvider, + useWalletContext, +} from './wallet'; + +export type { + WalletAdapter, + WalletAccount, + TokenBalance, + WalletError, + WalletErrorCode, + WalletEvent, + WalletState, + ChainId, + NetworkType, + WalletType, + WalletTransaction, + UseWalletReturn, + UseWalletOptions, + WalletProviderProps, + WalletContextValue, +} from './wallet'; diff --git a/libs/ui-components/src/wallet/WalletProvider.tsx b/libs/ui-components/src/wallet/WalletProvider.tsx new file mode 100644 index 0000000..b8df2d9 --- /dev/null +++ b/libs/ui-components/src/wallet/WalletProvider.tsx @@ -0,0 +1,81 @@ +/** + * Wallet Provider Component + * React context provider for wallet integration + */ + +'use client'; + +import React, { createContext, useContext, useMemo } from 'react'; +import { useWallet } from './useWallet'; +import type { + WalletProviderProps, + WalletContextValue, + WalletAdapter, +} from './types'; + +// Create context +const WalletContext = createContext(null); + +/** + * WalletProvider component + * Provides wallet state and functions to child components + * + * @example + * ```tsx + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + */ +export const WalletProvider: React.FC = ({ + children, + adapters, + autoConnect = false, + onConnect, + onDisconnect, + onError, +}) => { + const walletState = useWallet({ + adapters, + autoConnect, + onConnect, + onDisconnect, + onError, + }); + + const value = useMemo( + () => ({ + ...walletState, + }), + [walletState] + ); + + return ( + + {children} + + ); +}; + +/** + * Hook to access wallet context + * Must be used within a WalletProvider + * + * @example + * ```tsx + * const { connect, disconnect, account, connected } = useWalletContext(); + * ``` + */ +export const useWalletContext = (): WalletContextValue => { + const context = useContext(WalletContext); + if (!context) { + throw new Error('useWalletContext must be used within a WalletProvider'); + } + return context; +}; + +export default WalletProvider; diff --git a/libs/ui-components/src/wallet/__tests__/types.spec.ts b/libs/ui-components/src/wallet/__tests__/types.spec.ts new file mode 100644 index 0000000..680a078 --- /dev/null +++ b/libs/ui-components/src/wallet/__tests__/types.spec.ts @@ -0,0 +1,193 @@ +/** + * Wallet Adapter Type Tests + * Type validation for wallet integration layer + */ + +import type { + WalletAdapter, + WalletAccount, + TokenBalance, + WalletError, + WalletErrorCode, + WalletEvent, + WalletState, + ChainId, + NetworkType, + WalletType, + WalletTransaction, + UseWalletReturn, + UseWalletOptions, +} from '../types'; + +// Type assertion helper +function assertType(_value: T) {} + +// Validate WalletErrorCode +const errorCodes: WalletErrorCode[] = [ + 'WALLET_NOT_FOUND', + 'CONNECTION_REJECTED', + 'CONNECTION_FAILED', + 'DISCONNECT_FAILED', + 'NETWORK_SWITCH_REJECTED', + 'NETWORK_NOT_SUPPORTED', + 'BALANCE_FETCH_FAILED', + 'ACCOUNT_NOT_FOUND', + 'ALREADY_CONNECTED', + 'NOT_CONNECTED', + 'USER_REJECTED', + 'TIMEOUT', + 'UNKNOWN_ERROR', +]; + +assertType(errorCodes); + +// Validate WalletError +const walletError: WalletError = { + code: 'CONNECTION_FAILED', + message: 'Failed to connect', + originalError: new Error('Original'), +}; + +assertType(walletError); + +// Validate WalletEvent +const events: WalletEvent[] = [ + 'connect', + 'disconnect', + 'accountsChanged', + 'chainChanged', + 'networkChanged', + 'error', +]; + +assertType(events); + +// Validate TokenBalance +const tokenBalance: TokenBalance = { + token: 'native', + symbol: 'ETH', + decimals: 18, + balance: '1000000000000000000', + balanceFormatted: '1.0 ETH', + usdValue: 2000, +}; + +assertType(tokenBalance); + +// Validate WalletAccount +const walletAccount: WalletAccount = { + address: '0x1234567890abcdef', + publicKey: '0xpubkey', + chainId: 'eip155:1', + network: 'evm', +}; + +assertType(walletAccount); + +// Validate WalletState +const walletState: WalletState = { + connected: true, + connecting: false, + disconnecting: false, + account: walletAccount, + balances: [tokenBalance], + chainId: 'eip155:1', + network: 'evm', + error: null, +}; + +assertType(walletState); + +// Validate ChainId +const chainIds: ChainId[] = [ + 'eip155:1', + 'eip155:137', + 'stellar:public', + 'stellar:testnet', +]; + +assertType(chainIds); + +// Validate NetworkType +const networkTypes: NetworkType[] = ['evm', 'stellar']; +assertType(networkTypes); + +// Validate WalletType +const walletTypes: WalletType[] = ['metamask', 'walletconnect', 'stellar', 'custom']; +assertType(walletTypes); + +// Validate WalletTransaction +const transaction: WalletTransaction = { + to: '0xabcdef123456', + from: '0x123456abcdef', + value: '0x0', + data: '0x', + gasLimit: '0x5208', + gasPrice: '0x1', + nonce: 0, + chainId: 'eip155:1', +}; + +assertType(transaction); + +// Validate UseWalletOptions +const walletOptions: UseWalletOptions = { + autoConnect: false, + onConnect: (account) => console.log(account), + onDisconnect: () => console.log('Disconnected'), + onAccountChange: (account) => console.log(account), + onNetworkChange: (chainId, network) => console.log(chainId, network), + onError: (error) => console.error(error), +}; + +assertType(walletOptions); + +// Validate UseWalletReturn +const walletReturn: UseWalletReturn = { + state: walletState, + connected: true, + connecting: false, + account: '0x123', + chainId: 'eip155:1', + network: 'evm', + balances: [tokenBalance], + error: null, + availableWallets: [], + selectedWallet: null, + connect: async () => {}, + disconnect: async () => {}, + selectWallet: () => {}, + switchNetwork: async () => {}, + refreshBalances: async () => {}, + sign: async () => '0xsignature', + sendTransaction: async () => '0xtxhash', +}; + +assertType(walletReturn); + +// Validate WalletAdapter interface structure +const mockAdapter: WalletAdapter = { + id: 'mock', + name: 'Mock Wallet', + type: 'custom', + networkType: 'evm', + supportedChains: ['eip155:1'], + isAvailable: true, + icon: 'https://example.com/icon.png', + + connect: async () => walletAccount, + disconnect: async () => {}, + getAccount: async () => walletAccount, + getBalance: async () => tokenBalance, + getAllBalances: async () => [tokenBalance], + switchNetwork: async () => {}, + sign: async () => '0xsignature', + sendTransaction: async () => '0xtxhash', + on: () => {}, + off: () => {}, +}; + +assertType(mockAdapter); + +// Export to make this a module +export {}; diff --git a/libs/ui-components/src/wallet/adapters/StellarAdapter.ts b/libs/ui-components/src/wallet/adapters/StellarAdapter.ts new file mode 100644 index 0000000..ab994b6 --- /dev/null +++ b/libs/ui-components/src/wallet/adapters/StellarAdapter.ts @@ -0,0 +1,516 @@ +'use client'; + +import type { + WalletAdapter, + WalletAccount, + TokenBalance, + WalletError, + WalletEvent, + WalletEventCallback, + ChainId, + WalletTransaction, + StellarProvider, + WindowWithStellar, +} from '../types'; + +// Stellar network passphrases +const STELLAR_PUBLIC = 'Public Global Stellar Network ; September 2015'; +const STELLAR_TESTNET = 'Test SDF Network ; September 2015'; +const STELLAR_FUTURENET = 'Test SDF Future Network ; October 2022'; + +// Supported chains (Stellar networks) +const SUPPORTED_CHAINS: ChainId[] = ['stellar:public', 'stellar:testnet', 'stellar:futurenet']; + +// Default to public network +const DEFAULT_NETWORK = 'stellar:public'; + +// Network name mapping +const NETWORK_NAMES: Record = { + 'stellar:public': 'Public', + 'stellar:testnet': 'Testnet', + 'stellar:futurenet': 'Futurenet', +}; + +// Network passphrase mapping +const NETWORK_PASSPHRASES: Record = { + 'stellar:public': STELLAR_PUBLIC, + 'stellar:testnet': STELLAR_TESTNET, + 'stellar:futurenet': STELLAR_FUTURENET, +}; + +// Stellar horizon URLs +const HORIZON_URLS: Record = { + 'stellar:public': 'https://horizon.stellar.org', + 'stellar:testnet': 'https://horizon-testnet.stellar.org', + 'stellar:futurenet': 'https://horizon-futurenet.stellar.org', +}; + +/** + * Stellar wallet adapter options + */ +export interface StellarAdapterOptions { + /** Preferred wallet provider (freighter, rabet, albedo, xbull) */ + preferredProvider?: 'freighter' | 'rabet' | 'albedo' | 'xbull'; + /** Default network */ + network?: 'public' | 'testnet' | 'futurenet'; +} + +/** + * Stellar wallet adapter + * Supports multiple Stellar wallet providers + */ +export class StellarAdapter implements WalletAdapter { + readonly id = 'stellar'; + readonly name = 'Stellar Wallet'; + readonly type = 'stellar' as const; + readonly networkType = 'stellar' as const; + readonly supportedChains = SUPPORTED_CHAINS; + readonly icon = 'https://stellar.org/favicon.ico'; + + private preferredProvider: string | undefined; + private currentNetwork: string; + private provider: StellarProvider | null = null; + private eventListeners: Map> = new Map(); + private currentAccount: string | null = null; + + constructor(options: StellarAdapterOptions = {}) { + this.preferredProvider = options.preferredProvider; + this.currentNetwork = options.network || 'public'; + } + + /** + * Check if any Stellar wallet is available + */ + get isAvailable(): boolean { + if (typeof window === 'undefined') return false; + const windowWithStellar = window as WindowWithStellar; + return !!( + windowWithStellar.freighter || + windowWithStellar.rabet || + windowWithStellar.albedo || + windowWithStellar.xBull + ); + } + + /** + * Get available Stellar providers + */ + getAvailableProviders(): { id: string; name: string; available: boolean }[] { + if (typeof window === 'undefined') return []; + const windowWithStellar = window as WindowWithStellar; + + return [ + { id: 'freighter', name: 'Freighter', available: !!windowWithStellar.freighter }, + { id: 'rabet', name: 'Rabet', available: !!windowWithStellar.rabet }, + { id: 'albedo', name: 'Albedo', available: !!windowWithStellar.albedo }, + { id: 'xbull', name: 'xBull', available: !!windowWithStellar.xBull }, + ]; + } + + /** + * Detect and select the best available provider + */ + private detectProvider(): StellarProvider | null { + if (typeof window === 'undefined') return null; + const windowWithStellar = window as WindowWithStellar; + + // Try preferred provider first + if (this.preferredProvider) { + const provider = this.getProviderByName(this.preferredProvider, windowWithStellar); + if (provider) return provider; + } + + // Try providers in order of preference + const providers: (keyof WindowWithStellar)[] = ['freighter', 'rabet', 'albedo', 'xBull']; + for (const providerName of providers) { + const provider = windowWithStellar[providerName]; + if (provider) return provider; + } + + return null; + } + + /** + * Get provider by name + */ + private getProviderByName( + name: string, + windowWithStellar: WindowWithStellar + ): StellarProvider | null { + const providerMap: Record = { + freighter: 'freighter', + rabet: 'rabet', + albedo: 'albedo', + xbull: 'xBull', + }; + + const key = providerMap[name.toLowerCase()]; + return key ? windowWithStellar[key] || null : null; + } + + /** + * Connect to Stellar wallet + */ + async connect(chainId?: ChainId): Promise { + this.provider = this.detectProvider(); + + if (!this.provider) { + throw this.createError( + 'WALLET_NOT_FOUND', + 'No Stellar wallet found. Please install Freighter, Rabet, Albedo, or xBull.' + ); + } + + try { + // Get public key from provider + const publicKey = await this.provider.publicKey(); + + if (!publicKey) { + throw this.createError('CONNECTION_FAILED', 'Failed to get public key from wallet'); + } + + this.currentAccount = publicKey; + + // Determine network + const targetNetwork = chainId ? chainId.replace('stellar:', '') : this.currentNetwork; + this.currentNetwork = targetNetwork; + + const networkPassphrase = NETWORK_PASSPHRASES[`stellar:${targetNetwork}`]; + + // For Freighter, we can get the current network + if ('getNetwork' in this.provider) { + try { + const walletNetwork = await this.provider.getNetwork(); + // Map wallet network to our chain ID format + if (walletNetwork === STELLAR_PUBLIC) { + this.currentNetwork = 'public'; + } else if (walletNetwork === STELLAR_TESTNET) { + this.currentNetwork = 'testnet'; + } else if (walletNetwork === STELLAR_FUTURENET) { + this.currentNetwork = 'futurenet'; + } + } catch { + // Fallback to default + } + } + + const account: WalletAccount = { + address: publicKey, + publicKey, + chainId: `stellar:${this.currentNetwork}`, + network: 'stellar', + }; + + this.emit('connect', account); + this.setupEventListeners(); + + return account; + } catch (error) { + if (this.isUserRejectedError(error)) { + throw this.createError('USER_REJECTED', 'User rejected the connection request'); + } + throw this.createError('CONNECTION_FAILED', 'Failed to connect to Stellar wallet', error); + } + } + + /** + * Setup event listeners for the provider + */ + private setupEventListeners(): void { + // Stellar wallets typically don't have event listeners like EVM wallets + // We rely on polling or manual refresh for account/network changes + } + + /** + * Disconnect from Stellar wallet + */ + async disconnect(): Promise { + this.currentAccount = null; + this.provider = null; + this.emit('disconnect', null); + } + + /** + * Get the current account + */ + async getAccount(): Promise { + if (!this.currentAccount) { + return null; + } + + return { + address: this.currentAccount, + publicKey: this.currentAccount, + chainId: `stellar:${this.currentNetwork}`, + network: 'stellar', + }; + } + + /** + * Get balance for a specific token + */ + async getBalance(token: string): Promise { + if (!this.currentAccount) { + throw this.createError('NOT_CONNECTED', 'Wallet is not connected'); + } + + const horizonUrl = HORIZON_URLS[`stellar:${this.currentNetwork}`]; + + try { + // Native XLM balance + if (token.toLowerCase() === 'native' || token.toLowerCase() === 'xlm') { + const response = await fetch(`${horizonUrl}/accounts/${this.currentAccount}`); + + if (!response.ok) { + throw new Error(`Failed to fetch account: ${response.statusText}`); + } + + const accountData = (await response.json()) as { balances: Array<{ asset_type: string; balance: string }> }; + const nativeBalance = accountData.balances.find((b) => b.asset_type === 'native'); + + const balance = nativeBalance ? nativeBalance.balance : '0'; + const balanceFormatted = parseFloat(balance).toFixed(7); + + return { + token: 'native', + symbol: 'XLM', + decimals: 7, + balance, + balanceFormatted: `${balanceFormatted} XLM`, + }; + } + + // Asset balance (format: CODE:ISSUER) + const [assetCode, issuer] = token.split(':'); + + if (assetCode && issuer) { + const response = await fetch(`${horizonUrl}/accounts/${this.currentAccount}`); + + if (!response.ok) { + throw new Error(`Failed to fetch account: ${response.statusText}`); + } + + const accountData = (await response.json()) as { + balances: Array<{ + asset_type: string; + asset_code?: string; + asset_issuer?: string; + balance: string; + }>; + }; + + const assetBalance = accountData.balances.find( + (b) => b.asset_code === assetCode && b.asset_issuer === issuer + ); + + const balance = assetBalance ? assetBalance.balance : '0'; + const balanceFormatted = parseFloat(balance).toFixed(7); + + return { + token, + symbol: assetCode, + decimals: 7, + balance, + balanceFormatted: `${balanceFormatted} ${assetCode}`, + }; + } + + // Unknown token format + return { + token, + symbol: token, + decimals: 7, + balance: '0', + balanceFormatted: `0 ${token}`, + }; + } catch (error) { + throw this.createError('BALANCE_FETCH_FAILED', `Failed to fetch balance for ${token}`, error); + } + } + + /** + * Get all balances for the connected account + */ + async getAllBalances(): Promise { + if (!this.currentAccount) { + throw this.createError('NOT_CONNECTED', 'Wallet is not connected'); + } + + const horizonUrl = HORIZON_URLS[`stellar:${this.currentNetwork}`]; + + try { + const response = await fetch(`${horizonUrl}/accounts/${this.currentAccount}`); + + if (!response.ok) { + throw new Error(`Failed to fetch account: ${response.statusText}`); + } + + const accountData = (await response.json()) as { + balances: Array<{ + asset_type: string; + asset_code?: string; + asset_issuer?: string; + balance: string; + }>; + }; + + const balances: TokenBalance[] = accountData.balances.map((balance) => { + if (balance.asset_type === 'native') { + return { + token: 'native', + symbol: 'XLM', + decimals: 7, + balance: balance.balance, + balanceFormatted: `${parseFloat(balance.balance).toFixed(7)} XLM`, + }; + } + + const tokenId = balance.asset_code && balance.asset_issuer + ? `${balance.asset_code}:${balance.asset_issuer}` + : balance.asset_code || 'unknown'; + + return { + token: tokenId, + symbol: balance.asset_code || 'unknown', + decimals: 7, + balance: balance.balance, + balanceFormatted: `${parseFloat(balance.balance).toFixed(7)} ${balance.asset_code || 'unknown'}`, + }; + }); + + return balances; + } catch (error) { + throw this.createError('BALANCE_FETCH_FAILED', 'Failed to fetch balances', error); + } + } + + /** + * Switch to a different Stellar network + */ + async switchNetwork(chainId: ChainId): Promise { + if (!SUPPORTED_CHAINS.includes(chainId)) { + throw this.createError('NETWORK_NOT_SUPPORTED', `Network ${chainId} is not supported`); + } + + const network = chainId.replace('stellar:', ''); + + // For Stellar, network switching is typically done in the wallet UI + // We just update our internal state and emit the event + this.currentNetwork = network; + + this.emit('chainChanged', { chainId }); + this.emit('networkChanged', { chainId, network: 'stellar' }); + } + + /** + * Sign data + */ + async sign(data: string | object): Promise { + if (!this.provider || !this.currentAccount) { + throw this.createError('NOT_CONNECTED', 'Wallet is not connected'); + } + + try { + // Use signData method + const result = await this.provider.signData(data); + return typeof result === 'string' ? result : JSON.stringify(result); + } catch (error) { + if (this.isUserRejectedError(error)) { + throw this.createError('USER_REJECTED', 'User rejected the signing request'); + } + throw this.createError('UNKNOWN_ERROR', 'Failed to sign data', error); + } + } + + /** + * Send a transaction + */ + async sendTransaction(transaction: WalletTransaction): Promise { + if (!this.provider || !this.currentAccount) { + throw this.createError('NOT_CONNECTED', 'Wallet is not connected'); + } + + try { + // For Stellar, we typically sign the transaction first + // The transaction hash is returned after submission to the network + const signedTx = await this.provider.signTransaction(transaction); + + // In a real implementation, you would submit the signed transaction to the Stellar network + // and return the transaction hash + const txHash = typeof signedTx === 'string' + ? signedTx.slice(0, 64) // Mock hash from signed transaction + : 'mock_tx_hash_' + Date.now(); + + return txHash; + } catch (error) { + if (this.isUserRejectedError(error)) { + throw this.createError('USER_REJECTED', 'User rejected the transaction'); + } + throw this.createError('UNKNOWN_ERROR', 'Failed to send transaction', error); + } + } + + /** + * Subscribe to wallet events + */ + on(event: WalletEvent, callback: WalletEventCallback): void { + if (!this.eventListeners.has(event)) { + this.eventListeners.set(event, new Set()); + } + this.eventListeners.get(event)!.add(callback); + } + + /** + * Unsubscribe from wallet events + */ + off(event: WalletEvent, callback: WalletEventCallback): void { + const listeners = this.eventListeners.get(event); + if (listeners) { + listeners.delete(callback); + } + } + + /** + * Emit an event to all registered listeners + */ + private emit(event: WalletEvent, data: unknown): void { + const listeners = this.eventListeners.get(event); + if (listeners) { + listeners.forEach((callback) => { + try { + callback(data); + } catch (error) { + console.error(`Error in ${event} listener:`, error); + } + }); + } + } + + /** + * Check if error is user rejection + */ + private isUserRejectedError(error: unknown): boolean { + if (error instanceof Error) { + return ( + error.message.includes('User rejected') || + error.message.includes('user rejected') || + error.message.includes('cancelled') || + error.message.includes('denied') + ); + } + return false; + } + + /** + * Create a wallet error + */ + private createError(code: string, message: string, originalError?: unknown): WalletError { + return { + code: code as WalletError['code'], + message, + originalError, + }; + } +} + +export default StellarAdapter; diff --git a/libs/ui-components/src/wallet/index.ts b/libs/ui-components/src/wallet/index.ts new file mode 100644 index 0000000..a0534fe --- /dev/null +++ b/libs/ui-components/src/wallet/index.ts @@ -0,0 +1,38 @@ +/** + * Wallet Integration Layer + * Main entry point for wallet adapters and hooks + */ + +// Types +export type { + WalletAdapter, + WalletAccount, + TokenBalance, + WalletError, + WalletErrorCode, + WalletEvent, + WalletEventCallback, + WalletState, + ChainId, + NetworkType, + WalletType, + WalletTransaction, + WalletAdapterConfig, + UseWalletReturn, + UseWalletOptions, + WalletProviderProps, + WalletContextValue, + EVMProvider, + StellarProvider, + WindowWithEthereum, + WindowWithStellar, +} from './types'; + +// Adapters +export { MetaMaskAdapter } from './adapters/MetaMaskAdapter'; +export { WalletConnectAdapter } from './adapters/WalletConnectAdapter'; +export { StellarAdapter } from './adapters/StellarAdapter'; + +// Hooks and Provider +export { useWallet } from './useWallet'; +export { WalletProvider, useWalletContext } from './WalletProvider'; diff --git a/libs/ui-components/src/wallet/types.ts b/libs/ui-components/src/wallet/types.ts new file mode 100644 index 0000000..cdeafc2 --- /dev/null +++ b/libs/ui-components/src/wallet/types.ts @@ -0,0 +1,361 @@ +/** + * Wallet Adapter Types + * Type definitions for the Wallet Adapter Integration Layer + */ + +import type { ReactNode } from 'react'; + +/** + * Supported wallet types + */ +export type WalletType = 'metamask' | 'walletconnect' | 'stellar' | 'custom'; + +/** + * Supported blockchain networks + */ +export type NetworkType = 'evm' | 'stellar'; + +/** + * Chain identifier (CAIP-2 format or custom) + */ +export type ChainId = string; + +/** + * Token balance information + */ +export interface TokenBalance { + token: string; + symbol: string; + decimals: number; + balance: string; + balanceFormatted: string; + usdValue?: number; +} + +/** + * Wallet account information + */ +export interface WalletAccount { + address: string; + publicKey?: string; + chainId: ChainId; + network: NetworkType; +} + +/** + * Wallet connection state + */ +export interface WalletState { + connected: boolean; + connecting: boolean; + disconnecting: boolean; + account: WalletAccount | null; + balances: TokenBalance[]; + chainId: ChainId | null; + network: NetworkType | null; + error: WalletError | null; +} + +/** + * Wallet error structure + */ +export interface WalletError { + code: WalletErrorCode; + message: string; + originalError?: unknown; +} + +/** + * Wallet error codes + */ +export type WalletErrorCode = + | 'WALLET_NOT_FOUND' + | 'CONNECTION_REJECTED' + | 'CONNECTION_FAILED' + | 'DISCONNECT_FAILED' + | 'NETWORK_SWITCH_REJECTED' + | 'NETWORK_NOT_SUPPORTED' + | 'BALANCE_FETCH_FAILED' + | 'ACCOUNT_NOT_FOUND' + | 'ALREADY_CONNECTED' + | 'NOT_CONNECTED' + | 'USER_REJECTED' + | 'TIMEOUT' + | 'UNKNOWN_ERROR'; + +/** + * Wallet event types + */ +export type WalletEvent = + | 'connect' + | 'disconnect' + | 'accountsChanged' + | 'chainChanged' + | 'networkChanged' + | 'error'; + +/** + * Event callback type + */ +export type WalletEventCallback = (data: unknown) => void; + +/** + * Wallet adapter interface + * Unified interface for all wallet types + */ +export interface WalletAdapter { + /** Unique wallet identifier */ + readonly id: string; + /** Wallet display name */ + readonly name: string; + /** Wallet type */ + readonly type: WalletType; + /** Supported network type */ + readonly networkType: NetworkType; + /** Whether the wallet is installed/available */ + readonly isAvailable: boolean; + /** Wallet icon URL or data URI */ + readonly icon?: string; + /** Supported chain IDs */ + readonly supportedChains: ChainId[]; + + /** + * Connect to the wallet + * @param chainId Optional chain ID to connect to + */ + connect(chainId?: ChainId): Promise; + + /** + * Disconnect from the wallet + */ + disconnect(): Promise; + + /** + * Get the current account + */ + getAccount(): Promise; + + /** + * Get balance for a specific token + * @param token Token address or symbol + */ + getBalance(token: string): Promise; + + /** + * Get all balances for the connected account + */ + getAllBalances(): Promise; + + /** + * Switch to a different network + * @param chainId Chain ID to switch to + */ + switchNetwork(chainId: ChainId): Promise; + + /** + * Sign a transaction or message + * @param data Data to sign + */ + sign(data: string | object): Promise; + + /** + * Send a transaction + * @param transaction Transaction object + */ + sendTransaction(transaction: WalletTransaction): Promise; + + /** + * Subscribe to wallet events + * @param event Event name + * @param callback Event callback + */ + on(event: WalletEvent, callback: WalletEventCallback): void; + + /** + * Unsubscribe from wallet events + * @param event Event name + * @param callback Event callback + */ + off(event: WalletEvent, callback: WalletEventCallback): void; +} + +/** + * Wallet transaction structure + */ +export interface WalletTransaction { + to: string; + from?: string; + value?: string; + data?: string; + gasLimit?: string; + gasPrice?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: number; + chainId?: ChainId; +} + +/** + * EVM provider interface (EIP-1193) + */ +export interface EVMProvider { + request(args: { method: string; params?: unknown[] }): Promise; + on(event: string, callback: (data: unknown) => void): void; + removeListener(event: string, callback: (data: unknown) => void): void; + isMetaMask?: boolean; + isWalletConnect?: boolean; +} + +/** + * Stellar wallet provider interface + */ +export interface StellarProvider { + publicKey(): Promise; + signTransaction(transaction: unknown): Promise; + signData(data: unknown): Promise; + getNetwork(): Promise; + isConnected(): boolean; +} + +/** + * Wallet adapter configuration + */ +export interface WalletAdapterConfig { + /** Wallet type */ + type: WalletType; + /** Custom adapter instance (optional) */ + adapter?: WalletAdapter; + /** WalletConnect project ID (for WalletConnect) */ + projectId?: string; + /** Supported chains */ + chains?: ChainId[]; + /** Auto-connect on mount */ + autoConnect?: boolean; + /** RPC URLs for chain IDs */ + rpcUrls?: Record; +} + +/** + * useWallet hook return type + */ +export interface UseWalletReturn { + /** Current wallet state */ + state: WalletState; + /** Whether wallet is connected */ + connected: boolean; + /** Whether wallet is connecting */ + connecting: boolean; + /** Current account address */ + account: string | null; + /** Current chain ID */ + chainId: ChainId | null; + /** Current network type */ + network: NetworkType | null; + /** Token balances */ + balances: TokenBalance[]; + /** Current error */ + error: WalletError | null; + /** Available wallets */ + availableWallets: WalletAdapter[]; + /** Currently selected wallet */ + selectedWallet: WalletAdapter | null; + + /** + * Connect to a wallet + * @param walletType Wallet type or adapter instance + * @param chainId Optional chain ID + */ + connect: (walletType: WalletType | WalletAdapter, chainId?: ChainId) => Promise; + + /** + * Disconnect from the current wallet + */ + disconnect: () => Promise; + + /** + * Select a wallet (without connecting) + * @param wallet Wallet type or adapter instance + */ + selectWallet: (wallet: WalletType | WalletAdapter) => void; + + /** + * Switch network + * @param chainId Chain ID to switch to + */ + switchNetwork: (chainId: ChainId) => Promise; + + /** + * Refresh balances + */ + refreshBalances: () => Promise; + + /** + * Sign data + * @param data Data to sign + */ + sign: (data: string | object) => Promise; + + /** + * Send transaction + * @param transaction Transaction to send + */ + sendTransaction: (transaction: WalletTransaction) => Promise; +} + +/** + * useWallet hook options + */ +export interface UseWalletOptions { + /** Wallet adapters to use */ + adapters?: WalletAdapter[]; + /** Auto-connect on mount */ + autoConnect?: boolean; + /** Callback when wallet connects */ + onConnect?: (account: WalletAccount) => void; + /** Callback when wallet disconnects */ + onDisconnect?: () => void; + /** Callback when account changes */ + onAccountChange?: (account: WalletAccount | null) => void; + /** Callback when network changes */ + onNetworkChange?: (chainId: ChainId, network: NetworkType) => void; + /** Callback on error */ + onError?: (error: WalletError) => void; +} + +/** + * Wallet provider props + */ +export interface WalletProviderProps { + /** Child components */ + children: ReactNode; + /** Wallet adapters to provide */ + adapters?: WalletAdapter[]; + /** Auto-connect on mount */ + autoConnect?: boolean; + /** Callbacks */ + onConnect?: (account: WalletAccount) => void; + onDisconnect?: () => void; + onError?: (error: WalletError) => void; +} + +/** + * Wallet context value + */ +export interface WalletContextValue extends UseWalletReturn {} + +/** + * MetaMask provider window interface + */ +export interface WindowWithEthereum extends Window { + ethereum?: EVMProvider; +} + +/** + * Stellar window interface + */ +export interface WindowWithStellar extends Window { + freighter?: StellarProvider; + rabet?: StellarProvider; + albedo?: StellarProvider; + xBull?: StellarProvider; +} diff --git a/libs/ui-components/src/wallet/useWallet.ts b/libs/ui-components/src/wallet/useWallet.ts new file mode 100644 index 0000000..b1f525c --- /dev/null +++ b/libs/ui-components/src/wallet/useWallet.ts @@ -0,0 +1,515 @@ +/** + * useWallet Hook + * React hook for wallet integration with SSR-safe behavior + */ + +'use client'; + +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import type { + WalletAdapter, + WalletAccount, + TokenBalance, + WalletError, + WalletEvent, + WalletEventCallback, + ChainId, + NetworkType, + WalletTransaction, + UseWalletReturn, + UseWalletOptions, + WalletType, +} from './types'; + +// Import adapters +import { MetaMaskAdapter } from './adapters/MetaMaskAdapter'; +import { WalletConnectAdapter } from './adapters/WalletConnectAdapter'; +import { StellarAdapter } from './adapters/StellarAdapter'; + +// Default adapters factory +const createDefaultAdapters = (): WalletAdapter[] => { + const adapters: WalletAdapter[] = []; + + // MetaMask + const metaMask = new MetaMaskAdapter(); + if (metaMask.isAvailable) { + adapters.push(metaMask); + } + + // Stellar wallets + const stellar = new StellarAdapter(); + if (stellar.isAvailable) { + adapters.push(stellar); + } + + return adapters; +}; + +/** + * useWallet hook + * + * @example + * ```tsx + * const { + * connect, + * disconnect, + * account, + * balances, + * connected, + * error + * } = useWallet({ + * onConnect: (account) => console.log('Connected:', account), + * onError: (error) => console.error('Error:', error), + * }); + * + * // Connect to MetaMask + * await connect('metamask'); + * + * // Connect to Stellar + * await connect('stellar'); + * ``` + */ +export function useWallet(options: UseWalletOptions = {}): UseWalletReturn { + const { + adapters: userAdapters, + autoConnect = false, + onConnect, + onDisconnect, + onAccountChange, + onNetworkChange, + onError, + } = options; + + // SSR safety + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + // Initialize adapters + const adapters = useMemo(() => { + if (!isClient) return []; + return userAdapters || createDefaultAdapters(); + }, [userAdapters, isClient]); + + // State + const [connected, setConnected] = useState(false); + const [connecting, setConnecting] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); + const [account, setAccount] = useState(null); + const [chainId, setChainId] = useState(null); + const [network, setNetwork] = useState(null); + const [balances, setBalances] = useState([]); + const [error, setError] = useState(null); + const [selectedWallet, setSelectedWallet] = useState(null); + + // Refs for tracking + const isMountedRef = useRef(true); + const eventCleanupRef = useRef<(() => void) | null>(null); + + // Cleanup on unmount + useEffect(() => { + return () => { + isMountedRef.current = false; + if (eventCleanupRef.current) { + eventCleanupRef.current(); + } + }; + }, []); + + // Setup event listeners for the selected wallet + const setupWalletEvents = useCallback((wallet: WalletAdapter) => { + // Clean up previous listeners + if (eventCleanupRef.current) { + eventCleanupRef.current(); + eventCleanupRef.current = null; + } + + const handleConnect = (data: unknown) => { + if (!isMountedRef.current) return; + const accountData = data as WalletAccount; + setAccount(accountData.address); + setChainId(accountData.chainId); + setNetwork(accountData.network); + setConnected(true); + onConnect?.(accountData); + }; + + const handleDisconnect = () => { + if (!isMountedRef.current) return; + setAccount(null); + setChainId(null); + setNetwork(null); + setBalances([]); + setConnected(false); + setSelectedWallet(null); + onDisconnect?.(); + }; + + const handleAccountsChanged = (data: unknown) => { + if (!isMountedRef.current) return; + const { account: newAccount } = data as { account: string }; + setAccount(newAccount); + if (newAccount) { + wallet.getAccount().then((acc) => { + if (acc && isMountedRef.current) { + onAccountChange?.(acc); + } + }); + } else { + onAccountChange?.(null); + } + }; + + const handleChainChanged = (data: unknown) => { + if (!isMountedRef.current) return; + const { chainId: newChainId } = data as { chainId: ChainId }; + setChainId(newChainId); + wallet.getAccount().then((acc) => { + if (acc && isMountedRef.current) { + setNetwork(acc.network); + onNetworkChange?.(newChainId, acc.network); + } + }); + }; + + const handleError = (data: unknown) => { + if (!isMountedRef.current) return; + const walletError = data as WalletError; + setError(walletError); + onError?.(walletError); + }; + + // Subscribe to events + wallet.on('connect', handleConnect); + wallet.on('disconnect', handleDisconnect); + wallet.on('accountsChanged', handleAccountsChanged); + wallet.on('chainChanged', handleChainChanged); + wallet.on('error', handleError); + + // Store cleanup function + eventCleanupRef.current = () => { + wallet.off('connect', handleConnect); + wallet.off('disconnect', handleDisconnect); + wallet.off('accountsChanged', handleAccountsChanged); + wallet.off('chainChanged', handleChainChanged); + wallet.off('error', handleError); + }; + }, [onConnect, onDisconnect, onAccountChange, onNetworkChange, onError]); + + // Connect to a wallet + const connect = useCallback( + async (walletType: WalletType | WalletAdapter, targetChainId?: ChainId) => { + if (!isClient) { + throw new Error('Cannot connect on server side'); + } + + setConnecting(true); + setError(null); + + try { + let wallet: WalletAdapter; + + // Handle adapter instance + if (typeof walletType === 'object' && 'connect' in walletType) { + wallet = walletType; + } else { + // Find adapter by type + const foundAdapter = adapters.find((a) => a.type === walletType || a.id === walletType); + if (!foundAdapter) { + throw { + code: 'WALLET_NOT_FOUND', + message: `Wallet adapter for ${walletType} not found or not available`, + } as WalletError; + } + wallet = foundAdapter; + } + + // Check if already connected + if (selectedWallet?.id === wallet.id && connected) { + throw { + code: 'ALREADY_CONNECTED', + message: 'Already connected to this wallet', + } as WalletError; + } + + // Disconnect from current wallet if any + if (selectedWallet && selectedWallet.id !== wallet.id) { + await selectedWallet.disconnect(); + } + + // Connect + const accountData = await wallet.connect(targetChainId); + + if (!isMountedRef.current) return; + + // Update state + setSelectedWallet(wallet); + setAccount(accountData.address); + setChainId(accountData.chainId); + setNetwork(accountData.network); + setConnected(true); + + // Setup event listeners + setupWalletEvents(wallet); + + // Fetch initial balances + try { + const initialBalances = await wallet.getAllBalances(); + if (isMountedRef.current) { + setBalances(initialBalances); + } + } catch (balanceError) { + console.error('Failed to fetch initial balances:', balanceError); + } + + onConnect?.(accountData); + } catch (err) { + if (!isMountedRef.current) return; + const walletError = err as WalletError; + setError(walletError); + onError?.(walletError); + throw err; + } finally { + if (isMountedRef.current) { + setConnecting(false); + } + } + }, + [adapters, connected, isClient, onConnect, onError, selectedWallet, setupWalletEvents] + ); + + // Disconnect from wallet + const disconnect = useCallback(async () => { + if (!selectedWallet) return; + + setDisconnecting(true); + + try { + await selectedWallet.disconnect(); + + if (!isMountedRef.current) return; + + // Clean up event listeners + if (eventCleanupRef.current) { + eventCleanupRef.current(); + eventCleanupRef.current = null; + } + + // Reset state + setSelectedWallet(null); + setAccount(null); + setChainId(null); + setNetwork(null); + setBalances([]); + setConnected(false); + setError(null); + + onDisconnect?.(); + } catch (err) { + if (!isMountedRef.current) return; + const walletError = err as WalletError; + setError(walletError); + onError?.(walletError); + throw err; + } finally { + if (isMountedRef.current) { + setDisconnecting(false); + } + } + }, [onDisconnect, onError, selectedWallet]); + + // Select a wallet without connecting + const selectWallet = useCallback( + (wallet: WalletType | WalletAdapter) => { + if (typeof wallet === 'object' && 'connect' in wallet) { + setSelectedWallet(wallet); + } else { + const foundAdapter = adapters.find((a) => a.type === wallet || a.id === wallet); + if (foundAdapter) { + setSelectedWallet(foundAdapter); + } + } + }, + [adapters] + ); + + // Switch network + const switchNetwork = useCallback( + async (targetChainId: ChainId) => { + if (!selectedWallet) { + throw { + code: 'NOT_CONNECTED', + message: 'No wallet connected', + } as WalletError; + } + + try { + await selectedWallet.switchNetwork(targetChainId); + + if (!isMountedRef.current) return; + + setChainId(targetChainId); + + // Refresh balances after network switch + const newBalances = await selectedWallet.getAllBalances(); + if (isMountedRef.current) { + setBalances(newBalances); + } + } catch (err) { + if (!isMountedRef.current) return; + const walletError = err as WalletError; + setError(walletError); + onError?.(walletError); + throw err; + } + }, + [onError, selectedWallet] + ); + + // Refresh balances + const refreshBalances = useCallback(async () => { + if (!selectedWallet) return; + + try { + const newBalances = await selectedWallet.getAllBalances(); + if (isMountedRef.current) { + setBalances(newBalances); + } + } catch (err) { + if (!isMountedRef.current) return; + const walletError = err as WalletError; + setError(walletError); + onError?.(walletError); + } + }, [onError, selectedWallet]); + + // Sign data + const sign = useCallback( + async (data: string | object) => { + if (!selectedWallet) { + throw { + code: 'NOT_CONNECTED', + message: 'No wallet connected', + } as WalletError; + } + + try { + return await selectedWallet.sign(data); + } catch (err) { + const walletError = err as WalletError; + setError(walletError); + onError?.(walletError); + throw err; + } + }, + [onError, selectedWallet] + ); + + // Send transaction + const sendTransaction = useCallback( + async (transaction: WalletTransaction) => { + if (!selectedWallet) { + throw { + code: 'NOT_CONNECTED', + message: 'No wallet connected', + } as WalletError; + } + + try { + return await selectedWallet.sendTransaction(transaction); + } catch (err) { + const walletError = err as WalletError; + setError(walletError); + onError?.(walletError); + throw err; + } + }, + [onError, selectedWallet] + ); + + // Auto-connect on mount + useEffect(() => { + if (!isClient || !autoConnect) return; + + // Try to restore connection from storage + const tryAutoConnect = async () => { + try { + const storedWalletId = localStorage.getItem('bridgewise_wallet_id'); + const storedChainId = localStorage.getItem('bridgewise_chain_id'); + + if (storedWalletId) { + const wallet = adapters.find((a) => a.id === storedWalletId); + if (wallet) { + await connect(wallet, storedChainId as ChainId | undefined); + } + } + } catch { + // Auto-connect failed, clear storage + localStorage.removeItem('bridgewise_wallet_id'); + localStorage.removeItem('bridgewise_chain_id'); + } + }; + + void tryAutoConnect(); + }, [adapters, autoConnect, connect, isClient]); + + // Persist connection to storage + useEffect(() => { + if (!isClient) return; + + if (connected && selectedWallet) { + localStorage.setItem('bridgewise_wallet_id', selectedWallet.id); + if (chainId) { + localStorage.setItem('bridgewise_chain_id', chainId); + } + } else if (!connected) { + localStorage.removeItem('bridgewise_wallet_id'); + localStorage.removeItem('bridgewise_chain_id'); + } + }, [chainId, connected, isClient, selectedWallet]); + + // Build state object + const state = useMemo( + () => ({ + connected, + connecting, + disconnecting, + account: account + ? { + address: account, + chainId: chainId!, + network: network!, + } + : null, + balances, + chainId, + network, + error, + }), + [account, balances, chainId, connected, connecting, disconnecting, error, network] + ); + + return { + state, + connected, + connecting, + account, + chainId, + network, + balances, + error, + availableWallets: adapters, + selectedWallet, + connect, + disconnect, + selectWallet, + switchNetwork, + refreshBalances, + sign, + sendTransaction, + }; +} + +export default useWallet;