Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/components/shared/ErrorDisplay.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 (
<div className={`rounded-lg border-l-4 p-4 shadow-sm ${error.severity === 'error' ? 'border-red-500 bg-red-50' : 'border-amber-500 bg-amber-50'}`}>
<div className="flex items-start gap-3">
<span className="text-xl">{severityIcons[error.severity]}</span>
<div className="flex-1">
<h4 className="font-semibold text-lg">{error.humanTitle}</h4>
<p className="text-sm text-gray-600 mt-1">{error.humanDescription}</p>

{error.troubleshootingSteps.length > 0 && (
<div className="mt-3">
<p className="text-xs uppercase tracking-widest text-gray-500 mb-1">Troubleshooting Steps</p>
<ol className="list-decimal list-inside text-sm space-y-1 text-gray-700">
{error.troubleshootingSteps.map((step, idx) => (
<li key={idx}>{step}</li>
))}
</ol>
</div>
)}

{error.docsUrl && (
<a
href={error.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-3 text-blue-600 hover:underline text-sm"
>
Learn More →
</a>
)}

<div className="mt-4 flex gap-3">
<button
onClick={copyRawError}
className="text-xs px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded border"
>
📋 Copy Raw Error
</button>
{onDismiss && (
<button
onClick={onDismiss}
className="text-xs px-3 py-1 bg-white hover:bg-gray-100 rounded border"
>
Dismiss
</button>
)}
</div>
</div>
</div>
</div>
);
}
50 changes: 38 additions & 12 deletions src/hooks/useSorobanStaking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -21,6 +22,7 @@ export function useSorobanStaking(onToast?: (message: string, type: 'info' | 'su
const [txHash, setTxHash] = useState<string | null>(null);
const queue = useTxRetryQueue();
const onToastRef = useRef(onToast);

useEffect(() => {
onToastRef.current = onToast;
}, [onToast]);
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions src/types/errors.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

export interface DecodedError extends ErrorDefinition {
rawError: string;
isUnknown?: boolean;
}
122 changes: 122 additions & 0 deletions src/utils/errorDecoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// src/utils/errorDecoder.ts
import type { DecodedError, ErrorDefinition } from '@/types/errors';

const ErrorCatalog: Record<string, ErrorDefinition> = {
'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
};
}
18 changes: 18 additions & 0 deletions tests/errorDecoder.test.ts
Original file line number Diff line number Diff line change
@@ -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.
});
Loading