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
6 changes: 4 additions & 2 deletions agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
Networks,
BASE_FEE
} from "@stellar/stellar-sdk";
import { decodeStellarError } from "./utils/errorDecoder";

const { Server } = Horizon;

Expand Down Expand Up @@ -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}`);
}
}

Expand Down
15 changes: 9 additions & 6 deletions lib/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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)
Expand All @@ -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}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Rewrapping every failure here discards the original error object, so callers lose the underlying type, stack, and SDK-specific fields.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/contract.ts, line 157:

<comment>Rewrapping every failure here discards the original error object, so callers lose the underlying type, stack, and SDK-specific fields.</comment>

<file context>
@@ -149,9 +152,9 @@ import {
-      throw error;
+      const decodedError = decodeStellarError(error);
+      console.error(`Error in contract interaction (${functName}):`, decodedError);
+      throw new Error(`Contract interaction failed: ${decodedError}`);
     }
   };
</file context>

}
};

Expand Down
6 changes: 5 additions & 1 deletion lib/dex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 40 additions & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 81 additions & 0 deletions tests/unit/utils/errorDecoder.test.ts
Original file line number Diff line number Diff line change
@@ -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]");
});
});
111 changes: 111 additions & 0 deletions utils/errorDecoder.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
// 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);
}