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
139 changes: 87 additions & 52 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@ 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") {
const rootEnvPath = path.resolve(__dirname, "../../..", ".env");
dotenv.config({ path: rootEnvPath, override: false });
}

type ProxyResponse<T> = { 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";

app.use(express.json());

const solanaRpc = process.env.SOLANA_URL || "https://solana-rpc.publicnode.com";
const API_VERSION = 1;

const providers: Record<string, Protocol> = {
stonfi_v2: new StonfiProvider(process.env.TON_URL || "https://toncenter.com"),
Expand All @@ -29,67 +33,98 @@ const providers: Record<string, Protocol> = {
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<Quote>);
} 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<SwapQuoteData>);
} 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<never> {
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<SwapperError, { message: string }> {
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();
}
35 changes: 35 additions & 0 deletions packages/swapper/src/mayan/error.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
return typeof obj.message === "string" ? obj.message : undefined;
}
105 changes: 3 additions & 102 deletions packages/swapper/src/mayan/index.ts
Original file line number Diff line number Diff line change
@@ -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<Quote> {
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<SwapQuoteData> {
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";
Loading
Loading