diff --git a/agent.ts b/agent.ts index 36d4d8c3..f449eddb 100644 --- a/agent.ts +++ b/agent.ts @@ -24,6 +24,7 @@ import { Networks, BASE_FEE } from "@stellar/stellar-sdk"; +import { decodeStellarError } from "./utils/errorDecoder"; const { Server } = Horizon; @@ -398,8 +399,9 @@ export class AgentClient { }; } catch (error) { - console.error("Token launch failed:", error); - throw new Error(`Token launch failed: ${error instanceof Error ? error.message : String(error)}`); + const decodedError = decodeStellarError(error); + console.error("Token launch failed:", decodedError); + throw new Error(`Token launch failed: ${decodedError}`); } } diff --git a/lib/contract.ts b/lib/contract.ts index 200884d5..4cf129f4 100644 --- a/lib/contract.ts +++ b/lib/contract.ts @@ -11,6 +11,7 @@ import { } from "@stellar/stellar-sdk"; import { signTransaction } from "./stellar"; import { buildTransaction } from "../utils/buildTransaction"; + import { decodeStellarError } from "../utils/errorDecoder"; export interface SorobanContractConfig { network: "testnet" | "mainnet"; @@ -111,8 +112,9 @@ import { const passphrase = config.network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET; const tx = TransactionBuilder.fromXDR(signedXDR, passphrase); const txResult = await server.sendTransaction(tx).catch((err) => { - console.error(`Send transaction failed for ${functName}: ${err.message}`); - throw new Error(`Send transaction failed: ${err.message}`); + const decodedError = decodeStellarError(err); + console.error(`Send transaction failed for ${functName}: ${decodedError}`); + throw new Error(`Send transaction failed: ${decodedError}`); }); let txResponse = await server.getTransaction(txResult.hash); @@ -130,8 +132,9 @@ import { } if (txResponse.status !== "SUCCESS") { + const decodedError = decodeStellarError(txResponse); console.error(`Transaction failed for ${functName} with status: ${txResponse.status}`, JSON.stringify(txResponse, null, 2)); - throw new Error(`Transaction failed with status: ${txResponse.status}`); + throw new Error(`Transaction failed: ${decodedError} (Status: ${txResponse.status})`); } // Parse return value if present (e.g., for withdraw) @@ -149,9 +152,9 @@ import { return null; // No return value for void functions } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`Error in contract interaction (${functName}):`, errorMessage); - throw error; + const decodedError = decodeStellarError(error); + console.error(`Error in contract interaction (${functName}):`, decodedError); + throw new Error(`Contract interaction failed: ${decodedError}`); } }; diff --git a/lib/dex.ts b/lib/dex.ts index d14da97a..bbf96929 100644 --- a/lib/dex.ts +++ b/lib/dex.ts @@ -8,6 +8,7 @@ import { } from "@stellar/stellar-sdk"; import { getSigningKeypair, signTransaction } from "./stellar"; import { buildPathPaymentTransaction } from "../utils/buildTransaction"; +import { decodeStellarError } from "../utils/errorDecoder"; export type StellarAssetInput = | { type: "native" } @@ -260,7 +261,10 @@ export async function swapBestRoute( signedXdr, getNetworkPassphrase(client.network) ); - const submission = await server.submitTransaction(signedTransaction); + const submission = await server.submitTransaction(signedTransaction).catch((err) => { + const decodedError = decodeStellarError(err); + throw new Error(`Swap failed: ${decodedError}`); + }); return { hash: submission.hash, diff --git a/package-lock.json b/package-lock.json index 184ce2b5..065f3907 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2420,6 +2420,20 @@ "node": ">=4.5" } }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/c8": { "version": "10.1.3", "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", @@ -4045,6 +4059,18 @@ } } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/noms": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", @@ -5127,6 +5153,20 @@ "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", "license": "MIT" }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -5459,17 +5499,6 @@ } } }, - "node_modules/web3-eth-abi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/web3-eth-accounts": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/web3-eth-accounts/-/web3-eth-accounts-4.3.1.tgz", diff --git a/tests/unit/utils/errorDecoder.test.ts b/tests/unit/utils/errorDecoder.test.ts new file mode 100644 index 00000000..08d6c719 --- /dev/null +++ b/tests/unit/utils/errorDecoder.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { decodeStellarError } from "../../../utils/errorDecoder"; + +describe("errorDecoder", () => { + it("decodes Horizon error with result codes", () => { + const error = { + response: { + data: { + extras: { + result_codes: { + transaction: "tx_failed", + operations: ["op_underfunded"], + }, + }, + }, + }, + }; + const decoded = decodeStellarError(error); + expect(decoded).toContain("The transaction failed to execute."); + expect(decoded).toContain("Account has insufficient funds for this operation."); + }); + + it("decodes Horizon error with multiple operation failures", () => { + const error = { + response: { + data: { + extras: { + result_codes: { + transaction: "tx_failed", + operations: ["op_success", "op_no_trust"], + }, + }, + }, + }, + }; + const decoded = decodeStellarError(error); + expect(decoded).toContain("The transaction failed to execute."); + expect(decoded).toContain("The account is missing a trustline for the required asset."); + expect(decoded).not.toContain("op_success"); + }); + + it("handles Horizon detail message if no result codes", () => { + const error = { + response: { + data: { + detail: "The request was invalid.", + }, + }, + }; + const decoded = decodeStellarError(error); + expect(decoded).toBe("The request was invalid."); + }); + + it("handles Soroban FAILED status", () => { + const error = { + status: "FAILED", + }; + const decoded = decodeStellarError(error); + expect(decoded).toBe("The smart contract transaction failed."); + }); + + it("handles general error objects", () => { + const error = new Error("Something went wrong"); + const decoded = decodeStellarError(error); + expect(decoded).toBe("Something went wrong"); + }); + + it("extracts codes from error messages", () => { + const error = new Error("Error: op_low_reserve"); + const decoded = decodeStellarError(error); + expect(decoded).toContain("Account does not have enough balance to meet the minimum reserve requirement."); + expect(decoded).toContain("(op_low_reserve)"); + }); + + it("handles unknown errors", () => { + const decoded = decodeStellarError(null); + expect(decoded).toBe("Unknown error occurred."); + + expect(decodeStellarError({})).toBe("[object Object]"); + }); +}); diff --git a/utils/errorDecoder.ts b/utils/errorDecoder.ts new file mode 100644 index 00000000..942934b3 --- /dev/null +++ b/utils/errorDecoder.ts @@ -0,0 +1,111 @@ +/** + * Utility to decode Stellar and Soroban error messages into human-readable strings. + */ + +export interface StellarError { + response?: { + data?: { + extras?: { + result_codes?: { + transaction?: string; + operations?: string[]; + }; + }; + detail?: string; + }; + status?: number; + }; + message?: string; +} + +const ERROR_MESSAGES: Record = { + // Transaction level + tx_failed: "The transaction failed to execute.", + tx_bad_seq: "Transaction sequence number is incorrect. Please try again.", + tx_too_late: "The transaction has expired.", + tx_too_early: "The transaction was submitted too early.", + tx_insufficient_fee: "The transaction fee was too low.", + tx_missing_operation: "The transaction has no operations.", + tx_bad_auth: "Transaction has too few or invalid signatures.", + tx_no_source_account: "The source account was not found.", + + // Operation level + op_underfunded: "Account has insufficient funds for this operation.", + op_low_reserve: "Account does not have enough balance to meet the minimum reserve requirement.", + op_no_trust: "The account is missing a trustline for the required asset.", + op_not_authorized: "The account is not authorized to hold this asset.", + op_line_full: "The trustline limit for this asset has been reached.", + op_no_destination: "The destination account does not exist.", + op_no_issuer: "The asset issuer does not exist.", + op_src_not_authorized: "The source account is not authorized to send this asset.", + op_src_no_trust: "The source account is missing a trustline for the asset.", + op_too_many_subentries: "The account has reached the maximum number of subentries (trustlines, offers, etc.).", + op_cross_self: "Cannot trade with oneself.", + op_bad_auth: "Operation has invalid signatures.", + op_immutable_set: "The account is immutable and its options cannot be changed.", + + // DEX specific + op_under_dest_min: "The swap could not be completed at the requested price (slippage too high).", + op_over_source_max: "The swap would require more source assets than the specified maximum.", + op_no_path: "No viable path found for this swap.", + + // Soroban/Smart Contract + HostError: "A smart contract execution error occurred.", + ContractError: "The smart contract returned an error.", +}; + +/** + * Decodes a Stellar/Horizon/Soroban error into a human-readable message. + */ +export function decodeStellarError(error: any): string { + if (!error) return "Unknown error occurred."; + + // Handle Horizon errors + const resultCodes = error.response?.data?.extras?.result_codes; + if (resultCodes) { + const messages: string[] = []; + + // Check transaction-level error + if (resultCodes.transaction && ERROR_MESSAGES[resultCodes.transaction]) { + messages.push(ERROR_MESSAGES[resultCodes.transaction]); + } + + // Check operation-level errors + if (resultCodes.operations && Array.isArray(resultCodes.operations)) { + resultCodes.operations.forEach((opCode: string) => { + if (opCode !== "op_success" && ERROR_MESSAGES[opCode]) { + messages.push(ERROR_MESSAGES[opCode]); + } else if (opCode !== "op_success") { + messages.push(`Operation failed: ${opCode}`); + } + }); + } + + if (messages.length > 0) { + return messages.join(" "); + } + } + + // Handle Horizon detail message + if (error.response?.data?.detail) { + return error.response.data.detail; + } + + // Handle Soroban RPC errors + if (error.status === "FAILED") { + return "The smart contract transaction failed."; + } + + // Handle general Error objects + if (error.message) { + // Try to extract known error codes from message strings + for (const [code, msg] of Object.entries(ERROR_MESSAGES)) { + if (error.message.includes(code)) { + return `${msg} (${code})`; + } + } + return error.message; + } + + return String(error); +}