Skip to content

Commit b6844ec

Browse files
authored
Return Mayan min amount (#59)
* return proper mayan error for amount too small * fix error format * code cleanup * Update error.ts * code cleanup
1 parent 8ad2562 commit b6844ec

File tree

9 files changed

+164
-58
lines changed

9 files changed

+164
-58
lines changed

apps/api/src/error.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { SwapperError } from "@gemwallet/types";
2+
import { Response } from "express";
3+
4+
type ErrorResponse = { type: string; message: string | object };
5+
export type ProxyErrorResponse = { err: ErrorResponse } | { error: string };
6+
7+
export function errorResponse(err: SwapperError, rawError: unknown, structured: boolean): ProxyErrorResponse {
8+
const rawMessage = extractMessage(rawError);
9+
if (!structured) {
10+
return { error: rawMessage ?? ("message" in err ? err.message : undefined) ?? "Unknown error occurred" };
11+
}
12+
if (hasStringMessage(err)) {
13+
return { err: { type: err.type, message: rawMessage ?? err.message ?? "" } };
14+
}
15+
const { type, ...rest } = err;
16+
return { err: { type, message: rest } };
17+
}
18+
19+
export function httpStatus(err: SwapperError): number {
20+
switch (err.type) {
21+
case "input_amount_error":
22+
case "not_supported_chain":
23+
case "not_supported_asset":
24+
case "invalid_route":
25+
return 400;
26+
case "no_available_provider":
27+
case "no_quote_available":
28+
return 404;
29+
case "compute_quote_error":
30+
case "transaction_error":
31+
default:
32+
return 500;
33+
}
34+
}
35+
36+
function extractMessage(error: unknown): string | undefined {
37+
if (error instanceof Error) return error.message;
38+
if (typeof error === "string") return error;
39+
return undefined;
40+
}
41+
42+
function hasStringMessage(err: SwapperError): err is Extract<SwapperError, { message: string }> {
43+
return err.type === "compute_quote_error" || err.type === "transaction_error";
44+
}
45+
46+
export function sendErrorResponse(
47+
res: Response,
48+
swapperError: SwapperError,
49+
rawError: unknown,
50+
objectResponse: boolean
51+
) {
52+
res.status(httpStatus(swapperError)).json(errorResponse(swapperError, rawError, objectResponse));
53+
}

apps/api/src/index.test.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
1-
describe('API', () => {
2-
it('should pass', () => {
3-
expect(true).toBe(true);
1+
import { errorResponse, httpStatus } from "./error";
2+
import { SwapperError } from "@gemwallet/types";
3+
4+
describe("httpStatus", () => {
5+
it.each([
6+
[400, { type: "input_amount_error", min_amount: "100" }],
7+
[404, { type: "no_quote_available" }],
8+
[500, { type: "compute_quote_error", message: "error" }],
9+
] as const)("returns %i for %s", (expected, err) => {
10+
expect(httpStatus(err as SwapperError)).toBe(expected);
11+
});
12+
});
13+
14+
describe("errorResponse", () => {
15+
it("wraps input_amount_error fields in message object", () => {
16+
const result = errorResponse({ type: "input_amount_error", min_amount: "19620000" }, null, true);
17+
expect(result).toEqual({ err: { type: "input_amount_error", message: { min_amount: "19620000" } } });
18+
});
19+
20+
it("uses raw error message for compute_quote_error", () => {
21+
const result = errorResponse({ type: "compute_quote_error", message: "" }, new Error("fail"), true);
22+
expect(result).toEqual({ err: { type: "compute_quote_error", message: "fail" } });
23+
});
24+
25+
it("returns plain error when not structured", () => {
26+
const result = errorResponse({ type: "compute_quote_error", message: "" }, new Error("fail"), false);
27+
expect(result).toEqual({ error: "fail" });
428
});
529
});

apps/api/src/index.ts

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@ import path from "node:path";
22
import dotenv from "dotenv";
33
import express from "express";
44

5-
import { Quote, QuoteRequest, SwapperError, SwapQuoteData } from "@gemwallet/types";
6-
import { StonfiProvider, Protocol, MayanProvider, CetusAggregatorProvider, RelayProvider, OrcaWhirlpoolProvider, PanoraProvider } from "@gemwallet/swapper";
5+
import { Quote, QuoteRequest, SwapQuoteData } from "@gemwallet/types";
6+
import { StonfiProvider, Protocol, MayanProvider, CetusAggregatorProvider, RelayProvider, OrcaWhirlpoolProvider, PanoraProvider, SwapperException } from "@gemwallet/swapper";
77
import versionInfo from "./version.json";
8+
import { errorResponse, sendErrorResponse, ProxyErrorResponse } from "./error";
89

910
if (process.env.NODE_ENV !== "production") {
1011
const rootEnvPath = path.resolve(__dirname, "../../..", ".env");
1112
dotenv.config({ path: rootEnvPath, override: false });
1213
}
1314

14-
type ProxyResponse<T> = { ok: T } | { err: SwapperError } | { error: string };
15+
type ProxyResponse<T> = { ok: T } | ProxyErrorResponse;
1516
type ProviderRequest = express.Request & { provider?: Protocol; objectResponse?: boolean };
1617

1718
const app = express();
@@ -60,7 +61,10 @@ app.post("/:providerId/quote", withProvider, async (req: ProviderRequest, res) =
6061
console.error("Error fetching quote via POST:", error);
6162
console.debug("Request metadata:", { providerId: req.params.providerId, hasBody: Boolean(req.body) });
6263
}
63-
res.status(500).json(errorResponse({ type: "compute_quote_error", message: "" }, error, objectResponse));
64+
const swapperError = SwapperException.isSwapperException(error)
65+
? error.swapperError
66+
: { type: "compute_quote_error" as const, message: "" };
67+
sendErrorResponse(res, swapperError, error, objectResponse);
6468
}
6569
});
6670

@@ -70,7 +74,6 @@ app.post("/:providerId/quote_data", withProvider, async (req: ProviderRequest, r
7074
const quote_request = req.body as Quote;
7175

7276
try {
73-
7477
const quote = await provider.get_quote_data(quote_request);
7578
if (objectResponse) {
7679
res.json({ ok: quote } satisfies ProxyResponse<SwapQuoteData>);
@@ -82,40 +85,22 @@ app.post("/:providerId/quote_data", withProvider, async (req: ProviderRequest, r
8285
console.error("Error fetching quote data:", error);
8386
console.debug("Quote metadata:", { providerId: req.params.providerId, hasQuote: Boolean(quote_request) });
8487
}
85-
res.status(500).json(errorResponse({ type: "transaction_error", message: "" }, error, objectResponse));
88+
const swapperError = SwapperException.isSwapperException(error)
89+
? error.swapperError
90+
: { type: "transaction_error" as const, message: "" };
91+
sendErrorResponse(res, swapperError, error, objectResponse);
8692
}
8793
});
8894

8995
app.listen(PORT, () => {
9096
console.log(`swapper api is running on port ${PORT}.`);
9197
});
9298

93-
function errorResponse(err: SwapperError, rawError: unknown, structured: boolean): ProxyResponse<never> {
94-
const message = extractMessage(rawError) ?? ("message" in err ? err.message : undefined);
95-
if (!structured) {
96-
return { error: message ?? "Unknown error occurred" };
97-
}
98-
if (isMessageError(err)) {
99-
return { err: { ...err, message: message ?? err.message ?? "" } };
100-
}
101-
return { err };
102-
}
103-
10499
function parseVersion(raw: unknown): number {
105100
const num = typeof raw === "string" ? Number(raw) : Array.isArray(raw) ? Number(raw[0]) : NaN;
106101
return Number.isFinite(num) ? num : 0;
107102
}
108103

109-
function extractMessage(error: unknown): string | undefined {
110-
if (error instanceof Error) return error.message;
111-
if (typeof error === "string") return error;
112-
return undefined;
113-
}
114-
115-
function isMessageError(err: SwapperError): err is Extract<SwapperError, { message: string }> {
116-
return err.type === "compute_quote_error" || err.type === "transaction_error";
117-
}
118-
119104
function withProvider(req: ProviderRequest, res: express.Response, next: express.NextFunction) {
120105
const providerId = req.params.providerId as string;
121106
const provider = providers[providerId];

packages/swapper/src/error.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { SwapperError } from "@gemwallet/types";
2+
3+
export class SwapperException extends Error {
4+
readonly swapperError: SwapperError;
5+
6+
constructor(swapperError: SwapperError) {
7+
const message = "message" in swapperError && swapperError.message
8+
? swapperError.message
9+
: swapperError.type;
10+
super(message);
11+
this.name = "SwapperException";
12+
this.swapperError = swapperError;
13+
}
14+
15+
static isSwapperException(error: unknown): error is SwapperException {
16+
return error instanceof SwapperException;
17+
}
18+
}

packages/swapper/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from './relay';
66
export * from './referrer';
77
export * from './orca';
88
export * from './panora';
9+
export * from './error';
Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,41 @@
1-
export enum MayanErrorCode {
2-
AmountTooSmall = "AMOUNT_TOO_SMALL",
3-
}
1+
import { SwapperException } from "../error";
2+
import { BigIntMath } from "../bigint_math";
43

5-
export type ErrorData = {
6-
code?: MayanErrorCode | string;
7-
message?: string;
8-
data?: unknown;
9-
};
4+
export function toMayanError(error: unknown, decimals: number): Error {
5+
if (SwapperException.isSwapperException(error)) {
6+
return error;
7+
}
108

11-
export function toMayanError(error: unknown): Error {
129
const message = extractErrorMessage(error);
1310
if (message) {
11+
const minAmount = extractMinAmount(message, decimals);
12+
if (minAmount !== undefined) {
13+
return new SwapperException({
14+
type: "input_amount_error",
15+
min_amount: minAmount,
16+
});
17+
}
1418
return new Error(message);
1519
}
20+
1621
if (error instanceof Error) {
1722
return error;
1823
}
1924
return new Error("Unknown Mayan error");
2025
}
2126

22-
function extractErrorMessage(error: unknown): string | undefined {
23-
const payloadMessage = extractPayloadMessage(error);
24-
if (payloadMessage) return payloadMessage;
25-
26-
if (error instanceof Error && error.message) return error.message;
27-
return undefined;
27+
function extractMinAmount(message: string, decimals: number): string | null | undefined {
28+
if (!message.includes("Amount too small")) {
29+
return undefined;
30+
}
31+
const match = message.match(/~?(\d+(?:\.\d+)?)\s*\w+\)/);
32+
return match ? BigIntMath.parseDecimals(match[1], decimals).toString() : null;
2833
}
2934

30-
function extractPayloadMessage(error: unknown): string | undefined {
31-
if (!error || typeof error !== "object") return undefined;
32-
33-
const obj = error as Record<string, unknown>;
34-
return typeof obj.message === "string" ? obj.message : undefined;
35+
function extractErrorMessage(error: unknown): string | undefined {
36+
if (error instanceof Error) return error.message;
37+
if (error && typeof error === "object" && "message" in error) {
38+
return typeof error.message === "string" ? error.message : undefined;
39+
}
40+
return undefined;
3541
}
Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,29 @@
1-
describe('Fetch Quote', () => {
2-
it('Convert hex BigInt string to decimal', () => {
3-
const hexValue = "0x2386f26fc10000";
4-
const expectedDecimalValue = BigInt(hexValue).toString();
1+
import { toMayanError } from "./error";
2+
import { SwapperException } from "../error";
53

6-
expect(expectedDecimalValue).toEqual("10000000000000000");
4+
describe('toMayanError', () => {
5+
it('converts "Amount too small (min ~0.01962 SOL)" to input_amount_error', () => {
6+
const result = toMayanError({ message: "Amount too small (min ~0.01962 SOL)" }, 9);
7+
8+
expect((result as SwapperException).swapperError).toEqual({
9+
type: "input_amount_error",
10+
min_amount: "19620000",
11+
});
12+
});
13+
14+
it('converts "Amount too small" without min to input_amount_error with null', () => {
15+
const result = toMayanError({ message: "Amount too small" }, 9);
16+
17+
expect((result as SwapperException).swapperError).toEqual({
18+
type: "input_amount_error",
19+
min_amount: null,
20+
});
21+
});
22+
23+
it('returns generic Error for non-amount errors', () => {
24+
const result = toMayanError({ message: "Network error" }, 9);
25+
26+
expect(result).not.toBeInstanceOf(SwapperException);
27+
expect(result.message).toBe("Network error");
728
});
829
});
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
11
export { MayanProvider } from "./provider";
2-
export { MayanErrorCode } from "./error";
3-
export type { ErrorData } from "./error";

packages/swapper/src/mayan/provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export class MayanProvider implements Protocol {
7373
try {
7474
quotes = await fetchQuote(params, options);
7575
} catch (error) {
76-
throw toMayanError(error);
76+
throw toMayanError(error, quoteRequest.from_asset.decimals);
7777
}
7878

7979
if (!quotes || quotes.length === 0) {

0 commit comments

Comments
 (0)