diff --git a/.clinerules/01-general-rules.md b/.clinerules/01-general-rules.md index 2aea4808c..90f08874a 100644 --- a/.clinerules/01-general-rules.md +++ b/.clinerules/01-general-rules.md @@ -7,7 +7,7 @@ ## Architecture Decision Records -Create ADRs in /docs/adr for: +Create ADRs in /docs/adr for: - Major dependency changes - Architectural pattern changes diff --git a/apps/api/.env.example b/apps/api/.env.example index 906c4fd22..aefc6a09f 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -33,6 +33,10 @@ DB_SSL_CA_CERT_PATH= # Blockchain AMPLITUDE_WSS=wss://rpc-amplitude.pendulumchain.tech PENDULUM_WSS=wss://rpc-pendulum.prd.pendulumchain.tech +# Optional Substrate RPC overrides. Comma-separate multiple URLs for failover. +ASSETHUB_WSS=wss://dot-rpc.stakeworld.io/assethub +HYDRATION_WSS=wss://rpc.hydradx.cloud +MOONBEAM_WSS=wss://wss.api.moonbeam.network,wss://moonbeam.api.onfinality.io/public-ws,wss://moonbeam.ibp.network FUNDING_SECRET=your-funding-secret PENDULUM_FUNDING_SEED=your-pendulum-funding-seed MOONBEAM_EXECUTOR_PRIVATE_KEY=your-moonbeam-executor-private-key diff --git a/apps/api/package.json b/apps/api/package.json index 79a779fab..aece93e61 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -88,7 +88,7 @@ "name": "vortex-backend", "scripts": { "build": "bun run swc src -d dist --strip-leading-paths", - "dev": "concurrently -n 'shared,backend' -c '#ffa500,007755' 'cd ../../packages/shared && bun run dev' 'NODE_ENV=development bun --watch src/index.ts'", + "dev": "NODE_ENV=development bun --watch src/index.ts", "migrate": "bun -r @swc-node/register src/database/migrator.ts", "migrate:revert": "bun -r @swc-node/register src/database/migrator.ts revert-all", "migrate:revert-last": "bun -r @swc-node/register src/database/migrator.ts revert", diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index a745a1ba6..7f72d0555 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -150,8 +150,20 @@ export class AlfredpayController { const alfredpayService = AlfredpayApiService.getInstance(); - const newCustomer = await alfredpayService.createCustomer(userEmail, AlfredpayCustomerType.INDIVIDUAL, country); - const customerId = newCustomer.customerId; + let customerId: string; + try { + const newCustomer = await alfredpayService.createCustomer(userEmail, AlfredpayCustomerType.INDIVIDUAL, country); + customerId = newCustomer.customerId; + } catch (error) { + const errorMessage = (error as Error)?.message || ""; + if (errorMessage.includes("409") || errorMessage.includes("already registered")) { + logger.info("Customer already exists in Alfredpay, fetching existing customer"); + const existingCustomer = await alfredpayService.findCustomer(userEmail, country); + customerId = existingCustomer.customerId; + } else { + throw error; + } + } await AlfredPayCustomer.create({ alfredPayId: customerId, @@ -360,7 +372,7 @@ export class AlfredpayController { const linkResponse = await alfredpayService.getKybRedirectLink(alfredPayCustomer.alfredPayId); await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); return res.json(linkResponse as AlfredpayGetKybRedirectLinkResponse); - } else if (country === "MX" || country === "CO") { + } else if (country === "MX" || country === "CO" || country === "AR") { // MX/CO use API-based (form) KYC — no redirect link needed. // Just reset status so the user can re-fill the form. await alfredPayCustomer.update({ status: AlfredPayStatus.Consulted }); @@ -399,8 +411,20 @@ export class AlfredpayController { const alfredpayService = AlfredpayApiService.getInstance(); - const newCustomer = await alfredpayService.createCustomer(userEmail, type, country); - const customerId = newCustomer.customerId; + let customerId: string; + try { + const newCustomer = await alfredpayService.createCustomer(userEmail, type, country); + customerId = newCustomer.customerId; + } catch (error) { + const errorMessage = (error as Error)?.message || ""; + if (errorMessage.includes("409") || errorMessage.includes("already registered")) { + logger.info("Business customer already exists in Alfredpay, fetching existing customer"); + const existingCustomer = await alfredpayService.findCustomer(userEmail, country); + customerId = existingCustomer.customerId; + } else { + throw error; + } + } await AlfredPayCustomer.create({ alfredPayId: customerId, @@ -463,7 +487,7 @@ export class AlfredpayController { static async submitKycInformation(req: Request, res: Response) { try { - const { country, ...kycData } = req.body as SubmitKycInformationRequest & { country: string }; + const { country, ...kycData } = req.body as SubmitKycInformationRequest; const userId = req.userId!; const alfredPayCustomer = await AlfredPayCustomer.findOne({ @@ -475,7 +499,21 @@ export class AlfredpayController { } const alfredpayService = AlfredpayApiService.getInstance(); - const result = await alfredpayService.submitKycInformation(alfredPayCustomer.alfredPayId, { ...kycData, country }); + let result: Awaited>; + try { + result = await alfredpayService.submitKycInformation(alfredPayCustomer.alfredPayId, { ...kycData, country }); + } catch (error) { + const errorMessage = (error as Error)?.message || ""; + if (errorMessage.includes("422") && errorMessage.includes("KYC record cannot be retried")) { + logger.info("KYC record cannot be retried, fetching existing submission"); + const existingSubmission = await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); + result = { submissionId: existingSubmission.submissionId } as Awaited< + ReturnType + >; + } else { + throw error; + } + } res.json(result); } catch (error) { @@ -557,7 +595,21 @@ export class AlfredpayController { } const alfredpayService = AlfredpayApiService.getInstance(); - const result = await alfredpayService.submitKybInformation(alfredPayCustomer.alfredPayId, { ...kybData, country }); + let result: Awaited>; + try { + result = await alfredpayService.submitKybInformation(alfredPayCustomer.alfredPayId, { ...kybData, country }); + } catch (error) { + const errorMessage = (error as Error)?.message || ""; + if (errorMessage.includes("422") && errorMessage.includes("KYC record cannot be retried")) { + logger.info("KYB record cannot be retried, fetching existing submission"); + const existingSubmission = await alfredpayService.getLastKybSubmission(alfredPayCustomer.alfredPayId); + result = { submissionId: existingSubmission.submissionId } as Awaited< + ReturnType + >; + } else { + throw error; + } + } res.json(result); } catch (error) { @@ -749,6 +801,11 @@ export class AlfredpayController { accountType: accountType ?? "", metadata: { accountHolderName: accountName, documentNumber, documentType } }; + } else if (alfredpayFiatAccountType === AlfredpayFiatAccountType.COELSA) { + fiatAccountFields = { + accountNumber, + accountType: accountType ?? "" + }; } else { // BANK_USA — external accounts need address fields inside metadata fiatAccountFields = isExternal diff --git a/apps/api/src/api/controllers/quote.controller.ts b/apps/api/src/api/controllers/quote.controller.ts index f14272bdb..471f36b85 100644 --- a/apps/api/src/api/controllers/quote.controller.ts +++ b/apps/api/src/api/controllers/quote.controller.ts @@ -11,7 +11,11 @@ import { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; import logger from "../../config/logger"; import { APIError } from "../errors/api-error"; -import { observeApiClientEvent } from "../observability/apiClientEvent.service"; +import { + buildApiClientRequestMetadata, + getSafeApiKeyPrefix, + observeApiClientEvent +} from "../observability/apiClientEvent.service"; import { classifyApiClientError, getErrorMessage } from "../observability/errorClassifier"; import { getRequestDurationMs } from "../observability/requestContext"; import quoteService from "../services/quote"; @@ -57,7 +61,7 @@ export const createQuote = async ( }); observeApiClientEvent({ - apiKeyPrefix: getSafePublicKeyPrefix(publicApiKey), + apiKeyPrefix: getSafeApiKeyPrefix(publicApiKey, ["pk_"]), durationMs: getRequestDurationMs(req), httpStatus: httpStatus.CREATED, network, @@ -76,7 +80,7 @@ export const createQuote = async ( } catch (error) { logger.error("Error creating quote", { errorType: classifyApiClientError(error), requestId: req.requestId }); observeQuoteFailure(req, "quote_create", error, { - apiKeyPrefix: getSafePublicKeyPrefix(req.body?.apiKey || req.validatedPublicKey?.apiKey), + apiKeyPrefix: getSafeApiKeyPrefix(req.body?.apiKey || req.validatedPublicKey?.apiKey, ["pk_"]), network: getNetworkFromDestination(req.body?.rampType === RampDirection.BUY ? req.body?.to : req.body?.from), partnerId: req.authenticatedPartner?.id || req.body?.partnerId || null, partnerName: req.authenticatedPartner?.name || req.validatedPublicKey?.partnerName || null, @@ -121,7 +125,7 @@ export const createBestQuote = async ( }); observeApiClientEvent({ - apiKeyPrefix: getSafePublicKeyPrefix(publicApiKey), + apiKeyPrefix: getSafeApiKeyPrefix(publicApiKey, ["pk_"]), durationMs: getRequestDurationMs(req), httpStatus: httpStatus.CREATED, network: quote.network, @@ -140,7 +144,7 @@ export const createBestQuote = async ( } catch (error) { logger.error("Error creating best quote", { errorType: classifyApiClientError(error), requestId: req.requestId }); observeQuoteFailure(req, "quote_create_best", error, { - apiKeyPrefix: getSafePublicKeyPrefix(req.body?.apiKey || req.validatedPublicKey?.apiKey), + apiKeyPrefix: getSafeApiKeyPrefix(req.body?.apiKey || req.validatedPublicKey?.apiKey, ["pk_"]), partnerId: req.authenticatedPartner?.id || req.body?.partnerId || null, partnerName: req.authenticatedPartner?.name || req.validatedPublicKey?.partnerName || null, rampType: req.body?.rampType @@ -194,6 +198,11 @@ export const getQuote = async ( type QuoteOperation = "quote_create" | "quote_create_best" | "quote_get"; interface ObservedQuoteRequest { + body?: unknown; + method?: string; + params?: unknown; + path?: string; + query?: unknown; requestId?: string; requestStartedAt?: number; userId?: string; @@ -220,6 +229,7 @@ function observeQuoteFailure( errorMessage: getErrorMessage(error), errorType: classifyApiClientError(error, status), httpStatus: status, + metadata: buildQuoteRequestMetadata(req, operation), operation, requestId: req.requestId, status: "failure", @@ -227,11 +237,26 @@ function observeQuoteFailure( }); } -function getHttpStatus(error: unknown): number { - return error instanceof APIError ? error.status || httpStatus.INTERNAL_SERVER_ERROR : httpStatus.INTERNAL_SERVER_ERROR; +function buildQuoteRequestMetadata(req: ObservedQuoteRequest, operation: QuoteOperation): Record { + if (operation === "quote_get") { + return buildApiClientRequestMetadata(req, { paramKeys: ["id"] }); + } + + return buildApiClientRequestMetadata(req, { + bodyKeys: [ + "countryCode", + "from", + "inputAmount", + "inputCurrency", + "networks", + "outputCurrency", + "partnerId", + "rampType", + "to" + ] + }); } -function getSafePublicKeyPrefix(apiKey: string | null | undefined): string | null { - if (!apiKey?.startsWith("pk_")) return null; - return apiKey.slice(0, 8); +function getHttpStatus(error: unknown): number { + return error instanceof APIError ? error.status || httpStatus.INTERNAL_SERVER_ERROR : httpStatus.INTERNAL_SERVER_ERROR; } diff --git a/apps/api/src/api/controllers/ramp.controller.ts b/apps/api/src/api/controllers/ramp.controller.ts index 21f1579bc..71c7be684 100644 --- a/apps/api/src/api/controllers/ramp.controller.ts +++ b/apps/api/src/api/controllers/ramp.controller.ts @@ -17,7 +17,7 @@ import logger from "../../config/logger"; import { APIError } from "../errors/api-error"; import { enrichAdditionalDataWithClientIp } from "../helpers/clientIp"; import { assertQuoteOwnership, assertRampOwnership } from "../middlewares/ownershipAuth"; -import { observeApiClientEvent } from "../observability/apiClientEvent.service"; +import { buildApiClientRequestMetadata, observeApiClientEvent } from "../observability/apiClientEvent.service"; import { classifyApiClientError, getErrorMessage } from "../observability/errorClassifier"; import { getRequestDurationMs } from "../observability/requestContext"; import { ApiClientOperation } from "../observability/types"; @@ -288,6 +288,11 @@ interface RampObservationContext { interface ObservedRampRequest { authenticatedPartner?: { id: string; name: string }; + body?: unknown; + method?: string; + params?: unknown; + path?: string; + query?: unknown; requestId?: string; requestStartedAt?: number; userId?: string; @@ -325,6 +330,7 @@ function observeRampFailure( errorMessage: getErrorMessage(error), errorType: classifyApiClientError(error, status), httpStatus: status, + metadata: buildRampRequestMetadata(req, operation), operation, partnerId: req.authenticatedPartner?.id || null, partnerName: req.authenticatedPartner?.name || null, @@ -334,6 +340,26 @@ function observeRampFailure( }); } +function buildRampRequestMetadata(req: ObservedRampRequest, operation: RampObservedOperation): Record { + if (operation === "ramp_register") { + return buildApiClientRequestMetadata(req, { bodyKeys: ["quoteId", "signingAccounts", "additionalData"] }); + } + + if (operation === "ramp_update") { + return buildApiClientRequestMetadata(req, { bodyKeys: ["rampId", "presignedTxs", "additionalData"] }); + } + + if (operation === "ramp_start") { + return buildApiClientRequestMetadata(req, { bodyKeys: ["rampId"] }); + } + + if (operation === "ramp_status") { + return buildApiClientRequestMetadata(req, { paramKeys: ["id"], queryKeys: ["showUnsignedTxs"] }); + } + + return buildApiClientRequestMetadata(req, { paramKeys: ["id"] }); +} + function getHttpStatus(error: unknown): number { return error instanceof APIError ? error.status || httpStatus.INTERNAL_SERVER_ERROR : httpStatus.INTERNAL_SERVER_ERROR; } diff --git a/apps/api/src/api/errors/api-error.ts b/apps/api/src/api/errors/api-error.ts index cf147b707..6d49c80b5 100644 --- a/apps/api/src/api/errors/api-error.ts +++ b/apps/api/src/api/errors/api-error.ts @@ -7,6 +7,7 @@ interface APIErrorParams { stack?: string; status?: number; isPublic?: boolean; + type?: string; } /** @@ -20,13 +21,14 @@ export class APIError extends ExtendableError { * @param {number} status - HTTP status code of error. * @param {boolean} isPublic - Whether the message should be visible to user or not. */ - constructor({ message, errors, stack, status = httpStatus.INTERNAL_SERVER_ERROR, isPublic = false }: APIErrorParams) { + constructor({ message, errors, stack, status = httpStatus.INTERNAL_SERVER_ERROR, isPublic = false, type }: APIErrorParams) { super({ errors, isPublic, message, stack, - status + status, + type }); } } diff --git a/apps/api/src/api/errors/extendable-error.ts b/apps/api/src/api/errors/extendable-error.ts index 8085fd103..1c525a542 100644 --- a/apps/api/src/api/errors/extendable-error.ts +++ b/apps/api/src/api/errors/extendable-error.ts @@ -4,6 +4,7 @@ interface ExtendableErrorParams { status?: number; isPublic?: boolean; stack?: string; + type?: string; } /** @@ -19,7 +20,9 @@ class ExtendableError extends Error { readonly isOperational: boolean; - constructor({ message, errors, status, isPublic = false, stack }: ExtendableErrorParams) { + readonly type?: string; + + constructor({ message, errors, status, isPublic = false, stack, type }: ExtendableErrorParams) { super(message); this.name = this.constructor.name; this.message = message; @@ -28,6 +31,7 @@ class ExtendableError extends Error { this.isPublic = isPublic; this.isOperational = true; this.stack = stack; + this.type = type; // Error.captureStackTrace(this, this.constructor.name); } } diff --git a/apps/api/src/api/middlewares/apiKeyAuth.ts b/apps/api/src/api/middlewares/apiKeyAuth.ts index 6047fb808..76fa3a03e 100644 --- a/apps/api/src/api/middlewares/apiKeyAuth.ts +++ b/apps/api/src/api/middlewares/apiKeyAuth.ts @@ -1,7 +1,11 @@ import { NextFunction, Request, Response } from "express"; import logger from "../../config/logger"; import Partner from "../../models/partner.model"; -import { observeApiClientEvent } from "../observability/apiClientEvent.service"; +import { + buildApiClientRequestMetadata, + getSafeApiKeyPrefix, + observeApiClientEvent +} from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; import { ApiClientErrorType } from "../observability/types"; import { AuthenticatedPartner, getKeyType, isValidSecretKeyFormat, validateApiKey } from "./apiKeyAuth.helpers"; @@ -51,7 +55,7 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { // Validate that it's a secret key format (sk_*) const keyType = getKeyType(apiKey); if (keyType !== "secret") { - recordAuthFailure(req, 401, "auth_invalid_api_key", getSafeKeyPrefix(apiKey)); + recordAuthFailure(req, 401, "auth_invalid_api_key", getSafeApiKeyPrefix(apiKey, ["sk_"])); return res.status(401).json({ error: { code: "INVALID_SECRET_KEY", @@ -63,7 +67,7 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { } if (!isValidSecretKeyFormat(apiKey)) { - recordAuthFailure(req, 401, "auth_invalid_api_key", getSafeKeyPrefix(apiKey)); + recordAuthFailure(req, 401, "auth_invalid_api_key", getSafeApiKeyPrefix(apiKey, ["sk_"])); return res.status(401).json({ error: { code: "INVALID_SECRET_KEY_FORMAT", @@ -77,7 +81,7 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { const partner = await validateApiKey(apiKey); if (!partner) { - recordAuthFailure(req, 401, "auth_invalid_api_key", getSafeKeyPrefix(apiKey)); + recordAuthFailure(req, 401, "auth_invalid_api_key", getSafeApiKeyPrefix(apiKey, ["sk_"])); return res.status(401).json({ error: { code: "INVALID_API_KEY", @@ -104,7 +108,7 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { const requestedPartner = await Partner.findByPk(partnerIdOrName); if (!requestedPartner) { - recordAuthFailure(req, 404, "auth_partner_not_found", getSafeKeyPrefix(apiKey), partner); + recordAuthFailure(req, 404, "auth_partner_not_found", getSafeApiKeyPrefix(apiKey, ["sk_"]), partner); return res.status(404).json({ error: { code: "PARTNER_NOT_FOUND", @@ -122,7 +126,7 @@ export function apiKeyAuth(options: ApiKeyAuthOptions = {}) { // Compare partner names since one API key works for all partners with same name if (requestedPartnerName !== partner.name) { - recordAuthFailure(req, 403, "auth_partner_mismatch", getSafeKeyPrefix(apiKey), partner); + recordAuthFailure(req, 403, "auth_partner_mismatch", getSafeApiKeyPrefix(apiKey, ["sk_"]), partner); return res.status(403).json({ error: { code: "PARTNER_MISMATCH", @@ -236,6 +240,7 @@ function recordAuthFailure( durationMs: getRequestDurationMs(req), errorType, httpStatus, + metadata: buildApiClientRequestMetadata(req, { bodyKeys: ["partnerId"] }), operation: "auth_api_key", partnerId: partner?.id || req.authenticatedPartner?.id || null, partnerName: partner?.name || req.authenticatedPartner?.name || null, @@ -244,9 +249,3 @@ function recordAuthFailure( userId: req.userId || null }); } - -function getSafeKeyPrefix(apiKey: string | undefined): string | null { - if (!apiKey) return null; - if (!apiKey.startsWith("pk_") && !apiKey.startsWith("sk_")) return null; - return apiKey.slice(0, 8); -} diff --git a/apps/api/src/api/middlewares/dualAuth.ts b/apps/api/src/api/middlewares/dualAuth.ts index cb83f13c0..361021d69 100644 --- a/apps/api/src/api/middlewares/dualAuth.ts +++ b/apps/api/src/api/middlewares/dualAuth.ts @@ -1,6 +1,10 @@ import { NextFunction, Request, Response } from "express"; import logger from "../../config/logger"; -import { observeApiClientEvent } from "../observability/apiClientEvent.service"; +import { + buildApiClientRequestMetadata, + getSafeApiKeyPrefix, + observeApiClientEvent +} from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; import { SupabaseAuthService } from "../services/auth"; import { getKeyType, isValidSecretKeyFormat, validateSecretApiKey } from "./apiKeyAuth.helpers"; @@ -37,7 +41,7 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } if (apiKey) { const keyType = getKeyType(apiKey); if (keyType !== "secret" || !isValidSecretKeyFormat(apiKey)) { - recordDualAuthFailure(req, 401, "auth_invalid_api_key", getSafeKeyPrefix(apiKey)); + recordDualAuthFailure(req, 401, "auth_invalid_api_key", getSafeApiKeyPrefix(apiKey, ["sk_"])); return res.status(401).json({ error: { code: "INVALID_SECRET_KEY", @@ -49,7 +53,7 @@ function dualAuthHandler({ requireCredentials }: { requireCredentials: boolean } const partner = await validateSecretApiKey(apiKey); if (!partner) { - recordDualAuthFailure(req, 401, "auth_invalid_api_key", getSafeKeyPrefix(apiKey)); + recordDualAuthFailure(req, 401, "auth_invalid_api_key", getSafeApiKeyPrefix(apiKey, ["sk_"])); return res.status(401).json({ error: { code: "INVALID_API_KEY", @@ -112,6 +116,7 @@ function recordDualAuthFailure( durationMs: getRequestDurationMs(req), errorType, httpStatus, + metadata: buildApiClientRequestMetadata(req, { bodyKeys: ["partnerId"] }), operation: "auth_dual", partnerId: req.authenticatedPartner?.id || null, partnerName: req.authenticatedPartner?.name || null, @@ -120,8 +125,3 @@ function recordDualAuthFailure( userId: req.userId || null }); } - -function getSafeKeyPrefix(apiKey: string | undefined): string | null { - if (!apiKey?.startsWith("sk_")) return null; - return apiKey.slice(0, 8); -} diff --git a/apps/api/src/api/middlewares/error.test.ts b/apps/api/src/api/middlewares/error.test.ts new file mode 100644 index 000000000..f5e0ec0e3 --- /dev/null +++ b/apps/api/src/api/middlewares/error.test.ts @@ -0,0 +1,61 @@ +import {describe, expect, it} from "bun:test"; +import httpStatus from "http-status"; +import {APIError} from "../errors/api-error"; +import {handler} from "./error"; + +function createMockResponse() { + const response = { + body: undefined as unknown, + statusCode: undefined as number | undefined, + json(body: unknown) { + response.body = body; + return response; + }, + status(statusCode: number) { + response.statusCode = statusCode; + return response; + } + }; + + return response; +} + +describe("error middleware", () => { + it("masks non-public internal server errors in production-like environments", () => { + const response = createMockResponse(); + + handler( + new APIError({ message: "provider secret details", status: httpStatus.INTERNAL_SERVER_ERROR }), + undefined as never, + response as never, + undefined as never + ); + + expect(response.statusCode).toBe(httpStatus.INTERNAL_SERVER_ERROR); + expect(response.body).toMatchObject({ + code: httpStatus.INTERNAL_SERVER_ERROR, + message: "Internal server error" + }); + }); + + it("preserves explicitly public internal server error messages", () => { + const response = createMockResponse(); + + handler( + new APIError({ + isPublic: true, + message: "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", + status: httpStatus.INTERNAL_SERVER_ERROR + }), + undefined as never, + response as never, + undefined as never + ); + + expect(response.statusCode).toBe(httpStatus.INTERNAL_SERVER_ERROR); + expect(response.body).toMatchObject({ + code: httpStatus.INTERNAL_SERVER_ERROR, + message: "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon." + }); + }); +}); diff --git a/apps/api/src/api/middlewares/error.ts b/apps/api/src/api/middlewares/error.ts index 69068a4cd..49fd1058f 100644 --- a/apps/api/src/api/middlewares/error.ts +++ b/apps/api/src/api/middlewares/error.ts @@ -12,6 +12,8 @@ interface ErrorResponse { message: string; errors?: unknown[]; stack?: string; + statusCode?: number; + type?: string; } /** @@ -29,12 +31,17 @@ const handler = (err: APIError | Error, _req: Request, res: Response, _next: Nex stack: err.stack }; + if (apiError.type) { + response.statusCode = statusCode; + response.type = apiError.type; + } + if (env !== "development") { delete response.stack; if (statusCode >= 500) { // Preserve messages for intentional 5xx codes (e.g. 503 SERVICE_UNAVAILABLE used by // ephemeral freshness checks). Only mask unexpected internal server errors (500). - if (statusCode === httpStatus.INTERNAL_SERVER_ERROR) { + if (statusCode === httpStatus.INTERNAL_SERVER_ERROR && !apiError.isPublic) { response.message = "Internal server error"; } } diff --git a/apps/api/src/api/middlewares/ownershipAuth.ts b/apps/api/src/api/middlewares/ownershipAuth.ts index 97b75d512..1d84bbfe4 100644 --- a/apps/api/src/api/middlewares/ownershipAuth.ts +++ b/apps/api/src/api/middlewares/ownershipAuth.ts @@ -3,12 +3,17 @@ import Partner from "../../models/partner.model"; import QuoteTicket from "../../models/quoteTicket.model"; import RampState from "../../models/rampState.model"; import { APIError } from "../errors/api-error"; -import { observeApiClientEvent } from "../observability/apiClientEvent.service"; +import { buildApiClientRequestMetadata, observeApiClientEvent } from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; import type { AuthenticatedPartner } from "./apiKeyAuth.helpers"; interface OwnershipRequest { authenticatedPartner?: AuthenticatedPartner; + body?: unknown; + method?: string; + params?: unknown; + path?: string; + query?: unknown; requestId?: string; requestStartedAt?: number; userId?: string; @@ -144,6 +149,7 @@ function recordOwnershipFailure( durationMs: getRequestDurationMs(req), errorType, httpStatus: status, + metadata: buildApiClientRequestMetadata(req, { bodyKeys: ["quoteId", "rampId"], paramKeys: ["id"] }), operation: "auth_ownership", partnerId: req.authenticatedPartner?.id || null, partnerName: req.authenticatedPartner?.name || null, diff --git a/apps/api/src/api/middlewares/publicKeyAuth.ts b/apps/api/src/api/middlewares/publicKeyAuth.ts index 57aee5a0b..9b66e542b 100644 --- a/apps/api/src/api/middlewares/publicKeyAuth.ts +++ b/apps/api/src/api/middlewares/publicKeyAuth.ts @@ -1,6 +1,10 @@ import { NextFunction, Request, Response } from "express"; import logger from "../../config/logger"; -import { observeApiClientEvent } from "../observability/apiClientEvent.service"; +import { + buildApiClientRequestMetadata, + getSafeApiKeyPrefix, + observeApiClientEvent +} from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; import { getKeyType, isValidApiKeyFormat, validatePublicApiKey } from "./apiKeyAuth.helpers"; @@ -36,7 +40,7 @@ export function validatePublicKey() { // Validate API key format if (!isValidApiKeyFormat(apiKey)) { - recordPublicKeyFailure(req, 400, getSafeKeyPrefix(apiKey)); + recordPublicKeyFailure(req, 400, getSafeApiKeyPrefix(apiKey)); return res.status(400).json({ error: { code: "INVALID_API_KEY_FORMAT", @@ -49,7 +53,7 @@ export function validatePublicKey() { // Check if it's a public key const keyType = getKeyType(apiKey); if (keyType !== "public") { - recordPublicKeyFailure(req, 400, getSafeKeyPrefix(apiKey)); + recordPublicKeyFailure(req, 400, getSafeApiKeyPrefix(apiKey)); return res.status(400).json({ error: { code: "INVALID_KEY_TYPE", @@ -63,7 +67,7 @@ export function validatePublicKey() { const partnerName = await validatePublicApiKey(apiKey); if (!partnerName) { - recordPublicKeyFailure(req, 401, getSafeKeyPrefix(apiKey)); + recordPublicKeyFailure(req, 401, getSafeApiKeyPrefix(apiKey)); return res.status(401).json({ error: { code: "INVALID_PUBLIC_KEY", @@ -93,15 +97,10 @@ function recordPublicKeyFailure(req: Request, httpStatus: number, apiKeyPrefix: durationMs: getRequestDurationMs(req), errorType: "auth_invalid_public_key", httpStatus, + metadata: buildApiClientRequestMetadata(req), operation: "auth_public_key", requestId: req.requestId, status: "failure", userId: req.userId || null }); } - -function getSafeKeyPrefix(apiKey: string | undefined): string | null { - if (!apiKey) return null; - if (!apiKey.startsWith("pk_") && !apiKey.startsWith("sk_")) return null; - return apiKey.slice(0, 8); -} diff --git a/apps/api/src/api/middlewares/validators.test.ts b/apps/api/src/api/middlewares/validators.test.ts index c3cff5208..d8c647b2b 100644 --- a/apps/api/src/api/middlewares/validators.test.ts +++ b/apps/api/src/api/middlewares/validators.test.ts @@ -2,7 +2,8 @@ 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"; +import { APIError } from "../errors/api-error"; +import { validateCreateBestQuoteInput, validateKycSubmission } from "./validators"; function buildRes() { const res: Partial & { statusCode?: number; body?: unknown } = {}; @@ -50,7 +51,8 @@ describe("validateCreateBestQuoteInput - networks whitelist", () => { 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; + const nextMock = mock((_error?: unknown) => undefined); + const next = nextMock as unknown as NextFunction; validateCreateBestQuoteInput(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(res.statusCode).toBeUndefined(); @@ -91,3 +93,49 @@ describe("validateCreateBestQuoteInput - networks whitelist", () => { expect(res.body).toEqual({ message: QuoteError.MissingRequiredFields }); }); }); + +describe("validateKycSubmission", () => { + it("forwards structured API errors for invalid Argentina submissions", () => { + const req = { + body: { + country: "AR", + pep: false, + phoneNumber: "+5511999999999" + } + } as unknown as Request; + const res = buildRes(); + const nextMock = mock((_error?: unknown) => undefined); + const next = nextMock as unknown as NextFunction; + + validateKycSubmission(req, res, next); + + expect(res.statusCode).toBeUndefined(); + expect(next).toHaveBeenCalledTimes(1); + const error = nextMock.mock.calls[0][0]; + expect(error).toBeInstanceOf(APIError); + expect((error as APIError).status).toBe(httpStatus.BAD_REQUEST); + expect((error as APIError).message).toBe("Phone number must use Argentina country code (+54)"); + expect((error as APIError).errors).toEqual([{ message: "Phone number must use Argentina country code (+54)" }]); + }); + + it("accepts valid Argentina-specific fields", () => { + const req = { + body: { + country: "AR", + cuit: "20123456789", + nationalities: ["AR"], + pep: false, + phoneNumber: "+5491112345678" + } + } as unknown as Request; + const res = buildRes(); + const nextMock = mock((_error?: unknown) => undefined); + const next = nextMock as unknown as NextFunction; + + validateKycSubmission(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(nextMock.mock.calls[0]?.[0]).toBeUndefined(); + expect(res.statusCode).toBeUndefined(); + }); +}); diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 8924973e6..77885dfe0 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -17,19 +17,21 @@ import { PriceProvider, QuoteError, RampDirection, + SubmitKycInformationRequest, TokenConfig, VALID_CRYPTO_CURRENCIES, VALID_FIAT_CURRENCIES, VALID_PROVIDERS } from "@vortexfi/shared"; -import { RequestHandler, Response } from "express"; +import { Request, RequestHandler, Response } from "express"; import httpStatus from "http-status"; import logger from "../../config/logger"; import { CONTACT_SHEET_HEADER_VALUES } from "../controllers/contact.controller"; import { EMAIL_SHEET_HEADER_VALUES } from "../controllers/email.controller"; import { RATING_SHEET_HEADER_VALUES } from "../controllers/rating.controller"; import { FLOW_HEADERS } from "../controllers/storage.controller"; -import { observeApiClientEvent } from "../observability/apiClientEvent.service"; +import { APIError } from "../errors/api-error"; +import { buildApiClientRequestMetadata, observeApiClientEvent } from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; interface CreationBody { @@ -472,13 +474,14 @@ const normalizeAxlUsdcCurrency = (value: unknown): unknown => { }; function observeQuoteValidationFailure( - req: { requestId?: string; requestStartedAt?: number; userId?: string }, + req: Request, operation: "quote_create" | "quote_create_best" ): void { observeApiClientEvent({ durationMs: getRequestDurationMs(req), errorType: "validation_error", httpStatus: httpStatus.BAD_REQUEST, + metadata: buildQuoteValidationRequestMetadata(req, operation), operation, requestId: req.requestId, status: "failure", @@ -486,6 +489,24 @@ function observeQuoteValidationFailure( }); } +function buildQuoteValidationRequestMetadata( + req: Request, + operation: "quote_create" | "quote_create_best" +): Record { + return buildApiClientRequestMetadata(req, { + bodyKeys: [ + ...(operation === "quote_create_best" ? ["countryCode", "networks"] : []), + "from", + "inputAmount", + "inputCurrency", + "outputCurrency", + "partnerId", + "rampType", + "to" + ] + }); +} + export const validateGetWidgetUrlInput: RequestHandler = ( req, res, @@ -505,6 +526,40 @@ export const validateGetWidgetUrlInput: RequestHandler string | null> = { + AR: ({ phoneNumber, cuit, nationalities, pep }) => { + if (!phoneNumber) return "Phone number is required for Argentina"; + if (!phoneNumber.startsWith("+54")) return "Phone number must use Argentina country code (+54)"; + if (cuit && !/^\d{11}$/.test(cuit)) return "CUIT must be exactly 11 digits"; + if (nationalities && !nationalities.every(n => /^[A-Z]{2}$/.test(n))) return "Nationalities must use alpha-2 country codes"; + if (typeof pep !== "boolean") return "PEP declaration is required for Argentina"; + return null; + } +}; + +export const validateKycSubmission: RequestHandler = (req, res, next) => { + const body = req.body as SubmitKycInformationRequest; + const validator = countryValidators[body.country]; + + if (!validator) { + return next(); + } + + const error = validator(body); + if (error) { + next( + new APIError({ + errors: [{ message: error }], + message: error, + status: httpStatus.BAD_REQUEST + }) + ); + return; + } + + next(); +}; + export const validateStartKyc2: RequestHandler = (req, res, next) => { const { documentType } = req.body as AveniaKYCDataUploadRequest; diff --git a/apps/api/src/api/observability/apiClientEvent.service.test.ts b/apps/api/src/api/observability/apiClientEvent.service.test.ts index b1c4f029b..1d45d779d 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.test.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; import ApiClientEvent from "../../models/apiClientEvent.model"; -import { recordApiClientEventSafe, sanitizeApiClientEvent } from "./apiClientEvent.service"; +import { + buildApiClientRequestMetadata, + getSafeApiKeyPrefix, + recordApiClientEventSafe, + sanitizeApiClientEvent +} from "./apiClientEvent.service"; describe("sanitizeApiClientEvent", () => { it("removes sensitive metadata and replaces raw error messages", () => { @@ -28,6 +33,27 @@ describe("sanitizeApiClientEvent", () => { expect(sanitized.partnerName).toHaveLength(100); }); + it("keeps sanitized request summaries while stripping raw request details", () => { + const sanitized = sanitizeApiClientEvent({ + errorType: "validation_error", + metadata: { + authorization: "Bearer token", + requestBodyInputAmount: "100\n200", + requestBodyNetworksCount: 2, + requestMethod: "POST", + rawBody: { inputAmount: "100" } + }, + operation: "quote_create_best", + status: "failure" + }); + + expect(sanitized.metadata).toEqual({ + requestBodyInputAmount: "100 200", + requestBodyNetworksCount: 2, + requestMethod: "POST" + }); + }); + it("defaults successful events to the none error type", () => { const sanitized = sanitizeApiClientEvent({ operation: "quote_get", status: "success" }); @@ -49,6 +75,88 @@ describe("sanitizeApiClientEvent", () => { }); }); +describe("buildApiClientRequestMetadata", () => { + it("builds a scalar request summary from allowlisted request fields", () => { + const metadata = buildApiClientRequestMetadata( + { + body: { + additionalData: { taxId: "12345678900" }, + apiKey: "pk_live_secret", + inputAmount: "100", + networks: ["Polygon", "Base"] + }, + method: "POST", + params: { id: "ramp-1" }, + path: "/v1/quotes/best", + query: { showUnsignedTxs: "true" } + }, + { bodyKeys: ["apiKey", "inputAmount", "networks", "additionalData"], paramKeys: ["id"], queryKeys: ["showUnsignedTxs"] } + ); + + expect(metadata).toEqual({ + hasRequestBodyAdditionalData: true, + requestBodyInputAmount: "100", + requestBodyNetworksCount: 2, + requestMethod: "POST", + requestParamId: "ramp-1", + requestPath: "/v1/quotes/best", + requestQueryShowUnsignedTxs: "true" + }); + }); + + it("templates route param values out of request paths", () => { + const metadata = buildApiClientRequestMetadata( + { + method: "GET", + params: { walletAddress: "0xabc123" }, + path: "/v1/ramp/history/0xabc123" + }, + { paramKeys: [] } + ); + + expect(metadata).toEqual({ + requestMethod: "GET", + requestPath: "/v1/ramp/history/:walletAddress" + }); + }); + + it("records only counts or presence flags for allowlisted sensitive payload fields", () => { + const metadata = buildApiClientRequestMetadata( + { + body: { + additionalData: { receiverTaxId: "12345678900" }, + presignedTxs: ["signed-payload"], + signingAccounts: ["wallet-address-1", "wallet-address-2"], + taxId: "12345678900" + }, + method: "POST", + path: "/v1/ramp/register" + }, + { bodyKeys: ["additionalData", "presignedTxs", "signingAccounts", "taxId"] } + ); + + expect(metadata).toEqual({ + hasRequestBodyAdditionalData: true, + requestBodyPresignedTxsCount: 1, + requestBodySigningAccountsCount: 2, + requestMethod: "POST", + requestPath: "/v1/ramp/register" + }); + }); +}); + +describe("getSafeApiKeyPrefix", () => { + it("keeps enough key-specific characters to identify an API key", () => { + expect(getSafeApiKeyPrefix("pk_live_1234567890abcdef")).toBe("pk_live_12345678"); + expect(getSafeApiKeyPrefix("sk_live_abcdef1234567890", ["sk_"])).toBe("sk_live_abcdef12"); + }); + + it("rejects disallowed key types", () => { + expect(getSafeApiKeyPrefix("sk_live_abcdef1234567890", ["pk_"])).toBeNull(); + expect(getSafeApiKeyPrefix("not-a-key")).toBeNull(); + }); +}); + describe("recordApiClientEventSafe", () => { const originalCreate = ApiClientEvent.create; diff --git a/apps/api/src/api/observability/apiClientEvent.service.ts b/apps/api/src/api/observability/apiClientEvent.service.ts index a8b850485..32be8c975 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.ts @@ -4,24 +4,43 @@ import { recordApiClientMetricsSafe } from "./metrics"; import { logApiClientOperationSafe } from "./operationLogger"; import { ApiClientErrorType, ApiClientEventInput } from "./types"; +const API_KEY_PREFIX_LENGTH = 16; + const SENSITIVE_METADATA_KEYS = new Set([ - "additionalData", - "apiKey", + "additionaldata", + "apikey", "authorization", - "depositQrCode", - "ephemeralAccounts", - "ibanPaymentData", - "pixDestination", - "presignedTxs", - "rawBody", - "receiverTaxId", - "secretKey", - "signingAccounts", - "taxId", - "walletAddress", + "depositqrcode", + "ephemeralaccounts", + "ibanpaymentdata", + "pixdestination", + "presignedtxs", + "rawbody", + "receivertaxid", + "secretkey", + "signingaccounts", + "taxid", + "token", + "walletaddress", "x-api-key" ]); +type RequestMetadataValue = string | number | boolean | null; + +interface ApiClientRequestLike { + body?: unknown; + method?: string; + params?: unknown; + path?: string; + query?: unknown; +} + +interface RequestMetadataOptions { + bodyKeys?: string[]; + paramKeys?: string[]; + queryKeys?: string[]; +} + export function observeApiClientEvent(event: ApiClientEventInput): void { try { const sanitizedEvent = sanitizeApiClientEvent(event); @@ -45,7 +64,7 @@ export function sanitizeApiClientEvent(event: ApiClientEventInput): ApiClientEve const errorType = event.errorType || (event.status === "success" ? "none" : "unknown_error"); return { ...event, - apiKeyPrefix: trimString(event.apiKeyPrefix, 16), + apiKeyPrefix: trimString(event.apiKeyPrefix, API_KEY_PREFIX_LENGTH), errorMessage: getSafeErrorMessage(errorType), errorType, metadata: sanitizeMetadata(event.metadata), @@ -54,21 +73,128 @@ export function sanitizeApiClientEvent(event: ApiClientEventInput): ApiClientEve }; } +export function getSafeApiKeyPrefix( + apiKey: string | null | undefined, + allowedPrefixes: ("pk_" | "sk_")[] = ["pk_", "sk_"] +): string | null { + if (!apiKey || !allowedPrefixes.some(prefix => apiKey.startsWith(prefix))) return null; + + return apiKey.slice(0, API_KEY_PREFIX_LENGTH); +} + +export function buildApiClientRequestMetadata( + req: ApiClientRequestLike, + options: RequestMetadataOptions = {} +): Record { + const metadata: Record = { + requestMethod: req.method || null, + requestPath: buildTemplatedRequestPath(req.path, req.params) + }; + + addSelectedValues(metadata, "requestBody", req.body, options.bodyKeys); + addSelectedValues(metadata, "requestParam", req.params, options.paramKeys); + addSelectedValues(metadata, "requestQuery", req.query, options.queryKeys); + + return metadata; +} + function sanitizeMetadata(metadata: Record | null | undefined): Record | null { if (!metadata) return null; const sanitized: Record = {}; for (const [key, value] of Object.entries(metadata)) { - if (SENSITIVE_METADATA_KEYS.has(key) || typeof value === "object") continue; + if (isSensitiveMetadataKey(key) || typeof value === "object") { + continue; + } sanitized[key] = typeof value === "string" ? trimString(value, 100) : value; } return Object.keys(sanitized).length > 0 ? sanitized : null; } +function addSelectedValues( + metadata: Record, + prefix: string, + values: unknown, + keys: string[] | undefined +): void { + if (!isPlainObject(values) || !keys || keys.length === 0) return; + + for (const key of keys) { + const value = values[key]; + if (value === undefined) continue; + + if (isSensitiveMetadataKey(key) && !Array.isArray(value) && !isPlainObject(value)) continue; + + const metadataKey = `${prefix}${toPascalCase(key)}`; + if (Array.isArray(value)) { + metadata[`${metadataKey}Count`] = value.length; + continue; + } + if (isPlainObject(value)) { + metadata[`has${prefix.replace(/^request/, "Request")}${toPascalCase(key)}`] = true; + continue; + } + + metadata[metadataKey] = sanitizeRequestMetadataValue(value); + } +} + +function isSensitiveMetadataKey(key: string): boolean { + return SENSITIVE_METADATA_KEYS.has(key.toLowerCase()); +} + +function buildTemplatedRequestPath(path: string | undefined, params: unknown): string | null { + if (!path) return null; + if (!isPlainObject(params)) return path; + + const paramEntries = Object.entries(params) + .filter(([, value]) => isScalarPathParam(value)) + .map(([key, value]) => [key, String(value)] as const); + + if (paramEntries.length === 0) return path; + + return path + .split("/") + .map(segment => { + const decodedSegment = decodePathSegment(segment); + const matchingParam = paramEntries.find(([, value]) => segment === value || decodedSegment === value); + return matchingParam ? `:${matchingParam[0]}` : segment; + }) + .join("/"); +} + +function sanitizeRequestMetadataValue(value: unknown): RequestMetadataValue { + if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + + return String(value); +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isScalarPathParam(value: unknown): value is string | number | boolean { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +function decodePathSegment(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function toPascalCase(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + function trimString(value: string | null | undefined, maxLength: number): string | null { if (!value) return null; - return value.slice(0, maxLength); + return value.replace(/[\r\n\t]/g, " ").slice(0, maxLength); } function getSafeErrorMessage(errorType: ApiClientErrorType): string | null { diff --git a/apps/api/src/api/observability/errorClassifier.test.ts b/apps/api/src/api/observability/errorClassifier.test.ts index eb755baff..1a01f35b0 100644 --- a/apps/api/src/api/observability/errorClassifier.test.ts +++ b/apps/api/src/api/observability/errorClassifier.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from "bun:test"; +import {describe, expect, it} from "bun:test"; import httpStatus from "http-status"; -import { APIError } from "../errors/api-error"; -import { classifyApiClientError } from "./errorClassifier"; +import {APIError} from "../errors/api-error"; +import {classifyApiClientError} from "./errorClassifier"; describe("classifyApiClientError", () => { it("classifies common quote and ramp errors", () => { @@ -14,6 +14,14 @@ describe("classifyApiClientError", () => { expect(classifyApiClientError(new APIError({ message: "No presigned transactions found", status: httpStatus.BAD_REQUEST }))).toBe( "missing_presigned_transactions" ); + expect( + classifyApiClientError( + new APIError({ + message: "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", + status: httpStatus.INTERNAL_SERVER_ERROR + }) + ) + ).toBe("provider_error"); }); it("classifies auth and ownership failures", () => { diff --git a/apps/api/src/api/observability/errorClassifier.ts b/apps/api/src/api/observability/errorClassifier.ts index 6ebbb1c33..c4acba3ae 100644 --- a/apps/api/src/api/observability/errorClassifier.ts +++ b/apps/api/src/api/observability/errorClassifier.ts @@ -12,6 +12,8 @@ export function classifyApiClientError(error: unknown, fallbackStatus?: number | if (status === httpStatus.UNAUTHORIZED) return "auth_invalid_api_key"; if (status === httpStatus.SERVICE_UNAVAILABLE) return "service_unavailable"; + if (message.includes("low liquidity")) return "provider_error"; + if (status === httpStatus.BAD_REQUEST) { if (message.includes("expired")) return "quote_expired"; if (message.includes("no presigned transactions")) return "missing_presigned_transactions"; diff --git a/apps/api/src/api/routes/v1/alfredpay.route.ts b/apps/api/src/api/routes/v1/alfredpay.route.ts index e2009db8e..862fc869f 100644 --- a/apps/api/src/api/routes/v1/alfredpay.route.ts +++ b/apps/api/src/api/routes/v1/alfredpay.route.ts @@ -3,6 +3,7 @@ import multer from "multer"; import { AlfredpayController } from "../../controllers/alfredpay.controller"; import { validateResultCountry } from "../../middlewares/alfredpay.middleware"; import { requireAuth } from "../../middlewares/supabaseAuth"; +import { validateKycSubmission } from "../../middlewares/validators"; const router = Router(); const upload = multer({ limits: { fileSize: 5 * 1024 * 1024 }, storage: multer.memoryStorage() }); @@ -18,7 +19,13 @@ router.post("/createBusinessCustomer", requireAuth, validateResultCountry, Alfre router.get("/getKybRedirectLink", requireAuth, validateResultCountry, AlfredpayController.getKybRedirectLink); // MXN/CO API-based KYC -router.post("/submitKycInformation", requireAuth, validateResultCountry, AlfredpayController.submitKycInformation); +router.post( + "/submitKycInformation", + requireAuth, + validateResultCountry, + validateKycSubmission, + AlfredpayController.submitKycInformation +); router.post("/submitKycFile", requireAuth, upload.single("file"), validateResultCountry, AlfredpayController.submitKycFile); router.post("/sendKycSubmission", requireAuth, validateResultCountry, AlfredpayController.sendKycSubmission); diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts index 016ae5846..a82dfd06b 100644 --- a/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts +++ b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts @@ -23,6 +23,7 @@ import RampState from "../../../../models/rampState.model"; import TaxId from "../../../../models/taxId.model"; import { APIError } from "../../../errors/api-error"; import { BasePhaseHandler } from "../base-phase-handler"; +import { syncAveniaOnHoldState } from "../helpers/brla-onramp-hold"; import { StateMetadata } from "../meta-state-types"; // The rationale for these difference is that it allows for a finer check over the payment timeout in @@ -30,6 +31,7 @@ import { StateMetadata } from "../meta-state-types"; // process loop and check for the operation timestamp. const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +const AVENIA_HOLD_STATUS_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute // The pre-computed expected amount stored at quote-creation time can be slightly higher than the // amount actually transferred due to fee differences at execution time. We allow a 5% tolerance @@ -85,9 +87,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { // transferred to the ephemeral. We accept a balance of at least 95% of the // pre-computed expected amount to account for fee differences between quote // creation time and execution time. - const recoveryThresholdRaw = new Big(preComputedExpectedAmountRaw) - .times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR) - .toFixed(0, 0); + const recoveryThresholdRaw = new Big(preComputedExpectedAmountRaw).times(EPHEMERAL_FUNDED_TOLERANCE_FACTOR).toFixed(0, 0); if (await this.ephemeralAlreadyFunded(tokenDetails.erc20AddressSourceChain, evmEphemeralAddress, recoveryThresholdRaw)) { logger.info( @@ -97,6 +97,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { } const brlaApiService = BrlaApiService.getInstance(); + let lastAveniaHoldStatusCheckAt = 0; try { logger.info( `BrlaOnrampMintHandler: Waiting for Avenia balance to have at least ${quote.metadata.aveniaMint.outputAmountDecimal} BRL` @@ -107,6 +108,28 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { return false; } + const now = Date.now(); + if (now - lastAveniaHoldStatusCheckAt >= AVENIA_HOLD_STATUS_CHECK_INTERVAL_MS) { + lastAveniaHoldStatusCheckAt = now; + const ticketFound = await syncAveniaOnHoldState( + state.state, + updatedState => + state.update({ + state: { + ...state.state, + ...updatedState + } + }), + brlaApiService, + taxIdRecord.subAccountId + ); + if (!ticketFound) { + logger.warn( + `BrlaOnrampMintHandler: Avenia ticket ${state.state.aveniaTicketId} was not found while checking hold status.` + ); + } + } + // Check internal balance of Avenia subaccount const { balances } = await brlaApiService.getAccountBalance(taxIdRecord.subAccountId); if (!balances || balances.BRLA === undefined || balances.BRLA === null) { @@ -149,10 +172,7 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { // Derive the expected on-chain amount from the live quote's outputAmount rather than // the stale pre-computed metadata value. The live quote accounts for the actual fees // applied at execution time, so this is the amount that will truly arrive on Base. - const expectedAmountReceived = multiplyByPowerOfTen( - new Big(aveniaQuote.outputAmount), - tokenDetails.decimals - ).toFixed(0, 0); + const expectedAmountReceived = multiplyByPowerOfTen(new Big(aveniaQuote.outputAmount), tokenDetails.decimals).toFixed(0, 0); logger.info( `BrlaOnrampMintHandler: Live Avenia quote output is ${aveniaQuote.outputAmount} BRLA (raw: ${expectedAmountReceived}). Pre-computed metadata value was ${preComputedExpectedAmountRaw}.` diff --git a/apps/api/src/api/services/phases/helpers/brla-onramp-hold.test.ts b/apps/api/src/api/services/phases/helpers/brla-onramp-hold.test.ts new file mode 100644 index 000000000..9f9444a04 --- /dev/null +++ b/apps/api/src/api/services/phases/helpers/brla-onramp-hold.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { syncAveniaOnHoldState } from "./brla-onramp-hold"; + +const getAveniaPayinTickets = mock(async () => [{ id: "ticket-1", status: "ON-HOLD" }]); + +const brlaApiService = { + getAveniaPayinTickets +}; + +function makeState(initialOnHold?: boolean) { + const state: { aveniaTicketId: string; onHold?: boolean } = { + aveniaTicketId: "ticket-1", + onHold: initialOnHold + }; + return { + state: { + ...state + } + }; +} + +describe("syncAveniaOnHoldState", () => { + beforeEach(() => { + getAveniaPayinTickets.mockClear(); + getAveniaPayinTickets.mockImplementation(async () => [{ id: "ticket-1", status: "ON-HOLD" }]); + }); + + it("marks the ramp as on hold when the Avenia pay-in ticket is ON-HOLD", async () => { + const state = makeState(false); + + const ticketFound = await syncAveniaOnHoldState(state.state, async nextState => { + Object.assign(state.state, nextState); + }, brlaApiService, "subaccount-1"); + + expect(ticketFound).toBe(true); + expect(getAveniaPayinTickets).toHaveBeenCalledWith("subaccount-1"); + expect(state.state.onHold).toBe(true); + }); + + it("normalizes Avenia ticket status casing", async () => { + getAveniaPayinTickets.mockImplementationOnce(async () => [{ id: "ticket-1", status: "on-hold" }]); + const state = makeState(false); + + await syncAveniaOnHoldState(state.state, async nextState => { + Object.assign(state.state, nextState); + }, brlaApiService, "subaccount-1"); + + expect(state.state.onHold).toBe(true); + }); + + it("clears the on-hold flag when the Avenia pay-in ticket is no longer ON-HOLD", async () => { + getAveniaPayinTickets.mockImplementationOnce(async () => [{ id: "ticket-1", status: "PAID" }]); + const state = makeState(true); + + await syncAveniaOnHoldState(state.state, async nextState => { + Object.assign(state.state, nextState); + }, brlaApiService, "subaccount-1"); + + expect(state.state.onHold).toBe(false); + }); + + it("does not update state when the Avenia pay-in ticket is missing", async () => { + getAveniaPayinTickets.mockImplementationOnce(async () => []); + const state = makeState(false); + + const ticketFound = await syncAveniaOnHoldState(state.state, async nextState => { + Object.assign(state.state, nextState); + }, brlaApiService, "subaccount-1"); + + expect(ticketFound).toBe(false); + expect(state.state.onHold).toBe(false); + }); +}); diff --git a/apps/api/src/api/services/phases/helpers/brla-onramp-hold.ts b/apps/api/src/api/services/phases/helpers/brla-onramp-hold.ts new file mode 100644 index 000000000..c21fa8076 --- /dev/null +++ b/apps/api/src/api/services/phases/helpers/brla-onramp-hold.ts @@ -0,0 +1,36 @@ +import { AveniaTicketStatus } from "@vortexfi/shared"; + +interface AveniaPayinTicketClient { + getAveniaPayinTickets(subAccountId: string): Promise<{ id: string; status: string }[]>; +} + +interface RampOnHoldMetadata { + aveniaTicketId: string; + onHold?: boolean; +} + +export async function syncAveniaOnHoldState( + state: RampOnHoldMetadata, + updateState: (state: RampOnHoldMetadata) => Promise, + brlaApiService: AveniaPayinTicketClient, + subAccountId: string +): Promise { + const ticket = (await brlaApiService.getAveniaPayinTickets(subAccountId)).find( + aveniaTicket => aveniaTicket.id === state.aveniaTicketId + ); + + if (!ticket) { + return false; + } + + const isOnHold = ticket.status.trim().toUpperCase() === AveniaTicketStatus.ON_HOLD; + if (state.onHold === isOnHold) { + return true; + } + + await updateState({ + ...state, + onHold: isOnHold + }); + return true; +} diff --git a/apps/api/src/api/services/phases/meta-state-types.ts b/apps/api/src/api/services/phases/meta-state-types.ts index c7509915a..a1b81f1e5 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -8,6 +8,7 @@ export interface StateMetadata { distributeFeeHash: string; // Only used in onramp - brla aveniaTicketId: string; + onHold?: boolean; taxId: string; pixDestination: string; brlaEvmAddress: string; @@ -49,6 +50,8 @@ export interface StateMetadata { // Final transaction hash and explorer link (computed once when ramp is complete) finalTransactionHash?: string; finalTransactionExplorerLink?: string; + finalTransactionHashV2?: string; + finalTransactionExplorerLinkV2?: string; // Alfredpay alfredpayUserId?: string; alfredpayTransactionId?: string; diff --git a/apps/api/src/api/services/phases/post-process/index.ts b/apps/api/src/api/services/phases/post-process/index.ts index 997212013..f1d8067c3 100644 --- a/apps/api/src/api/services/phases/post-process/index.ts +++ b/apps/api/src/api/services/phases/post-process/index.ts @@ -1,7 +1,6 @@ import assetHubPostProcessHandler from "./assethub-post-process-handler"; import baseChainPostProcessHandler from "./base-chain-post-process-handler"; import { BasePostProcessHandler } from "./base-post-process-handler"; -import hydrationPostProcessHandler from "./hydration-post-process-handler"; import moonbeamPostProcessHandler from "./moonbeam-post-process-handler"; import pendulumPostProcessHandler from "./pendulum-post-process-handler"; import polygonPostProcessHandler from "./polygon-post-process-handler"; @@ -14,11 +13,9 @@ const postProcessHandlers: BasePostProcessHandler[] = [ moonbeamPostProcessHandler, polygonPostProcessHandler, baseChainPostProcessHandler, - hydrationPostProcessHandler, assetHubPostProcessHandler ]; -export { postProcessHandlers }; export { AssetHubPostProcessHandler } from "./assethub-post-process-handler"; export { BaseChainPostProcessHandler } from "./base-chain-post-process-handler"; export { BasePostProcessHandler } from "./base-post-process-handler"; @@ -26,3 +23,4 @@ export { HydrationPostProcessHandler } from "./hydration-post-process-handler"; export { MoonbeamPostProcessHandler } from "./moonbeam-post-process-handler"; export { PendulumPostProcessHandler } from "./pendulum-post-process-handler"; export { PolygonPostProcessHandler } from "./polygon-post-process-handler"; +export { postProcessHandlers }; diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index 782f2e2f6..fcbec5388 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -187,7 +187,7 @@ export class PriceFeedService { } // Check if the currency has a Pendulum representative (Nabla pool). - // Currencies like MXN and COP are TokenType.Fiat with no Pendulum pool — use CoinGecko for those. + // Currencies like MXN, COP, and ARS are TokenType.Fiat with no Pendulum pool — use CoinGecko for those. let outputTokenPendulumDetails; try { outputTokenPendulumDetails = getPendulumDetails(toCurrency); diff --git a/apps/api/src/api/services/quote/core/errors.test.ts b/apps/api/src/api/services/quote/core/errors.test.ts new file mode 100644 index 000000000..f3656f0a9 --- /dev/null +++ b/apps/api/src/api/services/quote/core/errors.test.ts @@ -0,0 +1,26 @@ +import {QuoteError} from "@vortexfi/shared"; +import {describe, expect, it} from "bun:test"; +import httpStatus from "http-status"; +import {createLowLiquidityQuoteError, isLowLiquidityQuoteError} from "./errors"; + +describe("quote error helpers", () => { + it("recognizes low-liquidity provider failures", () => { + expect(isLowLiquidityQuoteError(new Error("Low liquidity for selected route"))).toBe(true); + expect(isLowLiquidityQuoteError(new Error("Please reduce swap amount and try again"))).toBe(true); + expect(isLowLiquidityQuoteError(new Error("insufficientLiquidity"))).toBe(true); + expect(isLowLiquidityQuoteError(new Error("SwapPool: EXCEEDS_MAX_COVERAGE_RATIO"))).toBe(true); + }); + + it("does not classify unrelated provider failures as low liquidity", () => { + expect(isLowLiquidityQuoteError(new Error("Invalid Squidrouter response"))).toBe(false); + }); + + it("creates a user-facing 500 quote error", () => { + const error = createLowLiquidityQuoteError(); + + expect(error.isPublic).toBe(true); + expect(error.message).toBe(QuoteError.LowLiquidity); + expect(error.status).toBe(httpStatus.INTERNAL_SERVER_ERROR); + expect(error.type).toBeUndefined(); + }); +}); diff --git a/apps/api/src/api/services/quote/core/errors.ts b/apps/api/src/api/services/quote/core/errors.ts new file mode 100644 index 000000000..617c4d044 --- /dev/null +++ b/apps/api/src/api/services/quote/core/errors.ts @@ -0,0 +1,25 @@ +import { QuoteError } from "@vortexfi/shared"; +import httpStatus from "http-status"; +import { APIError } from "../../../errors/api-error"; + +const LOW_LIQUIDITY_ERROR_PATTERNS = [ + "low liquidity", + "reduce swap amount", + "insufficientliquidity", + "exceeds_max_coverage_ratio" +]; + +export function isLowLiquidityQuoteError(error: unknown): boolean { + const errorMessage = error instanceof Error ? error.message : String(error); + const normalizedMessage = errorMessage.toLowerCase().replace(/\s+/g, ""); + + return LOW_LIQUIDITY_ERROR_PATTERNS.some(pattern => normalizedMessage.includes(pattern.replace(/\s+/g, ""))); +} + +export function createLowLiquidityQuoteError(): APIError { + return new APIError({ + isPublic: true, + message: QuoteError.LowLiquidity, + status: httpStatus.INTERNAL_SERVER_ERROR + }); +} diff --git a/apps/api/src/api/services/quote/core/helpers.ts b/apps/api/src/api/services/quote/core/helpers.ts index abb6b96ca..01a3dd1c5 100644 --- a/apps/api/src/api/services/quote/core/helpers.ts +++ b/apps/api/src/api/services/quote/core/helpers.ts @@ -31,6 +31,7 @@ export const SUPPORTED_CHAINS: { from: [ EPaymentMethod.PIX as DestinationType, EPaymentMethod.SEPA as DestinationType, + EPaymentMethod.CBU as DestinationType, EPaymentMethod.ACH as DestinationType, EPaymentMethod.SPEI as DestinationType ], diff --git a/apps/api/src/api/services/quote/core/nabla.ts b/apps/api/src/api/services/quote/core/nabla.ts index e96b9dd97..d426a4949 100644 --- a/apps/api/src/api/services/quote/core/nabla.ts +++ b/apps/api/src/api/services/quote/core/nabla.ts @@ -16,6 +16,7 @@ import { Big } from "big.js"; import httpStatus from "http-status"; import logger from "../../../../config/logger"; import { APIError } from "../../../errors/api-error"; +import { createLowLiquidityQuoteError, isLowLiquidityQuoteError } from "./errors"; export interface NablaSwapRequest { inputAmountForSwap: string; @@ -122,6 +123,10 @@ export async function calculateNablaSwapOutput(request: NablaSwapRequest): Promi } } catch (error) { logger.error("Error calculating Nabla swap output:", error); + if (isLowLiquidityQuoteError(error)) { + throw createLowLiquidityQuoteError(); + } + throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR @@ -190,6 +195,10 @@ export async function calculateNablaSwapOutputEvm(request: NablaSwapEvmRequest): }; } catch (error) { logger.error("Error calculating EVM Nabla swap output:", error); + if (isLowLiquidityQuoteError(error)) { + throw createLowLiquidityQuoteError(); + } + throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index 02f0ac88b..8cd6fae41 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/quote/core/squidrouter.ts @@ -14,7 +14,9 @@ import { RampDirection, RouteParams, SquidrouterCachedRoute, + SquidrouterCachedRouteResult, SquidrouterRoute, + SquidrouterRouteResult, stringifyBigWithSignificantDecimals } from "@vortexfi/shared"; import { Big } from "big.js"; @@ -24,6 +26,7 @@ import logger from "../../../../config/logger"; import { APIError } from "../../../errors/api-error"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; import { priceFeedService } from "../../priceFeed.service"; +import { createLowLiquidityQuoteError, isLowLiquidityQuoteError } from "./errors"; export interface EvmBridgeRequest { amountRaw: string; // Raw amount to bridge/swap via Squidrouter @@ -197,7 +200,16 @@ function buildRouteRequest(request: EvmBridgeQuoteRequest) { } async function getSquidrouterRouteData(routeParams: RouteParams, fromNetwork: Networks) { - const routeResult = await getRoute(routeParams, { useCache: true }); + let routeResult: SquidrouterRouteResult | SquidrouterCachedRouteResult; + try { + routeResult = await getRoute(routeParams, { useCache: true }); + } catch (error) { + if (isLowLiquidityQuoteError(error)) { + throw createLowLiquidityQuoteError(); + } + + throw error; + } if (!routeResult?.data?.route?.estimate) { throw new APIError({ @@ -271,11 +283,8 @@ export async function calculateEvmBridgeAndNetworkFee(request: EvmBridgeRequest) logger.error(`Error calculating EVM bridge and network fee: ${errorMessage}`); // Check for specific SquidRouter error types - if (errorMessage.toLowerCase().includes("low liquidity") || errorMessage.toLowerCase().includes("reduce swap amount")) { - throw new APIError({ - message: QuoteError.LowLiquidity, - status: httpStatus.BAD_REQUEST - }); + if (isLowLiquidityQuoteError(error)) { + throw createLowLiquidityQuoteError(); } // Default to generic error for other cases diff --git a/apps/api/src/api/services/quote/engines/finalize/onramp.ts b/apps/api/src/api/services/quote/engines/finalize/onramp.ts index 6adbcc0dc..0e3f6f4f6 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.ts @@ -52,7 +52,8 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { } else if ( request.inputCurrency === FiatToken.USD || request.inputCurrency === FiatToken.MXN || - request.inputCurrency === FiatToken.COP + request.inputCurrency === FiatToken.COP || + request.inputCurrency === FiatToken.ARS ) { // evmToEvm is set when Squid Router ran (e.g. USDC Polygon → USDT Arbitrum). // When destination is USDC on Polygon, Squid Router is skipped (skipRouteCalculation) diff --git a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts index 436ae79ed..5df6ced51 100644 --- a/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/initialize/offramp-from-evm-alfredpay.ts @@ -24,7 +24,6 @@ export class OffRampFromEvmInitializeAlfredpayEngine extends BaseInitializeEngin rampType: req.rampType, toNetwork: Networks.Polygon }; - const bridgeQuote = await getEvmBridgeQuote(quoteRequest); ctx.evmToEvm = { diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 402a6a950..ddfc9a31f 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -19,12 +19,18 @@ import { config } from "../../../config/vars"; import Partner from "../../../models/partner.model"; import { APIError } from "../../errors/api-error"; import { BaseRampService } from "../ramp/base.service"; +import { createLowLiquidityQuoteError, isLowLiquidityQuoteError } from "./core/errors"; import { getTargetFiatCurrency, SUPPORTED_CHAINS, validateChainSupport } from "./core/helpers"; import { createQuoteContext } from "./core/quote-context"; import { QuoteOrchestrator } from "./core/quote-orchestrator"; import { buildQuoteResponse } from "./engines/finalize"; import { RouteResolver } from "./routes/route-resolver"; +type BestQuoteFailure = { + error: unknown; + network: Networks; +}; + export class QuoteService extends BaseRampService { public async createQuote( request: CreateQuoteRequest & { apiKey?: string | null; partnerName?: string | null; userId?: string } @@ -101,15 +107,22 @@ export class QuoteService extends BaseRampService { return { network, quote }; } catch (error) { logger.warn(`Failed to get quote for network ${network}: ${error instanceof Error ? error.message : String(error)}`); - return null; + return { error, network }; } }) ); const quoteResults = await Promise.all(quotePromises); - const validQuotes = quoteResults.filter((result): result is { network: Networks; quote: QuoteResponse } => result !== null); + const validQuotes = quoteResults.filter( + (result): result is { network: Networks; quote: QuoteResponse } => "quote" in result + ); if (validQuotes.length === 0) { + const failures = quoteResults.filter((result): result is BestQuoteFailure => "error" in result); + if (failures.length > 0 && failures.every(failure => isLowLiquidityQuoteError(failure.error))) { + throw createLowLiquidityQuoteError(); + } + throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR @@ -215,6 +228,10 @@ export class QuoteService extends BaseRampService { throw error; } + if (isLowLiquidityQuoteError(error)) { + throw createLowLiquidityQuoteError(); + } + // Detect Alfredpay trade limit error and surface it as a user-facing limit error if (error instanceof AlfredpayTradeLimitError) { throw mapAlfredpayLimitErrorToApiError(error, ctx.request.rampType === RampDirection.BUY); diff --git a/apps/api/src/api/services/quote/routes/route-definition.ts b/apps/api/src/api/services/quote/routes/route-definition.ts index 75e2754db..c851e33e7 100644 --- a/apps/api/src/api/services/quote/routes/route-definition.ts +++ b/apps/api/src/api/services/quote/routes/route-definition.ts @@ -20,18 +20,3 @@ export function defineRouteStrategy(definition: RouteDefinition): IRouteStrategy name: definition.name }; } - -export function withHydrationForNonUsdc(stages: readonly StageKey[]): StageListFactory { - return ctx => { - if (ctx.request.outputCurrency === "USDC") { - return [...stages]; - } - - const finalizeIndex = stages.indexOf(StageKey.Finalize); - if (finalizeIndex === -1) { - return [...stages, StageKey.HydrationSwap]; - } - - return [...stages.slice(0, finalizeIndex), StageKey.HydrationSwap, ...stages.slice(finalizeIndex)]; - }; -} diff --git a/apps/api/src/api/services/quote/routes/route-resolver.test.ts b/apps/api/src/api/services/quote/routes/route-resolver.test.ts new file mode 100644 index 000000000..d50934d74 --- /dev/null +++ b/apps/api/src/api/services/quote/routes/route-resolver.test.ts @@ -0,0 +1,61 @@ +import {describe, expect, it} from "bun:test"; +import {AssetHubToken, EPaymentMethod, FiatToken, Networks, RampDirection} from "@vortexfi/shared"; +import {APIError} from "../../../errors/api-error"; +import {createQuoteContext} from "../core/quote-context"; +import {RouteResolver} from "./route-resolver"; + +describe("RouteResolver", () => { + it("rejects AssetHub to CBU before creating an unexecutable Alfredpay quote", () => { + const ctx = createQuoteContext({ + partner: null, + request: { + from: Networks.AssetHub, + inputAmount: "100", + inputCurrency: AssetHubToken.USDC, + network: Networks.AssetHub, + outputCurrency: FiatToken.ARS, + rampType: RampDirection.SELL, + to: EPaymentMethod.CBU + }, + targetFeeFiatCurrency: FiatToken.ARS + }); + + expect(() => new RouteResolver().resolve(ctx)).toThrow(APIError); + }); + + it("rejects BRL onramp to non-USDC AssetHub before selecting the disabled Hydration route", () => { + const ctx = createQuoteContext({ + partner: null, + request: { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.AssetHub, + outputCurrency: AssetHubToken.USDT, + rampType: RampDirection.BUY, + to: Networks.AssetHub + }, + targetFeeFiatCurrency: FiatToken.BRL + }); + + expect(() => new RouteResolver().resolve(ctx)).toThrow(APIError); + }); + + it("keeps BRL onramp to AssetHub USDC available", () => { + const ctx = createQuoteContext({ + partner: null, + request: { + from: EPaymentMethod.PIX, + inputAmount: "100", + inputCurrency: FiatToken.BRL, + network: Networks.AssetHub, + outputCurrency: AssetHubToken.USDC, + rampType: RampDirection.BUY, + to: Networks.AssetHub + }, + targetFeeFiatCurrency: FiatToken.BRL + }); + + expect(new RouteResolver().resolve(ctx).name).toBe("OnRampAveniaToAssetHub"); + }); +}); diff --git a/apps/api/src/api/services/quote/routes/route-resolver.ts b/apps/api/src/api/services/quote/routes/route-resolver.ts index 4d2d71b08..5048b4c0c 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -23,7 +23,12 @@ import { onrampAveniaToAssethubStrategy } from "./strategies/onramp-avenia-to-as import { onrampAveniaToEvmBaseStrategy } from "./strategies/onramp-avenia-to-evm.strategy-base"; import { onrampMykoboToEvmStrategy } from "./strategies/onramp-mykobo-to-evm.strategy"; -const ALFREDPAY_PAYMENT_METHODS: ReadonlySet = new Set([EPaymentMethod.ACH, EPaymentMethod.SPEI, EPaymentMethod.WIRE]); +const ALFREDPAY_PAYMENT_METHODS: ReadonlySet = new Set([ + EPaymentMethod.ACH, + EPaymentMethod.CBU, + EPaymentMethod.SPEI, + EPaymentMethod.WIRE +]); export class RouteResolver { resolve(ctx: QuoteContext): IRouteStrategy { @@ -39,6 +44,9 @@ export class RouteResolver { status: httpStatus.BAD_REQUEST }); } + if (ctx.request.outputCurrency !== AssetHubToken.USDC) { + throw new APIError({ message: QuoteError.UnsupportedCurrency, status: httpStatus.BAD_REQUEST }); + } return onrampAveniaToAssethubStrategy; } else { if (ctx.request.inputCurrency === FiatToken.EURC) { @@ -71,12 +79,12 @@ export class RouteResolver { case "wire": case "ach": case "spei": + case "cbu": return offrampEvmToAlfredpayStrategy; case "sepa": return offrampToSepaEvmStrategy; - case "cbu": default: - throw new APIError({ message: "ARS offramp temporarily unavailable", status: httpStatus.BAD_REQUEST }); + throw new APIError({ message: `Unsupported offramp payment method: ${ctx.to}`, status: httpStatus.BAD_REQUEST }); } } } diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-assethub.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-assethub.strategy.ts index f4eda2aea..3073da78d 100644 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-assethub.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/onramp-avenia-to-assethub.strategy.ts @@ -2,11 +2,10 @@ import { StageKey } from "../../core/types"; import { OnRampDiscountEngine } from "../../engines/discount/onramp"; import { OnRampAveniaToAssethubFeeEngine } from "../../engines/fee/onramp-brl-to-assethub"; import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; -import { OnRampHydrationEngine } from "../../engines/hydration/onramp"; import { OnRampInitializeAveniaEngine } from "../../engines/initialize/onramp-avenia"; import { OnRampSwapEngine } from "../../engines/nabla-swap/onramp"; import { OnRampPendulumTransferEngine } from "../../engines/pendulum-transfers/onramp"; -import { defineRouteStrategy, withHydrationForNonUsdc } from "../route-definition"; +import { defineRouteStrategy } from "../route-definition"; export const onrampAveniaToAssethubStrategy = defineRouteStrategy({ engines: () => ({ @@ -15,16 +14,15 @@ export const onrampAveniaToAssethubStrategy = defineRouteStrategy({ [StageKey.NablaSwap]: new OnRampSwapEngine(), [StageKey.Discount]: new OnRampDiscountEngine(), [StageKey.PendulumTransfer]: new OnRampPendulumTransferEngine(), - [StageKey.HydrationSwap]: new OnRampHydrationEngine(), [StageKey.Finalize]: new OnRampFinalizeEngine() }), name: "OnRampAveniaToAssetHub", - stages: withHydrationForNonUsdc([ + stages: [ StageKey.Initialize, StageKey.Fee, StageKey.NablaSwap, StageKey.Discount, StageKey.PendulumTransfer, StageKey.Finalize - ]) + ] }); diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts index 0e6e1e424..c630b3937 100644 --- a/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts @@ -7,6 +7,7 @@ const EVM_ADDR = "0x1111111111111111111111111111111111111111"; let substrateNonce = 0; let substrateFree = "0"; +let checkedSubstrateNetworks: string[] = []; let evmNonce = 0; let evmGetClientShouldThrow = false; @@ -16,18 +17,22 @@ mock.module("@vortexfi/shared", () => { ...actual, ApiManager: { getInstance: () => ({ - getApi: async (_network: string) => ({ - api: { - query: { - system: { - account: async (_address: string) => ({ - data: { free: { toString: () => substrateFree } }, - nonce: { toNumber: () => substrateNonce } - }) + getApi: async (network: string) => { + checkedSubstrateNetworks.push(network); + + return { + api: { + query: { + system: { + account: async (_address: string) => ({ + data: { free: { toString: () => substrateFree } }, + nonce: { toNumber: () => substrateNonce } + }) + } } } - } - }) + }; + } }) }, EvmClientManager: { @@ -50,6 +55,7 @@ describe("validateEphemeralAccountsFresh", () => { beforeEach(() => { substrateNonce = 0; substrateFree = "0"; + checkedSubstrateNetworks = []; evmNonce = 0; evmGetClientShouldThrow = false; }); @@ -63,6 +69,12 @@ describe("validateEphemeralAccountsFresh", () => { ).resolves.toBeUndefined(); }); + it("does not check Hydration while Hydration-backed routes are disabled", async () => { + await validateEphemeralAccountsFresh({ [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }); + + expect(checkedSubstrateNetworks).toEqual(["pendulum", "assethub"]); + }); + it("passes when no ephemerals are submitted", async () => { await expect(validateEphemeralAccountsFresh({})).resolves.toBeUndefined(); }); diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.ts index ba8545001..e0aeae465 100644 --- a/apps/api/src/api/services/ramp/ephemeral-freshness.ts +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.ts @@ -10,7 +10,7 @@ import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../errors/api-error"; -const SUPPORTED_SUBSTRATE_NETWORKS: SubstrateApiNetwork[] = ["pendulum", "hydration", "assethub"]; +const SUPPORTED_SUBSTRATE_NETWORKS: SubstrateApiNetwork[] = ["pendulum", "assethub"]; const SUPPORTED_EVM_NETWORKS: EvmNetworks[] = [ Networks.Arbitrum, @@ -25,8 +25,8 @@ const SUPPORTED_EVM_NETWORKS: EvmNetworks[] = [ ]; // SECURITY: fail-closed. Any RPC error rejects the registration since we cannot prove freshness without on-chain data. -// We check every supported network unconditionally rather than deriving the route-relevant subset, so a buggy/missing -// route mapping cannot reopen a freshness gap when new phase handlers add chains an ephemeral signs on. +// Hydration is intentionally excluded while Hydration-backed routes are disabled; otherwise unrelated registrations +// would open the Hydration RPC even when their route never signs on Hydration. export async function validateEphemeralAccountsFresh( ephemerals: { [key in EphemeralAccountType]?: string; @@ -56,8 +56,10 @@ async function assertSubstrateAccountFresh(address: string, network: SubstrateAp let free: string; try { const { api } = await ApiManager.getInstance().getApi(network); - // @ts-ignore - api.query.system.account return type is dynamic per chain - const accountInfo = await api.query.system.account(address); + const accountInfo = (await api.query.system.account(address)) as { + data: { free: { toString(): string } }; + nonce: { toNumber(): number }; + }; nonce = accountInfo.nonce.toNumber(); free = accountInfo.data.free.toString(); } catch (error) { diff --git a/apps/api/src/api/services/ramp/helpers.test.ts b/apps/api/src/api/services/ramp/helpers.test.ts new file mode 100644 index 000000000..823e5b195 --- /dev/null +++ b/apps/api/src/api/services/ramp/helpers.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test"; +import { Networks, RampDirection } from "@vortexfi/shared"; +import QuoteTicket from "../../../models/quoteTicket.model"; +import RampState from "../../../models/rampState.model"; +import { getFinalTransactionHashForRampV2 } from "./helpers"; + +type RampStateTestOverrides = { + currentPhase?: string; + id?: string; + state?: Record; + type?: RampDirection; +}; + +function createRampState(overrides: RampStateTestOverrides): RampState { + return { + currentPhase: "complete", + id: "12345678-1234-1234-1234-123456789abc", + state: {}, + type: RampDirection.BUY, + ...overrides + } as unknown as RampState; +} + +function createQuote(network: Networks): QuoteTicket { + return { network } as unknown as QuoteTicket; +} + +describe("getFinalTransactionHashForRampV2", () => { + it("prefers destinationTransfer over the legacy squid router hash for EVM onramps", () => { + const result = getFinalTransactionHashForRampV2( + createRampState({ + state: { + destinationTransferTxHash: "0xdestination", + squidRouterSwapHash: "0xsquid" + } + }), + createQuote(Networks.Base) + ); + + expect(result).toEqual({ + transactionExplorerLink: "https://basescan.org/tx/0xdestination", + transactionHash: "0xdestination" + }); + }); + + it("uses the AssetHub XCM hash for AssetHub onramps", () => { + const result = getFinalTransactionHashForRampV2( + createRampState({ + state: { + destinationTransferTxHash: "0xdestination", + pendulumToAssethubXcmHash: "0xassethub" + } + }), + createQuote(Networks.AssetHub) + ); + + expect(result).toEqual({ + transactionExplorerLink: "https://pendulum.subscan.io/block/0xassethub", + transactionHash: "0xassethub" + }); + }); + + it("uses the corridor terminal transfer hash for offramps", () => { + const result = getFinalTransactionHashForRampV2( + createRampState({ + state: { + brlaPayoutTxHash: "0xbrla" + }, + type: RampDirection.SELL + }), + createQuote(Networks.Base) + ); + + expect(result).toEqual({ + transactionExplorerLink: "https://basescan.org/tx/0xbrla", + transactionHash: "0xbrla" + }); + }); + + it("uses Polygon explorer links for Alfredpay offramps", () => { + const result = getFinalTransactionHashForRampV2( + createRampState({ + state: { + alfredpayOfframpTransferTxHash: "0xalfredpay" + }, + type: RampDirection.SELL + }), + createQuote(Networks.Polygon) + ); + + expect(result).toEqual({ + transactionExplorerLink: "https://polygonscan.com/tx/0xalfredpay", + transactionHash: "0xalfredpay" + }); + }); +}); diff --git a/apps/api/src/api/services/ramp/helpers.ts b/apps/api/src/api/services/ramp/helpers.ts index ab6b34c62..595564a7d 100644 --- a/apps/api/src/api/services/ramp/helpers.ts +++ b/apps/api/src/api/services/ramp/helpers.ts @@ -1,3 +1,4 @@ +import { RampDirection } from "@vortexfi/shared"; import logger from "../../../config/logger"; import { config } from "../../../config/vars"; import QuoteTicket from "../../../models/quoteTicket.model"; @@ -7,23 +8,38 @@ import { fetchWithTimeout } from "../../helpers/fetchWithTimeout"; enum TransactionHashKey { HydrationToAssethubXcmHash = "hydrationToAssethubXcmHash", PendulumToAssethubXcmHash = "pendulumToAssethubXcmHash", - SquidRouterSwapHash = "squidRouterSwapHash" + SquidRouterSwapHash = "squidRouterSwapHash", + DestinationTransferTxHash = "destinationTransferTxHash", + BrlaPayoutTxHash = "brlaPayoutTxHash", + MykoboPayoutTxHash = "mykoboPayoutTxHash", + AlfredpayOfframpTransferTxHash = "alfredpayOfframpTransferTxHash" } -type ExplorerLinkBuilder = (hash: string, rampState: RampState, quote: QuoteTicket) => string; +type ExplorerLinkBuilder = (hash: string, rampState: RampState, quote: QuoteTicket) => string | undefined; -// Map chain names from AxelarScan to their respective explorer URLs +// Explorer base URLs used to build transaction links (includes AxelarScan chain slugs and EVM network identifiers) const CHAIN_EXPLORERS: Record = { arbitrum: "https://arbiscan.io/tx", avalanche: "https://snowtrace.io/tx", base: "https://basescan.org/tx", + "base-sepolia": "https://sepolia.basescan.org/tx", binance: "https://bscscan.com/tx", bsc: "https://bscscan.com/tx", ethereum: "https://etherscan.io/tx", moonbeam: "https://moonscan.io/tx", - polygon: "https://polygonscan.com/tx" + polygon: "https://polygonscan.com/tx", + polygonAmoy: "https://amoy.polygonscan.com/tx" }; +function buildEvmExplorerLink(hash: string, network: string | undefined): string | undefined { + if (!network) { + return undefined; + } + + const explorerBaseUrl = CHAIN_EXPLORERS[network]; + return explorerBaseUrl ? `${explorerBaseUrl}/${hash}` : undefined; +} + async function getAxelarScanExecutionLink(hash: string): Promise<{ explorerLink: string; executionHash: string }> { const url = "https://api.axelarscan.io/gmp/searchGMP"; const response = await fetchWithTimeout(url, { @@ -87,7 +103,15 @@ const EXPLORER_LINK_BUILDERS: Record = [TransactionHashKey.PendulumToAssethubXcmHash]: hash => `https://pendulum.subscan.io/block/${hash}`, - [TransactionHashKey.SquidRouterSwapHash]: hash => `https://axelarscan.io/gmp/${hash}` + [TransactionHashKey.SquidRouterSwapHash]: hash => `https://axelarscan.io/gmp/${hash}`, + + [TransactionHashKey.DestinationTransferTxHash]: (hash, _rampState, quote) => buildEvmExplorerLink(hash, quote.network), + + [TransactionHashKey.BrlaPayoutTxHash]: hash => `${CHAIN_EXPLORERS.base}/${hash}`, + + [TransactionHashKey.MykoboPayoutTxHash]: hash => `${CHAIN_EXPLORERS.base}/${hash}`, + + [TransactionHashKey.AlfredpayOfframpTransferTxHash]: hash => `${CHAIN_EXPLORERS.polygon}/${hash}` }; const TRANSACTION_HASH_PRIORITY: readonly TransactionHashKey[] = [ @@ -96,6 +120,18 @@ const TRANSACTION_HASH_PRIORITY: readonly TransactionHashKey[] = [ TransactionHashKey.SquidRouterSwapHash ] as const; +const BUY_TRANSACTION_HASH_V2_PRIORITY: readonly TransactionHashKey[] = [ + TransactionHashKey.HydrationToAssethubXcmHash, + TransactionHashKey.PendulumToAssethubXcmHash, + TransactionHashKey.DestinationTransferTxHash +] as const; + +const SELL_TRANSACTION_HASH_V2_PRIORITY: readonly TransactionHashKey[] = [ + TransactionHashKey.BrlaPayoutTxHash, + TransactionHashKey.MykoboPayoutTxHash, + TransactionHashKey.AlfredpayOfframpTransferTxHash +] as const; + /// Generate a sandbox transaction hash for testing purposes. It's derived from the id of the ramp state to ensure uniqueness. /// The form is 0x + first 64 hex characters of the UUID (without dashes) + padded with zeros to reach 64 characters. function deriveSandboxTransactionHash(rampState: RampState): string { @@ -157,3 +193,35 @@ export async function getFinalTransactionHashForRamp( return { transactionExplorerLink: undefined, transactionHash: undefined }; } + +export function getFinalTransactionHashForRampV2( + rampState: RampState, + quote: QuoteTicket +): { transactionExplorerLink: string | undefined; transactionHash: string | undefined } { + if (rampState.currentPhase !== "complete") { + return { transactionExplorerLink: undefined, transactionHash: undefined }; + } + + if (config.sandboxEnabled) { + const sandboxHash = deriveSandboxTransactionHash(rampState); + return { + transactionExplorerLink: `https://sandbox-explorer.example.com/tx/${sandboxHash}`, + transactionHash: sandboxHash + }; + } + + const priority = rampState.type === RampDirection.BUY ? BUY_TRANSACTION_HASH_V2_PRIORITY : SELL_TRANSACTION_HASH_V2_PRIORITY; + + for (const hashKey of priority) { + const hash = rampState.state[hashKey]; + + if (hash) { + return { + transactionExplorerLink: EXPLORER_LINK_BUILDERS[hashKey](hash, rampState, quote), + transactionHash: hash + }; + } + } + + return { transactionExplorerLink: undefined, transactionHash: undefined }; +} diff --git a/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts b/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts new file mode 100644 index 000000000..1d142a102 --- /dev/null +++ b/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, mock } from "bun:test"; +import { EPaymentMethod, FiatToken, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; +import QuoteTicket from "../../../models/quoteTicket.model"; +import RampState from "../../../models/rampState.model"; +import { StateMetadata } from "../phases/meta-state-types"; +import { RampService } from "./ramp.service"; + +const createdAt = new Date("2026-06-10T12:31:56.420Z"); +const updatedAt = new Date("2026-06-10T12:32:25.548Z"); + +QuoteTicket.findByPk = mock(async () => ({ + countryCode: "BR", + inputAmount: "25003", + inputCurrency: FiatToken.BRL, + metadata: { + fees: { + displayFiat: { + anchor: "0.75", + currency: "BRL", + network: "0", + partnerMarkup: "0", + total: "0.75", + vortex: "0" + }, + usd: { + anchor: "0.15", + network: "0", + partnerMarkup: "0", + total: "0.15", + vortex: "0" + } + } + }, + network: Networks.Base, + outputAmount: "25002.25", + outputCurrency: "BRLA" +})) as unknown as typeof QuoteTicket.findByPk; + +class TestRampService extends RampService { + public constructor(private readonly rampState: RampState) { + super(); + } + + protected async getRampState(): Promise { + return this.rampState; + } +} + +function makeRampState(onHold: boolean, currentPhase: RampPhase = "brlaOnrampMint") { + return RampState.build({ + createdAt, + currentPhase, + errorLogs: [], + flowVariant: "monerium", + from: EPaymentMethod.PIX, + id: "ramp-1", + paymentMethod: EPaymentMethod.PIX, + phaseHistory: [{ phase: currentPhase, timestamp: createdAt }], + postCompleteState: { + cleanup: { + cleanupAt: null, + cleanupCompleted: false, + errors: null + } + }, + presignedTxs: null, + processingLock: { + locked: false, + lockedAt: null + }, + quoteId: "quote-1", + state: makeStateMetadata({ onHold }), + to: Networks.Base, + type: RampDirection.BUY, + unsignedTxs: [], + userId: null, + updatedAt + }); +} + +function makeStateMetadata(overrides: Partial): StateMetadata { + return { + assethubToPendulumHash: "", + aveniaTicketId: "ticket-1", + brlaEvmAddress: "", + depositQrCode: undefined, + destinationAddress: "0x2222222222222222222222222222222222222222", + distributeFeeHash: "", + evmEphemeralAddress: "", + finalUserAddress: "", + ibanPaymentData: { + bic: "", + iban: "", + receiverName: "" + }, + moonbeamEphemeralAccount: { + address: "", + secret: "" + }, + moonbeamXcmTransactionHash: "0x0000000000000000000000000000000000000000000000000000000000000000", + nabla: { + approveExtrinsicOptions: makeExtrinsicOptions(), + swapExtrinsicOptions: makeExtrinsicOptions() + }, + nablaSoftMinimumOutputRaw: "", + payOutTicketId: undefined, + pixDestination: "", + presignChecksPass: true, + receiverTaxId: "", + sessionId: "session-1", + squidRouterApproveHash: "", + squidRouterPayTxHash: "", + squidRouterQuoteId: "", + squidRouterReceiverHash: "", + squidRouterReceiverId: "", + squidRouterSwapHash: "", + substrateEphemeralAddress: "", + taxId: "", + unhandledPaymentAlertSent: false, + walletAddress: undefined, + ...overrides + }; +} + +function makeExtrinsicOptions() { + return { + callerAddress: "", + contractDeploymentAddress: "", + limits: { + gas: { + proofSize: 0, + refTime: 0 + } + }, + messageArguments: [], + messageName: "" + }; +} + +describe("RampService.getRampStatus", () => { + it("returns onHoldForComplianceCheck when ramp state is marked as on hold", async () => { + const service = new TestRampService(makeRampState(true)); + + const status = await service.getRampStatus("ramp-1"); + + expect(status?.currentPhase).toBe("onHoldForComplianceCheck"); + }); + + it("returns the persisted current phase when ramp state is not on hold", async () => { + const service = new TestRampService(makeRampState(false)); + + const status = await service.getRampStatus("ramp-1"); + + expect(status?.currentPhase).toBe("brlaOnrampMint"); + }); + + it("does not mask later phases if a stale on-hold flag remains", async () => { + const service = new TestRampService(makeRampState(true, "fundEphemeral")); + + const status = await service.getRampStatus("ramp-1"); + + expect(status?.currentPhase).toBe("fundEphemeral"); + }); +}); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 9484a65f0..a2f250fc6 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -64,10 +64,10 @@ import { validatePresignedTxs } from "../transactions/validation"; import webhookDeliveryService from "../webhook/webhook-delivery.service"; import { BaseRampService } from "./base.service"; import { validateEphemeralAccountsFresh } from "./ephemeral-freshness"; -import { getFinalTransactionHashForRamp } from "./helpers"; +import { getFinalTransactionHashForRampV2 } from "./helpers"; import { RampTransactionPreparationKind, selectRampTransactionPreparationKind } from "./ramp-transaction-preparation"; -const RAMP_START_EXPIRATION_TIME_SECONDS = 480; +const RAMP_START_EXPIRATION_TIME_SECONDS = 900; // 15 minutes // Classifies unsigned txs by signer: ephemeral-signed (backend pre-signs) vs user-wallet-signed. function partitionUnsignedTxs( @@ -428,6 +428,18 @@ export class RampService extends BaseRampService { this.validateRampStateData(rampState, quote); + const rampStateCreationTime = new Date(rampState.createdAt); + const currentTime = new Date(); + const timeDifferenceSeconds = (currentTime.getTime() - rampStateCreationTime.getTime()) / 1000; + + if (timeDifferenceSeconds > RAMP_START_EXPIRATION_TIME_SECONDS) { + await this.cancelRamp(rampState.id); + throw new APIError({ + message: "Maximum time window to start process exceeded. Ramp invalidated.", + status: httpStatus.BAD_REQUEST + }); + } + // Check if presigned transactions are available (should be set by updateRamp) if (!rampState.presignedTxs || rampState.presignedTxs.length === 0) { throw new APIError({ @@ -443,19 +455,6 @@ export class RampService extends BaseRampService { }; await validatePresignedTxs(rampState.type, rampState.presignedTxs, ephemerals, rampState.unsignedTxs); - const rampStateCreationTime = new Date(rampState.createdAt); - const currentTime = new Date(); - const timeDifferenceSeconds = (currentTime.getTime() - rampStateCreationTime.getTime()) / 1000; - - // We leave 20% of the time window for to reach the stellar creation operation. - if (timeDifferenceSeconds > RAMP_START_EXPIRATION_TIME_SECONDS) { - this.cancelRamp(rampState.id); - throw new APIError({ - message: "Maximum time window to start process exceeded. Ramp invalidated.", - status: httpStatus.BAD_REQUEST - }); - } - logger.log("Triggering TRANSACTION_CREATED webhook for ramp state:", rampState.id); webhookDeliveryService .triggerTransactionCreated( @@ -538,26 +537,26 @@ export class RampService extends BaseRampService { const processingFeeFiat = new Big(fiatFees.anchor).plus(fiatFees.vortex).toFixed(); const processingFeeUsd = new Big(usdFees.anchor).plus(usdFees.vortex).toFixed(); + const isOnHoldForComplianceCheck = rampState.currentPhase === "brlaOnrampMint" && rampState.state.onHold; + // Never return 'failed' as current phase, instead return last known phase - const currentPhase = - rampState.currentPhase !== "failed" + const currentPhase: RampPhase = isOnHoldForComplianceCheck + ? "onHoldForComplianceCheck" + : rampState.currentPhase !== "failed" ? rampState.currentPhase : // Find second-last entry in phase history or show 'initial' if not available rampState.phaseHistory && rampState.phaseHistory.length > 1 ? rampState.phaseHistory[rampState.phaseHistory.length - 2].phase : "initial"; - // Get or compute final transaction hash and explorer link - let transactionHash = rampState.state.finalTransactionHash; - let transactionExplorerLink = rampState.state.finalTransactionExplorerLink; + // Get or compute the V2 final transaction hash and explorer link. The legacy field intentionally used the + // second-last network for older clients, so status/history responses ignore it here. + let transactionHash = rampState.state.finalTransactionHashV2; + let transactionExplorerLink = rampState.state.finalTransactionExplorerLinkV2; // If not stored yet and ramp is complete, compute and store them - if ( - rampState.type === RampDirection.BUY && - rampState.currentPhase === "complete" && - (!transactionHash || !transactionExplorerLink) - ) { - const result = await getFinalTransactionHashForRamp(rampState, quote); + if (rampState.currentPhase === "complete" && (!transactionHash || !transactionExplorerLink)) { + const result = getFinalTransactionHashForRampV2(rampState, quote); transactionHash = result.transactionHash; transactionExplorerLink = result.transactionExplorerLink; @@ -566,8 +565,8 @@ export class RampService extends BaseRampService { await rampState.update({ state: { ...rampState.state, - finalTransactionExplorerLink: transactionExplorerLink, - finalTransactionHash: transactionHash + finalTransactionExplorerLinkV2: transactionExplorerLink, + finalTransactionHashV2: transactionHash } }); } @@ -690,17 +689,14 @@ export class RampService extends BaseRampService { }); } - // Get or compute final transaction hash and explorer link (similar to getRampStatus) - let transactionHash = ramp.state.finalTransactionHash; - let transactionExplorerLink = ramp.state.finalTransactionExplorerLink; + // Get or compute the V2 final transaction hash and explorer link (similar to getRampStatus). + // Do not fall back to the legacy finalTransactionExplorerLink because that may point to Squid/Axelar. + let transactionHash = ramp.state.finalTransactionHashV2; + let transactionExplorerLink = ramp.state.finalTransactionExplorerLinkV2; // If not stored yet and ramp is complete, compute and store them - if ( - ramp.type === RampDirection.BUY && - ramp.currentPhase === "complete" && - (!transactionHash || !transactionExplorerLink) - ) { - const result = await getFinalTransactionHashForRamp(ramp, quote); + if (ramp.currentPhase === "complete" && (!transactionHash || !transactionExplorerLink)) { + const result = getFinalTransactionHashForRampV2(ramp, quote); transactionHash = result.transactionHash; transactionExplorerLink = result.transactionExplorerLink; @@ -709,8 +705,8 @@ export class RampService extends BaseRampService { await ramp.update({ state: { ...ramp.state, - finalTransactionExplorerLink: transactionExplorerLink, - finalTransactionHash: transactionHash + finalTransactionExplorerLinkV2: transactionExplorerLink, + finalTransactionHashV2: transactionHash } }); } @@ -1534,7 +1530,6 @@ export class RampService extends BaseRampService { return; } - const alfredpayService = AlfredpayApiService.getInstance(); const alfredpayQuoteId = quote.metadata.alfredpayOfframp?.quoteId; if (!alfredpayQuoteId) { diff --git a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts index bc6ba3bbe..eec483684 100644 --- a/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts +++ b/apps/api/src/api/services/transactions/offramp/routes/evm-to-alfredpay.ts @@ -46,7 +46,7 @@ import { addOnrampDestinationChainTransactions } from "../../onramp/common/trans import { preparePolygonCleanupApproval } from "../../polygon/cleanup"; import { OfframpTransactionParams, OfframpTransactionsWithMeta } from "../common/types"; -// TokenRelayer deployments. Address may differ per chain +// TokenRelayer deployments. Address may differ per chain export const RELAYER_ADDRESSES: Partial> = { [Networks.Arbitrum]: "0xC9ECD03c89349B3EAe4613c7091c6c3029413785", [Networks.Base]: "0xDbece5cE27984FC64688bcC57f75b96a28e8c68c", @@ -54,7 +54,6 @@ export const RELAYER_ADDRESSES: Partial> = { [Networks.Avalanche]: "0x11871C77Aa0170ae13864E4E82cFa471720e045e", [Networks.Ethereum]: "0x522A51f9c5B1683F0F15910075487c4D162A8b83", [Networks.BSC]: "0x2d657ac14088fED401b58FEd377988ed3F875220" - }; export function getRelayerAddress(network: EvmNetworks): `0x${string}` { @@ -202,7 +201,8 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ const fiatToCountry: Partial> = { [FiatToken.USD]: AlfredPayCountry.US, [FiatToken.MXN]: AlfredPayCountry.MX, - [FiatToken.COP]: AlfredPayCountry.CO + [FiatToken.COP]: AlfredPayCountry.CO, + [FiatToken.ARS]: AlfredPayCountry.AR }; const customerCountry = fiatToCountry[quote.outputCurrency as FiatToken]; if (!customerCountry) { @@ -338,9 +338,7 @@ export async function prepareEvmToAlfredpayOfframpTransactions({ const relayerAddress = RELAYER_ADDRESSES[fromNetwork]; if (!relayerAddress) { - throw new Error( - `Alfredpay offramp permit flow is not supported on ${fromNetwork}: no relayer deployment configured` - ); + throw new Error(`Alfredpay offramp permit flow is not supported on ${fromNetwork}: no relayer deployment configured`); } const permitTypedData: SignedTypedData = { diff --git a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts index e2f298d60..7c45ef6d6 100644 --- a/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts +++ b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts @@ -16,6 +16,7 @@ import { getOnChainTokenDetailsOrDefault, isEvmToken, isOnChainToken, + multiplyByPowerOfTen, Networks, UnsignedTx } from "@vortexfi/shared"; @@ -76,7 +77,8 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ const fiatToCountry: Partial> = { [FiatToken.USD]: AlfredPayCountry.US, [FiatToken.MXN]: AlfredPayCountry.MX, - [FiatToken.COP]: AlfredPayCountry.CO + [FiatToken.COP]: AlfredPayCountry.CO, + [FiatToken.ARS]: AlfredPayCountry.AR }; const customerCountry = fiatToCountry[quote.inputCurrency as FiatToken]; if (!customerCountry) { @@ -108,7 +110,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ // Special case: onramping the AlfredPay token directly on Polygon. Skip SquidRouter and transfer directly. if ((outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain === ALFREDPAY_ERC20_TOKEN) { const finalTransferTxData = await addOnrampDestinationChainTransactions({ - amountRaw: quote.metadata.evmToEvm.outputAmountRaw, + amountRaw: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), destinationNetwork: toNetwork as EvmNetworks, toAddress: destinationAddress, toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain @@ -172,7 +174,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ // executes them, and on a shared nonce sequence they would push destinationTransfer beyond the live nonce). if (toNetwork === Networks.Polygon) { const sameChainTransferTxData = await addOnrampDestinationChainTransactions({ - amountRaw: quote.metadata.alfredpayMint.outputAmountRaw, + amountRaw: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), destinationNetwork: Networks.Polygon, toAddress: destinationAddress, toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain @@ -225,7 +227,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ }); const finalTransferTxData = await addOnrampDestinationChainTransactions({ - amountRaw: quote.metadata.alfredpayMint.outputAmountRaw, + amountRaw: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), destinationNetwork: toNetwork as EvmNetworks, toAddress: destinationAddress, toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain @@ -255,7 +257,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ fromAddress: evmEphemeralEntry.address, fromToken: bridgedTokenForFallback, network: toNetwork as EvmNetworks, - rawAmount: quote.metadata.alfredpayMint.outputAmountRaw, + rawAmount: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain }); diff --git a/apps/api/src/api/workers/api-client-events-retention.worker.ts b/apps/api/src/api/workers/api-client-events-retention.worker.ts index 89076c1da..1a4378cd7 100644 --- a/apps/api/src/api/workers/api-client-events-retention.worker.ts +++ b/apps/api/src/api/workers/api-client-events-retention.worker.ts @@ -33,7 +33,7 @@ class ApiClientEventsRetentionWorker { logger.info("API client events retention worker cycle completed"); } catch (error) { - const errorDetails = error instanceof Error ? error.stack ?? error.message : String(error); + const errorDetails = error instanceof Error ? (error.stack ?? error.message) : String(error); logger.error(`Error during API client events retention worker cycle: ${errorDetails}`); } } diff --git a/apps/api/src/api/workers/ramp-recovery.worker.ts b/apps/api/src/api/workers/ramp-recovery.worker.ts index ab24bce6d..f76ee28a1 100644 --- a/apps/api/src/api/workers/ramp-recovery.worker.ts +++ b/apps/api/src/api/workers/ramp-recovery.worker.ts @@ -8,6 +8,7 @@ import phaseProcessor from "../services/phases/phase-processor"; import rampService from "../services/ramp/ramp.service"; const TEN_MINUTES_IN_MS = 10 * 60 * 1000; +const DISABLED_HYDRATION_PHASES = ["pendulumToHydrationXcm", "hydrationSwap", "hydrationToAssethubXcm"]; /** * Worker to recover failed ramp states @@ -57,7 +58,7 @@ class RampRecoveryWorker { const staleStates = await RampState.findAll({ where: { currentPhase: { - [Op.notIn]: ["complete", "failed", "initial"] + [Op.notIn]: ["complete", "failed", "initial", ...DISABLED_HYDRATION_PHASES] }, flowVariant: config.flowVariant, presignedTxs: { [Op.not]: null }, diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index 950e17f61..683b5a034 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -1,6 +1,7 @@ import { useCallback } from "react"; import { useAlfredpayKycActor, useAlfredpayKycSelector } from "../../contexts/rampState"; import { DoneScreen } from "../DoneScreen"; +import { ArKycFormScreen } from "./ArKycFormScreen"; import { ColKycFormScreen } from "./ColKycFormScreen"; import { CustomerDefinitionScreen } from "./CustomerDefinitionScreen"; import { FailureKycScreen } from "./FailureKycScreen"; @@ -30,7 +31,7 @@ export const AlfredpayKycFlow = () => { const retryProcess = useCallback(() => actor?.send({ type: "RETRY_PROCESS" }), [actor]); const cancelProcess = useCallback(() => actor?.send({ type: "CANCEL_PROCESS" }), [actor]); const submitForm = useCallback( - (data: import("../../machines/alfredpayKyc.machine").MxnKycFormData) => actor?.send({ data, type: "SUBMIT_FORM" }), + (data: import("../../machines/alfredpayKyc.machine").AlfredpayKycFormData) => actor?.send({ data, type: "SUBMIT_FORM" }), [actor] ); const submitFiles = useCallback( @@ -59,6 +60,7 @@ export const AlfredpayKycFlow = () => { const kycOrKyb = context.business ? "KYB" : "KYC"; const isMxn = context.country === "MX"; const isCo = context.country === "CO"; + const isAr = context.country === "AR"; if ( stateValue === "CheckingStatus" || @@ -86,8 +88,21 @@ export const AlfredpayKycFlow = () => { return ; } - if (stateValue === "UploadingDocuments" && (isMxn || isCo)) { - return ; + if (stateValue === "FillingKycForm" && isAr) { + return ; + } + + if (stateValue === "UploadingDocuments" && (isMxn || isCo || isAr)) { + const includeSelfie = isAr; + const i18nNamespace = isAr ? "components.arDocumentUpload" : undefined; + return ( + + ); } if (stateValue === "FillingKybForm") { diff --git a/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx new file mode 100644 index 000000000..bdda7d3d6 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx @@ -0,0 +1,184 @@ +import { AlfredpayArgentinaDocumentType } from "@vortexfi/shared"; +import type { UseFormReturn } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import type { AlfredpayKycFormData } from "../../machines/alfredpayKyc.machine"; +import { type KycFormConfig, KycFormScreen, kycInputClass } from "./KycFormScreen"; + +const schema = z + .object({ + address: z.string().min(1), + city: z.string().min(1), + countryCode: z.literal("AR"), + cuit: z.string().optional(), + dateOfBirth: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD format"), + dni: z.string().min(1), + email: z.string().email(), + firstName: z.string().min(1), + lastName: z.string().min(1), + nationalities: z.array(z.string().regex(/^[A-Z]{2}$/)).optional(), + pep: z.boolean(), + phoneNumber: z.string().regex(/^\+54\d{7,}$/, "Use Argentina format (+54...)"), + state: z.string().min(1), + typeDocumentAr: z.nativeEnum(AlfredpayArgentinaDocumentType), + zipCode: z.string().min(1) + }) + .superRefine((data, ctx) => { + if (data.cuit && !/^\d{11}$/.test(data.cuit)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "CUIT must be exactly 11 digits", path: ["cuit"] }); + } + }); + +type ArKycFormValues = z.infer; +type ArForm = UseFormReturn; + +function DocumentTypeField({ form }: { form: ArForm }) { + const { t } = useTranslation(); + const error = form.formState.errors.typeDocumentAr; + return ( + + ); +} + +function DniField({ form }: { form: ArForm }) { + const { t } = useTranslation(); + const documentType = form.watch("typeDocumentAr"); + const error = form.formState.errors.dni; + return ( + + ); +} + +function PhoneNumberField({ form }: { form: ArForm }) { + const error = form.formState.errors.phoneNumber; + return ( +
+ + { + event.currentTarget.value = event.currentTarget.value.replace(/\D/g, ""); + }} + pattern="[0-9]*" + placeholder="5491112345678" + type="tel" + {...form.register("phoneNumber", { + setValueAs: (value: string) => { + if (!value) return value; + const digits = value.replace(/\D/g, ""); + return digits.startsWith("54") ? `+${digits}` : `+54${digits}`; + } + })} + /> +
+ ); +} + +const config: KycFormConfig = { + defaultValues: { + countryCode: "AR", + cuit: "", + nationalities: ["AR"], + pep: false, + typeDocumentAr: AlfredpayArgentinaDocumentType.DNI + }, + fields: [ + { + fields: [ + { autoComplete: "given-name", labelKey: "components.mxnKycForm.firstName", name: "firstName", type: "text" }, + { autoComplete: "family-name", labelKey: "components.mxnKycForm.lastName", name: "lastName", type: "text" } + ], + type: "group" + }, + { + inputType: "date", + labelKey: "components.mxnKycForm.dateOfBirth", + name: "dateOfBirth", + placeholder: "YYYY-MM-DD", + type: "text" + }, + { + autoComplete: "email", + inputMode: "email", + inputType: "email", + labelKey: "components.mxnKycForm.email", + name: "email", + type: "text" + }, + { + labelKey: "components.arKycForm.phoneNumber", + name: "phoneNumber", + render: form => , + type: "custom" + }, + { + labelKey: "components.arKycForm.documentType", + name: "typeDocumentAr", + render: form => , + type: "custom" + }, + { + labelKey: "components.arKycForm.dni", + name: "dni", + render: form => , + type: "custom" + }, + { + inputMode: "numeric", + labelKey: "components.arKycForm.cuit", + name: "cuit", + placeholderKey: "components.arKycForm.cuitPlaceholder", + type: "text" + }, + { + autoComplete: "street-address", + labelKey: "components.mxnKycForm.address", + name: "address", + type: "text" + }, + { + fields: [ + { autoComplete: "address-level2", labelKey: "components.mxnKycForm.city", name: "city", type: "text" }, + { autoComplete: "address-level1", labelKey: "components.mxnKycForm.state", name: "state", type: "text" } + ], + type: "group" + }, + { + autoComplete: "postal-code", + inputMode: "numeric", + labelKey: "components.mxnKycForm.zipCode", + name: "zipCode", + type: "text" + }, + { labelKey: "components.arKycForm.pepLabel", name: "pep", type: "checkbox" } + ], + i18nNamespace: "components.arKycForm", + idPrefix: "ar", + schema +}; + +interface ArKycFormScreenProps { + onSubmit: (data: AlfredpayKycFormData) => void; +} + +export function ArKycFormScreen({ onSubmit }: ArKycFormScreenProps) { + return void} />; +} diff --git a/apps/frontend/src/components/Alfredpay/ColKycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/ColKycFormScreen.tsx index 1334c4de1..6bc95a092 100644 --- a/apps/frontend/src/components/Alfredpay/ColKycFormScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/ColKycFormScreen.tsx @@ -1,10 +1,10 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { AlfredpayColombiaDocumentType } from "@vortexfi/shared"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, type UseFormReturn } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; -import { MenuButtons } from "../MenuButtons"; +import type { AlfredpayKycFormData } from "../../machines/alfredpayKyc.machine"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import { type KycFormConfig, KycFormScreen, kycInputClass } from "./KycFormScreen"; const schema = z .object({ @@ -26,203 +26,134 @@ const schema = z }); type ColKycFormValues = z.infer; +type ColForm = UseFormReturn; -interface ColKycFormScreenProps { - onSubmit: (data: ColKycFormValues) => void; +function DocumentTypeField({ form }: { form: ColForm }) { + const { t } = useTranslation(); + const error = form.formState.errors.typeDocumentCol; + return ( + ( + + )} + /> + ); } -export function ColKycFormScreen({ onSubmit }: ColKycFormScreenProps) { +function DniField({ form }: { form: ColForm }) { const { t } = useTranslation(); - - const { - control, - formState: { errors }, - handleSubmit, - register, - watch - } = useForm({ resolver: zodResolver(schema) }); - - const documentType = watch("typeDocumentCol"); - - const inputClass = (hasError: boolean) => - `input-vortex-primary input-ghost w-full rounded-lg border p-2 text-base ${hasError ? "border-error" : "border-neutral-300"}`; - + const documentType = form.watch("typeDocumentCol"); + const error = form.formState.errors.dni; return ( -
- -

{t("components.colKycForm.title")}

-

{t("components.colKycForm.subtitle")}

- -
-
-
- - - {errors.firstName && {errors.firstName.message}} -
- -
- - - {errors.lastName && {errors.lastName.message}} -
-
- -
- - - {errors.dateOfBirth && {errors.dateOfBirth.message}} -
- -
- - ( - - )} - /> - {errors.typeDocumentCol && {errors.typeDocumentCol.message}} -
- -
- - - {errors.dni && {errors.dni.message}} -
- -
- -
- + - (v ? `+${v.replace(/^\+/, "").replace(/\D/g, "")}` : v) - })} - /> -
- {errors.phoneNumber && {errors.phoneNumber.message}} -
- -
- - - {errors.address && {errors.address.message}} -
+ + ); +} -
-
- - - {errors.city && {errors.city.message}} -
+function PhoneNumberField({ form }: { form: ColForm }) { + const error = form.formState.errors.phoneNumber; + return ( +
+ + + (v ? `+${v.replace(/^\+/, "").replace(/\D/g, "")}` : v) + })} + /> +
+ ); +} -
- - - {errors.state && {errors.state.message}} -
-
+const config: KycFormConfig = { + fields: [ + { + fields: [ + { autoComplete: "given-name", labelKey: "components.mxnKycForm.firstName", name: "firstName", type: "text" }, + { autoComplete: "family-name", labelKey: "components.mxnKycForm.lastName", name: "lastName", type: "text" } + ], + type: "group" + }, + { + inputType: "date", + labelKey: "components.mxnKycForm.dateOfBirth", + name: "dateOfBirth", + placeholder: "YYYY-MM-DD", + type: "text" + }, + { + labelKey: "components.colKycForm.documentType", + name: "typeDocumentCol", + render: form => , + type: "custom" + }, + { + labelKey: "components.colKycForm.dni", + name: "dni", + render: form => , + type: "custom" + }, + { + labelKey: "components.colKycForm.phoneNumber", + name: "phoneNumber", + render: form => , + type: "custom" + }, + { + autoComplete: "street-address", + labelKey: "components.mxnKycForm.address", + name: "address", + type: "text" + }, + { + fields: [ + { autoComplete: "address-level2", labelKey: "components.mxnKycForm.city", name: "city", type: "text" }, + { autoComplete: "address-level1", labelKey: "components.mxnKycForm.state", name: "state", type: "text" } + ], + type: "group" + }, + { + autoComplete: "postal-code", + inputMode: "numeric", + labelKey: "components.mxnKycForm.zipCode", + name: "zipCode", + type: "text" + } + ], + i18nNamespace: "components.colKycForm", + idPrefix: "col", + schema +}; -
- - - {errors.zipCode && {errors.zipCode.message}} -
+interface ColKycFormScreenProps { + onSubmit: (data: AlfredpayKycFormData) => void; +} - - -
- ); +export function ColKycFormScreen({ onSubmit }: ColKycFormScreenProps) { + return void} />; } diff --git a/apps/frontend/src/components/Alfredpay/KycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/KycFormScreen.tsx new file mode 100644 index 000000000..454c9994d --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/KycFormScreen.tsx @@ -0,0 +1,165 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { + type DefaultValues, + type FieldValues, + type Path, + type Resolver, + type SubmitHandler, + type UseFormReturn, + useForm +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import type { ZodType } from "zod"; +import { MenuButtons } from "../MenuButtons"; + +/** + * Renders one country-specific Alfredpay KYC form. + * + * Each country (MX, CO, AR) shares the same outer layout — title/subtitle, a vertical + * stack of labelled inputs, and a submit button — but differs in: + * - its Zod schema (validation rules and accepted document types) + * - the exact list and order of fields + * - the namespace under which its i18n strings live + */ + +type KycFormFieldGroup = { + type: "group"; + fields: KycLeafField[]; +}; + +type KycLeafField = KycTextField | KycCheckboxField | KycCustomField; + +interface KycTextField { + type: "text"; + name: Path; + labelKey: string; + placeholderKey?: string; + placeholder?: string; + inputType?: "text" | "email" | "tel" | "date"; + inputMode?: "text" | "email" | "tel" | "numeric"; + autoComplete?: string; +} + +interface KycCheckboxField { + type: "checkbox"; + name: Path; + labelKey: string; +} + +interface KycCustomField { + type: "custom"; + name: Path; + labelKey: string; + render: (form: UseFormReturn) => React.ReactNode; +} + +type KycFormField = KycLeafField | KycFormFieldGroup; + +export interface KycFormConfig { + i18nNamespace: string; + idPrefix: string; + schema: ZodType; + defaultValues?: DefaultValues; + fields: KycFormField[]; +} + +interface KycFormScreenProps { + config: KycFormConfig; + onSubmit: (data: TValues) => void; +} + +const inputClass = (hasError: boolean) => + `input-vortex-primary input-ghost w-full rounded-lg border p-2 text-base ${hasError ? "border-error" : "border-neutral-300"}`; + +export function KycFormScreen({ config, onSubmit }: KycFormScreenProps) { + const { t } = useTranslation(); + + const form = useForm({ + defaultValues: config.defaultValues, + resolver: zodResolver(config.schema) as Resolver + }); + const { + formState: { errors }, + handleSubmit + } = form; + + const renderSingleField = (field: KycLeafField) => { + const id = `${config.idPrefix}-${field.name}`; + const errorMessage = (errors as Record)[field.name as string]?.message; + + if (field.type === "checkbox") { + return ( +
+
+ + +
+ {errorMessage && {errorMessage}} +
+ ); + } + + if (field.type === "custom") { + return ( +
+ + {field.render(form)} + {errorMessage && {errorMessage}} +
+ ); + } + + const placeholder = field.placeholderKey ? t(field.placeholderKey) : field.placeholder; + return ( +
+ + + {errorMessage && {errorMessage}} +
+ ); + }; + + return ( +
+ +

{t(`${config.i18nNamespace}.title`)}

+

{t(`${config.i18nNamespace}.subtitle`)}

+ +
)} + > + {config.fields.map((field, index) => { + if (field.type === "group") { + return ( +
+ {field.fields.map(renderSingleField)} +
+ ); + } + return renderSingleField(field); + })} + + +
+
+ ); +} + +export { inputClass as kycInputClass }; diff --git a/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx b/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx index a16646d10..3f109688b 100644 --- a/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx @@ -7,10 +7,23 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB const ACCEPTED_TYPES = ["image/jpeg", "image/png", "application/pdf"]; interface MxnDocumentUploadScreenProps { + error?: string; + i18nNamespace?: string; + includeSelfie?: boolean; onSubmit: (files: MxnKycFiles) => void; } -function FileDropZone({ label, file, onChange }: { label: string; file: File | null; onChange: (file: File) => void }) { +function FileDropZone({ + i18nNamespace, + label, + file, + onChange +}: { + i18nNamespace: string; + label: string; + file: File | null; + onChange: (file: File) => void; +}) { const { t } = useTranslation(); const inputRef = useRef(null); const [error, setError] = useState(null); @@ -18,11 +31,11 @@ function FileDropZone({ label, file, onChange }: { label: string; file: File | n const handleFile = (f: File) => { setError(null); if (!ACCEPTED_TYPES.includes(f.type)) { - setError(t("components.mxnDocumentUpload.invalidType")); + setError(t(`${i18nNamespace}.invalidType`)); return; } if (f.size > MAX_FILE_SIZE) { - setError(t("components.mxnDocumentUpload.fileTooLarge")); + setError(t(`${i18nNamespace}.fileTooLarge`)); return; } onChange(f); @@ -41,7 +54,7 @@ function FileDropZone({ label, file, onChange }: { label: string; file: File | n {file ? ( {file.name} ) : ( - {t("components.mxnDocumentUpload.tapToSelect")} + {t(`${i18nNamespace}.tapToSelect`)} )} (null); const [back, setBack] = useState(null); + const [selfie, setSelfie] = useState(null); - const isValid = front !== null && back !== null; + const isValid = front !== null && back !== null && (!includeSelfie || selfie !== null); const handleSubmit = () => { if (!front || !back) return; - onSubmit({ back, front }); + if (includeSelfie && !selfie) return; + onSubmit({ back, front, selfie: selfie ?? undefined }); }; return (
-

{t("components.mxnDocumentUpload.title")}

-

{t("components.mxnDocumentUpload.subtitle")}

+

{t(`${i18nNamespace}.title`)}

+

{t(`${i18nNamespace}.subtitle`)}

- - + + + {includeSelfie && ( + + )} + +

{t(`${i18nNamespace}.fileHint`)}

-

{t("components.mxnDocumentUpload.fileHint")}

+ {error &&

{error}

}
diff --git a/apps/frontend/src/components/Alfredpay/MxnKycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/MxnKycFormScreen.tsx index dbab1af23..b6b7a58a5 100644 --- a/apps/frontend/src/components/Alfredpay/MxnKycFormScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/MxnKycFormScreen.tsx @@ -1,9 +1,6 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; import { z } from "zod"; -import type { MxnKycFormData } from "../../machines/alfredpayKyc.machine"; -import { MenuButtons } from "../MenuButtons"; +import type { AlfredpayKycFormData } from "../../machines/alfredpayKyc.machine"; +import { type KycFormConfig, KycFormScreen } from "./KycFormScreen"; const schema = z.object({ address: z.string().min(1), @@ -17,165 +14,63 @@ const schema = z.object({ zipCode: z.string().min(1) }); +type MxnKycFormValues = z.infer; + +const config: KycFormConfig = { + fields: [ + { + fields: [ + { autoComplete: "given-name", labelKey: "components.mxnKycForm.firstName", name: "firstName", type: "text" }, + { autoComplete: "family-name", labelKey: "components.mxnKycForm.lastName", name: "lastName", type: "text" } + ], + type: "group" + }, + { + inputType: "date", + labelKey: "components.mxnKycForm.dateOfBirth", + name: "dateOfBirth", + placeholder: "YYYY-MM-DD", + type: "text" + }, + { + autoComplete: "email", + inputMode: "email", + inputType: "email", + labelKey: "components.mxnKycForm.email", + name: "email", + type: "text" + }, + { labelKey: "components.mxnKycForm.dni", name: "dni", placeholder: "CURP / INE number", type: "text" }, + { + autoComplete: "street-address", + labelKey: "components.mxnKycForm.address", + name: "address", + type: "text" + }, + { + fields: [ + { autoComplete: "address-level2", labelKey: "components.mxnKycForm.city", name: "city", type: "text" }, + { autoComplete: "address-level1", labelKey: "components.mxnKycForm.state", name: "state", type: "text" } + ], + type: "group" + }, + { + autoComplete: "postal-code", + inputMode: "numeric", + labelKey: "components.mxnKycForm.zipCode", + name: "zipCode", + type: "text" + } + ], + i18nNamespace: "components.mxnKycForm", + idPrefix: "mxn", + schema +}; + interface MxnKycFormScreenProps { - onSubmit: (data: MxnKycFormData) => void; + onSubmit: (data: AlfredpayKycFormData) => void; } export function MxnKycFormScreen({ onSubmit }: MxnKycFormScreenProps) { - const { t } = useTranslation(); - - const { - formState: { errors }, - handleSubmit, - register - } = useForm({ resolver: zodResolver(schema) }); - - const inputClass = (hasError: boolean) => - `input-vortex-primary input-ghost w-full rounded-lg border p-2 text-base ${hasError ? "border-error" : "border-neutral-300"}`; - - return ( -
- -

{t("components.mxnKycForm.title")}

-

{t("components.mxnKycForm.subtitle")}

- -
-
-
- - - {errors.firstName && {errors.firstName.message}} -
- -
- - - {errors.lastName && {errors.lastName.message}} -
-
- -
- - - {errors.dateOfBirth && {errors.dateOfBirth.message}} -
- -
- - - {errors.email && {errors.email.message}} -
- -
- - - {errors.dni && {errors.dni.message}} -
- -
- - - {errors.address && {errors.address.message}} -
- -
-
- - - {errors.city && {errors.city.message}} -
- -
- - - {errors.state && {errors.state.message}} -
-
- -
- - - {errors.zipCode && {errors.zipCode.message}} -
- - -
-
- ); + return void} />; } diff --git a/apps/frontend/src/components/ContactForm/ContactInfo.tsx b/apps/frontend/src/components/ContactForm/ContactInfo.tsx index 8edefa8f4..143267310 100644 --- a/apps/frontend/src/components/ContactForm/ContactInfo.tsx +++ b/apps/frontend/src/components/ContactForm/ContactInfo.tsx @@ -13,7 +13,7 @@ export function ContactInfo() {
  • - {t("pages.contact.info.requestDemo")} + {t("pages.contact.info.requestCommercials")}
  • diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/ARSOnrampDetails.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/ARSOnrampDetails.tsx new file mode 100644 index 000000000..35a73b19b --- /dev/null +++ b/apps/frontend/src/components/widget-steps/SummaryStep/ARSOnrampDetails.tsx @@ -0,0 +1,79 @@ +import { useSelector } from "@xstate/react"; +import { FC } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { useRampActor } from "../../../contexts/rampState"; +import { CopyButton } from "../../CopyButton"; +import { InfoBox } from "../../InfoBox"; + +export const ARSOnrampDetails: FC = () => { + const { t } = useTranslation(); + const rampActor = useRampActor(); + const { isQuoteExpired } = useSelector(rampActor, state => ({ + isQuoteExpired: state.context.isQuoteExpired + })); + const { rampState } = useSelector(rampActor, state => ({ + rampState: state.context.rampState + })); + + const achPaymentData = rampState?.ramp?.achPaymentData; + if (!achPaymentData) return null; + if (isQuoteExpired) return null; + + const cvu = achPaymentData.cvu ? String(achPaymentData.cvu) : undefined; + const alias = achPaymentData.alias ? String(achPaymentData.alias) : undefined; + const reference = achPaymentData.reference ? String(achPaymentData.reference) : undefined; + const expirationDate = achPaymentData.expirationDate ? String(achPaymentData.expirationDate) : undefined; + + return ( + <> +
    +

    {t("components.SummaryPage.ARSOnrampDetails.title")}

    +

    {t("components.SummaryPage.ARSOnrampDetails.instruction")}

    +
    +

    + + Once done, please click on "I have made the payment" + +

    +
    +
    + + {cvu && ( +

    + {t("components.SummaryPage.ARSOnrampDetails.cvuLabel")}: {cvu} +

    + )} + {alias && ( +

    + {t("components.SummaryPage.ARSOnrampDetails.aliasLabel")}: {alias} +

    + )} + {reference && ( +

    + {t("components.SummaryPage.ARSOnrampDetails.reference")}: {reference} +

    + )} + {expirationDate && ( +

    + {t("components.SummaryPage.ARSOnrampDetails.expiresAt")}: {expirationDate} +

    + )} +
    +
    +
    + {cvu && ( +
    + {t("components.SummaryPage.ARSOnrampDetails.cvuLabel")} + +
    + )} + {alias && ( +
    + {t("components.SummaryPage.ARSOnrampDetails.aliasLabel")} + +
    + )} +
    + + ); +}; diff --git a/apps/frontend/src/config/networkAvailability.ts b/apps/frontend/src/config/networkAvailability.ts index b1cc688ed..ac4ecb96c 100644 --- a/apps/frontend/src/config/networkAvailability.ts +++ b/apps/frontend/src/config/networkAvailability.ts @@ -17,7 +17,7 @@ export function isFrontendNetworkEnabled(network: Networks | string | undefined) } export function getFallbackFrontendNetwork(): Networks { - return Networks.Polygon; + return Networks.Base; } export function getEnabledFrontendNetwork(network: Networks | string | undefined): Networks { diff --git a/apps/frontend/src/constants/fiatAccountForms.ts b/apps/frontend/src/constants/fiatAccountForms.ts index 966796383..26f1b9697 100644 --- a/apps/frontend/src/constants/fiatAccountForms.ts +++ b/apps/frontend/src/constants/fiatAccountForms.ts @@ -84,6 +84,38 @@ export const FORMS: Record = { type: "select" } ], + COELSA: [ + { + field: "accountNumber", + label: "components.fiatAccountForms.accountNumber", + placeholder: "22-digit CBU/CVU or Alias", + required: true, + type: "text" + }, + { + field: "accountType", + label: "components.fiatAccountForms.accountType", + options: [ + { label: "CBU", value: "CBU" }, + { label: "CVU", value: "CVU" }, + { label: "Alias", value: "ALIAS" } + ], + required: true, + type: "select" + }, + { field: "accountName", label: "components.fiatAccountForms.accountName", required: true, type: "text" }, + { + defaultValue: "own", + field: "isOwnAccount", + label: "components.fiatAccountForms.isOwnAccount", + options: [ + { label: "components.fiatAccountForms.options.ownAccount", value: "own" }, + { label: "components.fiatAccountForms.options.externalAccount", value: "external" } + ], + required: true, + type: "select" + } + ], SPEI: [ { field: "accountNumber", diff --git a/apps/frontend/src/constants/fiatAccountMethods.ts b/apps/frontend/src/constants/fiatAccountMethods.ts index b516c2331..380df9e02 100644 --- a/apps/frontend/src/constants/fiatAccountMethods.ts +++ b/apps/frontend/src/constants/fiatAccountMethods.ts @@ -2,7 +2,7 @@ import { BuildingLibraryIcon, CreditCardIcon } from "@heroicons/react/24/outline import { GlobeAmericasIcon } from "@heroicons/react/24/solid"; import { AlfredpayFiatAccountType, FiatToken } from "@vortexfi/shared"; -export type FiatAccountTypeKey = "SPEI" | "ACH" | "ACH_COL" | "WIRE"; +export type FiatAccountTypeKey = "SPEI" | "ACH" | "ACH_COL" | "WIRE" | "COELSA"; export interface CountryFiatAccountConfig { country: string; @@ -33,12 +33,20 @@ export const ALFREDPAY_COUNTRY_METHODS: CountryFiatAccountConfig[] = [ currency: "COP", offramp: ["ACH_COL"], onramp: ["ACH_COL"] + }, + { + country: "AR", + countryName: "Argentina", + currency: "ARS", + offramp: ["COELSA"], + onramp: ["COELSA"] } ]; export const ACCOUNT_TYPE_ICONS: Record>> = { ACH: BuildingLibraryIcon, ACH_COL: BuildingLibraryIcon, + COELSA: BuildingLibraryIcon, SPEI: CreditCardIcon, WIRE: GlobeAmericasIcon }; @@ -46,6 +54,7 @@ export const ACCOUNT_TYPE_ICONS: Record = { ACH: "components.fiatAccountMethods.labels.ACH", ACH_COL: "components.fiatAccountMethods.labels.ACH_COL", + COELSA: "components.fiatAccountMethods.labels.COELSA", SPEI: "components.fiatAccountMethods.labels.SPEI", WIRE: "components.fiatAccountMethods.labels.WIRE" }; @@ -53,6 +62,7 @@ export const ACCOUNT_TYPE_LABELS: Record = { export const ACCOUNT_TYPE_DESCRIPTIONS: Record = { ACH: "components.fiatAccountMethods.descriptions.ACH", ACH_COL: "components.fiatAccountMethods.descriptions.ACH_COL", + COELSA: "components.fiatAccountMethods.descriptions.COELSA", SPEI: "components.fiatAccountMethods.descriptions.SPEI", WIRE: "components.fiatAccountMethods.descriptions.WIRE" }; @@ -60,16 +70,16 @@ export const ACCOUNT_TYPE_DESCRIPTIONS: Record = { export const ACCOUNT_TYPE_TO_ALFRED_TYPE: Record = { ACH: AlfredpayFiatAccountType.ACH, ACH_COL: AlfredpayFiatAccountType.ACH, + COELSA: AlfredpayFiatAccountType.COELSA, SPEI: AlfredpayFiatAccountType.SPEI, WIRE: AlfredpayFiatAccountType.BANK_USA }; -// ACH_COL and ACH both map to AlfredpayFiatAccountType.ACH on the API side. -// We prefer "ACH" as the display key for ACH accounts since it's the more general label. export const ALFRED_TO_ACCOUNT_TYPE: Partial> = { [AlfredpayFiatAccountType.ACH]: "ACH", [AlfredpayFiatAccountType.SPEI]: "SPEI", - [AlfredpayFiatAccountType.BANK_USA]: "WIRE" + [AlfredpayFiatAccountType.BANK_USA]: "WIRE", + [AlfredpayFiatAccountType.COELSA]: "COELSA" }; // Resolves the display key for a fiat account, taking country into account. diff --git a/apps/frontend/src/contexts/network.tsx b/apps/frontend/src/contexts/network.tsx index 70f3c10db..0f7cef49a 100644 --- a/apps/frontend/src/contexts/network.tsx +++ b/apps/frontend/src/contexts/network.tsx @@ -29,7 +29,7 @@ interface NetworkProviderProps { export const NetworkProvider = ({ children }: NetworkProviderProps) => { const { state: selectedNetworkLocalStorageState, set: setSelectedNetworkLocalStorage } = useLocalStorage({ - defaultValue: Networks.Polygon, + defaultValue: Networks.Base, key: LocalStorageKeys.SELECTED_NETWORK }); diff --git a/apps/frontend/src/hooks/ramp/useIsQuoteComponentDisplayed.ts b/apps/frontend/src/hooks/ramp/useIsQuoteComponentDisplayed.ts index 682eb4261..c70f26627 100644 --- a/apps/frontend/src/hooks/ramp/useIsQuoteComponentDisplayed.ts +++ b/apps/frontend/src/hooks/ramp/useIsQuoteComponentDisplayed.ts @@ -1,3 +1,4 @@ +import { TransactionStatus } from "@vortexfi/shared"; import { RampSearchParams } from "./../../types/searchParams"; import { useRampComponentState } from "./useRampComponentState"; @@ -12,11 +13,11 @@ function isExternalFlow(params: RampSearchParams): boolean { export const useIsQuoteComponentDisplayed = (): boolean => { const { rampState, rampMachineState, searchParams } = useRampComponentState(); - if (rampState?.ramp?.currentPhase === "complete") { + if (rampState?.ramp?.status === TransactionStatus.COMPLETE || rampState?.ramp?.currentPhase === "complete") { return false; } - if (rampState?.ramp?.currentPhase === "failed") { + if (rampState?.ramp?.status === TransactionStatus.FAILED || rampState?.ramp?.currentPhase === "failed") { return false; } diff --git a/apps/frontend/src/hooks/ramp/useRampNavigation.ts b/apps/frontend/src/hooks/ramp/useRampNavigation.ts index dea1435be..e5bed61d2 100644 --- a/apps/frontend/src/hooks/ramp/useRampNavigation.ts +++ b/apps/frontend/src/hooks/ramp/useRampNavigation.ts @@ -1,15 +1,17 @@ +import { TransactionStatus } from "@vortexfi/shared"; import { ReactNode, useCallback, useLayoutEffect } from "react"; import { useIsQuoteComponentDisplayed } from "./useIsQuoteComponentDisplayed"; import { useRampComponentState } from "./useRampComponentState"; function getActiveScreen( currentPhase: string | undefined, + status: TransactionStatus | undefined, rampStateDefined: boolean, machineValue: string, isQuoteDisplayed: boolean ): string { - if (currentPhase === "complete") return "success"; - if (currentPhase === "failed") return "failure"; + if (status === TransactionStatus.COMPLETE || currentPhase === "complete") return "success"; + if (status === TransactionStatus.FAILED || currentPhase === "failed") return "failure"; if (rampStateDefined && machineValue === "RampFollowUp") return "progress"; if (isQuoteDisplayed) return "quote"; return "form"; @@ -27,6 +29,7 @@ export const useRampNavigation = ( const activeScreen = getActiveScreen( rampState?.ramp?.currentPhase, + rampState?.ramp?.status, rampState !== undefined, String(rampMachineState.value), isQuoteDisplayed @@ -39,11 +42,11 @@ export const useRampNavigation = ( }, [activeScreen]); const getCurrentComponent = useCallback(() => { - if (rampState?.ramp?.currentPhase === "complete") { + if (rampState?.ramp?.status === TransactionStatus.COMPLETE || rampState?.ramp?.currentPhase === "complete") { return successComponent; } - if (rampState?.ramp?.currentPhase === "failed") { + if (rampState?.ramp?.status === TransactionStatus.FAILED || rampState?.ramp?.currentPhase === "failed") { return failureComponent; } diff --git a/apps/frontend/src/machines/alfredpayKyc.machine.ts b/apps/frontend/src/machines/alfredpayKyc.machine.ts index 3ff7c7686..c9c8cf21a 100644 --- a/apps/frontend/src/machines/alfredpayKyc.machine.ts +++ b/apps/frontend/src/machines/alfredpayKyc.machine.ts @@ -13,12 +13,17 @@ import { assign, fromPromise, setup } from "xstate"; import { AlfredpayService } from "../services/api/alfredpay.service"; import { AlfredpayKycContext } from "./kyc.states"; -export type MxnKycFormData = Omit; +/** + * Generic Alfredpay KYC form payload (country is added by the API layer). + * Fields are a union across MX/CO/AR; country-specific schemas pick which are required. + */ +export type AlfredpayKycFormData = Omit; export type KybFormData = Omit; export interface MxnKycFiles { front: File; back: File; + selfie?: File; } export interface KybBusinessFiles { @@ -178,12 +183,16 @@ export const alfredpayKycMachine = setup({ }), submitFiles: fromPromise( - async ({ input }: { input: AlfredpayKycContext & { mxnFormData?: MxnKycFormData; mxnFiles?: MxnKycFiles } }) => { + async ({ input }: { input: AlfredpayKycContext & { mxnFormData?: AlfredpayKycFormData; mxnFiles?: MxnKycFiles } }) => { const country = input.country || "MX"; if (!input.submissionId) throw new Error("Submission ID missing"); if (!input.mxnFiles) throw new Error("KYC files missing"); await AlfredpayService.submitKycFile(country, input.submissionId, AlfredpayKycFileType.FRONT, input.mxnFiles.front); await AlfredpayService.submitKycFile(country, input.submissionId, AlfredpayKycFileType.BACK, input.mxnFiles.back); + if (country === "AR") { + if (!input.mxnFiles.selfie) throw new Error("Selfie file missing"); + await AlfredpayService.submitKycFile(country, input.submissionId, AlfredpayKycFileType.SELFIE, input.mxnFiles.selfie); + } } ), @@ -265,7 +274,7 @@ export const alfredpayKycMachine = setup({ ); } ), - submitKycInfo: fromPromise(async ({ input }: { input: AlfredpayKycContext & { mxnFormData?: MxnKycFormData } }) => { + submitKycInfo: fromPromise(async ({ input }: { input: AlfredpayKycContext & { mxnFormData?: AlfredpayKycFormData } }) => { const country = input.country || "MX"; if (!input.mxnFormData) throw new Error("KYC form data missing"); return AlfredpayService.submitKycInformation(country, input.mxnFormData); @@ -312,7 +321,7 @@ export const alfredpayKycMachine = setup({ }, types: { context: {} as AlfredpayKycContext & { - mxnFormData?: MxnKycFormData; + mxnFormData?: AlfredpayKycFormData; mxnFiles?: MxnKycFiles; kybFormData?: KybFormData; kybBusinessFiles?: KybBusinessFiles; @@ -333,7 +342,7 @@ export const alfredpayKycMachine = setup({ | { type: "USER_RETRY" } | { type: "USER_CANCEL" } | { type: "GO_BACK" } - | { type: "SUBMIT_FORM"; data: MxnKycFormData } + | { type: "SUBMIT_FORM"; data: AlfredpayKycFormData } | { type: "SUBMIT_FILES"; files: MxnKycFiles } | { type: "SUBMIT_KYB_FORM"; data: KybFormData } | { type: "SUBMIT_KYB_BUSINESS_FILES"; files: KybBusinessFiles } @@ -375,8 +384,8 @@ export const alfredpayKycMachine = setup({ target: "FailureKyc" }, { - // MXN and CO use API-based form, not iFrame link - guard: ({ context }) => context.country === "MX" || context.country === "CO", + // MXN, CO, and AR use API-based form, not iFrame link + guard: ({ context }) => context.country === "MX" || context.country === "CO" || context.country === "AR", target: "FillingKycForm" }, { @@ -415,11 +424,12 @@ export const alfredpayKycMachine = setup({ input: ({ context }) => context, onDone: [ { - guard: ({ context }) => (context.country === "MX" || context.country === "CO") && !!context.business, + guard: ({ context }) => + (context.country === "MX" || context.country === "CO" || context.country === "AR") && !!context.business, target: "FillingKybForm" }, { - guard: ({ context }) => context.country === "MX" || context.country === "CO", + guard: ({ context }) => context.country === "MX" || context.country === "CO" || context.country === "AR", target: "FillingKycForm" }, { @@ -654,11 +664,12 @@ export const alfredpayKycMachine = setup({ input: ({ context }) => context, onDone: [ { - guard: ({ context }) => (context.country === "MX" || context.country === "CO") && !!context.business, + guard: ({ context }) => + (context.country === "MX" || context.country === "CO" || context.country === "AR") && !!context.business, target: "FillingKybForm" }, { - guard: ({ context }) => context.country === "MX" || context.country === "CO", + guard: ({ context }) => context.country === "MX" || context.country === "CO" || context.country === "AR", target: "FillingKycForm" }, { @@ -722,10 +733,15 @@ export const alfredpayKycMachine = setup({ }, onError: { actions: assign({ - error: () => - new AlfredpayKycMachineError("Failed to upload ID documents", AlfredpayKycMachineErrorType.UnknownError) + error: ({ event }) => { + const err = event.error; + const msg = err instanceof Error ? err.message : ""; + return msg + ? new AlfredpayKycMachineError(msg, AlfredpayKycMachineErrorType.UnknownError) + : new AlfredpayKycMachineError("Failed to upload ID documents", AlfredpayKycMachineErrorType.UnknownError); + } }), - target: "Failure" + target: "UploadingDocuments" }, src: "submitFiles" } diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 85199a9af..9c6b52ae0 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -4,12 +4,12 @@ import { ALFREDPAY_FIAT_TOKEN_TO_COUNTRY } from "../constants/fiatAccountMethods import { KYCFormData } from "../hooks/brla/useKYCForm"; import { KycStatus } from "../services/signingService"; import { + AlfredpayKycFormData, AlfredpayKycMachineError, KybBusinessFiles, KybFormData, KybPersonFiles, - MxnKycFiles, - MxnKycFormData + MxnKycFiles } from "./alfredpayKyc.machine"; import { AveniaKycMachineError, UploadIds } from "./brlaKyc.machine"; import { MykoboKycFiles, MykoboKycFormData, MykoboKycMachineError, MykoboKycMachineErrorType } from "./mykoboKyc.machine"; @@ -32,7 +32,7 @@ export interface AlfredpayKycContext extends RampContext { country: string; error?: AlfredpayKycMachineError; business?: boolean; - mxnFormData?: MxnKycFormData; + mxnFormData?: AlfredpayKycFormData; mxnFiles?: MxnKycFiles; kybFormData?: KybFormData; kybBusinessFiles?: KybBusinessFiles; diff --git a/apps/frontend/src/pages/progress/index.tsx b/apps/frontend/src/pages/progress/index.tsx index 9695d33be..a80ab228e 100644 --- a/apps/frontend/src/pages/progress/index.tsx +++ b/apps/frontend/src/pages/progress/index.tsx @@ -267,6 +267,7 @@ export const ProgressPage = () => { const rampId = rampState?.ramp?.id; const prevPhaseRef = useRef(rampState?.ramp?.currentPhase || "initial"); + const prevRampIdRef = useRef(rampId); const [currentPhase, setCurrentPhase] = useState(prevPhaseRef.current); const flowType = getRampFlow(rampState); @@ -294,14 +295,22 @@ export const ProgressPage = () => { const newPhase = rampState?.ramp?.currentPhase ?? "initial"; if (newPhase === prevPhaseRef.current) return; const phaseIndex = phaseSequence.indexOf(newPhase); + const previousPhaseIndex = phaseSequence.indexOf(prevPhaseRef.current); + const isSameRamp = rampId === prevRampIdRef.current; + + if (isSameRamp && phaseIndex >= 0 && previousPhaseIndex >= 0 && phaseIndex < previousPhaseIndex) { + return; + } + trackEvent({ event: "progress", phase_index: phaseIndex >= 0 ? phaseIndex : 0, phase_name: newPhase }); + prevRampIdRef.current = rampId; prevPhaseRef.current = newPhase; setCurrentPhase(newPhase); - }, [rampState?.ramp?.currentPhase, phaseSequence, trackEvent]); + }, [rampId, rampState?.ramp?.currentPhase, phaseSequence, trackEvent]); useEffect(() => { if (!rampId || !flowType) return; diff --git a/apps/frontend/src/pages/progress/phaseFlows.test.ts b/apps/frontend/src/pages/progress/phaseFlows.test.ts index c4c8b7b6a..e01cd7bd3 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.test.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.test.ts @@ -20,6 +20,7 @@ describe("progress phase flows", () => { expect(PHASE_FLOWS.onramp_brl).toEqual([ "initial", "brlaOnrampMint", + "onHoldForComplianceCheck", "fundEphemeral", "subsidizePreSwap", "nablaApprove", diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index 717c4461d..444133d82 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -27,6 +27,7 @@ export const PHASE_DURATIONS: Record = { mykoboPayoutOnBase: 60, nablaApprove: 24, nablaSwap: 24, + onHoldForComplianceCheck: 0, pendulumToAssethubXcm: 30, pendulumToHydrationXcm: 30, pendulumToMoonbeamXcm: 40, @@ -70,6 +71,7 @@ export const PHASE_FLOWS = { onramp_brl: [ "initial", "brlaOnrampMint", + "onHoldForComplianceCheck", "fundEphemeral", "subsidizePreSwap", "nablaApprove", diff --git a/apps/frontend/src/pages/progress/phaseMessages.ts b/apps/frontend/src/pages/progress/phaseMessages.ts index 8af888520..fefabad7e 100644 --- a/apps/frontend/src/pages/progress/phaseMessages.ts +++ b/apps/frontend/src/pages/progress/phaseMessages.ts @@ -88,6 +88,7 @@ export function getMessageForPhase(ramp: RampState | undefined, t: TFunction<"tr mykoboPayoutOnBase: getTransferringMessage(), nablaApprove: getSwappingMessage(), nablaSwap: getSwappingMessage(), + onHoldForComplianceCheck: t("pages.progress.onHoldForComplianceCheck"), pendulumToAssethubXcm: t("pages.progress.pendulumToAssethubXcm", { assetSymbol: outputAssetSymbol }), diff --git a/apps/frontend/src/services/api/ramp.service.ts b/apps/frontend/src/services/api/ramp.service.ts index 7d31672db..1fcb70ca7 100644 --- a/apps/frontend/src/services/api/ramp.service.ts +++ b/apps/frontend/src/services/api/ramp.service.ts @@ -119,7 +119,12 @@ export class RampService { onUpdate(status); } - if (status.currentPhase === "complete" || status.currentPhase === "failed") { + if ( + status.status === "COMPLETE" || + status.status === "FAILED" || + status.currentPhase === "complete" || + status.currentPhase === "failed" + ) { return status; } diff --git a/apps/frontend/src/stores/quote/useQuoteFormStore.ts b/apps/frontend/src/stores/quote/useQuoteFormStore.ts index c795d8851..824878d50 100644 --- a/apps/frontend/src/stores/quote/useQuoteFormStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteFormStore.ts @@ -29,7 +29,7 @@ const defaultFiatToken = getLanguageFromPath() === Language.Portuguese_Brazil ? const defaultFiatAmount = getLanguageFromPath() === Language.Portuguese_Brazil ? DEFAULT_BRL_AMOUNT : defaultFiatTokenAmounts[defaultFiatToken]; -const defaultOnChainToken = getRampDirectionFromPath() === RampDirection.BUY ? EvmToken.USDT : EvmToken.USDC; +const defaultOnChainToken = EvmToken.USDC; interface RampFormState { inputAmount: string; diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index dc3e4fe66..435474762 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -31,6 +31,33 @@ "verifyingStatus": "Verifying {{kycOrKyb}} Status", "verifyingStatusDescription": "This may take a few moments. Please do not close this window." }, + "arDocumentUpload": { + "backLabel": "Back of DNI", + "fileHint": "Accepted formats: JPG, PNG, PDF — max 5 MB each", + "fileTooLarge": "File exceeds the 5 MB limit.", + "frontLabel": "Front of DNI", + "invalidType": "Only JPG, PNG, or PDF files are accepted.", + "selfieLabel": "Selfie", + "submit": "Submit Documents", + "subtitle": "Please upload a photo or scan of your DNI (front and back) and a selfie. Max 5 MB per file.", + "tapToSelect": "Tap to select file", + "title": "Upload ID Documents" + }, + "arKycForm": { + "continue": "Continue", + "cuit": "CUIT", + "cuitPlaceholder": "CUIT", + "dni": "DNI Number", + "dniPlaceholder": "DNI number", + "documentType": "Document Type", + "options": { + "dni": "DNI (Documento Nacional de Identidad)" + }, + "pepLabel": "I am a Politically Exposed Person (PEP)", + "phoneNumber": "Phone Number", + "subtitle": "Please provide your personal information to complete KYC.", + "title": "Identity Verification" + }, "authEmailStep": { "buttons": { "continue": "Continue", @@ -230,6 +257,7 @@ } }, "colKycForm": { + "continue": "Continue", "dni": "Document Number", "dniPlaceholderCc": "10-digit CC number", "dniPlaceholderCe": "6–10 digit CE number", @@ -388,12 +416,14 @@ "descriptions": { "ACH": "1-2 business days", "ACH_COL": "Colombian bank transfer", + "COELSA": "Argentine bank transfer", "SPEI": "Instant interbank transfer via CLABE number", "WIRE": "Same day" }, "labels": { "ACH": "ACH Transfer", "ACH_COL": "ACH Colombia", + "COELSA": "COELSA", "SPEI": "SPEI", "WIRE": "Wire Transfer" }, @@ -531,6 +561,7 @@ "fileTooLarge": "File exceeds the 5 MB limit.", "frontLabel": "Front of Document", "invalidType": "Only JPG, PNG, or PDF files are accepted.", + "selfieLabel": "Selfie", "submit": "Submit Documents", "subtitle": "Please upload a photo or scan of your ID document. Max 5 MB per file (JPG, PNG, PDF).", "tapToSelect": "Tap to select file", @@ -643,6 +674,15 @@ "title": "Your opinion matters!" }, "SummaryPage": { + "ARSOnrampDetails": { + "aliasLabel": "Alias", + "cvuLabel": "CBU/CVU", + "expiresAt": "Expires", + "instruction": "Transfer funds to the bank account below", + "qrCodeDescription": "Once done, please click on \"I have made the payment\"", + "reference": "Reference", + "title": "Pay via Bank Transfer" + }, "BRLOnrampDetails": { "copyCode": "or copy the PIX code below and paste it in your bank app", "pixCode": "Pix code", @@ -898,12 +938,13 @@ "submit": "Submit" }, "info": { - "integrationHelp": "Get integration help", - "onboardingHelp": "Get onboarding help", + "integrationHelp": "Integrate with your platform", + "onboardingHelp": "Get onboarding support", + "requestCommercials": "Request commercials", "requestDemo": "Request a demo", "supportLink": "Contact support", "technicalQuestions": "Technical issues or product questions?", - "title": "Contact sales" + "title": "Scale your stablecoin payouts" }, "success": "Thank you! We'll be in touch soon.", "title": "Tell us how to help", @@ -1209,6 +1250,7 @@ "initial": "Starting process", "moonbeamToPendulum": "Transferring {{assetSymbol}} from Moonbeam --> Pendulum", "mykoboOnrampDeposit": "Waiting to receive payment", + "onHoldForComplianceCheck": "Your payment is on hold for a compliance check. Vortex will continue automatically once Avenia clears it.", "pendulumToAssethubXcm": "Transferring {{assetSymbol}} from Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferring {{assetSymbol}} from Pendulum --> Hydration", "pendulumToMoonbeamXcm": "Transferring {{assetSymbol}} from Pendulum --> Moonbeam", @@ -1273,7 +1315,7 @@ "buy": "Minimum buy amount is {{minAmountUnits}} {{assetSymbol}}.", "sell": "Minimum sell amount is {{minAmountUnits}} {{assetSymbol}}." }, - "lowLiquidity": "Low liquidity for this route. Please try a smaller amount.", + "lowLiquidity": "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", "MXN_tokenUnavailable": "Building your MXN rail - available soon!", "missingFields": "Missing required fields", "moreThanMaximumWithdrawal": { diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index ff06500bb..894b09ab4 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -31,6 +31,33 @@ "verifyingStatus": "Verificando Status {{kycOrKyb}}", "verifyingStatusDescription": "Isso pode levar alguns momentos. Por favor, não feche esta janela." }, + "arDocumentUpload": { + "backLabel": "Verso do DNI", + "fileHint": "Formatos aceitos: JPG, PNG, PDF — máx. 5 MB cada", + "fileTooLarge": "O arquivo excede o limite de 5 MB.", + "frontLabel": "Frente do DNI", + "invalidType": "Somente arquivos JPG, PNG ou PDF são aceitos.", + "selfieLabel": "Selfie", + "submit": "Enviar Documentos", + "subtitle": "Por favor, envie uma foto ou digitalização do seu DNI (frente e verso) e uma selfie. Máx. 5 MB por arquivo.", + "tapToSelect": "Toque para selecionar o arquivo", + "title": "Enviar Documentos de Identidade" + }, + "arKycForm": { + "continue": "Continuar", + "cuit": "CUIT", + "cuitPlaceholder": "CUIT", + "dni": "Número do DNI", + "dniPlaceholder": "Número do DNI", + "documentType": "Tipo de Documento", + "options": { + "dni": "DNI (Documento Nacional de Identidad)" + }, + "pepLabel": "Sou uma Pessoa Politicamente Exposta (PEP)", + "phoneNumber": "Número de Telefone", + "subtitle": "Por favor, forneça suas informações pessoais para concluir o KYC.", + "title": "Verificação de Identidade" + }, "authEmailStep": { "buttons": { "continue": "Continuar", @@ -233,6 +260,7 @@ } }, "colKycForm": { + "continue": "Continuar", "dni": "Número de Documento", "dniPlaceholderCc": "Número CC de 10 dígitos", "dniPlaceholderCe": "Número CE de 6 a 10 dígitos", @@ -391,12 +419,14 @@ "descriptions": { "ACH": "1 a 2 dias úteis", "ACH_COL": "Transferência bancária colombiana", + "COELSA": "Transferência bancária argentina", "SPEI": "Transferência interbancária instantânea via número CLABE", "WIRE": "No mesmo dia" }, "labels": { "ACH": "Transferência ACH", "ACH_COL": "ACH Colômbia", + "COELSA": "COELSA", "SPEI": "SPEI", "WIRE": "Transferência Internacional" }, @@ -535,6 +565,7 @@ "fileTooLarge": "O arquivo excede o limite de 5 MB.", "frontLabel": "Frente do Documento", "invalidType": "Somente arquivos JPG, PNG ou PDF são aceitos.", + "selfieLabel": "Selfie", "submit": "Enviar Documentos", "subtitle": "Por favor, envie uma foto ou digitalização do seu documento. Máx. 5 MB por arquivo (JPG, PNG, PDF).", "tapToSelect": "Toque para selecionar o arquivo", @@ -647,6 +678,15 @@ "title": "Sua opinião é importante!" }, "SummaryPage": { + "ARSOnrampDetails": { + "aliasLabel": "Alias", + "cvuLabel": "CBU/CVU", + "expiresAt": "Expira", + "instruction": "Transfira os fundos para a conta bancária abaixo", + "qrCodeDescription": "Uma vez concluído, clique em \"Eu fiz o pagamento\"", + "reference": "Referência", + "title": "Pagar via Transferência Bancária" + }, "BRLOnrampDetails": { "copyCode": "Ou copie o código PIX abaixo, selecione PIX no app do seu banco e cole o código fornecido.", "pixCode": "Código PIX", @@ -902,12 +942,13 @@ "submit": "Enviar" }, "info": { - "integrationHelp": "Obter ajuda de integração", - "onboardingHelp": "Obter ajuda de onboarding", + "integrationHelp": "Integre com sua plataforma", + "onboardingHelp": "Receba suporte de onboarding", + "requestCommercials": "Solicitar condições comerciais", "requestDemo": "Solicitar uma demonstração", "supportLink": "Contatar suporte", "technicalQuestions": "Problemas técnicos ou dúvidas sobre o produto?", - "title": "Fale com vendas" + "title": "Escale seus pagamentos em stablecoins" }, "success": "Obrigado! Entraremos em contato em breve.", "title": "Conte-nos como podemos ajudar", @@ -1214,6 +1255,7 @@ "initial": "Iniciando processo", "moonbeamToPendulum": "Transferindo {{assetSymbol}} de Moonbeam --> Pendulum", "mykoboOnrampDeposit": "Aguardando recebimento do pagamento", + "onHoldForComplianceCheck": "Seu pagamento está em espera para uma verificação de compliance. A Vortex continuará automaticamente quando a Avenia liberar.", "pendulumToAssethubXcm": "Transferindo {{assetSymbol}} de Pendulum --> AssetHub", "pendulumToHydrationXcm": "Transferindo {{assetSymbol}} de Pendulum --> Hydration", "pendulumToMoonbeamXcm": "Transferindo {{assetSymbol}} de Pendulum --> Moonbeam", @@ -1278,7 +1320,7 @@ "buy": "O valor mínimo de compra é {{minAmountUnits}} {{assetSymbol}}.", "sell": "O valor mínimo de venda é {{minAmountUnits}} {{assetSymbol}}." }, - "lowLiquidity": "Baixa liquidez para esta rota. Por favor, tente um valor menor.", + "lowLiquidity": "Esta rota está temporariamente indisponível devido à baixa liquidez. Tente um valor menor ou volte em breve.", "MXN_tokenUnavailable": "Construa seu trilho MXN - disponível em breve!", "missingFields": "Campos obrigatórios ausentes", "moreThanMaximumWithdrawal": { diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 7b089ed5f..221eb3747 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -196,12 +196,21 @@ export interface paths { }; requestBody?: never; responses: { + /** @description RSA-PSS public key in PEM format. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": Record; + /** + * @example { + * "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...replace-with-actual-key...\n-----END PUBLIC KEY-----\n" + * } + */ + "application/json": { + /** @description RSA-PSS 2048-bit public key in PEM format. Use this key to verify webhook signatures with RSA-PSS / SHA-256. */ + publicKey: string; + }; }; }; }; @@ -547,8 +556,16 @@ export interface paths { get?: never; put?: never; /** - * Generating widget URL (for existing quote) - * @description You can call this endpoint to get a widget URL ready with a quote you provide. You need to pass the `quoteId` parameter to the body, and optionally supply the `callbackUrl`, `walletAddressLocked` and `externalSessionId`. The quote will not automatically refresh and if it expires, the user needs to close the window and start over. + * Create widget session + * @description Creates a hosted Vortex Widget session and returns the URL to open for the user. + * + * This single endpoint supports two mutually exclusive request shapes: + * + * - **Fixed quote** (`GetWidgetUrlLocked`) — pass a `quoteId` you created via `POST /v1/quotes`. The widget uses that exact quote and does not refresh it. If the quote expires before the user finishes, they must close the window and start over. + * + * - **Auto-refresh** (`GetWidgetUrlRefresh`) — pass the route parameters (`network`, `rampType`, `inputAmount`, plus `fiat` / `cryptoLocked` / `paymentMethod` as relevant for the direction). The widget creates and refreshes quotes on demand for the user. + * + * Use the example switcher below to see the request shape for each mode. `externalSessionId` is required in both modes and is echoed back in webhook payloads. */ post: { parameters: { @@ -557,30 +574,60 @@ export interface paths { path?: never; cookie?: never; }; - requestBody?: { + requestBody: { content: { - /** - * @example { - * "callbackUrl": "https://www.example.com/", - * "externalSessionId": "my-session-id", - * "quoteId": "my-quote-id", - * "walletAddressLocked": "0x00000000000000000000000000000000" - * } - */ - "application/json": components["schemas"]["GetWidgetUrlLocked"]; + "application/json": components["schemas"]["GetWidgetUrlLocked"] | components["schemas"]["GetWidgetUrlRefresh"]; }; }; responses: { + /** @description Returned when a fixed-quote session was created. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "url": "https://widget.vortexfinance.co/?externalSessionId=my-session-id"eId=quote_01HXY..." + * } + */ + "application/json": { + /** @description The widget URL to open for the user. */ + url: string; + }; + }; + }; + /** @description Returned when an auto-refresh session was created. */ 201: { headers: { [name: string]: unknown; }; content: { + /** + * @example { + * "url": "https://widget.vortexfinance.co/?externalSessionId=my-session-id&rampType=BUY&network=polygon&inputAmount=150&fiat=BRL&cryptoLocked=USDC&paymentMethod=pix" + * } + */ "application/json": { + /** @description The widget URL to open for the user. */ url: string; }; }; }; + /** @description Missing required fields, or `quoteId` not provided for fixed-quote mode and route fields not provided for auto-refresh mode. */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Quote not found or expired (fixed-quote mode only). */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; delete?: never; @@ -1012,6 +1059,8 @@ export interface components { inputAmount: string; /** @description The currency type for the input amount. */ inputCurrency: components["schemas"]["RampCurrency"]; + /** @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. */ + networks?: components["schemas"]["Networks"][]; /** @description The desired currency type for the output amount. */ outputCurrency: components["schemas"]["RampCurrency"]; /** @description Your partner ID, if available. */ @@ -1095,8 +1144,16 @@ export interface components { validateLivenessToken?: string; }; ErrorResponse: { + /** @description HTTP status code returned by the API error handler. */ + code?: number; + /** @description Validation error details, when the request fails schema or input validation. */ + errors?: Record[]; /** @description A human-readable error message. */ message?: string; + /** @description HTTP status code included by selected provider-style error responses. */ + statusCode?: number; + /** @description Provider-style error category, when available. */ + type?: string; }; /** @enum {string} */ FiatToken: "EUR" | "ARS" | "BRL"; @@ -1172,15 +1229,15 @@ export interface components { /** @description The widget will redirect to this callbackUrl after the user successfully created the transaction. */ callbackUrl?: string; countryCode?: components["schemas"]["CountryCode"]; - cryptoLocked: components["schemas"]["OnChainToken"]; + cryptoLocked?: components["schemas"]["OnChainToken"]; /** @description A unique identifier for yourself to keep track of the widget session. Returned in the responses of webhooks, if registered. */ externalSessionId: string; - fiat: components["schemas"]["FiatToken"]; + fiat?: components["schemas"]["FiatToken"]; inputAmount: string; network: components["schemas"]["Networks"]; /** @description The identifier of a partner. */ partnerId?: string; - paymentMethod: components["schemas"]["PaymentMethod"]; + paymentMethod?: components["schemas"]["PaymentMethod"]; rampType: components["schemas"]["RampDirection"]; /** @description Pass this parameter if you want to lock the wallet address for the user. It will not be editable in the widget. */ walletAddressLocked?: string; @@ -1349,6 +1406,7 @@ export interface components { | "subsidizePreSwap" | "subsidizePostSwap" | "brlaTeleport" + | "onHoldForComplianceCheck" | "brlaPayoutOnMoonbeam" | "failed"; RampProcess: { @@ -2052,28 +2110,23 @@ export interface operations { /** * @description Bad Request. Possible reasons: * - Missing required fields (rampType, from, to, inputAmount, inputCurrency, outputCurrency) - * - Invalid ramp type (must be "on" or "off") + * - Invalid ramp type (must be "BUY" or "SELL") */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": Record; + "application/json": components["schemas"]["ErrorResponse"]; }; }; - /** @description Internal Server Error. */ + /** @description Internal Server Error. Low-liquidity route failures use this status with a safe user-facing message; unexpected internal failures remain masked. */ 500: { headers: { [name: string]: unknown; }; content: { - /** - * @example { - * "message": "An unexpected error occurred." - * } - */ - "application/json": Record; + "application/json": components["schemas"]["ErrorResponse"]; }; }; }; @@ -2147,23 +2200,23 @@ export interface operations { /** * @description Bad Request. Possible reasons: * - Missing required fields (rampType, from, to, inputAmount, inputCurrency, outputCurrency) - * - Invalid ramp type (must be "on" or "off") + * - Invalid ramp type (must be "BUY" or "SELL") */ 400: { headers: { [name: string]: unknown; }; content: { - "application/json": Record; + "application/json": components["schemas"]["ErrorResponse"]; }; }; - /** @description Internal Server Error. */ + /** @description Internal Server Error. Low-liquidity route failures use this status with a safe user-facing message when every eligible route cannot serve the requested amount; unexpected internal failures remain masked. */ 500: { headers: { [name: string]: unknown; }; content: { - "application/json": Record; + "application/json": components["schemas"]["ErrorResponse"]; }; }; }; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 3dcecca48..32c1c2b8f 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -362,9 +362,28 @@ }, "ErrorResponse": { "properties": { + "code": { + "description": "HTTP status code returned by the API error handler.", + "type": "integer" + }, + "errors": { + "description": "Validation error details, when the request fails schema or input validation.", + "items": { + "type": "object" + }, + "type": "array" + }, "message": { "description": "A human-readable error message.", "type": "string" + }, + "statusCode": { + "description": "HTTP status code included by selected provider-style error responses.", + "type": "integer" + }, + "type": { + "description": "Provider-style error category, when available.", + "type": "string" } }, "type": "object" @@ -864,6 +883,7 @@ "subsidizePreSwap", "subsidizePostSwap", "brlaTeleport", + "onHoldForComplianceCheck", "brlaPayoutOnMoonbeam", "failed" ], @@ -1958,32 +1978,43 @@ "3": { "summary": "Example of invalid ramp type error", "value": { - "message": "Invalid ramp type, must be \"on\" or \"off\"" + "message": "Invalid ramp type, must be \"BUY\" or \"SELL\"" } } }, "schema": { - "properties": {}, - "type": "object" + "$ref": "#/components/schemas/ErrorResponse" } } }, - "description": "Bad Request. Possible reasons:\n- Missing required fields (rampType, from, to, inputAmount, inputCurrency, outputCurrency)\n- Invalid ramp type (must be \"on\" or \"off\")", + "description": "Bad Request. Possible reasons:\n- Missing required fields (rampType, from, to, inputAmount, inputCurrency, outputCurrency)\n- Invalid ramp type (must be \"BUY\" or \"SELL\")", "headers": {} }, "500": { "content": { "application/json": { - "example": { - "message": "An unexpected error occurred." + "examples": { + "internal": { + "summary": "Unexpected internal failure", + "value": { + "code": 500, + "message": "Internal server error" + } + }, + "lowLiquidity": { + "summary": "Low-liquidity route failure", + "value": { + "code": 500, + "message": "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon." + } + } }, "schema": { - "properties": {}, - "type": "object" + "$ref": "#/components/schemas/ErrorResponse" } } }, - "description": "Internal Server Error.", + "description": "Internal Server Error. Low-liquidity route failures use this status with a safe user-facing message; unexpected internal failures remain masked.", "headers": {} } }, @@ -2077,7 +2108,7 @@ "3": { "summary": "Example of invalid ramp type error", "value": { - "message": "Invalid ramp type, must be \"on\" or \"off\"" + "message": "Invalid ramp type, must be \"BUY\" or \"SELL\"" } }, "4": { @@ -2189,24 +2220,31 @@ "content": { "application/json": { "schema": { - "properties": {}, - "type": "object" + "$ref": "#/components/schemas/ErrorResponse" } } }, - "description": "Bad Request. Possible reasons:\n- Missing required fields (rampType, from, to, inputAmount, inputCurrency, outputCurrency)\n- Invalid ramp type (must be \"on\" or \"off\")", + "description": "Bad Request. Possible reasons:\n- Missing required fields (rampType, from, to, inputAmount, inputCurrency, outputCurrency)\n- Invalid ramp type (must be \"BUY\" or \"SELL\")", "headers": {} }, "500": { "content": { "application/json": { + "examples": { + "lowLiquidity": { + "summary": "Low-liquidity route failure", + "value": { + "code": 500, + "message": "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon." + } + } + }, "schema": { - "properties": {}, - "type": "object" + "$ref": "#/components/schemas/ErrorResponse" } } }, - "description": "Internal Server Error.", + "description": "Internal Server Error. Low-liquidity route failures use this status with a safe user-facing message when every eligible route cannot serve the requested amount; unexpected internal failures remain masked.", "headers": {} } }, diff --git a/docs/api/pages/06-quotes-and-pricing.md b/docs/api/pages/06-quotes-and-pricing.md index 97ec589ae..5bbac3f3b 100644 --- a/docs/api/pages/06-quotes-and-pricing.md +++ b/docs/api/pages/06-quotes-and-pricing.md @@ -83,6 +83,19 @@ To restrict the search to a subset of chains (for example when you only support } ``` +## Quote Error Handling + +Expected route-availability failures are returned as `500` responses with a user-facing message. The HTTP status reflects that the route exists but current pool or route liquidity cannot serve the requested amount. Clients should treat this as a user-correctable liquidity failure and ask the user to try a smaller amount or check back soon. Both `POST /v1/quotes` and `POST /v1/quotes/best` can return: + +```json +{ + "code": 500, + "message": "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon." +} +``` + +For `POST /v1/quotes/best`, this low-liquidity response is returned when every eligible candidate route fails because of liquidity. Unexpected provider or calculation errors remain internal failures and should be retried or escalated with the response request ID if they persist. + ## 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. diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 067d739bd..b5ad81b88 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -4,7 +4,8 @@ Quotes are the entry point for every ramp. A quote calculates the expected output amount for a given input, factoring in exchange rates, fees, and dynamic pricing adjustments. The lifecycle: -1. **Creation** — Client requests a quote via `POST /v1/quotes` with input currency, output currency, amount, and ramp direction (on/off). The API calculates fees, fetches live exchange rates (Nabla DEX, price providers), applies the dynamic pricing adjustment, and returns a `QuoteResponse` including the expected output amount, fee breakdown, and a quote ID. +1. **Creation** — Client requests a quote via `POST /v1/quotes` with input currency, output currency, amount, and ramp direction (`BUY` for on-ramp or `SELL` for off-ramp). The API calculates fees, fetches live exchange rates (Nabla DEX, price providers), applies the dynamic pricing adjustment, and returns a `QuoteResponse` including the expected output amount, fee breakdown, and a quote ID. + - If live route/pool liquidity cannot serve the quote at the requested amount, the API returns a user-facing `500` quote error (`This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.`). Clients should treat it as a user-correctable liquidity failure and ask for a smaller amount or to check back soon. This applies to Nabla pool coverage failures, Squid route low-liquidity responses, and `/v1/quotes/best` when every candidate route fails for liquidity. Unexpected provider or calculation failures still follow the global production error policy and are masked as internal errors. 2. **Expiry** — Quotes expire **10 minutes** after creation (hardcoded in `QuoteTicket.create()` and the model default: `new Date(Date.now() + 10 * 60 * 1000)`). After expiry, the quote cannot be used to start a ramp. Note: this is a separate timeout from `discountStateTimeoutMinutes` (see Dynamic Pricing below). 3. **Binding** — When a ramp is registered (`POST /v1/ramp/register`), it binds to a specific quote ID. The quote's amounts become the committed values for the ramp. 4. **Consumption** — A quote can only be bound to one ramp. Once consumed, it cannot be reused. diff --git a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md index ec3a62f09..92a41f670 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -57,6 +57,8 @@ There are 29+ phase handlers in `apps/api/src/api/services/phases/handlers/`. Th - From Base to supported EVM destinations (BRL and EUR onramps): `squidRouterApprove` → `squidRouterSwap` → `squidRouterPay` → optional `backupSquidRouter*` on destination → `destinationTransfer` - Trivial case (Base→Base USDC): direct `destinationTransfer` only (Squid skipped) +**History/status terminal transaction link:** The API's V2 final transaction hash/link must point to the terminal user-facing on-chain delivery phase, not to intermediate bridge/swap phases such as `squidRouterSwap`. For EVM onramps this is `destinationTransferTxHash`; for AssetHub onramps it is `pendulumToAssethubXcmHash` or `hydrationToAssethubXcmHash`; for active offramps it is the corridor terminal payout hash (`brlaPayoutTxHash`, `mykoboPayoutTxHash`, or `alfredpayOfframpTransferTxHash`). Post-complete cleanup sweeps are not user-facing delivery and must not be exposed as the final transaction. + ### Phase Transition Diagrams The following diagrams show the phase transitions for all on-ramp and off-ramp corridors as registered in `register-handlers.ts` and assembled by the route builders in `apps/api/src/api/services/transactions/{on,off}ramp/routes/`. Diamond nodes denote conditional branches resolved at route-build time (not runtime phase transitions). diff --git a/docs/security-spec/07-operations/client-observability.md b/docs/security-spec/07-operations/client-observability.md index 939826179..d58c3ff21 100644 --- a/docs/security-spec/07-operations/client-observability.md +++ b/docs/security-spec/07-operations/client-observability.md @@ -18,11 +18,11 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev ## Security Invariants 1. **Observability MUST NOT affect API behavior** — Event persistence, structured logging, and metric hooks must be best-effort. Failures in the observability layer must not change response bodies, HTTP statuses, ramp state, quote state, or retry behavior. -2. **Events MUST be sanitized before persistence** — Only approved scalar fields may be stored. Raw request bodies, raw headers, nested metadata objects, and sensitive keys must be dropped before inserting `api_client_events` rows. +2. **Events MUST be sanitized before persistence** — Only approved scalar fields may be stored. Failure events may include allowlisted request-derived scalar summaries in `metadata` (for example method, templated path shapes, selected quote/ramp IDs, selected quote inputs, query flags, array counts, and object-presence flags). Raw request bodies, raw headers, concrete path identifiers, nested metadata objects, and sensitive keys must be dropped before inserting `api_client_events` rows. 3. **Secrets MUST NOT be logged or persisted** — `X-API-Key`, bearer tokens, secret API keys, provider credentials, private keys, seeds, ephemeral private material, and signed transaction payloads must not appear in logs or observability events. 4. **Sensitive user/payment data MUST NOT be logged or persisted** — Tax IDs, PIX destinations, QR codes, KYC data, bank details, and raw payment credentials must be excluded from observability metadata. 5. **Request correlation MUST be non-secret** — `requestId`, `quoteId`, and `rampId` may be stored for debugging, but they must not be used as high-cardinality metric labels. They are correlation identifiers, not authentication material. -6. **Partner attribution MUST use safe identifiers** — Events may store `partnerId`, `partnerName`, and short API key prefixes. Full secret keys and raw auth headers are forbidden. +6. **Partner attribution MUST use safe identifiers** — Events may store `partnerId`, `partnerName`, and short API key prefixes capped at 16 characters. Full secret keys and raw auth headers are forbidden. 7. **Operational metrics MUST remain low-cardinality** — Future metric exporters must group by bounded labels such as operation, partner, status, HTTP status, and error type. They must not label by user ID, wallet address, request ID, quote ID, ramp ID, tax ID, PIX key, or free-form request values. 8. **Event persistence SHOULD have automated retention before production operational use** — Raw operational events are useful for investigation but must not be retained indefinitely without aggregation or cleanup. The backend retention worker keeps the current UTC calendar day plus the previous six full UTC calendar days and removes older `api_client_events` rows on startup and daily. 9. **Client observability access MUST go through metrics-dashboard-authenticated backend APIs** — Internal consumers must call protected backend endpoints and must not ship database credentials, Supabase service-role keys, Metabase embed secrets, or other server-only credentials to client-side code. @@ -31,9 +31,9 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev | Threat | Mitigation | |---|---| -| **Observability database leak** — An attacker gains read access to `api_client_events` | Store only minimal sanitized event fields. Do not persist secrets, raw request bodies, tax IDs, PIX data, KYC data, or private key material. Treat the table as operationally sensitive even after redaction. | -| **API key/header capture** — Instrumentation accidentally records `X-API-Key`, bearer tokens, or raw headers | Use an allowlist-shaped event schema and denylist sensitive metadata keys before persistence. Store only short key prefixes when explicitly safe. | -| **PII leakage through metadata** — Client-provided `additionalData` or error messages include tax IDs, PIX keys, or bank details | Do not persist nested metadata objects. Keep metadata scalar-only and sanitized. Avoid passing request bodies to observability helpers. Truncate error messages and prefer stable `errorType` categories. | +| **Observability database leak** — An attacker gains read access to `api_client_events` | Store only minimal sanitized event fields and allowlisted request summaries. Do not persist secrets, raw request bodies, tax IDs, PIX data, KYC data, or private key material. Treat the table as operationally sensitive even after redaction. | +| **API key/header capture** — Instrumentation accidentally records `X-API-Key`, bearer tokens, or raw headers | Use an allowlist-shaped event schema and denylist sensitive metadata keys before persistence. Store only short 16-character API key prefixes when explicitly safe. | +| **PII leakage through metadata** — Client-provided `additionalData` or error messages include tax IDs, PIX keys, or bank details | Do not persist nested metadata objects. Keep metadata scalar-only and sanitized. Pass only allowlisted request-derived fields to observability helpers; use counts or presence flags for arrays/objects such as presigned transactions, signing accounts, and `additionalData`. Truncate error messages and prefer stable `errorType` categories. | | **Business flow disruption** — Database/logging outage causes quote/ramp requests to fail | Observability writes are fire-and-forget/best-effort and catch their own errors. The request path must proceed exactly as it would without observability. | | **Missing correlation during incidents** — Operators cannot connect a partner report to backend logs | Generate or propagate `requestId` for all requests and return it via `X-Request-ID`. Persist request IDs alongside quote/ramp IDs when available. | | **High-cardinality metric explosion** — Future observability metrics use ramp IDs or user IDs as labels | Keep high-cardinality identifiers in logs/event rows only. Export aggregate metrics using bounded labels. | @@ -48,7 +48,7 @@ Internal operators can inspect these events through `GET /v1/admin/api-client-ev - [ ] Verify `api_client_events` stores only the approved fields: operation, status, HTTP status, error type/message, safe partner attribution, quote/ramp IDs, duration, and sanitized metadata. - [ ] Verify event persistence helpers catch their own errors and cannot throw into controller or middleware responses. - [ ] Verify auth, quote, and ramp request instrumentation does not alter existing response bodies or HTTP status codes. -- [ ] Verify no observability event stores `X-API-Key`, bearer tokens, raw headers, raw request bodies, tax IDs, PIX destinations, QR codes, KYC data, private keys, seeds, ephemeral secrets, or signed transaction payloads. +- [ ] Verify failure-event metadata contains only allowlisted scalar request summaries and no `X-API-Key`, bearer tokens, raw headers, raw request bodies, tax IDs, PIX destinations, QR codes, KYC data, private keys, seeds, ephemeral secrets, or signed transaction payloads. - [ ] Verify error messages are truncated and alerts/consumers use stable `errorType` categories rather than raw messages. - [ ] Verify future metric exporters do not use request ID, quote ID, ramp ID, user ID, wallet address, tax ID, or PIX key as metric labels. - [ ] Verify `GET /v1/admin/api-client-events` uses `metricsDashboardAuth` and returns only sanitized event fields. diff --git a/docs/security-spec/07-operations/secret-management.md b/docs/security-spec/07-operations/secret-management.md index c5fedc4f3..f8047b40b 100644 --- a/docs/security-spec/07-operations/secret-management.md +++ b/docs/security-spec/07-operations/secret-management.md @@ -56,7 +56,7 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a 7. **Database credentials (`DB_*`) MUST NOT be accessible from the public internet** — Direct PostgreSQL access should be restricted to the application server's network. 8. **No secret MUST be passed as a URL query parameter** — Query parameters are logged by proxies, CDNs, and web servers. Secrets must only travel in headers or request bodies. 9. **`MYKOBO_CLIENT_DOMAIN` MUST be set in production** — Not a secret, but operationally critical: when unset, Mykobo silently applies its default fee tier (~5x worse than the negotiated rate). Quote-engine fee defaults will then diverge from what Mykobo actually charges. Deployment automation MUST treat a missing `MYKOBO_CLIENT_DOMAIN` as a hard failure rather than letting it fall through to default-tier fees. -10. **Observability MUST follow the same no-secret rule as logs** — API client events, request correlation logs, metrics, and observability data must not contain full API keys, bearer tokens, provider credentials, private keys, seeds, raw request headers, or raw request bodies. See `07-operations/client-observability.md`. +10. **Observability MUST follow the same no-secret rule as logs** — API client events, request correlation logs, metrics, and observability data must not contain full API keys, bearer tokens, provider credentials, private keys, seeds, raw request headers, or raw request bodies. Sanitized request summaries may be stored only when they are allowlisted, scalar, and stripped of secrets or sensitive payment/user data. See `07-operations/client-observability.md`. ## Threat Vectors & Mitigations @@ -69,7 +69,7 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a | **Lateral movement from price provider keys** — Compromise of AlchemyPay/Transak/MoonPay keys | Limited blast radius — these keys access price data, not funds. However, an attacker could manipulate prices shown to users (if the provider API allows it) or access transaction data. | | **Google Sheets credentials** — Access to fee logging spreadsheet | Could expose fee data and ramp metadata. Could manipulate fee records. Lower severity than financial keys but still a data leak. | | **`SUPABASE_SERVICE_KEY` used for all database operations** — No principle of least privilege | The service key bypasses all RLS. If any code path leaks this key, the attacker has unrestricted database access. A more secure approach would use the anon key with RLS for read operations and the service key only for privileged writes. | -| **Observability event leak** — Operational telemetry captures secret values or payment/KYC data | Client observability uses a sanitized event schema, short key prefixes only, scalar metadata filtering, and explicit exclusion of raw headers/bodies, tax IDs, PIX data, KYC data, and private material. | +| **Observability event leak** — Operational telemetry captures secret values or payment/KYC data | Client observability uses a sanitized event schema, 16-character key prefixes only, allowlisted scalar request summaries, scalar metadata filtering, and explicit exclusion of raw headers/bodies, tax IDs, PIX data, KYC data, and private material. | ## Audit Checklist @@ -88,4 +88,4 @@ This spec catalogs every secret, its purpose, its blast radius if compromised, a - [x] Check whether `GOOGLE_PRIVATE_KEY` contains newlines that might be mis-parsed — a common issue with PEM keys in env vars. **PASS** — PEM key handling present; standard env var parsing. - [x] Map the full blast radius: if the API server is compromised, list every account, service, and database that becomes accessible. **PASS (comprehensive)** — full blast radius documented in the Secret Inventory table above. - [x] **FINDING F-062 (MEDIUM)**: Verify SDK does not log API keys or secrets to console. **PASS (FIXED)** — removed `console.log("Creating quote with request:", request)` from `ApiService.ts` that was leaking the full request object including API key. -- [ ] Verify API client event persistence stores only short key prefixes and never stores full `X-API-Key`, bearer tokens, raw auth headers, or request bodies. +- [ ] Verify API client event persistence stores only 16-character key prefixes and never stores full `X-API-Key`, bearer tokens, raw auth headers, or request bodies. diff --git a/package.json b/package.json index 977cf7875..721ef502d 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "build:sdk": "bun run --cwd packages/sdk build", "build:shared": "bun run --cwd packages/shared build", "compile:contracts:relayer": "bun run --cwd contracts/relayer compile", - "dev": "concurrently -n 'shared,backend,frontend' -c '#ffa500,#007755,#2f6da3' 'cd packages/shared && bun run dev' 'cd apps/api && bun dev' 'cd apps/frontend && bun dev'", + "dev": "bun run --cwd packages/shared dev & bun run --cwd apps/api dev & bun run --cwd apps/frontend dev & wait", "dev:backend": "bun run --cwd apps/api dev", "dev:contracts:relayer": "bun run --cwd contracts/relayer node", "dev:frontend": "bun run --cwd apps/frontend dev", diff --git a/packages/sdk/src/VortexSdk.ts b/packages/sdk/src/VortexSdk.ts index 2b0ac5765..29cc06210 100644 --- a/packages/sdk/src/VortexSdk.ts +++ b/packages/sdk/src/VortexSdk.ts @@ -5,6 +5,7 @@ import { createPendulumEphemeral, EphemeralAccount, EphemeralAccountType, + GetRampStatusResponse, isEvmTransactionData, isSignedTypedData, isSignedTypedDataArray, @@ -65,7 +66,7 @@ export class VortexSdk { return this.apiService.getQuote(quoteId); } - async getRampStatus(rampId: string): Promise { + async getRampStatus(rampId: string): Promise { return this.apiService.getRampStatus(rampId); } diff --git a/packages/sdk/src/services/ApiService.ts b/packages/sdk/src/services/ApiService.ts index 52e374741..49d2ac5fc 100644 --- a/packages/sdk/src/services/ApiService.ts +++ b/packages/sdk/src/services/ApiService.ts @@ -1,8 +1,8 @@ import type { CreateQuoteRequest, + GetRampStatusResponse, QuoteResponse, RampDirection, - RampProcess, RegisterRampRequest, RegisterRampResponse, StartRampRequest, @@ -77,14 +77,14 @@ export class ApiService { return handleAPIResponse(response, "/v1/ramp/start"); } - async getRampStatus(rampId: string): Promise { + async getRampStatus(rampId: string): Promise { const url = new URL(`${this.apiBaseUrl}/v1/ramp/${rampId}`); const response = await fetch(url.toString(), { headers: this.buildHeaders(), method: "GET" }); - return handleAPIResponse(response, `/v1/ramp/status?id=${rampId}`); + return handleAPIResponse(response, `/v1/ramp/status?id=${rampId}`); } async getBrlKycStatus(taxId: string): Promise { diff --git a/packages/shared/package.json b/packages/shared/package.json index fe6e2687b..759123877 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -56,7 +56,7 @@ }, "scripts": { "build": "rm -rf ./dist && bun build ./src/index.ts --outdir ./dist/node --target=node && bun build ./src/index.ts --outdir ./dist/browser --target=browser && tsc -p tsconfig.json --emitDeclarationOnly --outDir dist", - "dev": "bun build ./src/index.ts --outdir ./dist/node --target=node --watch & bun build ./src/index.ts --outdir ./dist/browser --target=browser --watch", + "dev": "bun build ./src/index.ts --outdir ./dist/node --target=node & bun build ./src/index.ts --outdir ./dist/browser --target=browser", "format": "prettier . --write", "prepublishOnly": "bun run build" }, diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 62bd9c13d..a5367bdae 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -102,7 +102,7 @@ export enum QuoteError { InputAmountForSwapMustBeGreaterThanZero = "Input amount for swap must be greater than 0", InputAmountTooLow = "Input amount too low. Please try a larger amount.", InputAmountTooLowToCoverCalculatedFees = "Input amount too low to cover calculated fees.", - LowLiquidity = "Low liquidity for this route. Please try a smaller amount.", + LowLiquidity = "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", BelowLowerLimitSell = "Output amount below minimum SELL limit of", BelowLowerLimitBuy = "Input amount below minimum BUY limit of", AboveUpperLimitSell = "Output amount exceeds maximum SELL limit of", diff --git a/packages/shared/src/endpoints/ramp.endpoints.ts b/packages/shared/src/endpoints/ramp.endpoints.ts index 217f0ac09..ab4a9ecbb 100644 --- a/packages/shared/src/endpoints/ramp.endpoints.ts +++ b/packages/shared/src/endpoints/ramp.endpoints.ts @@ -40,6 +40,7 @@ export type RampPhase = | "alfredpayOfframpTransfer" | "alfredpayOfframpTransferFallback" | "brlaOnrampMint" + | "onHoldForComplianceCheck" | "brlaPayoutOnBase" | "mykoboOnrampDeposit" | "mykoboPayoutOnBase" diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index e8725830d..26f7157bc 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -265,10 +265,15 @@ export class AlfredpayApiService { data: SubmitKycInformationRequest ): Promise { const path = `/api/v1/third-party-service/penny/customers/${customerId}/kyc`; - const kycSubmission: Record = { ...data, nationalities: [data.country] }; + const kycSubmission: Record = { ...data }; + if (!kycSubmission.nationalities) kycSubmission.nationalities = [data.country]; if (!data.typeDocument) delete kycSubmission.typeDocument; if (!data.typeDocumentCol) delete kycSubmission.typeDocumentCol; + delete kycSubmission.typeDocumentAr; // Currently not required, (typeDocument throws an error on Alfredpay side) if (!data.phoneNumber) delete kycSubmission.phoneNumber; + if (!data.cuit) delete kycSubmission.cuit; + if (data.pep !== false && !data.pep) delete kycSubmission.pep; + if (!data.countryCode) delete kycSubmission.countryCode; return (await this.executeRequest(path, "POST", { kycSubmission })) as SubmitKycInformationResponse; } @@ -279,7 +284,7 @@ export class AlfredpayApiService { file: Blob ): Promise { const formData = new FormData(); - formData.append("rawBody", file); + formData.append("fileBody", file); formData.append("fileType", fileType); const url = `${ALFREDPAY_BASE_URL}/api/v1/third-party-service/penny/customers/${customerId}/kyc/${submissionId}/files`; diff --git a/packages/shared/src/services/alfredpay/types.ts b/packages/shared/src/services/alfredpay/types.ts index b4a4c2dd3..4730485fc 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -353,7 +353,12 @@ export interface AlfredpayFiatAccount extends AlfredpayFiatAccountFields { export type ListAlfredpayFiatAccountsResponse = AlfredpayFiatAccount[]; -const ALFREDPAY_FIAT_TOKEN_SET: ReadonlySet = new Set([FiatToken.USD, FiatToken.MXN, FiatToken.COP]); +const ALFREDPAY_FIAT_TOKEN_SET: ReadonlySet = new Set([ + FiatToken.USD, + FiatToken.MXN, + FiatToken.COP, + FiatToken.ARS +]); export const isAlfredpayToken = (token: RampCurrency): token is FiatToken => ALFREDPAY_FIAT_TOKEN_SET.has(token); @@ -422,7 +427,12 @@ export interface SubmitKycInformationRequest { dni: string; typeDocument?: string; typeDocumentCol?: AlfredpayColombiaDocumentType; - phoneNumber?: string; // Colombia + typeDocumentAr?: AlfredpayArgentinaDocumentType; + phoneNumber?: string; // Colombia, Argentina + countryCode?: string; // Argentina + nationalities?: string[]; // Argentina + pep?: boolean; // Argentina + cuit?: string; // Argentina, mandatory 11 digits } export interface SubmitKycInformationResponse { @@ -431,7 +441,12 @@ export interface SubmitKycInformationResponse { export enum AlfredpayKycFileType { FRONT = "National ID Front", - BACK = "National ID Back" + BACK = "National ID Back", + SELFIE = "Selfie" +} + +export enum AlfredpayArgentinaDocumentType { + DNI = "DNI" } // KYB form submission types diff --git a/packages/shared/src/services/brla/brlaApiService.ts b/packages/shared/src/services/brla/brlaApiService.ts index 72a4333a6..6628d3a45 100644 --- a/packages/shared/src/services/brla/brlaApiService.ts +++ b/packages/shared/src/services/brla/brlaApiService.ts @@ -395,7 +395,9 @@ export class BrlaApiService { const aveniaTicketsQueryResponse = await this.sendRequest(Endpoint.Tickets, "GET", query, undefined); if ("tickets" in aveniaTicketsQueryResponse) { - return aveniaTicketsQueryResponse.tickets.filter((ticket): ticket is AveniaPayinTicket => "brlPixInputInfo" in ticket); + return aveniaTicketsQueryResponse.tickets.filter( + (ticket): ticket is AveniaPayinTicket => "brlPixInputInfo" in ticket || "brazilianFiatSenderInfo" in ticket + ); } throw new Error("Invalid response from Avenia API for getAveniaPayinTickets"); } diff --git a/packages/shared/src/services/brla/types.ts b/packages/shared/src/services/brla/types.ts index 42d3d2ee8..166fcad7c 100644 --- a/packages/shared/src/services/brla/types.ts +++ b/packages/shared/src/services/brla/types.ts @@ -101,6 +101,7 @@ export interface OnchainSwapQuoteParams { } export enum AveniaTicketStatus { + ON_HOLD = "ON-HOLD", PENDING = "PENDING", PAID = "PAID", FAILED = "FAILED" @@ -219,8 +220,10 @@ export interface AveniaPayoutTicket extends BaseTicket { export interface AveniaPayinTicket extends BaseTicket { brazilianFiatSenderInfo: { - id: string; - ticketId: string; + id?: string; + ticketId?: string; + referenceLabel?: string; + additionalData?: string; name: string; taxId: string; bankCode: string; @@ -237,7 +240,7 @@ export interface AveniaPayinTicket extends BaseTicket { walletMemo: string; txHash: string; }; - brlPixInputInfo: { + brlPixInputInfo?: { id: string; ticketId: string; referenceLabel: string; diff --git a/packages/shared/src/services/pendulum/apiManager.ts b/packages/shared/src/services/pendulum/apiManager.ts index c5d27972e..23e2b6ca6 100644 --- a/packages/shared/src/services/pendulum/apiManager.ts +++ b/packages/shared/src/services/pendulum/apiManager.ts @@ -2,6 +2,7 @@ import { ApiPromise, WsProvider } from "@polkadot/api"; import { SubmittableExtrinsic } from "@polkadot/api/submittable/types"; import { KeyringPair } from "@polkadot/keyring/types"; import { ISubmittableResult } from "@polkadot/types/types"; +import { getEnvVar } from "../../helpers/environment"; import logger from "../../logger"; export type SubstrateApiNetwork = "assethub" | "pendulum" | "moonbeam" | "hydration" | "paseo"; @@ -11,14 +12,14 @@ export interface NetworkConfig { wsUrls: string[]; } -const NETWORKS: NetworkConfig[] = [ +const DEFAULT_NETWORKS: NetworkConfig[] = [ { name: "assethub", wsUrls: ["wss://dot-rpc.stakeworld.io/assethub"] }, { name: "hydration", - wsUrls: ["wss://hydration.ibp.network"] + wsUrls: ["wss://rpc.hydradx.cloud"] }, { name: "moonbeam", @@ -34,6 +35,31 @@ const NETWORKS: NetworkConfig[] = [ } ]; +const NETWORK_RPC_ENV_KEYS: Partial> = { + assethub: "ASSETHUB_WSS", + hydration: "HYDRATION_WSS", + moonbeam: "MOONBEAM_WSS" +}; + +function parseWsUrls(value: string): string[] { + return value + .split(",") + .map(url => url.trim()) + .filter(Boolean); +} + +export function getConfiguredNetworks(): NetworkConfig[] { + return DEFAULT_NETWORKS.map(network => { + const envKey = NETWORK_RPC_ENV_KEYS[network.name]; + const configuredWsUrls = envKey ? parseWsUrls(getEnvVar(envKey)) : []; + + return { + ...network, + wsUrls: configuredWsUrls.length > 0 ? configuredWsUrls : network.wsUrls + }; + }); +} + export type API = { api: ApiPromise; ss58Format: number; @@ -58,7 +84,7 @@ export class ApiManager { private usedRpcIndices: Map> = new Map(); private constructor() { - this.networks = NETWORKS; + this.networks = getConfiguredNetworks(); // Initialize nonce maps for each network this.networks.forEach(network => { @@ -75,7 +101,6 @@ export class ApiManager { } public async populateApi(networkName: SubstrateApiNetwork, wsUrlIndex?: number): Promise { - const network = this.getNetworkConfig(networkName); const index = wsUrlIndex ?? 0; const instanceKey = this.generateInstanceKey(networkName, index); const existingInstance = this.apiInstances.get(instanceKey); @@ -126,16 +151,18 @@ export class ApiManager { */ public async getApiWithShuffling(networkName: SubstrateApiNetwork, uuid?: string): Promise { const network = this.getNetworkConfig(networkName); - const usedIndices = uuid ? this.usedRpcIndices.get(uuid) || new Set() : null; + const usedIndices = uuid ? (this.usedRpcIndices.get(uuid) ?? new Set()) : undefined; // Get available indices: all if no UUID, unused ones if UUID provided const availableIndices = uuid - ? network.wsUrls.map((_, index) => index).filter(index => !usedIndices!.has(index)) + ? network.wsUrls.map((_, index) => index).filter(index => !usedIndices?.has(index)) : network.wsUrls.map((_, index) => index); // If no available indices any more, reset the used indices for this UUID and throw if (availableIndices.length === 0) { - this.usedRpcIndices.delete(uuid!); // uuid is guaranteed to be defined here. + if (uuid) { + this.usedRpcIndices.delete(uuid); + } throw new Error(`All RPC endpoints have been used for network ${networkName} with UUID ${uuid}`); } @@ -146,7 +173,7 @@ export class ApiManager { if (!this.usedRpcIndices.has(uuid)) { this.usedRpcIndices.set(uuid, new Set()); } - this.usedRpcIndices.get(uuid)!.add(randomIndex); + this.usedRpcIndices.get(uuid)?.add(randomIndex); } const instanceKey = this.generateInstanceKey(networkName, randomIndex); diff --git a/packages/shared/src/tokens/freeTokens/config.ts b/packages/shared/src/tokens/freeTokens/config.ts index 4f01c5262..9838b146b 100644 --- a/packages/shared/src/tokens/freeTokens/config.ts +++ b/packages/shared/src/tokens/freeTokens/config.ts @@ -81,6 +81,29 @@ const COP_LIMITS: AlfredpayLimitsTable = { } }; +const ARS_LIMITS: AlfredpayLimitsTable = { + offramp: { + USDC: { + BUSINESS: { maxRaw: "300000000000", minRaw: "650000" }, + INDIVIDUAL: { maxRaw: "5000000000000", minRaw: "650000" } + }, + USDT: { + BUSINESS: { maxRaw: "300000000000", minRaw: "650000" }, + INDIVIDUAL: { maxRaw: "300000000000", minRaw: "650000" } + } + }, + onramp: { + USDC: { + BUSINESS: { maxRaw: "41200000000", minRaw: "100000" }, + INDIVIDUAL: { maxRaw: "41200000000", minRaw: "100000" } + }, + USDT: { + BUSINESS: { maxRaw: "41200000000", minRaw: "100000" }, + INDIVIDUAL: { maxRaw: "13700000000", minRaw: "100000" } + } + } +}; + export const freeTokenConfig: Partial> = { [FiatToken.EURC]: { assetSymbol: "EURC", @@ -142,5 +165,20 @@ export const freeTokenConfig: Partial> = minBuyAmountRaw: "3500000", minSellAmountRaw: "1000000", type: TokenType.Fiat + }, + [FiatToken.ARS]: { + alfredpayLimits: ARS_LIMITS, + assetSymbol: "ARS", + decimals: 2, + fiat: { + assetIcon: "ars", + name: "Argentine Peso", + symbol: "ARS" + }, + maxBuyAmountRaw: "13700000000", + maxSellAmountRaw: "300000000000", + minBuyAmountRaw: "100000", + minSellAmountRaw: "650000", + type: TokenType.Fiat } };