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
7 changes: 5 additions & 2 deletions apps/api/src/api/controllers/quote.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
CreateBestQuoteRequest,
CreateQuoteRequest,
GetQuoteRequest,
getNetworkFromDestination,
Expand Down Expand Up @@ -64,12 +65,13 @@ export const createQuote = async (
* @public
*/
export const createBestQuote = async (
req: Request<unknown, unknown, Omit<CreateQuoteRequest, "network">>,
req: Request<unknown, unknown, CreateBestQuoteRequest>,
res: Response<QuoteResponse>,
next: NextFunction
): Promise<void> => {
try {
const { rampType, from, to, inputAmount, inputCurrency, outputCurrency, partnerId, apiKey, countryCode } = req.body;
const { rampType, from, to, inputAmount, inputCurrency, outputCurrency, partnerId, apiKey, countryCode, networks } =
req.body;

// Get apiKey from body or from validated public key middleware
const publicApiKey = apiKey || req.validatedPublicKey?.apiKey;
Expand All @@ -82,6 +84,7 @@ export const createBestQuote = async (
from,
inputAmount,
inputCurrency,
networks,
outputCurrency,
partnerId,
partnerName: publicKeyPartnerName,
Expand Down
93 changes: 93 additions & 0 deletions apps/api/src/api/middlewares/validators.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Networks, QuoteError, RampDirection } from "@vortexfi/shared";
import { describe, expect, it, mock } from "bun:test";
import type { NextFunction, Request, Response } from "express";
import httpStatus from "http-status";
import { validateCreateBestQuoteInput } from "./validators";

function buildRes() {
const res: Partial<Response> & { statusCode?: number; body?: unknown } = {};
res.status = mock((code: number) => {
res.statusCode = code;
return res as Response;
}) as Response["status"];
res.json = mock((payload: unknown) => {
res.body = payload;
return res as Response;
}) as Response["json"];
return res as Response & { statusCode?: number; body?: unknown };
}

function runValidator(body: Record<string, unknown>) {
const req = { body } as unknown as Request;
const res = buildRes();
const next: NextFunction = mock(() => undefined) as unknown as NextFunction;
validateCreateBestQuoteInput(req, res, next);
return { next, res };
}

const baseBody = {
inputAmount: "100",
inputCurrency: "BRL",
outputCurrency: "USDC",
rampType: RampDirection.BUY,
from: "pix"
};

describe("validateCreateBestQuoteInput - networks whitelist", () => {
it("passes when networks is omitted (preserves existing behavior)", () => {
const { next, res } = runValidator({ ...baseBody });
expect(next).toHaveBeenCalledTimes(1);
expect(res.statusCode).toBeUndefined();
});

it("passes when networks is a valid array of Networks values", () => {
const { next, res } = runValidator({ ...baseBody, networks: [Networks.Base, Networks.Polygon] });
expect(next).toHaveBeenCalledTimes(1);
expect(res.statusCode).toBeUndefined();
});

it("normalizes case-insensitive networks entries to canonical Networks values", () => {
const body: Record<string, unknown> = { ...baseBody, networks: ["BASE", "Polygon", "BASE-SEPOLIA", "polygonamoy"] };
const req = { body } as unknown as Request;
const res = buildRes();
const next: NextFunction = mock(() => undefined) as unknown as NextFunction;
validateCreateBestQuoteInput(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res.statusCode).toBeUndefined();
expect(body.networks).toEqual([Networks.Base, Networks.Polygon, Networks.BaseSepolia, Networks.PolygonAmoy]);
});

it("passes when networks is an empty array (treated as omitted)", () => {
const { next, res } = runValidator({ ...baseBody, networks: [] });
expect(next).toHaveBeenCalledTimes(1);
expect(res.statusCode).toBeUndefined();
});

it("rejects with 400 when networks contains an unknown identifier", () => {
const { next, res } = runValidator({ ...baseBody, networks: ["base", "not-a-real-chain"] });
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(httpStatus.BAD_REQUEST);
expect(res.body).toEqual({ message: QuoteError.InvalidNetworks });
});

it("rejects with 400 when networks is not an array", () => {
const { next, res } = runValidator({ ...baseBody, networks: "base" });
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(httpStatus.BAD_REQUEST);
expect(res.body).toEqual({ message: QuoteError.InvalidNetworks });
});

it("rejects with 400 when networks contains a non-string entry", () => {
const { next, res } = runValidator({ ...baseBody, networks: [Networks.Base, 42] });
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(httpStatus.BAD_REQUEST);
expect(res.body).toEqual({ message: QuoteError.InvalidNetworks });
});

it("rejects with 400 when required fields are missing even if networks is valid", () => {
const { next, res } = runValidator({ rampType: RampDirection.BUY, networks: [Networks.Base] });
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBe(httpStatus.BAD_REQUEST);
expect(res.body).toEqual({ message: QuoteError.MissingRequiredFields });
});
});
36 changes: 28 additions & 8 deletions apps/api/src/api/middlewares/validators.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import {
AveniaKYCDataUploadRequest,
CreateAveniaSubaccountRequest,
CreateBestQuoteRequest,
CreateQuoteRequest,
Currency,
GetWidgetUrlLocked,
GetWidgetUrlRefresh,
getCaseSensitiveNetwork,
isSupportedFiatCurrency,
isValidAveniaAccountType,
isValidCurrencyForDirection,
isValidDirection,
isValidKYCDocType,
isValidPriceProvider,
Networks,
PriceProvider,
QuoteError,
RampDirection,
Expand Down Expand Up @@ -419,17 +422,13 @@ export const validateCreateQuoteInput: RequestHandler<unknown, unknown, CreateQu
next();
};

export const validateCreateBestQuoteInput: RequestHandler<unknown, unknown, Omit<CreateQuoteRequest, "network">> = (
req,
res,
next
) => {
export const validateCreateBestQuoteInput: RequestHandler<unknown, unknown, CreateBestQuoteRequest> = (req, res, next) => {
if (req.body) {
req.body.inputCurrency = normalizeAxlUsdcCurrency(req.body.inputCurrency) as CreateQuoteRequest["inputCurrency"];
req.body.outputCurrency = normalizeAxlUsdcCurrency(req.body.outputCurrency) as CreateQuoteRequest["outputCurrency"];
req.body.inputCurrency = normalizeAxlUsdcCurrency(req.body.inputCurrency) as CreateBestQuoteRequest["inputCurrency"];
req.body.outputCurrency = normalizeAxlUsdcCurrency(req.body.outputCurrency) as CreateBestQuoteRequest["outputCurrency"];
}

const { rampType, from, to, inputAmount, inputCurrency, outputCurrency } = req.body;
const { rampType, from, to, inputAmount, inputCurrency, outputCurrency, networks } = req.body;

if (!rampType || !inputAmount || !inputCurrency || !outputCurrency) {
res.status(httpStatus.BAD_REQUEST).json({ message: QuoteError.MissingRequiredFields });
Expand All @@ -451,6 +450,27 @@ export const validateCreateBestQuoteInput: RequestHandler<unknown, unknown, Omit
return;
}

if (networks !== undefined) {
if (!Array.isArray(networks)) {
res.status(httpStatus.BAD_REQUEST).json({ message: QuoteError.InvalidNetworks });
return;
}
const normalized: Networks[] = [];
for (const entry of networks) {
if (typeof entry !== "string") {
res.status(httpStatus.BAD_REQUEST).json({ message: QuoteError.InvalidNetworks });
return;
}
const canonical = getCaseSensitiveNetwork(entry);
if (!canonical) {
res.status(httpStatus.BAD_REQUEST).json({ message: QuoteError.InvalidNetworks });
return;
}
normalized.push(canonical);
}
req.body.networks = normalized;
}

if (!validateSupportedFiatCurrency(rampType, inputCurrency, outputCurrency, res)) {
return;
}
Expand Down
14 changes: 11 additions & 3 deletions apps/api/src/api/services/quote/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,22 @@ export class QuoteService extends BaseRampService {
public async createBestQuote(
request: CreateBestQuoteRequest & { apiKey?: string | null; partnerName?: string | null; userId?: string }
): Promise<QuoteResponse> {
const { rampType, from, to } = request;
const { rampType, from, to, networks } = request;

// Determine eligible networks based on the corridor
const eligibleNetworks = this.getEligibleNetworks(rampType, from, to);
const allEligibleNetworks = this.getEligibleNetworks(rampType, from, to);

// Apply optional client-provided whitelist (empty array is treated as "no whitelist")
const eligibleNetworks =
networks && networks.length > 0 ? allEligibleNetworks.filter(network => networks.includes(network)) : allEligibleNetworks;

if (eligibleNetworks.length === 0) {
const message =
networks && networks.length > 0
? `No eligible networks found for ${rampType} from ${from} to ${to} within the requested networks: ${networks.join(", ")}`
: `No eligible networks found for ${rampType} from ${from} to ${to}`;
throw new APIError({
message: `No eligible networks found for ${rampType} from ${from} to ${to}`,
message,
status: httpStatus.BAD_REQUEST
});
}
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/stores/quote/useQuoteStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const friendlyErrorMessages: Record<QuoteError, string> = {
[QuoteError.MissingToField]: "pages.swap.error.missingToField",
[QuoteError.MissingFromField]: "pages.swap.error.missingFromField",
[QuoteError.InvalidRampType]: "pages.swap.error.invalidRampType",
[QuoteError.InvalidNetworks]: "pages.swap.error.invalidNetworks",
[QuoteError.QuoteNotFound]: "pages.swap.error.quoteNotFound",
[QuoteError.AssetHubNotSupportedForAlfredPay]: "pages.swap.error.assetHubNotSupportedForAlfredPay",

Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1242,6 +1242,7 @@
"insufficientFunds": "Exceeds balance. Your balance is {{userInputTokenBalance}} {{assetSymbol}}",
"insufficientLiquidity": "The amount is temporarily not available. Please, try with a smaller amount.",
"invalidInputAmount": "Invalid input amount",
"invalidNetworks": "Invalid networks. Please provide a valid list of supported networks.",
"invalidRampType": "Invalid ramp type",
"lessThanMinimumWithdrawal": {
"buy": "Minimum buy amount is {{minAmountUnits}} {{assetSymbol}}.",
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/translations/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,7 @@
"insufficientFunds": "Saldo insuficiente. Seu saldo é {{userInputTokenBalance}} {{assetSymbol}}",
"insufficientLiquidity": "O valor está temporariamente indisponível. Por favor, tente com um valor menor.",
"invalidInputAmount": "Valor de entrada inválido",
"invalidNetworks": "Redes inválidas. Por favor, forneça uma lista válida de redes suportadas.",
"invalidRampType": "Tipo de rampa inválido",
"lessThanMinimumWithdrawal": {
"buy": "O valor mínimo de compra é {{minAmountUnits}} {{assetSymbol}}.",
Expand Down
7 changes: 7 additions & 0 deletions docs/api/openapi/vortex.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@
"$ref": "#/components/schemas/RampCurrency",
"description": "The currency type for the input amount."
},
"networks": {
"description": "Optional whitelist of networks to evaluate when searching for the best quote. If omitted or empty, all eligible networks for the corridor are considered.",
"items": {
"$ref": "#/components/schemas/Networks"
Comment thread
ebma marked this conversation as resolved.
},
"type": "array"
},
"outputCurrency": {
"$ref": "#/components/schemas/RampCurrency",
"description": "The desired currency type for the output amount."
Expand Down
13 changes: 13 additions & 0 deletions docs/api/pages/06-quotes-and-pricing.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ POST /v1/quotes/best

Same request body as `POST /v1/quotes`, except `to` (for buys) or `from` (for sells) may be omitted; Vortex evaluates eligible routes and returns a single quote optimized for the input amount. The response shape matches `POST /v1/quotes`.

To restrict the search to a subset of chains (for example when you only support a fixed set of destination networks), pass an optional `networks` array of network identifiers. When omitted or empty, Vortex evaluates all eligible networks for the corridor; when provided, the search is intersected with the whitelist and a `400` is returned if the intersection is empty or if any entry is not a known network identifier.

```json
{
"rampType": "BUY",
"from": "pix",
"inputAmount": "100",
"inputCurrency": "BRL",
"outputCurrency": "USDC",
"networks": ["base", "polygon"]
}
```

## Quote Expiry

Quotes are immutable and short-lived. If the user takes too long to confirm, or if you delay before calling `POST /v1/ramp/register`, the quote expires and the register call rejects it. Catch the expiry error, create a fresh quote, and re-prompt the user before registering.
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/endpoints/quote.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface CreateBestQuoteRequest {
api?: boolean; // Optional flag to indicate API usage
paymentMethod?: PaymentMethod;
countryCode?: string;
networks?: Networks[]; // Optional whitelist of networks to evaluate; if omitted, all eligible networks are tried
}

export interface QuoteResponse {
Expand Down Expand Up @@ -91,6 +92,7 @@ export enum QuoteError {

MissingToField = "SELL rampType requires 'to' parameter",
MissingFromField = "BUY rampType requires 'from' parameter",
InvalidNetworks = "Invalid 'networks' value: must be an array of valid network identifiers",

// Quote lookup errors
QuoteNotFound = "Quote not found",
Expand Down
Loading