From 1359239ae156358bae689ecfeec36b5eaebd3985 Mon Sep 17 00:00:00 2001 From: Mercy Duru Date: Mon, 23 Feb 2026 15:42:13 +0100 Subject: [PATCH] Implement BridgeStatus Transaction Status Component --- .../BridgeStatus/BridgeStatus.headless.tsx | 202 ++++++ .../components/BridgeStatus/BridgeStatus.tsx | 650 ++++++++++++++++++ .../BridgeStatus/__tests__/types.spec.ts | 149 ++++ .../src/components/BridgeStatus/index.ts | 22 + .../src/components/BridgeStatus/types.ts | 221 ++++++ .../src/hooks/useBridgeExecution.ts | 465 +++++++++++++ .../src/hooks/useFeeSlippageBenchmark.ts | 2 + libs/ui-components/src/index.ts | 20 +- 8 files changed, 1730 insertions(+), 1 deletion(-) create mode 100644 libs/ui-components/src/components/BridgeStatus/BridgeStatus.headless.tsx create mode 100644 libs/ui-components/src/components/BridgeStatus/BridgeStatus.tsx create mode 100644 libs/ui-components/src/components/BridgeStatus/__tests__/types.spec.ts create mode 100644 libs/ui-components/src/components/BridgeStatus/index.ts create mode 100644 libs/ui-components/src/components/BridgeStatus/types.ts create mode 100644 libs/ui-components/src/hooks/useBridgeExecution.ts 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..8533a9a 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';