diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 55cff370..15962664 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/packages/ui/src/Common/Toast/Toast.stories.tsx b/packages/ui/src/Common/Toast/Toast.stories.tsx index bb0b53c8..3aee3797 100644 --- a/packages/ui/src/Common/Toast/Toast.stories.tsx +++ b/packages/ui/src/Common/Toast/Toast.stories.tsx @@ -53,6 +53,58 @@ Warning.args = { onClose: () => console.log("Toast closed"), }; +export const ErrorWithEnhancedDetails = SingleToastTemplate.bind({}); +ErrorWithEnhancedDetails.args = { + id: "enhanced-error", + type: "error", + title: "Contract Error", + message: + "The slippage exceeds the allowable limit. In Phase 1 of the launch, it's capped at 1%. Please try to swap a lower amount of tokens.", + onClose: () => console.log("Toast closed"), + // Simulate enhanced error object from the new error resolver + error: { + message: "Transaction simulation failed: HostError: Error(Contract, #300)", + userFriendlyMessage: + "The slippage exceeds the allowable limit. In Phase 1 of the launch, it's capped at 1%. Please try to swap a lower amount of tokens.", + errorCode: 300, + contractType: "pair", + stack: + "Error: Transaction simulation failed: HostError: Error(Contract, #300)\n at PhoenixPairContract.swap (contract.js:123:15)\n at executeContractTransaction (useContractTransaction.tsx:45:20)", + }, +}; + +export const ErrorWithStakeContract = SingleToastTemplate.bind({}); +ErrorWithStakeContract.args = { + id: "stake-error", + type: "error", + title: "Staking Error", + message: "Insufficient staking balance", + onClose: () => console.log("Toast closed"), + error: { + message: "Error(Contract, #503)", + userFriendlyMessage: "Insufficient staking balance", + errorCode: 503, + contractType: "stake", + }, +}; + +export const ErrorWithUnknownCode = SingleToastTemplate.bind({}); +ErrorWithUnknownCode.args = { + id: "unknown-error", + type: "error", + title: "Unknown Contract Error", + message: + "Contract error occurred (code: 999). Please try again or contact support.", + onClose: () => console.log("Toast closed"), + error: { + message: "Error(Contract, #999)", + userFriendlyMessage: + "Contract error occurred (code: 999). Please try again or contact support.", + errorCode: 999, + contractType: null, + }, +}; + export const Info = SingleToastTemplate.bind({}); Info.args = { id: "4", diff --git a/packages/ui/src/Common/Toast/Toast.tsx b/packages/ui/src/Common/Toast/Toast.tsx index 78b75466..605e2169 100644 --- a/packages/ui/src/Common/Toast/Toast.tsx +++ b/packages/ui/src/Common/Toast/Toast.tsx @@ -6,8 +6,8 @@ import { CircularProgress, Link, Collapse, + Button, } from "@mui/material"; -import { motion } from "framer-motion"; import CloseIcon from "@mui/icons-material/Close"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import ErrorIcon from "@mui/icons-material/Error"; @@ -16,6 +16,7 @@ import WarningIcon from "@mui/icons-material/Warning"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import LaunchIcon from "@mui/icons-material/Launch"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import { colors, typography, @@ -34,7 +35,16 @@ export interface ToastProps { onClose: (id: string) => void; autoHideDuration?: number; transactionId?: string; - error?: Error | string | { message: string; stack?: string }; // For collapsible error details + error?: + | Error + | string + | { + message?: string; + stack?: string; + userFriendlyMessage?: string; + errorCode?: number | null; + contractType?: string | null; + }; // For collapsible error details with enhanced error resolver support } const getToastIcon = (type: ToastType) => { @@ -48,7 +58,20 @@ const getToastIcon = (type: ToastType) => { case "info": return ; case "loading": - return ; + return ( + + ); default: return ; } @@ -70,6 +93,22 @@ const getToastColor = (type: ToastType) => { } }; +// Helper function to convert hex color to rgb values for gradient usage +const hexToRgb = (hex: string) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt( + result[3], + 16 + )}` + : "249, 115, 22"; // Fallback to primary color RGB +}; + +// Helper function to get RGB from toast color +const getToastColorRgb = (type: ToastType) => { + return hexToRgb(getToastColor(type)); +}; + // Helper function to get Explorer URL for transaction const getExplorerUrl = (transactionId: string) => { return `https://stellar.expert/explorer/public/tx/${transactionId}`; @@ -81,7 +120,7 @@ export const Toast = ({ title, message, onClose, - autoHideDuration = 5000, + autoHideDuration = type === "error" ? 10000 : 5000, // Error toasts stay 10 seconds, others 5 seconds transactionId, error, }: ToastProps) => { @@ -101,92 +140,217 @@ export const Toast = ({ }, [id, onClose, autoHideDuration, type]); // Format error details for display - const errorMessage = React.useMemo(() => { - if (!error) return ""; - if (typeof error === "string") return error; + const errorDetails = React.useMemo(() => { + if (!error) return { display: "", technical: "", hasTechnical: false }; + + if (typeof error === "string") { + return { display: error, technical: error, hasTechnical: false }; + } - // Handle Error objects and plain objects with message/stack properties + // Handle enhanced error objects with user-friendly messages const errorObj = error as any; - return errorObj.stack || errorObj.message || String(error); + + // If we have enhanced error details, format them nicely + if (errorObj.userFriendlyMessage && errorObj.errorCode !== undefined) { + const technicalInfo = [ + `Error Code: ${errorObj.errorCode}`, + errorObj.contractType + ? `Contract Type: ${errorObj.contractType}` + : null, + `Technical Details: ${ + errorObj.message || errorObj.stack || "Unknown error" + }`, + errorObj.stack ? `Stack Trace:\n${errorObj.stack}` : null, + ] + .filter(Boolean) + .join("\n"); + + return { + display: errorObj.stack || errorObj.message || String(error), + technical: technicalInfo, + hasTechnical: true, + }; + } + + // Fallback to original behavior + return { + display: errorObj.stack || errorObj.message || String(error), + technical: errorObj.stack || errorObj.message || String(error), + hasTechnical: Boolean(errorObj.stack), + }; }, [error]); - const hasErrorDetails = type === "error" && errorMessage; + const hasErrorDetails = type === "error" && errorDetails.display; + + // Copy technical details to clipboard + const copyErrorDetails = React.useCallback(async () => { + if (errorDetails.technical) { + try { + await navigator.clipboard.writeText(errorDetails.technical); + } catch (err) { + console.error("Failed to copy error details:", err); + // Fallback for older browsers + const textArea = document.createElement("textarea"); + textArea.value = errorDetails.technical; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); + } + } + }, [errorDetails.technical]); return ( - - - {type === "loading" ? ( - - ) : ( - getToastIcon(type) - )} - - - - {title && ( - - {title} - - )} + {type === "loading" ? ( + + ) : ( + getToastIcon(type) + )} + + + {title && ( - {message} + {title} + )} + + + {message} + - {/* Transaction Link */} - {transactionId && ( + {/* Transaction Link */} + {transactionId && ( + View Transaction - )} + + )} - {/* Error expand/collapse button */} - {hasErrorDetails && ( + {/* Error expand/collapse and copy buttons */} + {hasErrorDetails && ( + setExpanded(!expanded)} sx={{ display: "flex", alignItems: "center", - mt: 1, - cursor: "pointer", - fontSize: typography.fontSize.xs, - color: colors.primary.main, + gap: spacing.sm, + flexWrap: "wrap", }} > - - {expanded ? "Hide Details" : "Show Details"} - - {expanded ? ( - - ) : ( - + setExpanded(!expanded)} + sx={{ + display: "flex", + alignItems: "center", + cursor: "pointer", + fontSize: typography.fontSize.xs, + color: colors.primary.main, + padding: "4px 8px", + borderRadius: borderRadius.sm, + background: `linear-gradient(135deg, rgba(249, 115, 22, 0.1) 0%, rgba(249, 115, 22, 0.05) 100%)`, + border: `1px solid rgba(249, 115, 22, 0.2)`, + transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)", + "&:hover": { + background: `linear-gradient(135deg, rgba(249, 115, 22, 0.2) 0%, rgba(249, 115, 22, 0.1) 100%)`, + border: `1px solid rgba(249, 115, 22, 0.4)`, + transform: "translateY(-1px)", + boxShadow: `0 4px 12px rgba(249, 115, 22, 0.2)`, + }, + }} + > + + {expanded ? "Hide Details" : "Show Details"} + + {expanded ? ( + + ) : ( + + )} + + + {errorDetails.hasTechnical && ( + )} - )} - - - {/* Only show close button for non-loading toasts */} - {type !== "loading" && ( - onClose(id)} - sx={{ - padding: "4px", - color: colors.neutral[400], - "&:hover": { - color: colors.neutral[200], - backgroundColor: "transparent", - }, - }} - > - - + )} - {/* Collapsible Error Details */} - {hasErrorDetails && ( - - - {errorMessage} - - + {/* Only show close button for non-loading toasts */} + {type !== "loading" && ( + onClose(id)} + sx={{ + padding: "6px", + color: colors.neutral[400], + borderRadius: "50%", + background: `linear-gradient(135deg, rgba(115, 115, 115, 0.1) 0%, rgba(115, 115, 115, 0.05) 100%)`, + border: `1px solid rgba(115, 115, 115, 0.2)`, + transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)", + "&:hover": { + color: colors.neutral[200], + background: `linear-gradient(135deg, rgba(115, 115, 115, 0.2) 0%, rgba(115, 115, 115, 0.1) 100%)`, + border: `1px solid rgba(115, 115, 115, 0.4)`, + boxShadow: `0 4px 12px rgba(115, 115, 115, 0.2)`, + }, + }} + > + + )} - + + {/* Collapsible Error Details */} + {hasErrorDetails && ( + + + {errorDetails.display} + + + )} + ); }; diff --git a/packages/ui/src/Common/Toast/ToastContainer.tsx b/packages/ui/src/Common/Toast/ToastContainer.tsx index b2a9a70c..04320aaa 100644 --- a/packages/ui/src/Common/Toast/ToastContainer.tsx +++ b/packages/ui/src/Common/Toast/ToastContainer.tsx @@ -1,8 +1,8 @@ import React, { useContext } from "react"; import { Box } from "@mui/material"; -import { AnimatePresence } from "framer-motion"; +import { AnimatePresence, motion } from "framer-motion"; import { Toast, ToastProps } from "./Toast"; -import { spacing } from "../../Theme/styleConstants"; +import { spacing, colors, borderRadius } from "../../Theme/styleConstants"; import { useToast } from "./useToast"; interface ToastContainerProps { @@ -51,35 +51,76 @@ export const ToastContainer = ({ }; return ( - - - {toasts.map((toast) => ( - - ))} - - + + + {toasts.map((toast, index) => ( + + + + ))} + + + ); }; diff --git a/packages/ui/src/Common/Toast/useToast.tsx b/packages/ui/src/Common/Toast/useToast.tsx index 2dbd4d04..d849426a 100644 --- a/packages/ui/src/Common/Toast/useToast.tsx +++ b/packages/ui/src/Common/Toast/useToast.tsx @@ -2,6 +2,9 @@ import React, { useState, useCallback, useContext, createContext } from "react"; import { v4 as uuidv4 } from "uuid"; import { ToastProps, ToastType } from "./Toast"; +// Import enhanced error resolver +import { resolveContractErrorEnhanced } from "@phoenix-protocol/utils/src/enhancedErrorResolver"; + interface ToastContextType { toasts: ToastProps[]; addToast: (options: Omit) => string; @@ -112,38 +115,61 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ : "Operation completed successfully!", transactionId, }); + + // Auto remove success toasts after 5 seconds + setTimeout(() => { + removeToast(toastId); + }, 5000); + return result; } catch (error) { - // Create a serializable error object - const errorMessage = + // Enhanced error processing using the new error resolver + const errorString = error instanceof Error ? error.message : typeof error === "object" && error !== null && "message" in error - ? error.message - : "Operation failed"; + ? String(error.message) + : String(error); + // Use enhanced error resolver to get user-friendly message + const errorResult = resolveContractErrorEnhanced(errorString); + + // Create a serializable error object with enhanced details const errorObj = error instanceof Error - ? { message: error.message, stack: error.stack } + ? { + message: error.message, + stack: error.stack, + userFriendlyMessage: errorResult.userFriendlyMessage, + errorCode: errorResult.errorCode, + contractType: errorResult.contractType, + } : typeof error === "object" && error !== null - ? error - : { message: String(error) }; + ? { + ...error, + userFriendlyMessage: errorResult.userFriendlyMessage, + errorCode: errorResult.errorCode, + contractType: errorResult.contractType, + } + : { + message: String(error), + userFriendlyMessage: errorResult.userFriendlyMessage, + errorCode: errorResult.errorCode, + contractType: errorResult.contractType, + }; updateToast(toastId, { type: "error", - message: errorMessage as string, - error: errorObj as - | string - | Error - | { message: string; stack?: string | undefined } - | undefined, // Store a serializable error object + message: errorResult.userFriendlyMessage, // Use user-friendly message as primary + error: errorObj, // Store enhanced error details for "Show Details" functionality }); - throw error; - } finally { - // Auto remove the toast after 5 seconds + + // Auto remove error toasts after 10 seconds setTimeout(() => { removeToast(toastId); - }, 5000); + }, 10000); + + throw error; } }, [addToast, updateToast, removeToast] diff --git a/packages/utils/jest.config.js b/packages/utils/jest.config.js index b413e106..f1a69ee1 100644 --- a/packages/utils/jest.config.js +++ b/packages/utils/jest.config.js @@ -2,4 +2,6 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + testMatch: ['/src/**/__tests__/**/*.test.(ts|tsx|js)'], + roots: ['/src'], }; \ No newline at end of file diff --git a/packages/utils/package.json b/packages/utils/package.json index 966fe8fd..0c8939c2 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -10,7 +10,7 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", - "test": "echo \"Error: no test specified\" && exit 0" + "test": "jest" }, "np": { "publish": false, diff --git a/packages/utils/src/__tests__/enhancedErrorResolver.test.ts b/packages/utils/src/__tests__/enhancedErrorResolver.test.ts new file mode 100644 index 00000000..6017b828 --- /dev/null +++ b/packages/utils/src/__tests__/enhancedErrorResolver.test.ts @@ -0,0 +1,172 @@ +import { + resolveContractErrorEnhanced, + extractErrorCodeFromMessage, +} from "../enhancedErrorResolver"; + +describe("Enhanced Error Resolver", () => { + describe("extractErrorCodeFromMessage", () => { + it("should extract error codes from standard Soroban error format", () => { + const result = extractErrorCodeFromMessage("Error(Contract, #10)"); + expect(result).toEqual({ code: 10 }); + }); + + it("should extract error codes from HostError format", () => { + const result = extractErrorCodeFromMessage( + "HostError: Error(Contract, #300)" + ); + expect(result).toEqual({ code: 300 }); + }); + + it("should extract error codes from transaction simulation failed format", () => { + const result = extractErrorCodeFromMessage( + "Transaction simulation failed: HostError: Error(Contract, #101)" + ); + expect(result).toEqual({ code: 101 }); + }); + + it("should extract error codes from complex transaction simulation with event logs", () => { + const complexError = + 'Transaction simulation failed: "HostError: Error(Contract, #300) Event log (newest first): 0: [Diagnostic Event] contract:CCLZRD4E72T7JCZCN3P7KNPYNXFYKQCL64ECLX7WP5GNVYPHJGU2IO2G, topics:[error, Error(Contract, #300)], data:"escalating error to VM trap from failed host function call: call"'; + const result = extractErrorCodeFromMessage(complexError); + expect(result).toEqual({ code: 300 }); + }); + + it("should extract error codes from event log data", () => { + const eventLogError = + 'topics:[error, Error(Contract, #300)], data:["failing with contract error", 300]'; + const result = extractErrorCodeFromMessage(eventLogError); + expect(result).toEqual({ code: 300 }); + }); + + it("should return null for messages without error codes", () => { + const result = extractErrorCodeFromMessage("Generic error message"); + expect(result).toBeNull(); + }); + }); + + describe("resolveContractErrorEnhanced", () => { + it("should resolve factory contract errors correctly", () => { + const result = resolveContractErrorEnhanced("Error(Contract, #100)"); + expect(result.userFriendlyMessage).toBe( + "The selected token is not supported. Please choose a different token." + ); + expect(result.errorCode).toBe(100); + }); + + it("should resolve pair contract errors correctly", () => { + const result = resolveContractErrorEnhanced("Error(Contract, #300)"); + expect(result.userFriendlyMessage).toContain( + "price difference is too high" + ); + expect(result.errorCode).toBe(300); + }); + + it("should resolve stake contract errors correctly", () => { + const result = resolveContractErrorEnhanced("Error(Contract, #500)"); + expect(result.userFriendlyMessage).toBe( + "The staking system is not ready yet. Please try again later." + ); + expect(result.errorCode).toBe(500); + }); + + it("should resolve multihop contract errors correctly", () => { + const result = resolveContractErrorEnhanced("Error(Contract, #200)"); + expect(result.userFriendlyMessage).toBe( + "The multi-step trading system is not ready yet. Please try again later." + ); + expect(result.errorCode).toBe(200); + }); + + it("should resolve vesting contract errors correctly", () => { + const result = resolveContractErrorEnhanced("Error(Contract, #700)"); + expect(result.userFriendlyMessage).toBe( + "The token vesting system is not ready yet. Please try again later." + ); + expect(result.errorCode).toBe(700); + }); + + it("should handle unknown error codes gracefully", () => { + const result = resolveContractErrorEnhanced("Error(Contract, #999)"); + expect(result.userFriendlyMessage).toContain( + "Something went wrong (error code: 999)" + ); + expect(result.errorCode).toBe(999); + }); + + it("should handle messages without error codes", () => { + const result = resolveContractErrorEnhanced("Generic network error"); + expect(result.userFriendlyMessage).toContain( + "Something unexpected happened" + ); + expect(result.errorCode).toBeNull(); + }); + + it("should infer contract type from error code ranges", () => { + const result = resolveContractErrorEnhanced("Error(Contract, #300)"); + expect(result.contractType).toBe("pair"); + expect(result.userFriendlyMessage).toContain( + "price difference is too high" + ); + }); + + it("should infer contract type from context when error code is unknown", () => { + const result = resolveContractErrorEnhanced( + "Liquidity pool error: Error(Contract, #999)" + ); + expect(result.contractType).toBe("pair"); + expect(result.userFriendlyMessage).toContain( + "Something went wrong (error code: 999)" + ); + }); + + it("should preserve technical details", () => { + const errorMessage = + "Transaction simulation failed: HostError: Error(Contract, #101)"; + const result = resolveContractErrorEnhanced(errorMessage); + expect(result.technicalDetails).toBe(errorMessage); + expect(result.userFriendlyMessage).toBe( + "This token pair already exists. You can find it in the pools section." + ); + }); + + it("should handle real-world complex transaction simulation errors", () => { + const complexRealError = + 'Transaction simulation failed: "HostError: Error(Contract, #300) Event log (newest first): 0: [Diagnostic Event] contract:CCLZRD4E72T7JCZCN3P7KNPYNXFYKQCL64ECLX7WP5GNVYPHJGU2IO2G, topics:[error, Error(Contract, #300)], data:"escalating error to VM trap from failed host function call: call" 1: [Diagnostic Event] contract:CCLZRD4E72T7JCZCN3P7KNPYNXFYKQCL64ECLX7WP5GNVYPHJGU2IO2G, topics:[error, Error(Contract, #300)], data:["contract call failed", swap, [GAPRPZYCIV3QPMCTWSRDNY64EJMZNCJFUCTJHQDQNW6RJ66TEVEH5UDU, CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA, 60000000000, Void, 100, Void, Void]]'; + const result = resolveContractErrorEnhanced(complexRealError); + expect(result.errorCode).toBe(300); + expect(result.userFriendlyMessage).toContain( + "price difference is too high" + ); + expect(result.contractType).toBe("pair"); + expect(result.technicalDetails).toBe(complexRealError); + }); + }); +}); + +// Example usage demonstrations +console.log("=== Enhanced Error Resolver Examples ==="); + +// Example 1: Standard contract error +const error1 = resolveContractErrorEnhanced("Error(Contract, #300)"); +console.log("Example 1 - Standard error:"); +console.log("User message:", error1.userFriendlyMessage); +console.log("Error code:", error1.errorCode); +console.log("Technical:", error1.technicalDetails); +console.log(""); + +// Example 2: Complex transaction error +const error2 = resolveContractErrorEnhanced( + "Transaction simulation failed: HostError: Error(Contract, #101)" +); +console.log("Example 2 - Transaction simulation error:"); +console.log("User message:", error2.userFriendlyMessage); +console.log("Error code:", error2.errorCode); +console.log("Technical:", error2.technicalDetails); +console.log(""); + +// Example 3: Unknown error +const error3 = resolveContractErrorEnhanced("Network connection failed"); +console.log("Example 3 - Unknown error:"); +console.log("User message:", error3.userFriendlyMessage); +console.log("Error code:", error3.errorCode); +console.log("Technical:", error3.technicalDetails); diff --git a/packages/utils/src/enhancedErrorResolver.ts b/packages/utils/src/enhancedErrorResolver.ts new file mode 100644 index 00000000..b3aa7184 --- /dev/null +++ b/packages/utils/src/enhancedErrorResolver.ts @@ -0,0 +1,294 @@ +// Enhanced error code extraction that supports multiple error formats +export function extractErrorCodeFromMessage( + message: string +): { code: number; contractType?: string } | null { + // Pattern 1: Standard Soroban error - "Error(Contract, #10)" + const standardMatch = message.match(/Error\(Contract,\s*#(\d+)\)/); + if (standardMatch) { + return { code: parseInt(standardMatch[1], 10) }; + } + + // Pattern 2: Detailed error messages - "HostError: Error(Contract, #10)" + const hostErrorMatch = message.match( + /HostError:\s*Error\(Contract,\s*#(\d+)\)/ + ); + if (hostErrorMatch) { + return { code: parseInt(hostErrorMatch[1], 10) }; + } + + // Pattern 3: Transaction simulation failed with diagnostic events + // Example: "Transaction simulation failed: "HostError: Error(Contract, #300) Event log..." + const simulationDetailedMatch = message.match( + /Transaction simulation failed:\s*"?HostError:\s*Error\(Contract,\s*#(\d+)\)/i + ); + if (simulationDetailedMatch) { + return { code: parseInt(simulationDetailedMatch[1], 10) }; + } + + // Pattern 4: Error within event logs - look for error codes in event data + const eventLogMatch = message.match( + /topics:\[error,\s*Error\(Contract,\s*#(\d+)\)\]|data:.*?Error\(Contract,\s*#(\d+)\)|failing with contract error.*?(\d+)/i + ); + if (eventLogMatch) { + const errorCode = eventLogMatch[1] || eventLogMatch[2] || eventLogMatch[3]; + return { code: parseInt(errorCode, 10) }; + } + + // Pattern 5: Contract-specific error with contract name + const contractSpecificMatch = message.match( + /(\w+)(?:Contract)?.*?Error.*?#(\d+)/i + ); + if (contractSpecificMatch) { + return { + code: parseInt(contractSpecificMatch[2], 10), + contractType: contractSpecificMatch[1].toLowerCase(), + }; + } + + // Pattern 6: Simple transaction simulation failed format + const simulationMatch = message.match( + /simulation failed.*?Error\(Contract,\s*#(\d+)\)/i + ); + if (simulationMatch) { + return { code: parseInt(simulationMatch[1], 10) }; + } + + return null; +} + +// Contract error definitions organized by error code ranges +// This approach works better since multihop can throw pair errors, factory errors, etc. +// Note: Soroban token standard (SEP-41) uses panic-based error handling rather than numbered codes +const ERROR_CODE_DEFINITIONS: { [code: number]: string } = { + // Note: Error codes 1-10 are reserved but not officially defined by SEP-41 + // The Soroban token standard uses panic! with descriptive messages instead of numbered codes + // These are generic fallbacks for custom token implementations that might use numbered errors + 1: "There was an issue with your token transaction. Please check your balance and try again.", + 2: "You don't have permission to perform this action with these tokens.", + 3: "This token operation is currently restricted. Please try again later.", + 4: "The token amount you entered is not valid. Please enter a positive number.", + 5: "The token operation failed. Please check your inputs and try again.", + 6: "You've exceeded the spending allowance for these tokens.", + 7: "The requested token operation could not be completed.", + 8: "There was an issue with the token transaction. Please verify the details and try again.", + 9: "The token operation could not be processed. Please try again.", + 10: "You don't have enough tokens to complete this request. If you are using XLM, make sure you have enough XLM for reserves and trustlines.", + + // Phoenix Factory Contract Errors (100-112) + 100: "The selected token is not supported. Please choose a different token.", + 101: "This token pair already exists. You can find it in the pools section.", + 102: "You cannot create a pair with the same token twice. Please select two different tokens.", + 103: "The system is already set up and ready to use.", + 104: "The system is not ready yet. Please try again later.", + 105: "No administrator has been assigned to manage this system.", + 106: "You don't have permission to perform this action. Only administrators can do this.", + 107: "The new administrator is the same as the current one. No changes needed.", + 108: "This token pair doesn't exist yet. You may need to create it first.", + 109: "Too many accounts have been whitelisted. The limit has been reached.", + 110: "Your account is not approved for this action. Please contact support.", + 111: "The settings for this token pair haven't been configured yet.", + 112: "The settings for this token pair have already been set up.", + + // Phoenix Multihop Contract Errors (200-206) + 200: "The multi-step trading system is not ready yet. Please try again later.", + 201: "You need to specify a valid trading path. The path cannot be empty.", + 202: "You don't have enough tokens to complete this multi-step trade.", + 203: "The price moved too much during your multi-step trade. Try with higher slippage tolerance.", + 204: "One or more trading pools in your path don't have enough tokens available.", + 205: "There's an invalid token in your trading path. Please check your route.", + 206: "Your multi-step trade took too long and expired. Please try again.", + + // Phoenix Pair Contract Errors (300-332) + 300: "The price difference is too high for your safety. Try swapping a smaller amount or increase your slippage tolerance in settings.", + 301: "This trading pair is not ready yet. Please try again later.", + 302: "This trading pair is already set up and working.", + 303: "The slippage tolerance you set is too high for providing liquidity. Please lower it for your safety.", + 304: "You need to enter an amount greater than zero for at least one token to provide liquidity.", + 305: "The minimum amount you'll receive doesn't meet your requirements. Try adjusting your settings.", + 306: "Both pools and deposit amounts must be positive numbers to split your deposit.", + 307: "The total fees cannot be more than 100%. Please check the fee settings.", + 308: "The minimum amount of the first token is higher than what you want to provide.", + 309: "The minimum amount of the second token is higher than what you want to provide.", + 310: "You're trying to provide more of the first token than you intended.", + 311: "You're providing less of the first token than the minimum required.", + 312: "You're trying to provide more of the second token than you intended.", + 313: "You're providing less of the second token than the minimum required.", + 314: "The total shares amount cannot be zero. Please check your liquidity amounts.", + 315: "The amounts you want to provide must be greater than zero.", + 316: "The minimum amounts cannot be negative numbers.", + 317: "This pool doesn't have enough tokens available for your trade. Try a smaller amount.", + 318: "You don't have enough tokens in your wallet for this swap.", + 319: "This trading pair is currently inactive. Please try again later.", + 320: "You don't have permission to perform this action.", + 321: "There's something wrong with your swap settings. Please check and try again.", + 322: "This liquidity pool is empty. No trades can be made right now.", + 323: "You cannot provide liquidity with zero amounts. Please enter valid amounts.", + 324: "You're trying to withdraw more than your available shares allow.", + 325: "This trade would affect the price too much. Try a smaller amount.", + 326: "Your transaction took too long and expired. Please try again.", + 327: "The fee settings are incorrect. Please contact support.", + 328: "The ratio between tokens is outside acceptable limits.", + 329: "The pool has run out of tokens. Please try again later.", + 330: "The price moved too much during your transaction. Try again with higher slippage tolerance.", + 331: "You would receive less tokens than expected. Try a smaller trade or higher slippage.", + 332: "This swap would result in a negative balance, which is not allowed.", + + // Phoenix Stake Contract Errors (500-520) + 500: "The staking system is not ready yet. Please try again later.", + 501: "The staking system is already set up and working.", + 502: "You don't have permission to perform this staking action.", + 503: "You don't have enough staked tokens for this action.", + 504: "You need to stake more than zero tokens. Please enter a valid amount.", + 505: "Your staking period hasn't finished yet. Please wait before withdrawing.", + 506: "You don't have any rewards to claim right now.", + 507: "The staking pool is full. Please try again later or stake in a different pool.", + 508: "The amount you want to stake is below the minimum required. Please stake more.", + 509: "The amount you want to stake is above the maximum allowed. Please stake less.", + 510: "Staking is temporarily paused. Please try again later.", + 511: "There's an issue with the staking settings. Please contact support.", + 512: "There was an error calculating your rewards. Please try again.", + 513: "You need to wait longer before you can unstake your tokens.", + 514: "You don't have any active stakes in this pool.", + 515: "The stake you're looking for doesn't exist.", + 516: "You've already withdrawn from this stake.", + 517: "Withdrawing early will result in a penalty fee.", + 518: "The reward pool has run out of tokens. Please contact support.", + 519: "The reward rate settings are incorrect. Please contact support.", + 520: "You need to wait before you can stake again.", + + // Phoenix Vesting Contract Errors (700-721) + 700: "The token vesting system is not ready yet. Please try again later.", + 701: "The token vesting system is already set up and working.", + 702: "You don't have access to this vesting schedule. Please check with the administrator.", + 703: "The vesting schedule you're looking for doesn't exist.", + 704: "You don't have any tokens available to withdraw right now.", + 705: "Your vesting period hasn't started yet. Please wait until the start date.", + 706: "There's an issue with the vesting settings. Please contact support.", + 707: "Your cliff period hasn't ended yet. You need to wait longer before claiming tokens.", + 708: "You're trying to claim more tokens than are available in your vesting schedule.", + 709: "You cannot create a vesting schedule with zero tokens.", + 710: "The vesting period must be longer than zero days.", + 711: "The cliff period cannot be longer than the total vesting period.", + 712: "The beneficiary address is not valid. Please check the address.", + 713: "You've already claimed all tokens from this vesting schedule.", + 714: "There aren't enough tokens in the contract to fulfill your vesting schedule.", + 715: "The vesting start time cannot be in the past.", + 716: "Too many vesting schedules have been created. The limit has been reached.", + 717: "This vesting schedule has been temporarily paused.", + 718: "Only the administrator can change vesting settings.", + 719: "Your vesting tokens are currently locked and cannot be accessed.", + 720: "Early withdrawal is not allowed for this vesting schedule.", + 721: "There was an error calculating your vesting amounts. Please contact support.", +}; + +/** + * Attempts to determine the contract type based on error code ranges or context + */ +function inferContractType( + errorCode: number, + errorMessage: string +): string | null { + // Determine contract type based on error code ranges + if (errorCode >= 1 && errorCode <= 10) { + // Note: These are generic token errors since SEP-41 doesn't define standard numbered codes + return "token"; + } else if (errorCode >= 100 && errorCode <= 112) { + return "factory"; + } else if (errorCode >= 200 && errorCode <= 206) { + return "multihop"; + } else if (errorCode >= 300 && errorCode <= 332) { + return "pair"; + } else if (errorCode >= 500 && errorCode <= 520) { + return "stake"; + } else if (errorCode >= 700 && errorCode <= 721) { + return "vesting"; + } + + // Fallback: try to infer from error message context + const lowerMessage = errorMessage.toLowerCase(); + if ( + lowerMessage.includes("factory") || + lowerMessage.includes("pair creation") + ) { + return "factory"; + } + if ( + lowerMessage.includes("liquidity") || + lowerMessage.includes("swap") || + lowerMessage.includes("pool") + ) { + return "pair"; + } + if (lowerMessage.includes("stake") || lowerMessage.includes("staking")) { + return "stake"; + } + if (lowerMessage.includes("multihop") || lowerMessage.includes("path")) { + return "multihop"; + } + if (lowerMessage.includes("vesting") || lowerMessage.includes("cliff")) { + return "vesting"; + } + if ( + lowerMessage.includes("token") || + lowerMessage.includes("balance") || + lowerMessage.includes("transfer") + ) { + return "token"; + } + + return null; +} + +/** + * Enhanced contract error resolver that provides user-friendly error messages + */ +export function resolveContractErrorEnhanced( + errorMessage: string, + contractAddress?: string, + contractType?: string +): { + userFriendlyMessage: string; + errorCode: number | null; + contractType: string | null; + technicalDetails: string; +} { + const extractedError = extractErrorCodeFromMessage(errorMessage); + + if (!extractedError) { + return { + userFriendlyMessage: + "Something unexpected happened. Please try again, and if the problem continues, contact our support team.", + errorCode: null, + contractType: null, + technicalDetails: errorMessage, + }; + } + + const { code } = extractedError; + + // Determine contract type based on error code or context + const finalContractType = + contractType || + extractedError.contractType || + inferContractType(code, errorMessage); + + // Get user-friendly message directly from error code + const userFriendlyMessage = + ERROR_CODE_DEFINITIONS[code] || + `Something went wrong (error code: ${code}). Please try again or contact our support team with this error code.`; + + return { + userFriendlyMessage, + errorCode: code, + contractType: finalContractType, + technicalDetails: errorMessage, + }; +} + +/** + * Legacy function for backward compatibility + */ +export function resolveContractErrorLegacy(eventString: string): string { + const result = resolveContractErrorEnhanced(eventString); + return result.userFriendlyMessage; +} diff --git a/packages/utils/src/errorResolver.ts b/packages/utils/src/errorResolver.ts index 4a7d3e62..53a73406 100644 --- a/packages/utils/src/errorResolver.ts +++ b/packages/utils/src/errorResolver.ts @@ -1,13 +1,11 @@ +// Legacy error definitions for backward compatibility enum ContractError { SpreadExceedsLimit = 1, - ProvideLiquiditySlippageToleranceTooHigh = 2, ProvideLiquidityAtLeastOneTokenMustBeBiggerThenZero = 3, - WithdrawLiquidityMinimumAmountOfAOrBIsNotSatisfied = 4, SplitDepositBothPoolsAndDepositMustBePositive = 5, ValidateFeeBpsTotalFeesCantBeGreaterThen100 = 6, - GetDepositAmountsMinABiggerThenDesiredA = 7, GetDepositAmountsMinBBiggerThenDesiredB = 8, GetDepositAmountsAmountABiggerThenDesiredA = 9, @@ -19,23 +17,39 @@ enum ContractError { MinAmountsBelowZero = 15, } +// Import enhanced error resolver +import { + resolveContractErrorEnhanced as resolveContractErrorEnhanced, + extractErrorCodeFromMessage, +} from "./enhancedErrorResolver"; + /** - * Extracts the error code from a diagnostic event string. - * @param {string} eventString - * @returns {number | null} The error code or null if not found - * @example - * extractErrorCodeFromDiagnosticEvent("Error(Contract, #1)") // 1 - * extractErrorCodeFromDiagnosticEvent("Error(Contract, #2)") // 2 + * Legacy function - extracts the error code from a diagnostic event string. + * @deprecated Use extractErrorCodeFromMessage from enhancedErrorResolver instead */ function extractErrorCodeFromDiagnosticEvent( eventString: string ): number | null { - const errorRegex = /Error\(Contract, #(\d+)\)/; - const match = eventString.match(errorRegex); - return match ? parseInt(match[1], 10) : null; + return extractErrorCodeFromMessage(eventString)?.code || null; } +/** + * Enhanced contract error resolver that provides user-friendly error messages + * with fallback to legacy error handling for backward compatibility + */ export function resolveContractError(eventString: string): string { + // Try enhanced resolver first + const enhancedResult = resolveContractErrorEnhanced(eventString); + + // If enhanced resolver found a meaningful message, use it + if ( + enhancedResult.errorCode !== null && + enhancedResult.userFriendlyMessage !== "Unknown error occurred." + ) { + return enhancedResult.userFriendlyMessage; + } + + // Fallback to legacy resolver for backward compatibility const errorCode = extractErrorCodeFromDiagnosticEvent(eventString); switch (errorCode) { @@ -70,6 +84,9 @@ export function resolveContractError(eventString: string): string { case ContractError.MinAmountsBelowZero: return "The minimum amounts cannot be below zero."; default: - return "Unknown error."; + return enhancedResult.userFriendlyMessage || "Unknown error."; } } + +// Re-export for convenience (but use the enhanced resolver internally) +export { extractErrorCodeFromMessage }; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index b8ac1801..7a34b6de 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -9,6 +9,7 @@ export * from "./sep10"; export * from "./graphql"; export * from "./prices"; export * from "./errorResolver"; +export * from "./enhancedErrorResolver"; export * from "./trustlines"; export * from "./api"; export * as TradeAPi from "./trade_api";