From 8ba050f6c54d416911b6724c99c1021d26ab68dd Mon Sep 17 00:00:00 2001 From: gracious01-tech Date: Tue, 23 Jun 2026 23:09:36 +0000 Subject: [PATCH] Improve Soroban and network error messaging --- src/utils/errors.ts | 110 ++++++++++++++++++++++---------------- tests/unit/errors.test.ts | 63 ++++++++++++++++++++++ 2 files changed, 128 insertions(+), 45 deletions(-) create mode 100644 tests/unit/errors.test.ts diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 626326a..03eb626 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,75 +1,95 @@ -const SOROBAN_ERROR_CODES: Record = { - "tx_not_found": "Transaction was not found. Please wait a moment and try again.", - "tx_failed": "The transaction could not be processed. Check your balance and try again.", - "tx_too_late": "The transaction submission window has expired. Please resubmit.", - "tx_bad_seq": "The account sequence number is out of sync. Try reconnecting your wallet.", - "insufficient_balance": "Your account does not have enough funds for this operation.", - "insufficient_fee": "The resource fee provided is too low. Increase the fee and try again.", - "contract_not_found": "The smart contract could not be found on the network.", - "contract_error": "The smart contract returned an error. Contact the development team.", - "contract_not_activated": "This contract has not been activated yet.", - "auth_required": "Wallet authorization is required to perform this action.", - "auth_revoked": "Your session authorization has been revoked. Please re-authenticate.", - "bad_nonce": "The provided nonce is invalid. Try again with a fresh one.", - "network_mismatch": "Your wallet is connected to a different network. Switch to the correct network.", - "timeout": "The request timed out. Check your network connection and try again.", - "rate_limited": "Too many requests. Please wait a moment before trying again.", - "internal_error": "An internal system error occurred. Please try again later.", - "unknown_error": "An unexpected error occurred. Please try again or contact support.", +export const SOROBAN_ERROR_CODES: Record = { + tx_not_found: "Transaction was not found. Please wait a moment and try again.", + tx_failed: "The transaction could not be processed. Check your balance and try again.", + tx_too_late: "The transaction submission window has expired. Please resubmit.", + tx_bad_seq: "The account sequence number is out of sync. Try reconnecting your wallet.", + insufficient_balance: "Your account does not have enough funds for this operation.", + insufficient_fee: "The resource fee provided is too low. Increase the fee and try again.", + contract_not_found: "The smart contract could not be found on the network.", + contract_error: "The smart contract returned an error. Contact the development team.", + contract_not_activated: "This contract has not been activated yet.", + auth_required: "Wallet authorization is required to perform this action.", + auth_revoked: "Your session authorization has been revoked. Please re-authenticate.", + bad_nonce: "The provided nonce is invalid. Try again with a fresh one.", + network_mismatch: "Your wallet is connected to a different network. Switch to the correct network.", + timeout: "The request timed out. Check your network connection and try again.", + rate_limited: "Too many requests. Please wait a moment before trying again.", + internal_error: "An internal system error occurred. Please try again later.", + unknown_error: "An unexpected error occurred. Please try again or contact support.", }; -const NETWORK_ERROR_KEYWORDS: Record = { - "timeout": "The request took too long. Check your internet connection.", - "network": "A network error occurred. Verify you are connected to the internet.", - "abort": "The request was cancelled. Please try again.", - "fetch": "Could not reach the server. The API may be down.", - "dns": "Could not resolve the server address. Check your DNS settings.", - "cors": "Cross-origin request blocked. Contact the server administrator.", +export const NETWORK_ERROR_KEYWORDS: Record = { + timeout: "The request took too long. Check your internet connection.", + network: "A network error occurred. Verify you are connected to the internet.", + websocket: "The realtime connection dropped. Please refresh and try again.", + socket: "The connection was interrupted. Please check your network and try again.", + abort: "The request was cancelled. Please try again.", + fetch: "Could not reach the server. The API may be down.", + dns: "Could not resolve the server address. Check your DNS settings.", + cors: "Cross-origin request blocked. Contact the server administrator.", + econnrefused: "The server refused the connection. Please try again shortly.", + econnreset: "The connection was reset. Please check your network and try again.", }; export function resolveSorobanError(rawError: string): string { const normalized = rawError.toLowerCase().trim(); - for (const [keyword, message] of Object.entries(SOROBAN_ERROR_CODES)) { - if (normalized.includes(keyword)) return message; + if (!normalized) { + return "An unexpected error occurred. Please try again or contact support."; + } + + if (normalized.includes("timeout")) { + return NETWORK_ERROR_KEYWORDS.timeout; } - for (const [keyword, message] of Object.entries(NETWORK_ERROR_KEYWORDS)) { - if (normalized.includes(keyword)) return message; + const networkPriority = [ + "websocket", + "socket", + "fetch", + "network", + "dns", + "cors", + "abort", + "econnrefused", + "econnreset", + ]; + + for (const keyword of networkPriority) { + if (normalized.includes(keyword)) { + return NETWORK_ERROR_KEYWORDS[keyword] ?? "A network error occurred. Please try again."; + } + } + + for (const [keyword, message] of Object.entries(SOROBAN_ERROR_CODES)) { + if (normalized.includes(keyword)) { + return message; + } } - return rawError.length > 120 - ? `An error occurred (code: ${rawError.slice(0, 16)}...). Please try again.` - : rawError; + const summary = normalized.replace(/\s+/g, " ").slice(0, 40); + return `An unexpected error occurred. Please try again or contact support. (${summary})`; } export function classifyError( - error: unknown + error: unknown, ): { message: string; severity: "low" | "medium" | "high"; actionable: boolean } { - if (!error) { + if (error === undefined || error === null || error === "") { return { message: "An unknown error occurred.", severity: "low", actionable: false }; } const msg = error instanceof Error ? error.message : String(error); + const normalized = msg.toLowerCase(); const resolved = resolveSorobanError(msg); - if ( - msg.includes("auth") || - msg.includes("unauthorized") || - msg.includes("revoked") - ) { + if (/auth|unauthorized|revoked|forbidden/.test(normalized)) { return { message: resolved, severity: "high", actionable: true }; } - if ( - msg.includes("balance") || - msg.includes("fee") || - msg.includes("insufficient") - ) { + if (/balance|fee|insufficient|funds|gas/.test(normalized)) { return { message: resolved, severity: "medium", actionable: true }; } - if (msg.includes("timeout") || msg.includes("network")) { + if (/timeout|network|fetch|socket|websocket|dns|cors|abort|econn/.test(normalized)) { return { message: resolved, severity: "medium", actionable: true }; } diff --git a/tests/unit/errors.test.ts b/tests/unit/errors.test.ts new file mode 100644 index 0000000..981c0cf --- /dev/null +++ b/tests/unit/errors.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + SOROBAN_ERROR_CODES, + classifyError, + formatErrorForDisplay, + resolveSorobanError, +} from "@/utils/errors"; + +describe("resolveSorobanError()", () => { + it("maps known Soroban error substrings to readable messages", () => { + expect(resolveSorobanError("tx_bad_seq")).toContain("sequence"); + expect(resolveSorobanError("AUTH_REVOKED")).toContain("re-authenticate"); + expect(resolveSorobanError("insufficient_balance")).toContain("funds"); + }); + + it("falls back to network error keywords", () => { + expect(resolveSorobanError("Failed to fetch")).toContain("server"); + expect(resolveSorobanError("WebSocket connection timeout")).toContain("internet"); + }); + + it("wraps unknown errors in a generic message", () => { + const unknown = "some obscure internal issue with no mapping"; + expect(resolveSorobanError(unknown)).toContain("unexpected error"); + }); + + it("exposes a sizable dictionary of known codes", () => { + expect(Object.keys(SOROBAN_ERROR_CODES).length).toBeGreaterThanOrEqual(15); + }); +}); + +describe("classifyError()", () => { + it("marks authorization problems as high severity and actionable", () => { + const result = classifyError(new Error("AUTH_REVOKED")); + expect(result.severity).toBe("high"); + expect(result.actionable).toBe(true); + expect(result.message).toContain("re-authenticate"); + }); + + it("marks balance and fee issues as medium severity and actionable", () => { + const result = classifyError("insufficient_balance"); + expect(result.severity).toBe("medium"); + expect(result.actionable).toBe(true); + }); + + it("marks timeouts as medium severity and actionable", () => { + const result = classifyError("request timeout"); + expect(result.severity).toBe("medium"); + expect(result.actionable).toBe(true); + }); + + it("marks unknown failures as low severity and non-actionable", () => { + const result = classifyError("something unexpected"); + expect(result.severity).toBe("low"); + expect(result.actionable).toBe(false); + }); +}); + +describe("formatErrorForDisplay()", () => { + it("prefixes the severity tag", () => { + expect(formatErrorForDisplay("request timeout")).toContain("[MEDIUM]"); + expect(formatErrorForDisplay(new Error("AUTH_REVOKED"))).toContain("[HIGH]"); + }); +});