diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6feb32b..77416d3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,7 +2,7 @@ import path from "node:path"; import dotenv from "dotenv"; import express from "express"; -import { Quote, QuoteRequest } from "@gemwallet/types"; +import { Quote, QuoteRequest, SwapperError, SwapQuoteData } from "@gemwallet/types"; import { StonfiProvider, Protocol, MayanProvider, CetusAggregatorProvider, RelayProvider, OrcaWhirlpoolProvider } from "@gemwallet/swapper"; if (process.env.NODE_ENV !== "production") { @@ -10,6 +10,9 @@ if (process.env.NODE_ENV !== "production") { dotenv.config({ path: rootEnvPath, override: false }); } +type ProxyResponse = { ok: T } | { err: SwapperError } | { error: string }; +type ProviderRequest = express.Request & { provider?: Protocol; objectResponse?: boolean }; + const app = express(); const PORT = process.env.PORT || 3000; const isProduction = process.env.NODE_ENV === "production"; @@ -17,6 +20,7 @@ const isProduction = process.env.NODE_ENV === "production"; app.use(express.json()); const solanaRpc = process.env.SOLANA_URL || "https://solana-rpc.publicnode.com"; +const API_VERSION = 1; const providers: Record = { stonfi_v2: new StonfiProvider(process.env.TON_URL || "https://toncenter.com"), @@ -29,67 +33,98 @@ const providers: Record = { orca: new OrcaWhirlpoolProvider(solanaRpc), }; -app.get('/', (_, res) => { - res.json({ - providers: Object.keys(providers), - version: process.env.npm_package_version - }); +app.get("/", (_, res) => { + res.json({ + providers: Object.keys(providers), + version: process.env.npm_package_version, + }); }); -app.post('/:providerId/quote', async (req, res) => { - const providerId = req.params.providerId; - const provider = providers[providerId]; - - if (!provider) { - res.status(404).json({ error: `Provider ${providerId} not found` }); - return; +app.post("/:providerId/quote", withProvider, async (req: ProviderRequest, res) => { + const provider = req.provider!; + const objectResponse = req.objectResponse!; + try { + const request: QuoteRequest = req.body; + + const quote = await provider.get_quote(request); + if (objectResponse) { + res.json({ ok: quote } satisfies ProxyResponse); + } else { + res.json(quote); } - - try { - const request: QuoteRequest = req.body; - - const quote = await provider.get_quote(request); - res.json(quote); - } catch (error) { - if (!isProduction) { - console.error("Error fetching quote via POST:", error); - console.debug("Request metadata:", { providerId, hasBody: Boolean(req.body) }); - } - if (error instanceof Error) { - res.status(500).json({ error: error.message }); - } else { - res.status(500).json({ error: 'Unknown error occurred' }); - } + } catch (error) { + if (!isProduction) { + console.error("Error fetching quote via POST:", error); + console.debug("Request metadata:", { providerId: req.params.providerId, hasBody: Boolean(req.body) }); } + res.status(500).json(errorResponse({ type: "compute_quote_error", message: "" }, error, objectResponse)); + } }); -app.post('/:providerId/quote_data', async (req, res) => { - const providerId = req.params.providerId; - const provider = providers[providerId]; +app.post("/:providerId/quote_data", withProvider, async (req: ProviderRequest, res) => { + const provider = req.provider!; + const objectResponse = req.objectResponse!; + const quote_request = req.body as Quote; + + try { - if (!provider) { - res.status(404).json({ error: `Provider ${providerId} not found` }); - return; + const quote = await provider.get_quote_data(quote_request); + if (objectResponse) { + res.json({ ok: quote } satisfies ProxyResponse); + } else { + res.json(quote); } - const quote_request = req.body as Quote; - - try { - - const quote = await provider.get_quote_data(quote_request); - res.json(quote); - } catch (error) { - if (!isProduction) { - console.error("Error fetching quote data:", error); - console.debug("Quote metadata:", { providerId, hasQuote: Boolean(quote_request) }); - } - if (error instanceof Error) { - res.status(500).json({ error: error.message }); - } else { - res.status(500).json({ error: 'Unknown error occurred' }); - } + } catch (error) { + if (!isProduction) { + console.error("Error fetching quote data:", error); + console.debug("Quote metadata:", { providerId: req.params.providerId, hasQuote: Boolean(quote_request) }); } + res.status(500).json(errorResponse({ type: "transaction_error", message: "" }, error, objectResponse)); + } }); app.listen(PORT, () => { - console.log(`swapper api is running on port ${PORT}.`); + console.log(`swapper api is running on port ${PORT}.`); }); + +function errorResponse(err: SwapperError, rawError: unknown, structured: boolean): ProxyResponse { + const message = extractMessage(rawError) ?? ("message" in err ? err.message : undefined); + if (!structured) { + return { error: message ?? "Unknown error occurred" }; + } + if (isMessageError(err)) { + return { err: { ...err, message: message ?? err.message ?? "" } }; + } + return { err }; +} + +function parseVersion(raw: unknown): number { + const num = typeof raw === "string" ? Number(raw) : Array.isArray(raw) ? Number(raw[0]) : NaN; + return Number.isFinite(num) ? num : 0; +} + +function extractMessage(error: unknown): string | undefined { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return undefined; +} + +function isMessageError(err: SwapperError): err is Extract { + return err.type === "compute_quote_error" || err.type === "transaction_error"; +} + +function withProvider(req: ProviderRequest, res: express.Response, next: express.NextFunction) { + const providerId = req.params.providerId; + const provider = providers[providerId]; + const version = parseVersion(req.query.v); + const objectResponse = version >= API_VERSION; + + if (!provider) { + res.status(404).json(errorResponse({ type: "no_available_provider" }, `Provider ${providerId} not found`, objectResponse)); + return; + } + + req.provider = provider; + req.objectResponse = objectResponse; + next(); +} diff --git a/packages/swapper/src/mayan/error.ts b/packages/swapper/src/mayan/error.ts new file mode 100644 index 0000000..246712e --- /dev/null +++ b/packages/swapper/src/mayan/error.ts @@ -0,0 +1,35 @@ +export enum MayanErrorCode { + AmountTooSmall = "AMOUNT_TOO_SMALL", +} + +export type ErrorData = { + code?: MayanErrorCode | string; + message?: string; + data?: unknown; +}; + +export function toMayanError(error: unknown): Error { + const message = extractErrorMessage(error); + if (message) { + return new Error(message); + } + if (error instanceof Error) { + return error; + } + return new Error("Unknown Mayan error"); +} + +function extractErrorMessage(error: unknown): string | undefined { + const payloadMessage = extractPayloadMessage(error); + if (payloadMessage) return payloadMessage; + + if (error instanceof Error && error.message) return error.message; + return undefined; +} + +function extractPayloadMessage(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + + const obj = error as Record; + return typeof obj.message === "string" ? obj.message : undefined; +} diff --git a/packages/swapper/src/mayan/index.ts b/packages/swapper/src/mayan/index.ts index ef3c561..0791ec0 100644 --- a/packages/swapper/src/mayan/index.ts +++ b/packages/swapper/src/mayan/index.ts @@ -1,102 +1,3 @@ -import { fetchQuote, ChainName, QuoteParams, QuoteOptions, Quote as MayanQuote, ReferrerAddresses } from "@mayanfinance/swap-sdk"; -import { QuoteRequest, Quote, SwapQuoteData, AssetId, Chain } from "@gemwallet/types"; -import { Protocol } from "../protocol"; -import { buildEvmQuoteData, EMPTY_ADDRESS } from "./evm"; -import { buildSolanaQuoteData } from "./solana"; -import { buildSuiQuoteData } from "./sui"; -import { BigIntMath } from "../bigint_math"; -import { getReferrerAddresses } from "../referrer"; -import { SUI_COIN_TYPE } from "../chain/sui/constants"; - -export class MayanProvider implements Protocol { - private solanaRpc: string; - private suiRpc: string; - - constructor(solanaRpc: string, suiRpc: string) { - this.solanaRpc = solanaRpc; - this.suiRpc = suiRpc; - } - - mapAssetToTokenId(asset: AssetId): string { - if (asset.isNative()) { - if (asset.chain === Chain.Sui) { - return SUI_COIN_TYPE; - } - return EMPTY_ADDRESS; - } - return asset.tokenId!; - } - - mapChainToName(chain: Chain): ChainName { - switch (chain) { - case Chain.SmartChain: - return "bsc"; - case Chain.AvalancheC: - return "avalanche" as ChainName; - case Chain.Hyperliquid: - return "hyperevm" as ChainName; - default: - return chain as ChainName; - } - } - - async get_quote(quoteRequest: QuoteRequest): Promise { - const fromAsset = AssetId.fromString(quoteRequest.from_asset.id); - const toAsset = AssetId.fromString(quoteRequest.to_asset.id); - const referrerBps = quoteRequest.referral_bps; - const referrerAddresses = getReferrerAddresses() as ReferrerAddresses; - - const params: QuoteParams = { - fromToken: this.mapAssetToTokenId(fromAsset), - toToken: this.mapAssetToTokenId(toAsset), - amountIn64: quoteRequest.from_value, - fromChain: this.mapChainToName(fromAsset.chain), - toChain: this.mapChainToName(toAsset.chain), - slippageBps: "auto", - referrer: referrerAddresses.solana!, - referrerBps, - } - - // explicitly set which types of quotes we want to fetch - const options: QuoteOptions = { - "wormhole": true, - "swift": true, - "gasless": false, - "mctp": true, - "shuttle": false, - "fastMctp": true, - "onlyDirect": false, - } - - const quotes = await fetchQuote(params, options); - - if (!quotes || quotes.length === 0) { - throw new Error("No quotes available"); - } - - const quote = quotes[0]; - - const output_value = BigIntMath.parseDecimals(quote.expectedAmountOut, quote.toToken.decimals); - const output_min_value = BigIntMath.parseDecimals(quote.minAmountOut, quote.toToken.decimals); - - return { - quote: quoteRequest, - output_value: output_value.toString(), - output_min_value: output_min_value.toString(), - eta_in_seconds: quote.etaSeconds, - route_data: quote - }; - } - - async get_quote_data(quote: Quote): Promise { - const fromAsset = AssetId.fromString(quote.quote.from_asset.id); - - if (fromAsset.chain === Chain.Solana) { - return buildSolanaQuoteData(quote.quote, quote.route_data as MayanQuote, this.solanaRpc); - } else if (fromAsset.chain === Chain.Sui) { - return buildSuiQuoteData(quote.quote, quote.route_data as MayanQuote, this.suiRpc); - } else { - return buildEvmQuoteData(quote.quote, quote.route_data as MayanQuote); - } - } -} +export { MayanProvider } from "./provider"; +export { MayanErrorCode } from "./error"; +export type { ErrorData } from "./error"; diff --git a/packages/swapper/src/mayan/provider.ts b/packages/swapper/src/mayan/provider.ts new file mode 100644 index 0000000..ca81aad --- /dev/null +++ b/packages/swapper/src/mayan/provider.ts @@ -0,0 +1,108 @@ +import { fetchQuote, ChainName, QuoteParams, QuoteOptions, Quote as MayanQuote, ReferrerAddresses } from "@mayanfinance/swap-sdk"; +import { QuoteRequest, Quote, SwapQuoteData, AssetId, Chain } from "@gemwallet/types"; +import { Protocol } from "../protocol"; +import { buildEvmQuoteData, EMPTY_ADDRESS } from "./evm"; +import { buildSolanaQuoteData } from "./solana"; +import { buildSuiQuoteData } from "./sui"; +import { BigIntMath } from "../bigint_math"; +import { getReferrerAddresses } from "../referrer"; +import { SUI_COIN_TYPE } from "../chain/sui/constants"; +import { toMayanError } from "./error"; + +export class MayanProvider implements Protocol { + private solanaRpc: string; + private suiRpc: string; + + constructor(solanaRpc: string, suiRpc: string) { + this.solanaRpc = solanaRpc; + this.suiRpc = suiRpc; + } + + mapAssetToTokenId(asset: AssetId): string { + if (asset.isNative()) { + if (asset.chain === Chain.Sui) { + return SUI_COIN_TYPE; + } + return EMPTY_ADDRESS; + } + return asset.tokenId!; + } + + mapChainToName(chain: Chain): ChainName { + switch (chain) { + case Chain.SmartChain: + return "bsc"; + case Chain.AvalancheC: + return "avalanche" as ChainName; + case Chain.Hyperliquid: + return "hyperevm" as ChainName; + default: + return chain as ChainName; + } + } + + async get_quote(quoteRequest: QuoteRequest): Promise { + const fromAsset = AssetId.fromString(quoteRequest.from_asset.id); + const toAsset = AssetId.fromString(quoteRequest.to_asset.id); + const referrerBps = quoteRequest.referral_bps; + const referrerAddresses = getReferrerAddresses() as ReferrerAddresses; + + const params: QuoteParams = { + fromToken: this.mapAssetToTokenId(fromAsset), + toToken: this.mapAssetToTokenId(toAsset), + amountIn64: quoteRequest.from_value, + fromChain: this.mapChainToName(fromAsset.chain), + toChain: this.mapChainToName(toAsset.chain), + slippageBps: "auto", + referrer: referrerAddresses.solana!, + referrerBps, + } + + // explicitly set which types of quotes we want to fetch + const options: QuoteOptions = { + "wormhole": true, + "swift": true, + "gasless": false, + "mctp": true, + "shuttle": false, + "fastMctp": true, + "onlyDirect": false, + } + + let quotes: MayanQuote[]; + try { + quotes = await fetchQuote(params, options); + } catch (error) { + throw toMayanError(error); + } + + if (!quotes || quotes.length === 0) { + throw new Error("No quotes available"); + } + + const quote = quotes[0]; + + const output_value = BigIntMath.parseDecimals(quote.expectedAmountOut, quote.toToken.decimals); + const output_min_value = BigIntMath.parseDecimals(quote.minAmountOut, quote.toToken.decimals); + + return { + quote: quoteRequest, + output_value: output_value.toString(), + output_min_value: output_min_value.toString(), + eta_in_seconds: quote.etaSeconds, + route_data: quote + }; + } + + async get_quote_data(quote: Quote): Promise { + const fromAsset = AssetId.fromString(quote.quote.from_asset.id); + + if (fromAsset.chain === Chain.Solana) { + return buildSolanaQuoteData(quote.quote, quote.route_data as MayanQuote, this.solanaRpc); + } else if (fromAsset.chain === Chain.Sui) { + return buildSuiQuoteData(quote.quote, quote.route_data as MayanQuote, this.suiRpc); + } else { + return buildEvmQuoteData(quote.quote, quote.route_data as MayanQuote); + } + } +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a10b2f0..99b7be0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -2,6 +2,7 @@ export * from './asset'; export { Chain, SwapProvider, + SwapperError, QuoteAsset, ProxyQuoteRequest as QuoteRequest, ProxyQuote as Quote, diff --git a/packages/types/src/primitives/index.ts b/packages/types/src/primitives/index.ts index be1dc0b..3f4f24f 100644 --- a/packages/types/src/primitives/index.ts +++ b/packages/types/src/primitives/index.ts @@ -1,4 +1,5 @@ export { Chain } from "./Chain"; export { SwapProvider } from "./SwapProvider"; +export { SwapperError } from "./swap/Error"; export { QuoteAsset, SwapQuoteData, SwapQuoteDataType } from "./swap/Approval"; export { ProxyQuoteRequest, ProxyQuote } from "./swap/Mod"; diff --git a/packages/types/src/primitives/swap/Error.ts b/packages/types/src/primitives/swap/Error.ts new file mode 100644 index 0000000..cd01396 --- /dev/null +++ b/packages/types/src/primitives/swap/Error.ts @@ -0,0 +1,13 @@ +/* + Generated by typeshare 1.13.3 +*/ + +export type SwapperError = + | { type: "not_supported_chain", message?: undefined } + | { type: "not_supported_asset", message?: undefined } + | { type: "no_available_provider", message?: undefined } + | { type: "input_amount_error", min_amount: string | null } + | { type: "invalid_route", message?: undefined } + | { type: "compute_quote_error", message: string } + | { type: "transaction_error", message: string } + | { type: "no_quote_available", message?: undefined }; diff --git a/packages/types/src/primitives/swap/Mod.ts b/packages/types/src/primitives/swap/Mod.ts index 044a4c2..f476429 100644 --- a/packages/types/src/primitives/swap/Mod.ts +++ b/packages/types/src/primitives/swap/Mod.ts @@ -12,6 +12,7 @@ export interface ProxyQuoteRequest { from_value: string; referral_bps: number; slippage_bps: number; + use_max_amount?: boolean; } export interface ProxyQuote { @@ -21,4 +22,3 @@ export interface ProxyQuote { route_data: object; eta_in_seconds: number; } - diff --git a/scripts/prepare_typeshare.py b/scripts/prepare_typeshare.py index e35d96c..3051206 100755 --- a/scripts/prepare_typeshare.py +++ b/scripts/prepare_typeshare.py @@ -3,6 +3,7 @@ import argparse import shutil +import subprocess from pathlib import Path REQUIRED_FILES = [ @@ -31,16 +32,35 @@ def copy_required_files(source_root: Path, target_root: Path) -> None: for relative_path in REQUIRED_FILES: source = primitives_root / relative_path - if not source.exists(): - raise FileNotFoundError(f"Expected generated file missing: {source}") destination = target_root / relative_path destination.parent.mkdir(parents=True, exist_ok=True) + + if not source.exists(): + raise FileNotFoundError(f"Expected generated file missing: {source}") shutil.copy2(source, destination) apply_import_patch(destination, IMPORT_PATCHES.get(relative_path)) index_path = target_root / "index.ts" index_path.write_text(INDEX_TEMPLATE.read_text()) + # Generate SwapperError directly from the swapper crate to avoid duplicate definitions. + generate_swapper_error(target_root / "swap" / "Error.ts") + + +def generate_swapper_error(output_path: Path) -> None: + project_root = Path(__file__).resolve().parents[1] + swapper_error = project_root.parent / "core" / "crates" / "swapper" / "src" / "error.rs" + output_path.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + "typeshare", + str(swapper_error), + "--lang=typescript", + f"--output-file={output_path}", + ], + check=True, + ) + def apply_import_patch(file_path: Path, patch: str | None) -> None: if not patch: diff --git a/scripts/templates/primitives_index.ts b/scripts/templates/primitives_index.ts index be1dc0b..3f4f24f 100644 --- a/scripts/templates/primitives_index.ts +++ b/scripts/templates/primitives_index.ts @@ -1,4 +1,5 @@ export { Chain } from "./Chain"; export { SwapProvider } from "./SwapProvider"; +export { SwapperError } from "./swap/Error"; export { QuoteAsset, SwapQuoteData, SwapQuoteDataType } from "./swap/Approval"; export { ProxyQuoteRequest, ProxyQuote } from "./swap/Mod";