diff --git a/src/components/shared/ErrorDisplay.tsx b/src/components/shared/ErrorDisplay.tsx new file mode 100644 index 0000000..7f66ad2 --- /dev/null +++ b/src/components/shared/ErrorDisplay.tsx @@ -0,0 +1,78 @@ +// src/components/shared/ErrorDisplay.tsx +'use client'; + +import type { DecodedError } from '@/types/errors'; +import { useToast } from '@/components/Toast'; + +interface ErrorDisplayProps { + error: DecodedError | null; + onDismiss?: () => void; +} + +const severityIcons: Record = { + info: 'ℹ️', + warning: '⚠️', + error: '❌' +}; + +export function ErrorDisplay({ error, onDismiss }: ErrorDisplayProps) { + const { showToast } = useToast(); + + if (!error) return null; + + const copyRawError = () => { + navigator.clipboard.writeText(error.rawError); + showToast('Raw error copied to clipboard', 'info'); + }; + + return ( +
+
+ {severityIcons[error.severity]} +
+

{error.humanTitle}

+

{error.humanDescription}

+ + {error.troubleshootingSteps.length > 0 && ( +
+

Troubleshooting Steps

+
    + {error.troubleshootingSteps.map((step, idx) => ( +
  1. {step}
  2. + ))} +
+
+ )} + + {error.docsUrl && ( + + Learn More → + + )} + +
+ + {onDismiss && ( + + )} +
+
+
+
+ ); +} diff --git a/src/hooks/useSorobanStaking.ts b/src/hooks/useSorobanStaking.ts index fa00c89..c415ef6 100644 --- a/src/hooks/useSorobanStaking.ts +++ b/src/hooks/useSorobanStaking.ts @@ -4,6 +4,7 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { sendTransaction as rpcSendTransaction } from '@/src/lib/stellar/rpcClient'; import { sha256 } from '@/src/lib/crypto'; import { useTxRetryQueue, MAX_RETRY_ATTEMPTS, CONFIRMED_REMOVAL_DELAY_MS, computeBackoff } from '@/src/hooks/useTxRetryQueue'; +import { decodeTransactionError } from '@/utils/errorDecoder'; export type SubmitState = 'idle' | 'submitting' | 'confirmed' | 'error'; @@ -21,6 +22,7 @@ export function useSorobanStaking(onToast?: (message: string, type: 'info' | 'su const [txHash, setTxHash] = useState(null); const queue = useTxRetryQueue(); const onToastRef = useRef(onToast); + useEffect(() => { onToastRef.current = onToast; }, [onToast]); @@ -97,11 +99,14 @@ export function useSorobanStaking(onToast?: (message: string, type: 'info' | 'su if (result.status === 'confirmed') { queue.updateEntry(computedHash, { status: 'confirmed' }); - setTxHash(result.txHash); + setTxHash(result.txHash ?? computedHash); setState('confirmed'); onToastRef.current?.('Transaction confirmed', 'success'); setTimeout(() => queue.removeEntry(computedHash), CONFIRMED_REMOVAL_DELAY_MS); - } else if (result.status === 'error') { + return; + } + + if (result.status === 'error') { if (result.code === 'tx_bad_seq') { queue.updateEntry(computedHash, { status: 'confirmed' }); setTxHash(computedHash); @@ -110,28 +115,49 @@ export function useSorobanStaking(onToast?: (message: string, type: 'info' | 'su setTimeout(() => queue.removeEntry(computedHash), CONFIRMED_REMOVAL_DELAY_MS); } else { queue.updateEntry(computedHash, { status: 'failed' }); - setError(result.error); + const decoded = decodeTransactionError(result.error); + setError(decoded.humanTitle); setState('error'); - onToastRef.current?.(result.error, 'error'); + onToastRef.current?.(decoded.humanTitle, 'error'); } - } else if (result.status === 'network_error') { + return; + } + + if (result.status === 'network_error') { const retryCount = entry.retryCount + 1; const nextRetryAt = Date.now() + computeBackoff(retryCount); queue.updateEntry(computedHash, { retryCount, nextRetryAt, status: 'pending' }); - setError(result.error); + const decoded = decodeTransactionError(result.error); + setError(decoded.humanTitle); setState('error'); onToastRef.current?.(`Network error — will retry (attempt ${retryCount}/${MAX_RETRY_ATTEMPTS})`, 'error'); + return; } - } catch (err: unknown) { + + // Unknown result shape: decode and report + const decoded = decodeTransactionError( + typeof result === 'object' && result !== null && 'error' in result + ? (result as { error: string }).error + : 'Unknown error' + ); + setError(decoded.humanTitle); + setState('error'); + onToastRef.current?.(decoded.humanTitle, 'error'); + + } catch (err: unknown) { // FIX: added err parameter + const decoded = decodeTransactionError( + err instanceof Error ? err.message : 'Unknown error' // FIX: using err instead of state error + ); + setError(decoded.humanTitle); + setState('error'); + onToastRef.current?.(decoded.humanTitle, 'error'); + + // schedule a retry entry const retryCount = entry.retryCount + 1; const nextRetryAt = Date.now() + computeBackoff(retryCount); queue.updateEntry(computedHash, { retryCount, nextRetryAt, status: 'pending' }); - const msg = err instanceof Error ? err.message : 'Unknown error'; - setError(msg); - setState('error'); - onToastRef.current?.(`Error — will retry (attempt ${retryCount}/${MAX_RETRY_ATTEMPTS})`, 'error'); } - }, [queue]); + }, [queue]); // FIX: properly closed the useCallback here return { submitStake, diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 0000000..72a977f --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,19 @@ +// src/types/errors.ts +export type ErrorCategory = 'balance' | 'auth' | 'network' | 'contract' | 'wallet' | 'generic'; +export type ErrorSeverity = 'info' | 'warning' | 'error'; + +export interface ErrorDefinition { + category: ErrorCategory; + severity: ErrorSeverity; + humanTitle: string; + humanDescription: string; + troubleshootingSteps: string[]; + docsUrl?: string; + // For parameterized errors (e.g., extract amounts, addresses) + paramMap?: Record; +} + +export interface DecodedError extends ErrorDefinition { + rawError: string; + isUnknown?: boolean; +} diff --git a/src/utils/errorDecoder.ts b/src/utils/errorDecoder.ts new file mode 100644 index 0000000..f91727f --- /dev/null +++ b/src/utils/errorDecoder.ts @@ -0,0 +1,122 @@ +// src/utils/errorDecoder.ts +import type { DecodedError, ErrorDefinition } from '@/types/errors'; + +const ErrorCatalog: Record = { + 'tx_bad_seq': { + category: 'network', + severity: 'warning', + humanTitle: 'Sequence Number Mismatch', + humanDescription: 'Your account\'s transaction sequence has moved ahead of the submitted transaction.', + troubleshootingSteps: [ + 'Refresh the dashboard to sync latest sequence number', + 'Try submitting the transaction again' + ], + docsUrl: 'https://developers.stellar.org/docs/learn/encyclopedia/transactions/sequence-numbers' + }, + 'op_underfunded': { + category: 'balance', + severity: 'error', + humanTitle: 'Insufficient Funds', + humanDescription: 'The account does not have enough XLM to cover the minimum balance or transaction fees. Required minimum: {minBalance} XLM.', + troubleshootingSteps: [ + 'Add more XLM to your account', + 'Check current account balance in the wallet' + ] + }, + 'op_no_trust': { + category: 'auth', + severity: 'error', + humanTitle: 'No Trustline Established', + humanDescription: 'You need to establish a trustline for the asset before performing this operation.', + troubleshootingSteps: [ + 'Create a trustline for the required asset', + 'Confirm asset issuer in your wallet' + ] + }, + 'HostError: ValueUnknown': { + category: 'contract', + severity: 'error', + humanTitle: 'Contract Data Not Found', + humanDescription: 'The requested contract storage value was not found. This often occurs if data has expired or the contract state was reset.', + troubleshootingSteps: [ + 'The system will attempt to restore the data automatically', + 'Try the operation again in a few moments' + ] + }, + 'HostError: HostObjectError\\(ContractError\\(\\d+\\)\\)': { + category: 'contract', + severity: 'error', + humanTitle: 'Smart Contract Error', + humanDescription: 'The Soroban contract returned an error during execution. Check contract logic or input parameters.', + troubleshootingSteps: [ + 'Verify input parameters match contract expectations', + 'Review recent contract updates' + ] + }, + // Add 50-100+ more patterns here as needed (regex keys) + // Generic fallback + '.*': { + category: 'generic', + severity: 'error', + humanTitle: 'Unknown Error', + humanDescription: 'An unexpected error occurred. Raw details below.', + troubleshootingSteps: [ + 'Copy the raw error and report to support', + 'Try refreshing the page and retrying' + ] + } +}; + +function extractErrorMessage(rawError: unknown): string { + if (rawError instanceof Error) return rawError.message; + if (typeof rawError === 'string') return rawError; + if (rawError && typeof rawError === 'object' && 'message' in rawError) { + return (rawError as { message: string }).message; + } + if (rawError && typeof rawError === 'object' && 'error' in rawError) { + return String((rawError as { error: unknown }).error); + } + return String(rawError || 'Unknown error'); +} + +function interpolateParams(description: string, rawMessage: string): string { + // Simple param extraction example (extend as needed) + const minBalanceMatch = rawMessage.match(/minimum balance (\d+)/i); + if (minBalanceMatch) { + return description.replace('{minBalance}', minBalanceMatch[1]); + } + return description; +} + +export function decodeTransactionError(rawError: unknown): DecodedError { + const rawMessage = extractErrorMessage(rawError); + + for (const [pattern, definition] of Object.entries(ErrorCatalog)) { + const regex = new RegExp(pattern, 'i'); + if (regex.test(rawMessage)) { + const description = interpolateParams(definition.humanDescription, rawMessage); + const isUnknown = pattern === '.*'; +if (isUnknown) { + import('@sentry/nextjs').then(({ captureException }) => { + captureException(rawError, { + tags: { errorType: 'decoder-unknown' }, + fingerprint: ['error-decoder-unknown', rawMessage.substring(0, 100)] + }); + }); +} +return { + ...definition, + humanDescription: description, + rawError: rawMessage, + isUnknown +}; + } + } + + // Should never reach here due to generic fallback + return { + ...ErrorCatalog['.*'], + rawError: rawMessage, + isUnknown: true + }; + } diff --git a/tests/errorDecoder.test.ts b/tests/errorDecoder.test.ts new file mode 100644 index 0000000..ada8c6f --- /dev/null +++ b/tests/errorDecoder.test.ts @@ -0,0 +1,18 @@ +// tests/errorDecoder.test.ts +import { decodeTransactionError } from '@/utils/errorDecoder'; +import { describe, it, expect } from 'vitest'; + +describe('Error Decoder', () => { + it('decodes tx_bad_seq', () => { + const decoded = decodeTransactionError('tx_bad_seq'); + expect(decoded.humanTitle).toBe('Sequence Number Mismatch'); + expect(decoded.troubleshootingSteps.length).toBeGreaterThan(0); + }); + + it('handles unknown errors', () => { + const decoded = decodeTransactionError('some_cryptic_host_error_123'); + expect(decoded.isUnknown).toBe(true); + }); + + // Add more for HostError patterns, null/undefined, etc. +});