Skip to content
Merged
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
110 changes: 65 additions & 45 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,95 @@
const SOROBAN_ERROR_CODES: Record<string, string> = {
"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<string, string> = {
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<string, string> = {
"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<string, string> = {
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 };
}

Expand Down
63 changes: 63 additions & 0 deletions tests/unit/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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]");
});
});
Loading