From 425eaccb0fade8917a0dfae40318d39e9c9ecea5 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 28 May 2026 20:05:39 +0200 Subject: [PATCH 1/3] feat(api): support optional networks whitelist on POST /v1/quotes/best Adds CreateBestQuoteRequest with an optional networks?: Networks[] field that integrators can use to restrict best-quote computation to a subset of supported chains. The validator normalizes entries case-insensitively via getCaseSensitiveNetwork so integrators don't have to match the exact casing of canonical Networks values (e.g. polygonAmoy, base-sepolia). The service intersects the whitelist with getEligibleNetworks and returns 400 InvalidNetworks when the resulting set is empty. --- .../src/api/controllers/quote.controller.ts | 7 +- .../src/api/middlewares/validators.test.ts | 93 +++++++++++++++++++ apps/api/src/api/middlewares/validators.ts | 36 +++++-- apps/api/src/api/services/quote/index.ts | 14 ++- .../shared/src/endpoints/quote.endpoints.ts | 2 + 5 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/api/middlewares/validators.test.ts diff --git a/apps/api/src/api/controllers/quote.controller.ts b/apps/api/src/api/controllers/quote.controller.ts index 90e74df30..d3b98cd56 100644 --- a/apps/api/src/api/controllers/quote.controller.ts +++ b/apps/api/src/api/controllers/quote.controller.ts @@ -1,4 +1,5 @@ import { + CreateBestQuoteRequest, CreateQuoteRequest, GetQuoteRequest, getNetworkFromDestination, @@ -64,12 +65,13 @@ export const createQuote = async ( * @public */ export const createBestQuote = async ( - req: Request>, + req: Request, res: Response, next: NextFunction ): Promise => { 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; @@ -82,6 +84,7 @@ export const createBestQuote = async ( from, inputAmount, inputCurrency, + networks, outputCurrency, partnerId, partnerName: publicKeyPartnerName, diff --git a/apps/api/src/api/middlewares/validators.test.ts b/apps/api/src/api/middlewares/validators.test.ts new file mode 100644 index 000000000..c3cff5208 --- /dev/null +++ b/apps/api/src/api/middlewares/validators.test.ts @@ -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 & { 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) { + 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 = { ...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 }); + }); +}); diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 303090653..7373c0e39 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -1,16 +1,19 @@ import { AveniaKYCDataUploadRequest, CreateAveniaSubaccountRequest, + CreateBestQuoteRequest, CreateQuoteRequest, Currency, GetWidgetUrlLocked, GetWidgetUrlRefresh, + getCaseSensitiveNetwork, isSupportedFiatCurrency, isValidAveniaAccountType, isValidCurrencyForDirection, isValidDirection, isValidKYCDocType, isValidPriceProvider, + Networks, PriceProvider, QuoteError, RampDirection, @@ -419,17 +422,13 @@ export const validateCreateQuoteInput: RequestHandler> = ( - req, - res, - next -) => { +export const validateCreateBestQuoteInput: RequestHandler = (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 }); @@ -451,6 +450,27 @@ export const validateCreateBestQuoteInput: RequestHandler { - 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 }); } diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 19a28c404..2953ec3af 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -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 { @@ -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", From db10363dc542603f8dc6d97576b4f39919b824ae Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 28 May 2026 20:05:48 +0200 Subject: [PATCH 2/3] docs(api): document networks filter on POST /v1/quotes/best Updates the OpenAPI schema and partner-facing quotes-and-pricing guide to describe the optional networks parameter and the InvalidNetworks error response. --- docs/api/openapi/vortex.openapi.json | 7 +++++++ docs/api/pages/06-quotes-and-pricing.md | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index cf591bbe0..3dcecca48 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -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" + }, + "type": "array" + }, "outputCurrency": { "$ref": "#/components/schemas/RampCurrency", "description": "The desired currency type for the output amount." diff --git a/docs/api/pages/06-quotes-and-pricing.md b/docs/api/pages/06-quotes-and-pricing.md index e17d0f726..97ec589ae 100644 --- a/docs/api/pages/06-quotes-and-pricing.md +++ b/docs/api/pages/06-quotes-and-pricing.md @@ -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. From d7ceec9bd490cd54128ed574be67df0b8947b1c7 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 28 May 2026 20:05:54 +0200 Subject: [PATCH 3/3] feat(frontend): map InvalidNetworks quote error with en/pt translations Adds a friendly error message mapping for QuoteError.InvalidNetworks in the quote store and the corresponding English and Portuguese translation strings under pages.swap.error.invalidNetworks. --- apps/frontend/src/stores/quote/useQuoteStore.ts | 1 + apps/frontend/src/translations/en.json | 1 + apps/frontend/src/translations/pt.json | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index 7238378e1..e82556e92 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -54,6 +54,7 @@ const friendlyErrorMessages: Record = { [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", diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 8656f2b7e..e729f6a18 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -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}}.", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 1b28095d1..c6660f9c9 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -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}}.",