diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index fa8ac004..30590229 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -3,7 +3,7 @@ "description": "Hathor Network Snap integration", "proposedName": "Hathor Wallet", "source": { - "shasum": "cVvK+qMhEy0PYL6JKlfwQpWXe0gCJ2oyU5THdlb8jLc=", + "shasum": "UP48HeVrcHOdxuR/5bm9Iy6aa8u2Wxoo9pX5MvRwsc4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/index.tsx b/packages/snap/src/index.tsx index 69669975..0fd639a2 100644 --- a/packages/snap/src/index.tsx +++ b/packages/snap/src/index.tsx @@ -85,9 +85,16 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ return bigIntUtils.JSONBigInt.stringify(response); } catch (e: any) { // Re-throw using SnapError to properly serialize the data property + // Include stack trace and error type for debugging in the web-wallet + const errorData = { + ...e.data, + errorType: e.name || e.data?.errorType || 'UnknownError', + stack: e.stack || undefined, + }; + const snapError = new SnapError( e.message || 'Unknown error', - e.data || { errorType: e.name || 'UnknownError' } + errorData ); // Try to preserve the original error code if (e.code) { diff --git a/packages/web-wallet/src/components/CreateTokenDialog.tsx b/packages/web-wallet/src/components/CreateTokenDialog.tsx index 6f428819..e954df32 100644 --- a/packages/web-wallet/src/components/CreateTokenDialog.tsx +++ b/packages/web-wallet/src/components/CreateTokenDialog.tsx @@ -12,6 +12,7 @@ import { getAddressForMode } from '../utils/addressMode'; // TODO: Re-enable when fee token feature is ready // import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { useToast } from '@/hooks/use-toast'; +import TransactionErrorDisplay from './TransactionErrorDisplay'; interface CreateTokenDialogProps { isOpen: boolean; @@ -95,7 +96,7 @@ type CreateTokenFormData = z.infer; const CreateTokenDialog: React.FC = ({ isOpen, onClose }) => { const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [successData, setSuccessData] = useState<{ configString: string; tokenName: string; @@ -253,21 +254,40 @@ const CreateTokenDialog: React.FC = ({ isOpen, onClose } tokenName: data.name, tokenSymbol: data.symbol, }); - } catch (err) { + } catch (err: unknown) { console.error('Failed to create token:', err); - // User-friendly error messages - const errorMsg = err instanceof Error ? err.message : 'Failed to create token'; + // Snap errors come as objects with message/data properties, not Error instances + const snapErr = err as { message?: string; data?: { errorType?: string; stack?: string }; name?: string; stack?: string }; + const errorMsg = snapErr?.message || 'Failed to create token'; + // Create user-friendly error with appropriate type + let userError: Error; if (errorMsg.includes('rejected') || errorMsg.includes('User rejected')) { - setError('Transaction was cancelled. Please try again.'); + userError = new Error('Transaction was cancelled. Please try again.'); + userError.name = 'UserRejectedError'; } else if (errorMsg.includes('insufficient') || errorMsg.includes('Insufficient')) { - setError('Insufficient HTR balance for deposit and fees.'); + userError = new Error('Insufficient HTR balance for deposit and fees.'); + userError.name = 'InsufficientBalanceError'; } else if (errorMsg.includes('timeout') || errorMsg.includes('timed out')) { - setError('Request timed out. Please check your connection and try again.'); + userError = new Error('Request timed out. Please check your connection and try again.'); + userError.name = 'TimeoutError'; } else { - setError(errorMsg); + userError = new Error(errorMsg); + userError.name = snapErr?.data?.errorType || snapErr?.name || 'Error'; } + + // Preserve stack trace from snap error data + if (snapErr?.data?.stack) { + userError.stack = snapErr.data.stack; + } else if (snapErr?.stack) { + userError.stack = snapErr.stack; + } + + // Attach the original data for TransactionErrorDisplay to extract + (userError as any).data = snapErr?.data; + + setError(userError); } finally { setIsLoading(false); } @@ -601,10 +621,7 @@ const CreateTokenDialog: React.FC = ({ isOpen, onClose } {/* Error Message */} {error && ( -
- - {error} -
+ )} {/* Create Button */} diff --git a/packages/web-wallet/src/components/ErrorDetailsModal.tsx b/packages/web-wallet/src/components/ErrorDetailsModal.tsx new file mode 100644 index 00000000..f478c258 --- /dev/null +++ b/packages/web-wallet/src/components/ErrorDetailsModal.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { Copy } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Button } from './ui/button'; + +export interface ErrorDetails { + type: string; + message: string; + timestamp: Date; + stack?: string; +} + +interface ErrorDetailsModalProps { + isOpen: boolean; + onClose: () => void; + error: ErrorDetails; +} + +const ErrorDetailsModal: React.FC = ({ + isOpen, + onClose, + error, +}) => { + const { toast } = useToast(); + + const formatTimestamp = (date: Date) => { + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }); + }; + + const getErrorText = () => { + const lines = [ + `Error Type: ${error.type}`, + `Message: ${error.message}`, + `Timestamp: ${formatTimestamp(error.timestamp)}`, + ]; + if (error.stack) { + lines.push('', 'Stack Trace:', error.stack); + } + return lines.join('\n'); + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(getErrorText()); + toast({ + variant: 'success', + title: 'Error details copied to clipboard', + }); + } catch (err) { + console.error('Failed to copy error details:', err); + toast({ + variant: 'destructive', + title: 'Failed to copy to clipboard', + }); + } + }; + + return ( + !open && onClose()}> + + + + Error Details + + + +
+ {/* Error Type */} +
+

Error Type

+

{error.type}

+
+ + {/* Message */} +
+

Message

+

{error.message}

+
+ + {/* Timestamp */} +
+

Timestamp

+

{formatTimestamp(error.timestamp)}

+
+ + {/* Stack Trace */} + {error.stack && ( +
+

Stack Trace

+
+
+                  {error.stack}
+                
+
+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export default ErrorDetailsModal; diff --git a/packages/web-wallet/src/components/SendDialog.tsx b/packages/web-wallet/src/components/SendDialog.tsx index 0dc4d20a..5726c312 100644 --- a/packages/web-wallet/src/components/SendDialog.tsx +++ b/packages/web-wallet/src/components/SendDialog.tsx @@ -13,6 +13,7 @@ import { TOKEN_IDS } from '../constants'; import { readOnlyWalletWrapper } from '../services/ReadOnlyWalletWrapper'; import { getAddressForMode } from '../utils/addressMode'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import TransactionErrorDisplay from './TransactionErrorDisplay'; interface SendDialogProps { isOpen: boolean; @@ -85,7 +86,7 @@ type SendFormData = z.infer>; const SendDialog: React.FC = ({ isOpen, onClose, initialTokenUid }) => { const [showAdvanced, setShowAdvanced] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [transactionError, setTransactionError] = useState(null); + const [transactionError, setTransactionError] = useState(null); const { sendTransaction, network, addressMode } = useWallet(); const { allTokens } = useTokens(); @@ -214,11 +215,13 @@ const SendDialog: React.FC = ({ isOpen, onClose, initialTokenUi ? shortfall.toString() : centsToAmount(shortfall); - setTransactionError( + const insufficientBalanceError = new Error( `Insufficient balance. You need ${displayAmount} ${selectedToken?.symbol} ` + `but only have ${displayAvailable} ${selectedToken?.symbol} available. ` + `Short by ${displayShortfall} ${selectedToken?.symbol}.` ); + insufficientBalanceError.name = 'InsufficientBalanceError'; + setTransactionError(insufficientBalanceError); setIsLoading(false); return; } @@ -271,8 +274,25 @@ const SendDialog: React.FC = ({ isOpen, onClose, initialTokenUi reset(); setTransactionError(null); onClose(); - } catch (err) { - setTransactionError(err instanceof Error ? err.message : 'Failed to send transaction'); + } catch (err: unknown) { + // Snap errors come as objects with message/data properties, not Error instances + // We need to preserve the snap error data (errorType, stack) for display + const snapErr = err as { message?: string; data?: { errorType?: string; stack?: string }; name?: string; stack?: string }; + + const error = new Error(snapErr?.message || 'Failed to send transaction'); + error.name = snapErr?.data?.errorType || snapErr?.name || 'Error'; + + // Preserve stack trace from snap error data, or from the error itself + if (snapErr?.data?.stack) { + error.stack = snapErr.data.stack; + } else if (snapErr?.stack) { + error.stack = snapErr.stack; + } + + // Attach the original data for TransactionErrorDisplay to extract + (error as any).data = snapErr?.data; + + setTransactionError(error); } finally { setIsLoading(false); } @@ -379,21 +399,13 @@ const SendDialog: React.FC = ({ isOpen, onClose, initialTokenUi {transactionError && ( -
- {transactionError} - {transactionError.includes('permission') && ( - - )} -
+ { + onClose(); + window.location.reload(); + }} + /> )} {/* Advanced Options */} diff --git a/packages/web-wallet/src/components/TransactionErrorDisplay.tsx b/packages/web-wallet/src/components/TransactionErrorDisplay.tsx new file mode 100644 index 00000000..d454bfc7 --- /dev/null +++ b/packages/web-wallet/src/components/TransactionErrorDisplay.tsx @@ -0,0 +1,123 @@ +import React, { useState, useMemo } from 'react'; +import { ChevronDown, ChevronUp, AlertCircle } from 'lucide-react'; +import ErrorDetailsModal from './ErrorDetailsModal'; +import type { ErrorDetails } from './ErrorDetailsModal'; + +/** + * Extended error type that may include data from snap errors + */ +interface SnapError extends Error { + data?: { + errorType?: string; + stack?: string; + [key: string]: unknown; + }; + code?: number; +} + +interface TransactionErrorDisplayProps { + /** The error object or error message string */ + error: Error | SnapError | string; + /** Optional: Show a refresh/reconnect button for permission errors */ + onRefreshClick?: () => void; + /** Optional: Custom class names for the container */ + className?: string; +} + +/** + * A reusable error display component with "Advanced options" collapsible + * that reveals a "See error details" button for viewing stack traces. + */ +const TransactionErrorDisplay: React.FC = ({ + error, + onRefreshClick, + className = '', +}) => { + const [showAdvanced, setShowAdvanced] = useState(false); + const [showErrorDetails, setShowErrorDetails] = useState(false); + + // Parse the error into a structured format + // Handles both regular errors and snap errors with data property + const errorDetails: ErrorDetails = useMemo(() => { + if (typeof error === 'string') { + return { + type: 'Error', + message: error, + timestamp: new Date(), + stack: undefined, + }; + } + + // Check if this is a snap error with data property + const snapError = error as SnapError; + const errorType = snapError.data?.errorType || error.name || 'Error'; + const stack = snapError.data?.stack || error.stack; + + return { + type: errorType, + message: error.message || 'An unknown error occurred', + timestamp: new Date(), + stack, + }; + }, [error]); + + const errorMessage = typeof error === 'string' ? error : error.message; + const isPermissionError = errorMessage.includes('permission'); + + return ( + <> +
+ {/* Error Message */} + {errorMessage} + + {/* Permission Error: Refresh Button */} + {isPermissionError && onRefreshClick && ( + + )} + + {/* Advanced Options Toggle */} + + + {/* Advanced Options Content */} + {showAdvanced && ( +
+ +
+ )} +
+ + {/* Error Details Modal */} + setShowErrorDetails(false)} + error={errorDetails} + /> + + ); +}; + +export default TransactionErrorDisplay;