From 68505d2a65b7015133492ef474cc88cd6518b0c7 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 8 May 2026 18:01:59 -0300 Subject: [PATCH 01/59] first pass, add ars changes --- .../api/controllers/alfredpay.controller.ts | 9 +- apps/api/src/api/middlewares/validators.ts | 29 ++ apps/api/src/api/routes/v1/alfredpay.route.ts | 9 +- .../components/Alfredpay/AlfredpayKycFlow.tsx | 16 ++ .../Alfredpay/ArDocumentUploadScreen.tsx | 94 +++++++ .../components/Alfredpay/ArKycFormScreen.tsx | 262 ++++++++++++++++++ .../src/constants/fiatAccountForms.ts | 32 +++ .../src/constants/fiatAccountMethods.ts | 18 +- .../src/machines/alfredpayKyc.machine.ts | 59 +++- apps/frontend/src/machines/kyc.states.ts | 2 + apps/frontend/src/translations/en.json | 28 ++ apps/frontend/src/translations/pt.json | 28 ++ .../services/alfredpay/alfredpayApiService.ts | 5 + .../shared/src/services/alfredpay/types.ts | 14 +- 14 files changed, 590 insertions(+), 15 deletions(-) create mode 100644 apps/frontend/src/components/Alfredpay/ArDocumentUploadScreen.tsx create mode 100644 apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 1e628b314..bc5b68b41 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -340,7 +340,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 }); @@ -443,7 +443,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({ @@ -694,6 +694,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/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 303090653..8f252b4da 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -14,6 +14,7 @@ import { PriceProvider, QuoteError, RampDirection, + SubmitKycInformationRequest, TokenConfig, VALID_CRYPTO_CURRENCIES, VALID_FIAT_CURRENCIES, @@ -483,6 +484,34 @@ 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) { + res.status(httpStatus.BAD_REQUEST).json({ error }); + return; + } + + next(); +}; + export const validateStartKyc2: RequestHandler = (req, res, next) => { const { documentType } = req.body as AveniaKYCDataUploadRequest; diff --git a/apps/api/src/api/routes/v1/alfredpay.route.ts b/apps/api/src/api/routes/v1/alfredpay.route.ts index 1a464668d..aa4c3ae35 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/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index 3b12570ce..45a0d1d1e 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -1,5 +1,7 @@ import { useCallback } from "react"; import { useAlfredpayKycActor, useAlfredpayKycSelector } from "../../contexts/rampState"; +import { ArDocumentUploadScreen } from "./ArDocumentUploadScreen"; +import { ArKycFormScreen } from "./ArKycFormScreen"; import { ColKycFormScreen } from "./ColKycFormScreen"; import { CustomerDefinitionScreen } from "./CustomerDefinitionScreen"; import { DoneScreen } from "./DoneScreen"; @@ -37,6 +39,10 @@ export const AlfredpayKycFlow = () => { (files: import("../../machines/alfredpayKyc.machine").MxnKycFiles) => actor?.send({ files, type: "SUBMIT_FILES" }), [actor] ); + const submitArFiles = useCallback( + (files: import("../../machines/alfredpayKyc.machine").ArKycFiles) => actor?.send({ files, type: "SUBMIT_AR_FILES" }), + [actor] + ); const submitKybForm = useCallback( (data: import("../../machines/alfredpayKyc.machine").KybFormData) => actor?.send({ data, type: "SUBMIT_KYB_FORM" }), [actor] @@ -59,6 +65,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" || @@ -67,6 +74,7 @@ export const AlfredpayKycFlow = () => { stateValue === "Retrying" || stateValue === "SubmittingKycInfo" || stateValue === "SubmittingFiles" || + stateValue === "SubmittingArFiles" || stateValue === "SendingSubmission" || stateValue === "SubmittingKybInfo" || stateValue === "SubmittingKybBusinessFiles" || @@ -84,10 +92,18 @@ export const AlfredpayKycFlow = () => { return ; } + if (stateValue === "FillingKycForm" && isAr) { + return ; + } + if (stateValue === "UploadingDocuments" && (isMxn || isCo)) { return ; } + if (stateValue === "UploadingArDocuments" && isAr) { + return ; + } + if (stateValue === "FillingKybForm") { return ; } diff --git a/apps/frontend/src/components/Alfredpay/ArDocumentUploadScreen.tsx b/apps/frontend/src/components/Alfredpay/ArDocumentUploadScreen.tsx new file mode 100644 index 000000000..76e45b0ee --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/ArDocumentUploadScreen.tsx @@ -0,0 +1,94 @@ +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { ArKycFiles } from "../../machines/alfredpayKyc.machine"; +import { MenuButtons } from "../MenuButtons"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; +const ACCEPTED_TYPES = ["image/jpeg", "image/png", "application/pdf"]; + +interface ArDocumentUploadScreenProps { + onSubmit: (files: ArKycFiles) => void; +} + +function FileDropZone({ label, file, onChange }: { label: string; file: File | null; onChange: (file: File) => void }) { + const { t } = useTranslation(); + const inputRef = useRef(null); + const [error, setError] = useState(null); + + const handleFile = (f: File) => { + setError(null); + if (!ACCEPTED_TYPES.includes(f.type)) { + setError(t("components.arDocumentUpload.invalidType")); + return; + } + if (f.size > MAX_FILE_SIZE) { + setError(t("components.arDocumentUpload.fileTooLarge")); + return; + } + onChange(f); + }; + + return ( +
+

{label}

+ + {error &&

{error}

} +
+ ); +} + +export function ArDocumentUploadScreen({ onSubmit }: ArDocumentUploadScreenProps) { + const { t } = useTranslation(); + const [front, setFront] = useState(null); + const [back, setBack] = useState(null); + const [selfie, setSelfie] = useState(null); + + const isValid = front !== null && back !== null && selfie !== null; + + const handleSubmit = () => { + if (!front || !back || !selfie) return; + onSubmit({ back, front, selfie }); + }; + + return ( +
+ +

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

+

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

+ +
+ + + + +

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

+ + +
+
+ ); +} diff --git a/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx new file mode 100644 index 000000000..64141085f --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx @@ -0,0 +1,262 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { AlfredpayArgentinaDocumentType } from "@vortexfi/shared"; +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"; + +const schema = z + .object({ + address: z.string().min(1), + city: z.string().min(1), + 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\s\-()]{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; + +interface ArKycFormScreenProps { + onSubmit: (data: MxnKycFormData) => void; +} + +export function ArKycFormScreen({ onSubmit }: ArKycFormScreenProps) { + const { t } = useTranslation(); + + const { + formState: { errors }, + handleSubmit, + register, + watch + } = useForm({ + defaultValues: { + cuit: "", + nationalities: ["AR"], + pep: false, + typeDocumentAr: AlfredpayArgentinaDocumentType.DNI + }, + resolver: zodResolver(schema) + }); + + const documentType = watch("typeDocumentAr"); + + 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.arKycForm.title")}

+

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

+ +
+
+
+ + + {errors.firstName && {errors.firstName.message}} +
+ +
+ + + {errors.lastName && {errors.lastName.message}} +
+
+ +
+ + + {errors.dateOfBirth && {errors.dateOfBirth.message}} +
+ +
+ + + {errors.email && {errors.email.message}} +
+ +
+ + + {errors.phoneNumber && {errors.phoneNumber.message}} +
+ +
+ + + {errors.typeDocumentAr && {errors.typeDocumentAr.message}} +
+ +
+ + + {errors.dni && {errors.dni.message}} +
+ +
+ + + {errors.cuit && {errors.cuit.message}} +
+ +
+ + + {errors.address && {errors.address.message}} +
+ +
+
+ + + {errors.city && {errors.city.message}} +
+ +
+ + + {errors.state && {errors.state.message}} +
+
+ +
+ + + {errors.zipCode && {errors.zipCode.message}} +
+ +
+ + +
+ {errors.pep && {errors.pep.message}} + + +
+
+ ); +} 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/machines/alfredpayKyc.machine.ts b/apps/frontend/src/machines/alfredpayKyc.machine.ts index a96a4a48e..eabf165d7 100644 --- a/apps/frontend/src/machines/alfredpayKyc.machine.ts +++ b/apps/frontend/src/machines/alfredpayKyc.machine.ts @@ -19,6 +19,12 @@ export interface MxnKycFiles { back: File; } +export interface ArKycFiles { + front: File; + back: File; + selfie: File; +} + export interface KybBusinessFiles { articlesIncorporation: File; proofAddress: File; @@ -144,6 +150,15 @@ export const alfredpayKycMachine = setup({ return AlfredpayService.sendKycSubmission(country, input.submissionId); }), + submitArFiles: fromPromise(async ({ input }: { input: AlfredpayKycContext & { arFiles?: ArKycFiles } }) => { + const country = input.country || "AR"; + if (!input.submissionId) throw new Error("Submission ID missing"); + if (!input.arFiles) throw new Error("KYC files missing"); + await AlfredpayService.submitKycFile(country, input.submissionId, AlfredpayKycFileType.FRONT, input.arFiles.front); + await AlfredpayService.submitKycFile(country, input.submissionId, AlfredpayKycFileType.BACK, input.arFiles.back); + await AlfredpayService.submitKycFile(country, input.submissionId, AlfredpayKycFileType.SELFIE, input.arFiles.selfie); + }), + submitFiles: fromPromise( async ({ input }: { input: AlfredpayKycContext & { mxnFormData?: MxnKycFormData; mxnFiles?: MxnKycFiles } }) => { const country = input.country || "MX"; @@ -264,6 +279,7 @@ export const alfredpayKycMachine = setup({ context: {} as AlfredpayKycContext & { mxnFormData?: MxnKycFormData; mxnFiles?: MxnKycFiles; + arFiles?: ArKycFiles; kybFormData?: KybFormData; kybBusinessFiles?: KybBusinessFiles; kybRelatedPersonFiles?: KybPersonFiles[]; @@ -285,6 +301,7 @@ export const alfredpayKycMachine = setup({ | { type: "GO_BACK" } | { type: "SUBMIT_FORM"; data: MxnKycFormData } | { type: "SUBMIT_FILES"; files: MxnKycFiles } + | { type: "SUBMIT_AR_FILES"; files: ArKycFiles } | { type: "SUBMIT_KYB_FORM"; data: KybFormData } | { type: "SUBMIT_KYB_BUSINESS_FILES"; files: KybBusinessFiles } | { type: "SUBMIT_KYB_PERSON_FILES"; files: KybPersonFiles }, @@ -325,8 +342,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" }, { @@ -365,11 +382,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" }, { @@ -567,11 +585,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" }, { @@ -626,6 +645,24 @@ export const alfredpayKycMachine = setup({ } }, + SubmittingArFiles: { + invoke: { + id: "submitArFiles", + input: ({ context }) => context, + onDone: { + target: "SendingSubmission" + }, + onError: { + actions: assign({ + error: () => + new AlfredpayKycMachineError("Failed to upload ID documents", AlfredpayKycMachineErrorType.UnknownError) + }), + target: "Failure" + }, + src: "submitArFiles" + } + }, + SubmittingFiles: { invoke: { id: "submitFiles", @@ -738,6 +775,16 @@ export const alfredpayKycMachine = setup({ } }, + UploadingArDocuments: { + on: { + GO_BACK: { target: "FillingKycForm" }, + SUBMIT_AR_FILES: { + actions: assign({ arFiles: ({ event }) => event.files }), + target: "SubmittingArFiles" + } + } + }, + UploadingDocuments: { on: { GO_BACK: { target: "FillingKycForm" }, diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 2493d5b95..04648189a 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -5,6 +5,7 @@ import { KYCFormData } from "../hooks/brla/useKYCForm"; import { KycStatus } from "../services/signingService"; import { AlfredpayKycMachineError, + ArKycFiles, KybBusinessFiles, KybFormData, KybPersonFiles, @@ -24,6 +25,7 @@ export interface AlfredpayKycContext extends RampContext { business?: boolean; mxnFormData?: MxnKycFormData; mxnFiles?: MxnKycFiles; + arFiles?: ArKycFiles; kybFormData?: KybFormData; kybBusinessFiles?: KybBusinessFiles; kybRelatedPersonFiles?: KybPersonFiles[]; diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 809048ca7..a8d756d1c 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -33,6 +33,32 @@ "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": { + "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", @@ -390,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" }, diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index f1f8f10a7..1a18c13ca 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -33,6 +33,32 @@ "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": { + "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", @@ -393,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" }, diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index 63097d978..962fdf3d4 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -252,7 +252,12 @@ export class AlfredpayApiService { const kycSubmission: Record = { ...data, nationalities: [data.country] }; if (!data.typeDocument) delete kycSubmission.typeDocument; if (!data.typeDocumentCol) delete kycSubmission.typeDocumentCol; + if (!data.typeDocumentAr) delete kycSubmission.typeDocumentAr; 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; + if (data.nationalities) kycSubmission.nationalities = data.nationalities; return (await this.executeRequest(path, "POST", { kycSubmission })) as SubmitKycInformationResponse; } diff --git a/packages/shared/src/services/alfredpay/types.ts b/packages/shared/src/services/alfredpay/types.ts index 6f553fc0d..0ed4a65fb 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -397,7 +397,12 @@ export interface SubmitKycInformationRequest { dni: string; typeDocument?: string; // MXN 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 { @@ -406,7 +411,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 From 3c5fedce330d7da92b07b76a876406b5b30d85f8 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 12 May 2026 10:10:05 -0300 Subject: [PATCH 02/59] simplify ARS components, helpers --- .../components/Alfredpay/AlfredpayKycFlow.tsx | 15 +-- .../Alfredpay/ArDocumentUploadScreen.tsx | 94 ------------------- .../Alfredpay/MxnDocumentUploadScreen.tsx | 12 ++- .../src/machines/alfredpayKyc.machine.ts | 50 +--------- apps/frontend/src/machines/kyc.states.ts | 2 - apps/frontend/src/translations/en.json | 1 + apps/frontend/src/translations/pt.json | 1 + 7 files changed, 19 insertions(+), 156 deletions(-) delete mode 100644 apps/frontend/src/components/Alfredpay/ArDocumentUploadScreen.tsx diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index 45a0d1d1e..b0cec2117 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -1,6 +1,5 @@ import { useCallback } from "react"; import { useAlfredpayKycActor, useAlfredpayKycSelector } from "../../contexts/rampState"; -import { ArDocumentUploadScreen } from "./ArDocumentUploadScreen"; import { ArKycFormScreen } from "./ArKycFormScreen"; import { ColKycFormScreen } from "./ColKycFormScreen"; import { CustomerDefinitionScreen } from "./CustomerDefinitionScreen"; @@ -39,10 +38,6 @@ export const AlfredpayKycFlow = () => { (files: import("../../machines/alfredpayKyc.machine").MxnKycFiles) => actor?.send({ files, type: "SUBMIT_FILES" }), [actor] ); - const submitArFiles = useCallback( - (files: import("../../machines/alfredpayKyc.machine").ArKycFiles) => actor?.send({ files, type: "SUBMIT_AR_FILES" }), - [actor] - ); const submitKybForm = useCallback( (data: import("../../machines/alfredpayKyc.machine").KybFormData) => actor?.send({ data, type: "SUBMIT_KYB_FORM" }), [actor] @@ -74,7 +69,6 @@ export const AlfredpayKycFlow = () => { stateValue === "Retrying" || stateValue === "SubmittingKycInfo" || stateValue === "SubmittingFiles" || - stateValue === "SubmittingArFiles" || stateValue === "SendingSubmission" || stateValue === "SubmittingKybInfo" || stateValue === "SubmittingKybBusinessFiles" || @@ -96,12 +90,9 @@ export const AlfredpayKycFlow = () => { return ; } - if (stateValue === "UploadingDocuments" && (isMxn || isCo)) { - return ; - } - - if (stateValue === "UploadingArDocuments" && isAr) { - return ; + if (stateValue === "UploadingDocuments" && (isMxn || isCo || isAr)) { + const includeSelfie = isAr; + return ; } if (stateValue === "FillingKybForm") { diff --git a/apps/frontend/src/components/Alfredpay/ArDocumentUploadScreen.tsx b/apps/frontend/src/components/Alfredpay/ArDocumentUploadScreen.tsx deleted file mode 100644 index 76e45b0ee..000000000 --- a/apps/frontend/src/components/Alfredpay/ArDocumentUploadScreen.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import type { ArKycFiles } from "../../machines/alfredpayKyc.machine"; -import { MenuButtons } from "../MenuButtons"; - -const MAX_FILE_SIZE = 5 * 1024 * 1024; -const ACCEPTED_TYPES = ["image/jpeg", "image/png", "application/pdf"]; - -interface ArDocumentUploadScreenProps { - onSubmit: (files: ArKycFiles) => void; -} - -function FileDropZone({ label, file, onChange }: { label: string; file: File | null; onChange: (file: File) => void }) { - const { t } = useTranslation(); - const inputRef = useRef(null); - const [error, setError] = useState(null); - - const handleFile = (f: File) => { - setError(null); - if (!ACCEPTED_TYPES.includes(f.type)) { - setError(t("components.arDocumentUpload.invalidType")); - return; - } - if (f.size > MAX_FILE_SIZE) { - setError(t("components.arDocumentUpload.fileTooLarge")); - return; - } - onChange(f); - }; - - return ( -
-

{label}

- - {error &&

{error}

} -
- ); -} - -export function ArDocumentUploadScreen({ onSubmit }: ArDocumentUploadScreenProps) { - const { t } = useTranslation(); - const [front, setFront] = useState(null); - const [back, setBack] = useState(null); - const [selfie, setSelfie] = useState(null); - - const isValid = front !== null && back !== null && selfie !== null; - - const handleSubmit = () => { - if (!front || !back || !selfie) return; - onSubmit({ back, front, selfie }); - }; - - return ( -
- -

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

-

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

- -
- - - - -

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

- - -
-
- ); -} diff --git a/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx b/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx index a16646d10..4e16ad79b 100644 --- a/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx @@ -7,6 +7,7 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB const ACCEPTED_TYPES = ["image/jpeg", "image/png", "application/pdf"]; interface MxnDocumentUploadScreenProps { + includeSelfie?: boolean; onSubmit: (files: MxnKycFiles) => void; } @@ -59,16 +60,18 @@ function FileDropZone({ label, file, onChange }: { label: string; file: File | n ); } -export function MxnDocumentUploadScreen({ onSubmit }: MxnDocumentUploadScreenProps) { +export function MxnDocumentUploadScreen({ onSubmit, includeSelfie = false }: MxnDocumentUploadScreenProps) { const { t } = useTranslation(); const [front, setFront] = useState(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 ( @@ -80,6 +83,9 @@ export function MxnDocumentUploadScreen({ onSubmit }: MxnDocumentUploadScreenPro
+ {includeSelfie && ( + + )}

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

diff --git a/apps/frontend/src/machines/alfredpayKyc.machine.ts b/apps/frontend/src/machines/alfredpayKyc.machine.ts index eabf165d7..e0a294bca 100644 --- a/apps/frontend/src/machines/alfredpayKyc.machine.ts +++ b/apps/frontend/src/machines/alfredpayKyc.machine.ts @@ -17,12 +17,7 @@ export type KybFormData = Omit; export interface MxnKycFiles { front: File; back: File; -} - -export interface ArKycFiles { - front: File; - back: File; - selfie: File; + selfie?: File; } export interface KybBusinessFiles { @@ -150,15 +145,6 @@ export const alfredpayKycMachine = setup({ return AlfredpayService.sendKycSubmission(country, input.submissionId); }), - submitArFiles: fromPromise(async ({ input }: { input: AlfredpayKycContext & { arFiles?: ArKycFiles } }) => { - const country = input.country || "AR"; - if (!input.submissionId) throw new Error("Submission ID missing"); - if (!input.arFiles) throw new Error("KYC files missing"); - await AlfredpayService.submitKycFile(country, input.submissionId, AlfredpayKycFileType.FRONT, input.arFiles.front); - await AlfredpayService.submitKycFile(country, input.submissionId, AlfredpayKycFileType.BACK, input.arFiles.back); - await AlfredpayService.submitKycFile(country, input.submissionId, AlfredpayKycFileType.SELFIE, input.arFiles.selfie); - }), - submitFiles: fromPromise( async ({ input }: { input: AlfredpayKycContext & { mxnFormData?: MxnKycFormData; mxnFiles?: MxnKycFiles } }) => { const country = input.country || "MX"; @@ -166,6 +152,10 @@ export const alfredpayKycMachine = setup({ 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); + } } ), @@ -279,7 +269,6 @@ export const alfredpayKycMachine = setup({ context: {} as AlfredpayKycContext & { mxnFormData?: MxnKycFormData; mxnFiles?: MxnKycFiles; - arFiles?: ArKycFiles; kybFormData?: KybFormData; kybBusinessFiles?: KybBusinessFiles; kybRelatedPersonFiles?: KybPersonFiles[]; @@ -301,7 +290,6 @@ export const alfredpayKycMachine = setup({ | { type: "GO_BACK" } | { type: "SUBMIT_FORM"; data: MxnKycFormData } | { type: "SUBMIT_FILES"; files: MxnKycFiles } - | { type: "SUBMIT_AR_FILES"; files: ArKycFiles } | { type: "SUBMIT_KYB_FORM"; data: KybFormData } | { type: "SUBMIT_KYB_BUSINESS_FILES"; files: KybBusinessFiles } | { type: "SUBMIT_KYB_PERSON_FILES"; files: KybPersonFiles }, @@ -645,24 +633,6 @@ export const alfredpayKycMachine = setup({ } }, - SubmittingArFiles: { - invoke: { - id: "submitArFiles", - input: ({ context }) => context, - onDone: { - target: "SendingSubmission" - }, - onError: { - actions: assign({ - error: () => - new AlfredpayKycMachineError("Failed to upload ID documents", AlfredpayKycMachineErrorType.UnknownError) - }), - target: "Failure" - }, - src: "submitArFiles" - } - }, - SubmittingFiles: { invoke: { id: "submitFiles", @@ -775,16 +745,6 @@ export const alfredpayKycMachine = setup({ } }, - UploadingArDocuments: { - on: { - GO_BACK: { target: "FillingKycForm" }, - SUBMIT_AR_FILES: { - actions: assign({ arFiles: ({ event }) => event.files }), - target: "SubmittingArFiles" - } - } - }, - UploadingDocuments: { on: { GO_BACK: { target: "FillingKycForm" }, diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 04648189a..2493d5b95 100644 --- a/apps/frontend/src/machines/kyc.states.ts +++ b/apps/frontend/src/machines/kyc.states.ts @@ -5,7 +5,6 @@ import { KYCFormData } from "../hooks/brla/useKYCForm"; import { KycStatus } from "../services/signingService"; import { AlfredpayKycMachineError, - ArKycFiles, KybBusinessFiles, KybFormData, KybPersonFiles, @@ -25,7 +24,6 @@ export interface AlfredpayKycContext extends RampContext { business?: boolean; mxnFormData?: MxnKycFormData; mxnFiles?: MxnKycFiles; - arFiles?: ArKycFiles; kybFormData?: KybFormData; kybBusinessFiles?: KybBusinessFiles; kybRelatedPersonFiles?: KybPersonFiles[]; diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index a8d756d1c..15c84e709 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -568,6 +568,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", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 1a18c13ca..8b8233d72 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -571,6 +571,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", From 11862c6b2556aca1ccf86f3669ace064de84f8b4 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 12 May 2026 14:25:45 -0300 Subject: [PATCH 03/59] add logic for broken kyc flows, ARS and cbu type supprts --- .../api/controllers/alfredpay.controller.ts | 71 +++++++++++++++++-- .../src/api/services/quote/core/helpers.ts | 1 + .../services/quote/engines/finalize/onramp.ts | 3 +- .../initialize/offramp-from-evm-alfredpay.ts | 1 - .../services/quote/routes/route-resolver.ts | 3 +- .../onramp/routes/alfredpay-to-evm.ts | 3 +- .../TokenSelectionList/helpers.tsx | 6 +- .../src/machines/alfredpayKyc.machine.ts | 6 +- .../services/alfredpay/alfredpayApiService.ts | 4 +- .../shared/src/services/alfredpay/types.ts | 2 +- 10 files changed, 76 insertions(+), 24 deletions(-) diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 75ee1d8e7..c6de2dab6 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -130,8 +130,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, @@ -379,8 +391,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, @@ -455,7 +479,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) { @@ -481,7 +519,12 @@ export class AlfredpayController { if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); } - + console.log("Received request to submit KYC file with data:", { + country, + fileName: req.file.originalname, + fileType, + submissionId + }); const fileBlob = new File([new Uint8Array(req.file.buffer)], req.file.originalname, { type: req.file.mimetype }); const alfredpayService = AlfredpayApiService.getInstance(); await alfredpayService.submitKycFile( @@ -537,7 +580,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) { 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/engines/finalize/onramp.ts b/apps/api/src/api/services/quote/engines/finalize/onramp.ts index f5db04940..1ff0acfa6 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.ts @@ -49,7 +49,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 0e1fe432e..bda109097 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 @@ -30,7 +30,6 @@ export class OffRampFromEvmInitializeEngine extends BaseInitializeEngine { rampType: req.rampType, toNetwork: this.network }; - const bridgeQuote = await getEvmBridgeQuote(quoteRequest); ctx.evmToEvm = { 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 70831fd15..46b211f20 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -70,9 +70,10 @@ export class RouteResolver { case "wire": case "ach": case "spei": + case "cbu": return new OfframpEvmToAlfredpayStrategy(); case "sepa": - case "cbu": + default: return new OfframpToStellarStrategy(); } 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 be9572e55..39dc9518f 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 @@ -71,7 +71,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) { diff --git a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx index 8d3ec5700..9eabda5f6 100644 --- a/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx +++ b/apps/frontend/src/components/TokenSelection/TokenSelectionList/helpers.tsx @@ -157,10 +157,6 @@ function getFiatTokens(filterEurcOnly = false): ExtendedTokenDefinition[] { })); } -function isFilterEurcOnly(type: "from" | "to", direction: RampDirection) { - return direction === RampDirection.BUY && type === "from"; -} - export function useIsFiatDirection() { const { tokenSelectModalType } = useTokenSelectionState(); const rampDirection = useRampDirection(); @@ -174,7 +170,7 @@ function isFiatDirection(type: "from" | "to", direction: RampDirection) { function getAllSupportedTokenDefinitions(type: "from" | "to", direction: RampDirection): ExtendedTokenDefinition[] { if (isFiatDirection(type, direction)) { - return getFiatTokens(isFilterEurcOnly(type, direction)); + return getFiatTokens(); } else { return getAllOnChainTokens(); } diff --git a/apps/frontend/src/machines/alfredpayKyc.machine.ts b/apps/frontend/src/machines/alfredpayKyc.machine.ts index 17855a5ae..98105bfdb 100644 --- a/apps/frontend/src/machines/alfredpayKyc.machine.ts +++ b/apps/frontend/src/machines/alfredpayKyc.machine.ts @@ -728,11 +728,7 @@ export const alfredpayKycMachine = setup({ target: "SendingSubmission" }, onError: { - actions: assign({ - error: () => - new AlfredpayKycMachineError("Failed to upload ID documents", AlfredpayKycMachineErrorType.UnknownError) - }), - target: "Failure" + target: "UploadingDocuments" }, src: "submitFiles" } diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index 28e9b8956..cfdd3307c 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -253,7 +253,7 @@ export class AlfredpayApiService { const kycSubmission: Record = { ...data, nationalities: [data.country] }; if (!data.typeDocument) delete kycSubmission.typeDocument; if (!data.typeDocumentCol) delete kycSubmission.typeDocumentCol; - if (!data.typeDocumentAr) delete kycSubmission.typeDocumentAr; + 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; @@ -269,7 +269,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 dad112d6c..166087cd5 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -356,7 +356,7 @@ export interface AlfredpayFiatAccount extends AlfredpayFiatAccountFields { export type ListAlfredpayFiatAccountsResponse = AlfredpayFiatAccount[]; -const ALFREDPAY_FIAT_TOKEN_SET = new Set([FiatToken.USD, FiatToken.MXN, FiatToken.COP]); +const ALFREDPAY_FIAT_TOKEN_SET = new Set([FiatToken.USD, FiatToken.MXN, FiatToken.COP, FiatToken.ARS]); export const isAlfredpayToken = (token: FiatToken): boolean => ALFREDPAY_FIAT_TOKEN_SET.has(token); From 3f55ea77562c87dd4a97864017e2723d5b4e2694 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 20 May 2026 18:50:14 -0300 Subject: [PATCH 04/59] add ars as "free" token configuration, minor adjustments --- .../api/src/api/services/priceFeed.service.ts | 4 +- .../components/Alfredpay/ArKycFormScreen.tsx | 2 + .../services/alfredpay/alfredpayApiService.ts | 6 ++- .../shared/src/tokens/freeTokens/config.ts | 14 ++++++ packages/shared/src/tokens/stellar/config.ts | 43 ------------------- 5 files changed, 22 insertions(+), 47 deletions(-) diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index d3fdcabf1..631919f23 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -116,7 +116,7 @@ export class PriceFeedService { logger.debug(`Cache miss for ${cacheKey}. Fetching from CoinGecko API.`); try { - logger.debug(`Fetching price for ${tokenId} in ${vsCurrency} from CoinGecko`); + logger.info(`Fetching price for ${tokenId} in ${vsCurrency} from CoinGecko`); // Construct the API URL const url = new URL(`${this.coingeckoApiBaseUrl}/simple/price`); @@ -200,7 +200,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/frontend/src/components/Alfredpay/ArKycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx index 64141085f..39127ae76 100644 --- a/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx @@ -10,6 +10,7 @@ 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), @@ -45,6 +46,7 @@ export function ArKycFormScreen({ onSubmit }: ArKycFormScreenProps) { watch } = useForm({ defaultValues: { + countryCode: "AR", cuit: "", nationalities: ["AR"], pep: false, diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index cfdd3307c..4a6de4652 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -250,7 +250,8 @@ 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) @@ -258,7 +259,8 @@ export class AlfredpayApiService { if (!data.cuit) delete kycSubmission.cuit; if (data.pep !== false && !data.pep) delete kycSubmission.pep; if (!data.countryCode) delete kycSubmission.countryCode; - if (data.nationalities) kycSubmission.nationalities = data.nationalities; + console.log("Submitting KYC information with payload:", kycSubmission); + throw new Error("KYC submission is currently disabled for testing purposes."); return (await this.executeRequest(path, "POST", { kycSubmission })) as SubmitKycInformationResponse; } diff --git a/packages/shared/src/tokens/freeTokens/config.ts b/packages/shared/src/tokens/freeTokens/config.ts index 3f4bbec72..788e16b57 100644 --- a/packages/shared/src/tokens/freeTokens/config.ts +++ b/packages/shared/src/tokens/freeTokens/config.ts @@ -46,5 +46,19 @@ export const freeTokenConfig: Partial> = minBuyAmountRaw: "3500000", minSellAmountRaw: "100", type: TokenType.Fiat + }, + [FiatToken.ARS]: { + assetSymbol: "ARS", + decimals: 2, + fiat: { + assetIcon: "ars", + name: "Argentine Peso", + symbol: "ARS" + }, + maxBuyAmountRaw: "10000000000", + maxSellAmountRaw: "100000000000000000000", + minBuyAmountRaw: "110000", + minSellAmountRaw: "1100", + type: TokenType.Fiat } }; diff --git a/packages/shared/src/tokens/stellar/config.ts b/packages/shared/src/tokens/stellar/config.ts index c174e6726..d51b36c28 100644 --- a/packages/shared/src/tokens/stellar/config.ts +++ b/packages/shared/src/tokens/stellar/config.ts @@ -49,48 +49,5 @@ export const stellarTokenConfig: Partial> type: TokenType.Stellar, usesMemo: false, vaultAccountId: "6dgJM1ijyHFEfzUokJ1AHq3z3R3Z8ouc8B5SL9YjMRUaLsjh" - }, - [FiatToken.ARS]: { - anchorHomepageUrl: "https://home.anclap.com", - assetSymbol: "ARS", - decimals: 12, - fiat: { - assetIcon: "ars", - name: "Argentine Peso", - symbol: "ARS" - }, - maxBuyAmountRaw: "500000000000000000", - maxSellAmountRaw: "500000000000000000", - minBuyAmountRaw: "11000000000000", - minSellAmountRaw: "11000000000000", - pendulumRepresentative: { - assetSymbol: "ARS", - currency: FiatToken.ARS, - currencyId: { - Stellar: { - AlphaNum4: { - code: "0x41525300", - issuer: "0xb04f8bff207a0b001aec7b7659a8d106e54e659cdf9533528f468e079628fba1" - } - } - }, - decimals: 12, - erc20WrapperAddress: "6f7VMG1ERxpZMvFE2CbdWb7phxDgnoXrdornbV3CCd51nFsj" - }, - stellarAsset: { - code: { - hex: "0x41525300", - string: "ARS" - }, - issuer: { - hex: "0xb04f8bff207a0b001aec7b7659a8d106e54e659cdf9533528f468e079628fba1", - stellarEncoding: "GCYE7C77EB5AWAA25R5XMWNI2EDOKTTFTTPZKM2SR5DI4B4WFD52DARS" - } - }, // 11 ARS - supportsClientDomain: true, // 500000 ARS - tomlFileUrl: getTomlFileUrl("ARS"), // 2% - type: TokenType.Stellar, // 10 ARS - usesMemo: true, - vaultAccountId: "6bE2vjpLRkRNoVDqDtzokxE34QdSJC2fz7c87R9yCVFFDNWs" } }; From ddbfd68b3bbb582fe448c863fede0ad9ed91ab4a Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 27 May 2026 18:12:50 -0300 Subject: [PATCH 05/59] rollback unintended changes --- .github/workflows/autodeploy.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/autodeploy.yaml b/.github/workflows/autodeploy.yaml index c7db62125..61229a231 100644 --- a/.github/workflows/autodeploy.yaml +++ b/.github/workflows/autodeploy.yaml @@ -3,7 +3,7 @@ run-name: Gitlab Pipeline Executor on: push: branches: - - main + - staging jobs: Execute-Gitlab-Pipeline: runs-on: ubuntu-latest @@ -13,4 +13,4 @@ jobs: - name: Triggers Gitlab Pipeline run: | ls ${{ github.workspace }} - curl -X POST -F token=${{ secrets.GITLAB_TRIGGER }} -F "ref=development" -F "variables[PRODVTX]=Y" https://gitlab.com/api/v4/projects/${{ secrets.PROJECT_ID }}/trigger/pipeline + curl -X POST -F token=${{ secrets.GITLAB_TRIGGER }} -F "ref=development" -F "variables[PRODUCTION]=N" -F "variables[STAGING]=N" -F "variables[STAGVTX]=Y" -F "variables[PRODPOL]=N" -F "variables[STAGPOL]=N" https://gitlab.com/api/v4/projects/${{ secrets.PROJECT_ID }}/trigger/pipeline \ No newline at end of file From 75b35fd1ebb87fe411e45856febfce47da01fddb Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 27 May 2026 18:33:14 -0300 Subject: [PATCH 06/59] remove testing logs --- apps/api/src/api/controllers/alfredpay.controller.ts | 7 +------ apps/api/src/api/services/priceFeed.service.ts | 2 +- .../shared/src/services/alfredpay/alfredpayApiService.ts | 2 -- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 3569dd150..7f72d0555 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -539,12 +539,7 @@ export class AlfredpayController { if (!alfredPayCustomer) { return res.status(404).json({ error: "Alfredpay customer not found" }); } - console.log("Received request to submit KYC file with data:", { - country, - fileName: req.file.originalname, - fileType, - submissionId - }); + const fileBlob = new File([new Uint8Array(req.file.buffer)], req.file.originalname, { type: req.file.mimetype }); const alfredpayService = AlfredpayApiService.getInstance(); await alfredpayService.submitKycFile( diff --git a/apps/api/src/api/services/priceFeed.service.ts b/apps/api/src/api/services/priceFeed.service.ts index 631919f23..dccbb80d3 100644 --- a/apps/api/src/api/services/priceFeed.service.ts +++ b/apps/api/src/api/services/priceFeed.service.ts @@ -116,7 +116,7 @@ export class PriceFeedService { logger.debug(`Cache miss for ${cacheKey}. Fetching from CoinGecko API.`); try { - logger.info(`Fetching price for ${tokenId} in ${vsCurrency} from CoinGecko`); + logger.debug(`Fetching price for ${tokenId} in ${vsCurrency} from CoinGecko`); // Construct the API URL const url = new URL(`${this.coingeckoApiBaseUrl}/simple/price`); diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index fa0c939e4..28ec79c8d 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -269,8 +269,6 @@ export class AlfredpayApiService { if (!data.cuit) delete kycSubmission.cuit; if (data.pep !== false && !data.pep) delete kycSubmission.pep; if (!data.countryCode) delete kycSubmission.countryCode; - console.log("Submitting KYC information with payload:", kycSubmission); - throw new Error("KYC submission is currently disabled for testing purposes."); return (await this.executeRequest(path, "POST", { kycSubmission })) as SubmitKycInformationResponse; } From af8aa7f0be902e89b89a46dcf229a6580aa59532 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 28 May 2026 11:11:42 -0300 Subject: [PATCH 07/59] fix decimal roundown issue for alfredpay offramp operation --- .../api/services/quote/engines/partners/offramp-alfredpay.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts index 7bd96c5a5..c28de518d 100644 --- a/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/partners/offramp-alfredpay.ts @@ -42,8 +42,8 @@ export class OfframpTransactionAlfredpayEngine extends BaseInitializeEngine { const deductibleFee = ctx.preNabla?.deductibleFeeAmountInSwapCurrency ?? new Big(0); const inputAmountDecimal = effectiveRate.gt(0) - ? ctx.subsidy.targetOutputAmountDecimal.div(effectiveRate) - : ctx.evmToEvm.outputAmountDecimal.minus(deductibleFee); + ? ctx.subsidy.targetOutputAmountDecimal.div(effectiveRate).round(ALFREDPAY_ERC20_DECIMALS, Big.roundDown) + : ctx.evmToEvm.outputAmountDecimal.minus(deductibleFee).round(ALFREDPAY_ERC20_DECIMALS, Big.roundDown); const alfredpayService = AlfredpayApiService.getInstance(); const quoteRequest: CreateAlfredpayOfframpQuoteRequest = { From 788314cc28d943793eac465520b953a1ea1c76f0 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 28 May 2026 12:49:09 -0300 Subject: [PATCH 08/59] add ARS limits and required config objects --- .../offramp/routes/evm-to-alfredpay.ts | 3 ++- .../shared/src/tokens/freeTokens/config.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) 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 e76db345e..e2696478f 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 @@ -197,7 +197,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) { diff --git a/packages/shared/src/tokens/freeTokens/config.ts b/packages/shared/src/tokens/freeTokens/config.ts index afe2cd09c..43f53465a 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: "1000000" }, + INDIVIDUAL: { maxRaw: "300000000000", minRaw: "1000000" } + }, + USDT: { + BUSINESS: { maxRaw: "300000000000", minRaw: "1000000" }, + INDIVIDUAL: { maxRaw: "100000000000", minRaw: "1000000" } + } + }, + onramp: { + USDC: { + BUSINESS: { maxRaw: "110596799945", minRaw: "110000" }, + INDIVIDUAL: { maxRaw: "110596799945", minRaw: "110000" } + }, + USDT: { + BUSINESS: { maxRaw: "110596799945", minRaw: "110000" }, + INDIVIDUAL: { maxRaw: "110596799945", minRaw: "110000" } + } + } +}; + export const freeTokenConfig: Partial> = { [FiatToken.USD]: { alfredpayLimits: USD_LIMITS, @@ -130,6 +153,7 @@ export const freeTokenConfig: Partial> = type: TokenType.Fiat }, [FiatToken.ARS]: { + alfredpayLimits: ARS_LIMITS, assetSymbol: "ARS", decimals: 2, fiat: { From b109ae0c4da36c9f2951ea2f05e763c42852a8ab Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 28 May 2026 15:25:45 -0300 Subject: [PATCH 09/59] re-use alfredpay kyc form component --- .../components/Alfredpay/AlfredpayKycFlow.tsx | 2 +- .../components/Alfredpay/ArKycFormScreen.tsx | 345 ++++++------------ .../components/Alfredpay/ColKycFormScreen.tsx | 317 +++++++--------- .../components/Alfredpay/KycFormScreen.tsx | 165 +++++++++ .../components/Alfredpay/MxnKycFormScreen.tsx | 219 +++-------- .../src/machines/alfredpayKyc.machine.ts | 14 +- apps/frontend/src/machines/kyc.states.ts | 6 +- apps/frontend/src/translations/en.json | 2 + apps/frontend/src/translations/pt.json | 2 + 9 files changed, 482 insertions(+), 590 deletions(-) create mode 100644 apps/frontend/src/components/Alfredpay/KycFormScreen.tsx diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index 54e880b4e..c0c255c31 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -31,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( diff --git a/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx index 39127ae76..39be75aee 100644 --- a/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx @@ -1,10 +1,9 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { AlfredpayArgentinaDocumentType } from "@vortexfi/shared"; -import { useForm } from "react-hook-form"; +import type { UseFormReturn } 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, kycInputClass } from "./KycFormScreen"; const schema = z .object({ @@ -31,234 +30,128 @@ const schema = z }); type ArKycFormValues = z.infer; +type ArForm = UseFormReturn; -interface ArKycFormScreenProps { - onSubmit: (data: MxnKycFormData) => void; +function DocumentTypeField({ form }: { form: ArForm }) { + const { t } = useTranslation(); + const error = form.formState.errors.typeDocumentAr; + return ( + + ); } -export function ArKycFormScreen({ onSubmit }: ArKycFormScreenProps) { +function DniField({ form }: { form: ArForm }) { const { t } = useTranslation(); - - const { - formState: { errors }, - handleSubmit, - register, - watch - } = useForm({ - defaultValues: { - countryCode: "AR", - cuit: "", - nationalities: ["AR"], - pep: false, - typeDocumentAr: AlfredpayArgentinaDocumentType.DNI - }, - resolver: zodResolver(schema) - }); - - const documentType = watch("typeDocumentAr"); - - 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("typeDocumentAr"); + const error = form.formState.errors.dni; return ( -
- -

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

-

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

- -
-
-
- - - {errors.firstName && {errors.firstName.message}} -
- -
- - - {errors.lastName && {errors.lastName.message}} -
-
- -
- - - {errors.dateOfBirth && {errors.dateOfBirth.message}} -
- -
- - - {errors.email && {errors.email.message}} -
- -
- - - {errors.phoneNumber && {errors.phoneNumber.message}} -
- -
- - - {errors.typeDocumentAr && {errors.typeDocumentAr.message}} -
- -
- - - {errors.dni && {errors.dni.message}} -
- -
- - - {errors.cuit && {errors.cuit.message}} -
- -
- - - {errors.address && {errors.address.message}} -
- -
-
- - - {errors.city && {errors.city.message}} -
- -
- - - {errors.state && {errors.state.message}} -
-
+ + ); +} -
- - - {errors.zipCode && {errors.zipCode.message}} -
+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" + }, + { + autoComplete: "tel", + inputMode: "tel", + inputType: "tel", + labelKey: "components.arKycForm.phoneNumber", + name: "phoneNumber", + placeholder: "+54 9 11 1234 5678", + type: "text" + }, + { + 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 +}; -
- - -
- {errors.pep && {errors.pep.message}} +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/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/machines/alfredpayKyc.machine.ts b/apps/frontend/src/machines/alfredpayKyc.machine.ts index 98105bfdb..1f0f386f7 100644 --- a/apps/frontend/src/machines/alfredpayKyc.machine.ts +++ b/apps/frontend/src/machines/alfredpayKyc.machine.ts @@ -13,7 +13,11 @@ 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 { @@ -179,7 +183,7 @@ 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"); @@ -270,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); @@ -317,7 +321,7 @@ export const alfredpayKycMachine = setup({ }, types: { context: {} as AlfredpayKycContext & { - mxnFormData?: MxnKycFormData; + mxnFormData?: AlfredpayKycFormData; mxnFiles?: MxnKycFiles; kybFormData?: KybFormData; kybBusinessFiles?: KybBusinessFiles; @@ -338,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 } diff --git a/apps/frontend/src/machines/kyc.states.ts b/apps/frontend/src/machines/kyc.states.ts index 1b1913f32..7e6874e35 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 { MoneriumKycMachineError, MoneriumKycMachineErrorType } from "./moneriumKyc.machine"; @@ -22,7 +22,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/translations/en.json b/apps/frontend/src/translations/en.json index 5520227d1..f9e50d394 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -46,6 +46,7 @@ "title": "Upload ID Documents" }, "arKycForm": { + "continue": "Continue", "cuit": "CUIT", "cuitPlaceholder": "CUIT", "dni": "DNI Number", @@ -258,6 +259,7 @@ } }, "colKycForm": { + "continue": "Continue", "dni": "Document Number", "dniPlaceholderCc": "10-digit CC number", "dniPlaceholderCe": "6–10 digit CE number", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index efaf2e54f..268a09f81 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -46,6 +46,7 @@ "title": "Enviar Documentos de Identidade" }, "arKycForm": { + "continue": "Continuar", "cuit": "CUIT", "cuitPlaceholder": "CUIT", "dni": "Número do DNI", @@ -261,6 +262,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", From a42b5d43b8ac7bf226d45925c5e13448596d4485 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 29 May 2026 10:54:41 -0300 Subject: [PATCH 10/59] Display deposit details --- .../SummaryStep/ARSOnrampDetails.tsx | 79 +++++++++++++++++++ .../SummaryStep/TransactionTokensDisplay.tsx | 2 + apps/frontend/src/translations/en.json | 9 +++ apps/frontend/src/translations/pt.json | 9 +++ 4 files changed, 99 insertions(+) create mode 100644 apps/frontend/src/components/widget-steps/SummaryStep/ARSOnrampDetails.tsx 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/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx index 261baa29d..bcfb4f834 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx @@ -24,6 +24,7 @@ import { useCountdown } from "../../../hooks/useCountdown"; import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { RampExecutionInput } from "../../../types/phases"; +import { ARSOnrampDetails } from "./ARSOnrampDetails"; import { AssetDisplay } from "./AssetDisplay"; import { BRLOnrampDetails } from "./BRLOnrampDetails"; import { COPOnrampDetails } from "./COPOnrampDetails"; @@ -133,6 +134,7 @@ export const TransactionTokensDisplay: FC = ({ ex partnerUrl={getPartnerUrl()} toToken={toToken} /> + {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.ARS && } {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.BRL && } {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.EURC && } {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.USD && } diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index f9e50d394..b91f48998 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -640,6 +640,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", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 268a09f81..624e0ed39 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -644,6 +644,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", From 690ce206d2de331be43fd5afb91a62edc7e589d7 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 29 May 2026 14:34:25 -0300 Subject: [PATCH 11/59] enforce maximum time window check only for stellar ramps --- apps/api/src/api/services/ramp/ramp.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index a6f9418ad..054d5bc7a 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -70,6 +70,10 @@ import { RampTransactionPreparationKind, selectRampTransactionPreparationKind } const RAMP_START_EXPIRATION_TIME_SECONDS = SEQUENCE_TIME_WINDOW_IN_SECONDS * 0.8; +function isStellarRamp(state: StateMetadata): boolean { + return !!state.stellarTarget; +} + // Classifies unsigned txs by signer: ephemeral-signed (backend pre-signs) vs user-wallet-signed. function partitionUnsignedTxs( unsignedTxs: UnsignedTx[], @@ -462,7 +466,7 @@ export class RampService extends BaseRampService { 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) { + if (isStellarRamp(rampState.state) && timeDifferenceSeconds > RAMP_START_EXPIRATION_TIME_SECONDS) { this.cancelRamp(rampState.id); throw new APIError({ message: "Maximum time window to start process exceeded. Ramp invalidated.", From 754a5644504288ba90fc03edfe4a52a312d5cd32 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 29 May 2026 15:10:34 -0300 Subject: [PATCH 12/59] fix ars limits, kyc error propagation in UI --- .../components/Alfredpay/AlfredpayKycFlow.tsx | 2 +- .../Alfredpay/MxnDocumentUploadScreen.tsx | 5 +++- .../src/machines/alfredpayKyc.machine.ts | 9 +++++++ .../shared/src/tokens/freeTokens/config.ts | 24 +++++++++---------- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index c0c255c31..c84e2779f 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -94,7 +94,7 @@ export const AlfredpayKycFlow = () => { if (stateValue === "UploadingDocuments" && (isMxn || isCo || isAr)) { const includeSelfie = isAr; - return ; + return ; } if (stateValue === "FillingKybForm") { diff --git a/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx b/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx index 4e16ad79b..907ec2884 100644 --- a/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx @@ -7,6 +7,7 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB const ACCEPTED_TYPES = ["image/jpeg", "image/png", "application/pdf"]; interface MxnDocumentUploadScreenProps { + error?: string; includeSelfie?: boolean; onSubmit: (files: MxnKycFiles) => void; } @@ -60,7 +61,7 @@ function FileDropZone({ label, file, onChange }: { label: string; file: File | n ); } -export function MxnDocumentUploadScreen({ onSubmit, includeSelfie = false }: MxnDocumentUploadScreenProps) { +export function MxnDocumentUploadScreen({ error, onSubmit, includeSelfie = false }: MxnDocumentUploadScreenProps) { const { t } = useTranslation(); const [front, setFront] = useState(null); const [back, setBack] = useState(null); @@ -89,6 +90,8 @@ export function MxnDocumentUploadScreen({ onSubmit, includeSelfie = false }: Mxn

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

+ {error &&

{error}

} + diff --git a/apps/frontend/src/machines/alfredpayKyc.machine.ts b/apps/frontend/src/machines/alfredpayKyc.machine.ts index 1f0f386f7..c9c8cf21a 100644 --- a/apps/frontend/src/machines/alfredpayKyc.machine.ts +++ b/apps/frontend/src/machines/alfredpayKyc.machine.ts @@ -732,6 +732,15 @@ export const alfredpayKycMachine = setup({ target: "SendingSubmission" }, onError: { + actions: assign({ + 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: "UploadingDocuments" }, src: "submitFiles" diff --git a/packages/shared/src/tokens/freeTokens/config.ts b/packages/shared/src/tokens/freeTokens/config.ts index 43f53465a..81373aad1 100644 --- a/packages/shared/src/tokens/freeTokens/config.ts +++ b/packages/shared/src/tokens/freeTokens/config.ts @@ -84,22 +84,22 @@ const COP_LIMITS: AlfredpayLimitsTable = { const ARS_LIMITS: AlfredpayLimitsTable = { offramp: { USDC: { - BUSINESS: { maxRaw: "300000000000", minRaw: "1000000" }, - INDIVIDUAL: { maxRaw: "300000000000", minRaw: "1000000" } + BUSINESS: { maxRaw: "300000000000", minRaw: "650000" }, + INDIVIDUAL: { maxRaw: "5000000000000", minRaw: "650000" } }, USDT: { - BUSINESS: { maxRaw: "300000000000", minRaw: "1000000" }, - INDIVIDUAL: { maxRaw: "100000000000", minRaw: "1000000" } + BUSINESS: { maxRaw: "300000000000", minRaw: "650000" }, + INDIVIDUAL: { maxRaw: "300000000000", minRaw: "650000" } } }, onramp: { USDC: { - BUSINESS: { maxRaw: "110596799945", minRaw: "110000" }, - INDIVIDUAL: { maxRaw: "110596799945", minRaw: "110000" } + BUSINESS: { maxRaw: "41200000000", minRaw: "100000" }, + INDIVIDUAL: { maxRaw: "41200000000", minRaw: "100000" } }, USDT: { - BUSINESS: { maxRaw: "110596799945", minRaw: "110000" }, - INDIVIDUAL: { maxRaw: "110596799945", minRaw: "110000" } + BUSINESS: { maxRaw: "41200000000", minRaw: "100000" }, + INDIVIDUAL: { maxRaw: "13700000000", minRaw: "100000" } } } }; @@ -161,10 +161,10 @@ export const freeTokenConfig: Partial> = name: "Argentine Peso", symbol: "ARS" }, - maxBuyAmountRaw: "10000000000", - maxSellAmountRaw: "100000000000000000000", - minBuyAmountRaw: "110000", - minSellAmountRaw: "1100", + maxBuyAmountRaw: "13700000000", + maxSellAmountRaw: "300000000000", + minBuyAmountRaw: "100000", + minSellAmountRaw: "650000", type: TokenType.Fiat } }; From 7e9a4198844d1494c6f8ff040033b88e861d25da Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 29 May 2026 15:24:24 -0300 Subject: [PATCH 13/59] fix i18 namespaces --- .../components/Alfredpay/AlfredpayKycFlow.tsx | 10 ++++- .../Alfredpay/MxnDocumentUploadScreen.tsx | 45 ++++++++++++++----- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index c84e2779f..78994a165 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -94,7 +94,15 @@ export const AlfredpayKycFlow = () => { if (stateValue === "UploadingDocuments" && (isMxn || isCo || isAr)) { const includeSelfie = isAr; - return ; + const i18nNamespace = isAr ? "components.arDocumentUpload" : undefined; + return ( + + ); } if (stateValue === "FillingKybForm") { diff --git a/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx b/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx index 907ec2884..3f109688b 100644 --- a/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/MxnDocumentUploadScreen.tsx @@ -8,11 +8,22 @@ 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); @@ -20,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); @@ -43,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); @@ -78,22 +94,27 @@ export function MxnDocumentUploadScreen({ error, onSubmit, includeSelfie = false return (
-

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

-

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

+

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

+

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

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

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

+

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

{error &&

{error}

}
From f5da1a8610701720a8c8f23bcdf25947e4b560e5 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 1 Jun 2026 10:27:37 -0300 Subject: [PATCH 14/59] improve bun config --- apps/api/package.json | 2 +- packages/shared/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index b4037afe5..fc1fa6c15 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' 'bun --watch src/index.ts'", + "dev": "concurrently -n 'shared,backend' -c '#ffa500,007755' 'cd ../../packages/shared && bun run dev' 'bun 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/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" }, From b6d52d5221d6fa216e44939c46e7f36fe5e237df Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:33:34 -0300 Subject: [PATCH 15/59] Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../widget-steps/SummaryStep/TransactionTokensDisplay.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx index cd3ad82ec..2927553ff 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx @@ -23,7 +23,6 @@ import { useCountdown } from "../../../hooks/useCountdown"; import { useTokenIcon } from "../../../hooks/useTokenIcon"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { RampExecutionInput } from "../../../types/phases"; -import { ARSOnrampDetails } from "./ARSOnrampDetails"; import { AssetDisplay } from "./AssetDisplay"; import { BRLOnrampDetails } from "./BRLOnrampDetails"; import { COPOnrampDetails } from "./COPOnrampDetails"; From 51e6d25329a650c341764b0685a801a79e327e1d Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 1 Jun 2026 10:44:21 -0300 Subject: [PATCH 16/59] fix typecheck error --- apps/api/src/api/services/ramp/ramp.service.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 887b4336c..21ec7997c 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -67,10 +67,6 @@ import { RampTransactionPreparationKind, selectRampTransactionPreparationKind } const RAMP_START_EXPIRATION_TIME_SECONDS = 480; -function isStellarRamp(state: StateMetadata): boolean { - return !!state.stellarTarget; -} - // Classifies unsigned txs by signer: ephemeral-signed (backend pre-signs) vs user-wallet-signed. function partitionUnsignedTxs( unsignedTxs: UnsignedTx[], @@ -450,19 +446,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 (isStellarRamp(rampState.state) && 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( From 13546a8e99bbc82ecddee34e8c27eba770529f00 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Sun, 7 Jun 2026 22:11:01 +0100 Subject: [PATCH 17/59] change default buy/sell onchain token to usdc --- apps/frontend/src/config/networkAvailability.ts | 2 +- apps/frontend/src/contexts/network.tsx | 2 +- apps/frontend/src/stores/quote/useQuoteFormStore.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/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/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; From 04d63934accf38e60a2a7d491579e14629607972 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 8 Jun 2026 10:23:39 -0300 Subject: [PATCH 18/59] adjust destinationTransfer params --- .../transactions/onramp/routes/alfredpay-to-evm.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 b4560bb9a..62546b78d 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"; @@ -109,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).toString(), destinationNetwork: toNetwork as EvmNetworks, toAddress: destinationAddress, toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain @@ -173,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).toString(), destinationNetwork: Networks.Polygon, toAddress: destinationAddress, toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain @@ -226,7 +227,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ }); const finalTransferTxData = await addOnrampDestinationChainTransactions({ - amountRaw: quote.metadata.alfredpayMint.outputAmountRaw, + amountRaw: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toString(), destinationNetwork: toNetwork as EvmNetworks, toAddress: destinationAddress, toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain @@ -256,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).toString(), toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain }); From 6b4f0c2c3156919737ec6ab75103704ad9c026d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:54:16 +0000 Subject: [PATCH 19/59] fix(api): avoid scientific notation in Alfredpay direct transfer amountRaw --- .../api/services/transactions/onramp/routes/alfredpay-to-evm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 62546b78d..369a80af6 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 @@ -110,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: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toString(), + amountRaw: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), destinationNetwork: toNetwork as EvmNetworks, toAddress: destinationAddress, toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain From 2024f7721836da79ea152cec9297d5a86a2443cc Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:54:36 -0300 Subject: [PATCH 20/59] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/api/src/api/services/quote/routes/route-resolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d0cd7b7fb..cb7195f54 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -76,7 +76,7 @@ export class RouteResolver { case "sepa": return offrampToSepaEvmStrategy; 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 }); } } } From eb54284ff097e44414d3de740073c88e6f01801c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:55:41 +0000 Subject: [PATCH 21/59] fix(api): use fixed-point raw amount string in onramp transfer --- .../api/services/transactions/onramp/routes/alfredpay-to-evm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 369a80af6..cc0430cb9 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 @@ -227,7 +227,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ }); const finalTransferTxData = await addOnrampDestinationChainTransactions({ - amountRaw: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toString(), + amountRaw: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), destinationNetwork: toNetwork as EvmNetworks, toAddress: destinationAddress, toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain From 0bfe4e73e65df9d15e01a88fc777c4483cbe8866 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:56:32 +0000 Subject: [PATCH 22/59] fix(api): avoid scientific notation in polygon same-chain amountRaw --- .../api/services/transactions/onramp/routes/alfredpay-to-evm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 cc0430cb9..56b87ffd3 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 @@ -174,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: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toString(), + amountRaw: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), destinationNetwork: Networks.Polygon, toAddress: destinationAddress, toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain From 36d8117041230d307c4f78bdd58746b49a40e2c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:57:29 +0000 Subject: [PATCH 23/59] fix(api): avoid scientific notation for fallback raw amount --- .../api/services/transactions/onramp/routes/alfredpay-to-evm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 56b87ffd3..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 @@ -257,7 +257,7 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ fromAddress: evmEphemeralEntry.address, fromToken: bridgedTokenForFallback, network: toNetwork as EvmNetworks, - rawAmount: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toString(), + rawAmount: multiplyByPowerOfTen(quote.outputAmount, outputTokenDetails.decimals).toFixed(0, 0), toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain }); From fab87bb6cf5c84ea082a018ab333ae6aa065d345 Mon Sep 17 00:00:00 2001 From: naka-bot Date: Tue, 9 Jun 2026 13:06:45 +0200 Subject: [PATCH 24/59] Update contact sales card copy --- apps/frontend/src/components/ContactForm/ContactInfo.tsx | 2 +- apps/frontend/src/translations/en.json | 7 ++++--- apps/frontend/src/translations/pt.json | 7 ++++--- 3 files changed, 9 insertions(+), 7 deletions(-) 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/translations/en.json b/apps/frontend/src/translations/en.json index dc3e4fe66..fca873170 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -898,12 +898,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", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index ff06500bb..419dbfb6d 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -902,12 +902,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", From 4c4ad2d8b6305a4cc9a329d9fc56ebc238d9aef4 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 9 Jun 2026 08:12:08 -0300 Subject: [PATCH 25/59] re-add expiration check for ramp --- apps/api/src/api/services/ramp/ramp.service.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 9073f5045..256e91fc4 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -67,7 +67,7 @@ import { validateEphemeralAccountsFresh } from "./ephemeral-freshness"; import { getFinalTransactionHashForRamp } 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) { + 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({ From ed04c575ca7e508956054c1d732e1e80a1e1c279 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 9 Jun 2026 09:45:12 -0300 Subject: [PATCH 26/59] remove concurrency from bun run dev script --- apps/api/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index fc1fa6c15..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' 'bun 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/package.json b/package.json index 26da763f9..68128a6ec 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,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,dashboard' -c '#ffa500,#007755,#2f6da3,#24c989' 'cd packages/shared && bun run dev' 'cd apps/api && bun dev' 'cd apps/frontend && bun dev' 'cd apps/dashboard && bun dev'", + "dev": "bun run --cwd packages/shared dev & bun run --cwd apps/api dev & bun run --cwd apps/frontend dev & bun run --cwd apps/dashboard dev & wait", "dev:backend": "bun run --cwd apps/api dev", "dev:contracts:relayer": "bun run --cwd contracts/relayer node", "dev:dashboard": "bun run --cwd apps/dashboard dev", From 2f4b002c5975113fe6ac87c1182261a68b73b8bb Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:19:01 +0200 Subject: [PATCH 27/59] Add API client event metadata helpers --- .../apiClientEvent.service.test.ts | 69 ++++++++++- .../observability/apiClientEvent.service.ts | 109 +++++++++++++++++- 2 files changed, 174 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/observability/apiClientEvent.service.test.ts b/apps/api/src/api/observability/apiClientEvent.service.test.ts index b1c4f029b..3b7ec1af7 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,47 @@ 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({ + requestBodyInputAmount: "100", + requestBodyNetworksCount: 2, + requestMethod: "POST", + requestParamId: "ramp-1", + requestPath: "/v1/quotes/best", + requestQueryShowUnsignedTxs: "true" + }); + }); +}); + +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..de4f82cbc 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.ts @@ -4,24 +4,56 @@ 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", + "additionaldata", "apiKey", + "apikey", "authorization", "depositQrCode", + "depositqrcode", "ephemeralAccounts", + "ephemeralaccounts", "ibanPaymentData", + "ibanpaymentdata", "pixDestination", + "pixdestination", "presignedTxs", + "presignedtxs", "rawBody", + "rawbody", "receiverTaxId", + "receivertaxid", "secretKey", + "secretkey", "signingAccounts", + "signingaccounts", "taxId", + "taxid", + "token", "walletAddress", + "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 +77,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 +86,92 @@ 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: req.path || null + }; + + 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 (SENSITIVE_METADATA_KEYS.has(key) || SENSITIVE_METADATA_KEYS.has(key.toLowerCase()) || 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) { + if (SENSITIVE_METADATA_KEYS.has(key) || SENSITIVE_METADATA_KEYS.has(key.toLowerCase())) continue; + + const value = values[key]; + if (value === undefined) 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 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 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 { From e84013645b229a4794b5b5966f600848b5e91b2f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:19:44 +0200 Subject: [PATCH 28/59] Record request context on quote and ramp failures --- .../src/api/controllers/quote.controller.ts | 45 ++++++++++++++----- .../src/api/controllers/ramp.controller.ts | 28 +++++++++++- apps/api/src/api/middlewares/validators.ts | 25 +++++++++-- 3 files changed, 84 insertions(+), 14 deletions(-) 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/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 7b387b9e5..4f6fd0f27 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -23,14 +23,14 @@ import { 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 { buildApiClientRequestMetadata, observeApiClientEvent } from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; interface CreationBody { @@ -473,13 +473,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", @@ -487,6 +488,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, From a8793e1883f8ca3a63690c81d58bc7857fb86799 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:20:34 +0200 Subject: [PATCH 29/59] Improve auth client event attribution --- apps/api/src/api/middlewares/apiKeyAuth.ts | 23 +++++++++---------- apps/api/src/api/middlewares/dualAuth.ts | 16 ++++++------- apps/api/src/api/middlewares/ownershipAuth.ts | 8 ++++++- apps/api/src/api/middlewares/publicKeyAuth.ts | 19 ++++++++------- 4 files changed, 35 insertions(+), 31 deletions(-) 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/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); -} From 3f9a2c0cc7b90fb6704f45a547da0f54d05291a3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:21:14 +0200 Subject: [PATCH 30/59] Document client event metadata safeguards --- .../07-operations/client-observability.md | 12 ++++++------ .../security-spec/07-operations/secret-management.md | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/security-spec/07-operations/client-observability.md b/docs/security-spec/07-operations/client-observability.md index 939826179..5ab2cd45a 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, path, selected quote/ramp IDs, selected quote inputs, query flags, array counts, and object-presence flags). Raw request bodies, raw headers, 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. From 412ee0b0fdf9712c28b23912c6e96849d97c8767 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 18:43:59 +0200 Subject: [PATCH 31/59] Address pr review comments --- .../apiClientEvent.service.test.ts | 41 +++++++++++++++++++ .../observability/apiClientEvent.service.ts | 39 ++++++++++++++++-- .../07-operations/client-observability.md | 2 +- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/observability/apiClientEvent.service.test.ts b/apps/api/src/api/observability/apiClientEvent.service.test.ts index 3b7ec1af7..1d45d779d 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.test.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.test.ts @@ -94,6 +94,7 @@ describe("buildApiClientRequestMetadata", () => { ); expect(metadata).toEqual({ + hasRequestBodyAdditionalData: true, requestBodyInputAmount: "100", requestBodyNetworksCount: 2, requestMethod: "POST", @@ -102,6 +103,46 @@ describe("buildApiClientRequestMetadata", () => { 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", () => { diff --git a/apps/api/src/api/observability/apiClientEvent.service.ts b/apps/api/src/api/observability/apiClientEvent.service.ts index de4f82cbc..ef5f47313 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.ts @@ -101,7 +101,7 @@ export function buildApiClientRequestMetadata( ): Record { const metadata: Record = { requestMethod: req.method || null, - requestPath: req.path || null + requestPath: buildTemplatedRequestPath(req.path, req.params) }; addSelectedValues(metadata, "requestBody", req.body, options.bodyKeys); @@ -134,11 +134,12 @@ function addSelectedValues( if (!isPlainObject(values) || !keys || keys.length === 0) return; for (const key of keys) { - if (SENSITIVE_METADATA_KEYS.has(key) || SENSITIVE_METADATA_KEYS.has(key.toLowerCase())) continue; - const value = values[key]; if (value === undefined) continue; + const isSensitiveKey = SENSITIVE_METADATA_KEYS.has(key) || SENSITIVE_METADATA_KEYS.has(key.toLowerCase()); + if (isSensitiveKey && !Array.isArray(value) && !isPlainObject(value)) continue; + const metadataKey = `${prefix}${toPascalCase(key)}`; if (Array.isArray(value)) { metadata[`${metadataKey}Count`] = value.length; @@ -153,6 +154,26 @@ function addSelectedValues( } } +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; @@ -165,6 +186,18 @@ 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); } diff --git a/docs/security-spec/07-operations/client-observability.md b/docs/security-spec/07-operations/client-observability.md index 5ab2cd45a..d58c3ff21 100644 --- a/docs/security-spec/07-operations/client-observability.md +++ b/docs/security-spec/07-operations/client-observability.md @@ -18,7 +18,7 @@ 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. Failure events may include allowlisted request-derived scalar summaries in `metadata` (for example method, path, selected quote/ramp IDs, selected quote inputs, query flags, array counts, and object-presence flags). 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. From ba09f101b44e5e5be21e3f6da6462f10f12a6278 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 9 Jun 2026 20:10:13 +0200 Subject: [PATCH 32/59] Address pr review comments --- .../observability/apiClientEvent.service.ts | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/apps/api/src/api/observability/apiClientEvent.service.ts b/apps/api/src/api/observability/apiClientEvent.service.ts index ef5f47313..32be8c975 100644 --- a/apps/api/src/api/observability/apiClientEvent.service.ts +++ b/apps/api/src/api/observability/apiClientEvent.service.ts @@ -7,33 +7,20 @@ import { ApiClientErrorType, ApiClientEventInput } from "./types"; const API_KEY_PREFIX_LENGTH = 16; const SENSITIVE_METADATA_KEYS = new Set([ - "additionalData", "additionaldata", - "apiKey", "apikey", "authorization", - "depositQrCode", "depositqrcode", - "ephemeralAccounts", "ephemeralaccounts", - "ibanPaymentData", "ibanpaymentdata", - "pixDestination", "pixdestination", - "presignedTxs", "presignedtxs", - "rawBody", "rawbody", - "receiverTaxId", "receivertaxid", - "secretKey", "secretkey", - "signingAccounts", "signingaccounts", - "taxId", "taxid", "token", - "walletAddress", "walletaddress", "x-api-key" ]); @@ -116,7 +103,7 @@ function sanitizeMetadata(metadata: Record | null | undefined): const sanitized: Record = {}; for (const [key, value] of Object.entries(metadata)) { - if (SENSITIVE_METADATA_KEYS.has(key) || SENSITIVE_METADATA_KEYS.has(key.toLowerCase()) || typeof value === "object") { + if (isSensitiveMetadataKey(key) || typeof value === "object") { continue; } sanitized[key] = typeof value === "string" ? trimString(value, 100) : value; @@ -137,8 +124,7 @@ function addSelectedValues( const value = values[key]; if (value === undefined) continue; - const isSensitiveKey = SENSITIVE_METADATA_KEYS.has(key) || SENSITIVE_METADATA_KEYS.has(key.toLowerCase()); - if (isSensitiveKey && !Array.isArray(value) && !isPlainObject(value)) continue; + if (isSensitiveMetadataKey(key) && !Array.isArray(value) && !isPlainObject(value)) continue; const metadataKey = `${prefix}${toPascalCase(key)}`; if (Array.isArray(value)) { @@ -154,6 +140,10 @@ function addSelectedValues( } } +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; From 2f682937926639171062480ea6c212976f3a94af Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 11:48:21 +0200 Subject: [PATCH 33/59] Improve 'no liquidity' error message --- apps/api/src/api/errors/api-error.ts | 6 +- apps/api/src/api/errors/extendable-error.ts | 6 +- apps/api/src/api/middlewares/error.test.ts | 64 ++++++++++++++++++ apps/api/src/api/middlewares/error.ts | 9 ++- .../api/observability/errorClassifier.test.ts | 11 +++- .../src/api/observability/errorClassifier.ts | 2 + .../api/services/quote/core/errors.test.ts | 26 ++++++++ .../api/src/api/services/quote/core/errors.ts | 26 ++++++++ apps/api/src/api/services/quote/core/nabla.ts | 9 +++ .../api/services/quote/core/squidrouter.ts | 21 ++++-- apps/api/src/api/services/quote/index.ts | 21 +++++- docs/api/openapi/vortex.openapi.json | 65 ++++++++++++++----- docs/api/pages/06-quotes-and-pricing.md | 15 +++++ .../03-ramp-engine/quote-lifecycle.md | 1 + 14 files changed, 251 insertions(+), 31 deletions(-) create mode 100644 apps/api/src/api/middlewares/error.test.ts create mode 100644 apps/api/src/api/services/quote/core/errors.test.ts create mode 100644 apps/api/src/api/services/quote/core/errors.ts 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/error.test.ts b/apps/api/src/api/middlewares/error.test.ts new file mode 100644 index 000000000..54cbced66 --- /dev/null +++ b/apps/api/src/api/middlewares/error.test.ts @@ -0,0 +1,64 @@ +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: "Low liquidity for this route. Please try a smaller amount.", + status: httpStatus.INTERNAL_SERVER_ERROR, + type: "BAD_REQUEST" + }), + 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: "Low liquidity for this route. Please try a smaller amount.", + statusCode: httpStatus.INTERNAL_SERVER_ERROR, + type: "BAD_REQUEST" + }); + }); +}); 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/observability/errorClassifier.test.ts b/apps/api/src/api/observability/errorClassifier.test.ts index eb755baff..2d786884f 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,11 @@ describe("classifyApiClientError", () => { expect(classifyApiClientError(new APIError({ message: "No presigned transactions found", status: httpStatus.BAD_REQUEST }))).toBe( "missing_presigned_transactions" ); + expect( + classifyApiClientError( + new APIError({ message: "Low liquidity for this route. Please try a smaller amount.", 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/services/quote/core/errors.test.ts b/apps/api/src/api/services/quote/core/errors.test.ts new file mode 100644 index 000000000..d78b3a08b --- /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).toBe("BAD_REQUEST"); + }); +}); 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..f08dffba3 --- /dev/null +++ b/apps/api/src/api/services/quote/core/errors.ts @@ -0,0 +1,26 @@ +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, + type: "BAD_REQUEST" + }); +} 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/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/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 3dcecca48..2bd15672e 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -362,9 +362,21 @@ }, "ErrorResponse": { "properties": { + "code": { + "description": "HTTP status code returned by the API error handler.", + "type": "integer" + }, "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" @@ -1958,32 +1970,44 @@ "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": { + "message": "An unexpected error occurred." + } + }, + "lowLiquidity": { + "summary": "Low-liquidity route failure", + "value": { + "code": 500, + "message": "Low liquidity for this route. Please try a smaller amount.", + "statusCode": 500, + "type": "BAD_REQUEST" + } + } }, "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 +2101,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 +2213,33 @@ "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": "Low liquidity for this route. Please try a smaller amount.", + "statusCode": 500, + "type": "BAD_REQUEST" + } + } + }, "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..e1d17cd9a 100644 --- a/docs/api/pages/06-quotes-and-pricing.md +++ b/docs/api/pages/06-quotes-and-pricing.md @@ -83,6 +83,21 @@ 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. This is not treated as a client validation problem: the route exists, but current pool or route liquidity cannot serve the requested amount. Both `POST /v1/quotes` and `POST /v1/quotes/best` can return: + +```json +{ + "code": 500, + "message": "Low liquidity for this route. Please try a smaller amount.", + "statusCode": 500, + "type": "BAD_REQUEST" +} +``` + +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..6a8a2f040 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -5,6 +5,7 @@ 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. + - If live route/pool liquidity cannot serve the quote at the requested amount, the API returns a user-facing `500` quote error (`Low liquidity for this route. Please try a smaller amount.`) with `type: "BAD_REQUEST"`. 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. From c9f679c9b7781f873c356516b5acf559c9f62bfd Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 12:03:02 +0200 Subject: [PATCH 34/59] Recognize Avenia on-hold pay-in tickets --- packages/shared/src/services/brla/brlaApiService.ts | 4 +++- packages/shared/src/services/brla/types.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) 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; From 113e88541e09e1b135856672d72b9fbed9286f2a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 12:03:33 +0200 Subject: [PATCH 35/59] Persist Avenia pay-in hold metadata --- .../phases/handlers/brla-onramp-hold.test.ts | 73 +++++++++++++++++++ .../phases/handlers/brla-onramp-hold.ts | 36 +++++++++ .../handlers/brla-onramp-mint-handler.ts | 19 +++++ .../api/services/phases/meta-state-types.ts | 1 + 4 files changed, 129 insertions(+) create mode 100644 apps/api/src/api/services/phases/handlers/brla-onramp-hold.test.ts create mode 100644 apps/api/src/api/services/phases/handlers/brla-onramp-hold.ts diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-hold.test.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-hold.test.ts new file mode 100644 index 000000000..9f9444a04 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/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/handlers/brla-onramp-hold.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-hold.ts new file mode 100644 index 000000000..c21fa8076 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/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/handlers/brla-onramp-mint-handler.ts b/apps/api/src/api/services/phases/handlers/brla-onramp-mint-handler.ts index d74a2dea4..48c7b6e82 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 @@ -24,6 +24,7 @@ import TaxId from "../../../../models/taxId.model"; import { APIError } from "../../../errors/api-error"; import { BasePhaseHandler } from "../base-phase-handler"; import { StateMetadata } from "../meta-state-types"; +import { syncAveniaOnHoldState } from "./brla-onramp-hold"; // The rationale for these difference is that it allows for a finer check over the payment timeout in // case of service restart. A smaller timeout for the balance check loop allows to get out to the outer @@ -105,6 +106,24 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { return false; } + 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) { 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..092e79935 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; From a27478ffc6106ae1ca2524fdebf5443784767496 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 12:03:57 +0200 Subject: [PATCH 36/59] Register compliance hold ramp phase --- apps/frontend/src/pages/progress/phaseFlows.ts | 1 + apps/frontend/src/pages/progress/phaseMessages.ts | 1 + packages/shared/src/endpoints/ramp.endpoints.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index 717c4461d..4421d2df3 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, 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/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" From a9524773f95627b32a111a2d58b0b10ac5b6c52a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 12:04:29 +0200 Subject: [PATCH 37/59] Return compliance hold phase from ramp status --- .../ramp/ramp.service.get-ramp-status.test.ts | 156 ++++++++++++++++++ .../api/src/api/services/ramp/ramp.service.ts | 5 +- 2 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts 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..a3c3aaa48 --- /dev/null +++ b/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, mock } from "bun:test"; +import { EPaymentMethod, FiatToken, Networks, RampDirection } 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) { + return RampState.build({ + createdAt, + currentPhase: "brlaOnrampMint", + errorLogs: [], + flowVariant: "monerium", + from: EPaymentMethod.PIX, + id: "ramp-1", + paymentMethod: EPaymentMethod.PIX, + phaseHistory: [{ phase: "brlaOnrampMint", 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"); + }); +}); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 256e91fc4..b5c6f0405 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -538,8 +538,9 @@ export class RampService extends BaseRampService { const processingFeeUsd = new Big(usdFees.anchor).plus(usdFees.vortex).toFixed(); // Never return 'failed' as current phase, instead return last known phase - const currentPhase = - rampState.currentPhase !== "failed" + const currentPhase: RampPhase = rampState.state.onHold + ? "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 From 1f949be45b0c70d22efda1c6876a845527217a10 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 12:04:52 +0200 Subject: [PATCH 38/59] Align SDK ramp status response typing --- packages/sdk/src/VortexSdk.ts | 3 ++- packages/sdk/src/services/ApiService.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) 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 { From 5c22e8bff358cdf4c38a527b5b0e55f391328bcc Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 12:05:14 +0200 Subject: [PATCH 39/59] Add compliance hold progress copy --- apps/frontend/src/translations/en.json | 1 + apps/frontend/src/translations/pt.json | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 26ab96a32..ab8bc38bf 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1250,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", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 22c6ca0f8..fdb72b1b6 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -1255,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", From 7a870fd3e2552ead88d066acbabbbae2958ab218 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 12:05:36 +0200 Subject: [PATCH 40/59] Document compliance hold ramp phase --- docs/api/openapi/vortex.openapi.d.ts | 1 + docs/api/openapi/vortex.openapi.json | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 7b089ed5f..63432cd51 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -1349,6 +1349,7 @@ export interface components { | "subsidizePreSwap" | "subsidizePostSwap" | "brlaTeleport" + | "onHoldForComplianceCheck" | "brlaPayoutOnMoonbeam" | "failed"; RampProcess: { diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 3dcecca48..0f702d16b 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -864,6 +864,7 @@ "subsidizePreSwap", "subsidizePostSwap", "brlaTeleport", + "onHoldForComplianceCheck", "brlaPayoutOnMoonbeam", "failed" ], From 89ccd5aeafe67865bc62addb58178eaa3cb17977 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 17:31:42 +0200 Subject: [PATCH 41/59] Address PR feedback --- .../handlers/brla-onramp-mint-handler.ts | 36 +++++++++++-------- .../ramp/ramp.service.get-ramp-status.test.ts | 16 ++++++--- .../api/src/api/services/ramp/ramp.service.ts | 4 ++- .../frontend/src/pages/progress/phaseFlows.ts | 1 + 4 files changed, 37 insertions(+), 20 deletions(-) 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 48c7b6e82..e4d00c2a1 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 @@ -31,6 +31,7 @@ import { syncAveniaOnHoldState } from "./brla-onramp-hold"; // 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 @@ -96,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` @@ -106,22 +108,26 @@ export class BrlaOnrampMintHandler extends BasePhaseHandler { return false; } - 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.` + 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 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 index a3c3aaa48..1d142a102 100644 --- 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 @@ -1,5 +1,5 @@ import { describe, expect, it, mock } from "bun:test"; -import { EPaymentMethod, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +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"; @@ -46,16 +46,16 @@ class TestRampService extends RampService { } } -function makeRampState(onHold: boolean) { +function makeRampState(onHold: boolean, currentPhase: RampPhase = "brlaOnrampMint") { return RampState.build({ createdAt, - currentPhase: "brlaOnrampMint", + currentPhase, errorLogs: [], flowVariant: "monerium", from: EPaymentMethod.PIX, id: "ramp-1", paymentMethod: EPaymentMethod.PIX, - phaseHistory: [{ phase: "brlaOnrampMint", timestamp: createdAt }], + phaseHistory: [{ phase: currentPhase, timestamp: createdAt }], postCompleteState: { cleanup: { cleanupAt: null, @@ -153,4 +153,12 @@ describe("RampService.getRampStatus", () => { 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 b5c6f0405..ea20b3543 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -537,8 +537,10 @@ 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: RampPhase = rampState.state.onHold + const currentPhase: RampPhase = isOnHoldForComplianceCheck ? "onHoldForComplianceCheck" : rampState.currentPhase !== "failed" ? rampState.currentPhase diff --git a/apps/frontend/src/pages/progress/phaseFlows.ts b/apps/frontend/src/pages/progress/phaseFlows.ts index 4421d2df3..444133d82 100644 --- a/apps/frontend/src/pages/progress/phaseFlows.ts +++ b/apps/frontend/src/pages/progress/phaseFlows.ts @@ -71,6 +71,7 @@ export const PHASE_FLOWS = { onramp_brl: [ "initial", "brlaOnrampMint", + "onHoldForComplianceCheck", "fundEphemeral", "subsidizePreSwap", "nablaApprove", From f9a28221c4f3fa1524d42c0eea8ceb87d6e7e0a3 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 17:34:22 +0200 Subject: [PATCH 42/59] Address PR review feedback --- docs/api/pages/06-quotes-and-pricing.md | 2 +- docs/security-spec/03-ramp-engine/quote-lifecycle.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/pages/06-quotes-and-pricing.md b/docs/api/pages/06-quotes-and-pricing.md index e1d17cd9a..1a7fad220 100644 --- a/docs/api/pages/06-quotes-and-pricing.md +++ b/docs/api/pages/06-quotes-and-pricing.md @@ -85,7 +85,7 @@ 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. This is not treated as a client validation problem: the route exists, but current pool or route liquidity cannot serve the requested amount. Both `POST /v1/quotes` and `POST /v1/quotes/best` can return: +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. The `type: "BAD_REQUEST"` field is provider-style compatibility metadata, not the HTTP status; clients should treat this as a user-correctable liquidity failure and ask the user to try a smaller amount. Both `POST /v1/quotes` and `POST /v1/quotes/best` can return: ```json { diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 6a8a2f040..214ecd4ce 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -5,7 +5,7 @@ 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. - - If live route/pool liquidity cannot serve the quote at the requested amount, the API returns a user-facing `500` quote error (`Low liquidity for this route. Please try a smaller amount.`) with `type: "BAD_REQUEST"`. 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. + - If live route/pool liquidity cannot serve the quote at the requested amount, the API returns a user-facing `500` quote error (`Low liquidity for this route. Please try a smaller amount.`). The optional `type: "BAD_REQUEST"` value is provider-style compatibility metadata, not the HTTP status; clients should treat it as a user-correctable liquidity failure and ask for a smaller amount. 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. From 5b23d13c84120266cd5437fd9abfdf0d5341f13a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 17:41:31 +0200 Subject: [PATCH 43/59] Move BRLA onramp hold helper out of handlers --- .../api/services/phases/handlers/brla-onramp-mint-handler.ts | 2 +- .../phases/{handlers => helpers}/brla-onramp-hold.test.ts | 0 .../services/phases/{handlers => helpers}/brla-onramp-hold.ts | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename apps/api/src/api/services/phases/{handlers => helpers}/brla-onramp-hold.test.ts (100%) rename apps/api/src/api/services/phases/{handlers => helpers}/brla-onramp-hold.ts (100%) 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 e4d00c2a1..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,8 +23,8 @@ 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"; -import { syncAveniaOnHoldState } from "./brla-onramp-hold"; // The rationale for these difference is that it allows for a finer check over the payment timeout in // case of service restart. A smaller timeout for the balance check loop allows to get out to the outer diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-hold.test.ts b/apps/api/src/api/services/phases/helpers/brla-onramp-hold.test.ts similarity index 100% rename from apps/api/src/api/services/phases/handlers/brla-onramp-hold.test.ts rename to apps/api/src/api/services/phases/helpers/brla-onramp-hold.test.ts diff --git a/apps/api/src/api/services/phases/handlers/brla-onramp-hold.ts b/apps/api/src/api/services/phases/helpers/brla-onramp-hold.ts similarity index 100% rename from apps/api/src/api/services/phases/handlers/brla-onramp-hold.ts rename to apps/api/src/api/services/phases/helpers/brla-onramp-hold.ts From cf431f05b2ce9b5efbb3d46c3c4b58ef0665aa99 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 17:51:02 +0200 Subject: [PATCH 44/59] Return correct final tx hash and explorer link --- .../api/services/phases/meta-state-types.ts | 2 + .../api/src/api/services/ramp/helpers.test.ts | 79 +++++++++++++++++++ apps/api/src/api/services/ramp/helpers.ts | 76 +++++++++++++++++- .../api/src/api/services/ramp/ramp.service.ts | 41 ++++------ .../03-ramp-engine/ramp-phase-flows.md | 2 + 5 files changed, 172 insertions(+), 28 deletions(-) create mode 100644 apps/api/src/api/services/ramp/helpers.test.ts 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..e6da78d5e 100644 --- a/apps/api/src/api/services/phases/meta-state-types.ts +++ b/apps/api/src/api/services/phases/meta-state-types.ts @@ -49,6 +49,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/ramp/helpers.test.ts b/apps/api/src/api/services/ramp/helpers.test.ts new file mode 100644 index 000000000..3f664f750 --- /dev/null +++ b/apps/api/src/api/services/ramp/helpers.test.ts @@ -0,0 +1,79 @@ +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" + }); + }); +}); diff --git a/apps/api/src/api/services/ramp/helpers.ts b/apps/api/src/api/services/ramp/helpers.ts index ab6b34c62..f246c0a8c 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 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.base}/${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.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 256e91fc4..4f41583c0 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -64,7 +64,7 @@ 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 = 900; // 15 minutes @@ -546,17 +546,14 @@ export class RampService extends BaseRampService { ? 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; @@ -565,8 +562,8 @@ export class RampService extends BaseRampService { await rampState.update({ state: { ...rampState.state, - finalTransactionExplorerLink: transactionExplorerLink, - finalTransactionHash: transactionHash + finalTransactionExplorerLinkV2: transactionExplorerLink, + finalTransactionHashV2: transactionHash } }); } @@ -689,17 +686,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; @@ -708,8 +702,8 @@ export class RampService extends BaseRampService { await ramp.update({ state: { ...ramp.state, - finalTransactionExplorerLink: transactionExplorerLink, - finalTransactionHash: transactionHash + finalTransactionExplorerLinkV2: transactionExplorerLink, + finalTransactionHashV2: transactionHash } }); } @@ -1533,7 +1527,6 @@ export class RampService extends BaseRampService { return; } - const alfredpayService = AlfredpayApiService.getInstance(); const alfredpayQuoteId = quote.metadata.alfredpayOfframp?.quoteId; if (!alfredpayQuoteId) { 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). From 2fda83d9b2d4e06042bc182be827619088983d29 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 18:05:54 +0200 Subject: [PATCH 45/59] Adjust text message --- apps/api/src/api/middlewares/error.test.ts | 4 ++-- apps/api/src/api/observability/errorClassifier.test.ts | 5 ++++- apps/frontend/src/translations/en.json | 2 +- apps/frontend/src/translations/pt.json | 2 +- docs/api/openapi/vortex.openapi.json | 4 ++-- docs/api/pages/06-quotes-and-pricing.md | 4 ++-- docs/security-spec/03-ramp-engine/quote-lifecycle.md | 2 +- packages/shared/src/endpoints/quote.endpoints.ts | 2 +- 8 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/api/src/api/middlewares/error.test.ts b/apps/api/src/api/middlewares/error.test.ts index 54cbced66..4fb79e6b0 100644 --- a/apps/api/src/api/middlewares/error.test.ts +++ b/apps/api/src/api/middlewares/error.test.ts @@ -44,7 +44,7 @@ describe("error middleware", () => { handler( new APIError({ isPublic: true, - message: "Low liquidity for this route. Please try a smaller amount.", + message: "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", status: httpStatus.INTERNAL_SERVER_ERROR, type: "BAD_REQUEST" }), @@ -56,7 +56,7 @@ describe("error middleware", () => { expect(response.statusCode).toBe(httpStatus.INTERNAL_SERVER_ERROR); expect(response.body).toMatchObject({ code: httpStatus.INTERNAL_SERVER_ERROR, - message: "Low liquidity for this route. Please try a smaller amount.", + message: "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", statusCode: httpStatus.INTERNAL_SERVER_ERROR, type: "BAD_REQUEST" }); diff --git a/apps/api/src/api/observability/errorClassifier.test.ts b/apps/api/src/api/observability/errorClassifier.test.ts index 2d786884f..1a01f35b0 100644 --- a/apps/api/src/api/observability/errorClassifier.test.ts +++ b/apps/api/src/api/observability/errorClassifier.test.ts @@ -16,7 +16,10 @@ describe("classifyApiClientError", () => { ); expect( classifyApiClientError( - new APIError({ message: "Low liquidity for this route. Please try a smaller amount.", status: httpStatus.INTERNAL_SERVER_ERROR }) + 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"); }); diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 26ab96a32..451711674 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -1314,7 +1314,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 22c6ca0f8..99e32c6d7 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -1319,7 +1319,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.json b/docs/api/openapi/vortex.openapi.json index 2bd15672e..fcf01f811 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -1996,7 +1996,7 @@ "summary": "Low-liquidity route failure", "value": { "code": 500, - "message": "Low liquidity for this route. Please try a smaller amount.", + "message": "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", "statusCode": 500, "type": "BAD_REQUEST" } @@ -2228,7 +2228,7 @@ "summary": "Low-liquidity route failure", "value": { "code": 500, - "message": "Low liquidity for this route. Please try a smaller amount.", + "message": "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", "statusCode": 500, "type": "BAD_REQUEST" } diff --git a/docs/api/pages/06-quotes-and-pricing.md b/docs/api/pages/06-quotes-and-pricing.md index 1a7fad220..4e5300a06 100644 --- a/docs/api/pages/06-quotes-and-pricing.md +++ b/docs/api/pages/06-quotes-and-pricing.md @@ -85,12 +85,12 @@ 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. The `type: "BAD_REQUEST"` field is provider-style compatibility metadata, not the HTTP status; clients should treat this as a user-correctable liquidity failure and ask the user to try a smaller amount. Both `POST /v1/quotes` and `POST /v1/quotes/best` can return: +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. The `type: "BAD_REQUEST"` field is provider-style compatibility metadata, not the HTTP status; 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": "Low liquidity for this route. Please try a smaller amount.", + "message": "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon.", "statusCode": 500, "type": "BAD_REQUEST" } diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 214ecd4ce..19a9b45a2 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -5,7 +5,7 @@ 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. - - If live route/pool liquidity cannot serve the quote at the requested amount, the API returns a user-facing `500` quote error (`Low liquidity for this route. Please try a smaller amount.`). The optional `type: "BAD_REQUEST"` value is provider-style compatibility metadata, not the HTTP status; clients should treat it as a user-correctable liquidity failure and ask for a smaller amount. 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. + - 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.`). The optional `type: "BAD_REQUEST"` value is provider-style compatibility metadata, not the HTTP status; 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/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", From bf7c89c27777d25864c68ec696ee5dd182d6d2ec Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 18:06:17 +0200 Subject: [PATCH 46/59] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/api/src/api/services/ramp/helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/api/services/ramp/helpers.ts b/apps/api/src/api/services/ramp/helpers.ts index f246c0a8c..6d731ef88 100644 --- a/apps/api/src/api/services/ramp/helpers.ts +++ b/apps/api/src/api/services/ramp/helpers.ts @@ -17,7 +17,7 @@ enum TransactionHashKey { 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", From 9168bddab483a96ca6ae70e209ed61239ed46887 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 18:29:29 +0200 Subject: [PATCH 47/59] Add validation error details and improve error response structure --- docs/api/openapi/vortex.openapi.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index fcf01f811..8ebbaafc7 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -366,6 +366,13 @@ "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" @@ -1970,6 +1977,7 @@ "3": { "summary": "Example of invalid ramp type error", "value": { + "code": 400, "message": "Invalid ramp type, must be \"BUY\" or \"SELL\"" } } @@ -1989,7 +1997,8 @@ "internal": { "summary": "Unexpected internal failure", "value": { - "message": "An unexpected error occurred." + "code": 500, + "message": "Internal server error" } }, "lowLiquidity": { From f579c36278e75c901c6c833b55fb87a9748caebd Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 18:41:21 +0200 Subject: [PATCH 48/59] Fix issue with wrong `bun run dev` script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 33f7e152d..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": "bun run --cwd packages/shared dev & bun run --cwd apps/api dev & bun run --cwd apps/frontend dev & bun run --cwd apps/dashboard dev & wait", + "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", From 1bf3d3f011f04b27b3926425e9239c29f0f2c9fe Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 18:45:20 +0200 Subject: [PATCH 49/59] Remove 'type: "BAD_REQUEST"' --- apps/api/src/api/middlewares/error.test.ts | 7 ++----- apps/api/src/api/services/quote/core/errors.ts | 3 +-- docs/api/openapi/vortex.openapi.json | 8 ++------ docs/api/pages/06-quotes-and-pricing.md | 6 ++---- docs/security-spec/03-ramp-engine/quote-lifecycle.md | 2 +- 5 files changed, 8 insertions(+), 18 deletions(-) diff --git a/apps/api/src/api/middlewares/error.test.ts b/apps/api/src/api/middlewares/error.test.ts index 4fb79e6b0..f5e0ec0e3 100644 --- a/apps/api/src/api/middlewares/error.test.ts +++ b/apps/api/src/api/middlewares/error.test.ts @@ -45,8 +45,7 @@ describe("error middleware", () => { 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, - type: "BAD_REQUEST" + status: httpStatus.INTERNAL_SERVER_ERROR }), undefined as never, response as never, @@ -56,9 +55,7 @@ describe("error middleware", () => { 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.", - statusCode: httpStatus.INTERNAL_SERVER_ERROR, - type: "BAD_REQUEST" + 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/services/quote/core/errors.ts b/apps/api/src/api/services/quote/core/errors.ts index f08dffba3..617c4d044 100644 --- a/apps/api/src/api/services/quote/core/errors.ts +++ b/apps/api/src/api/services/quote/core/errors.ts @@ -20,7 +20,6 @@ export function createLowLiquidityQuoteError(): APIError { return new APIError({ isPublic: true, message: QuoteError.LowLiquidity, - status: httpStatus.INTERNAL_SERVER_ERROR, - type: "BAD_REQUEST" + status: httpStatus.INTERNAL_SERVER_ERROR }); } diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 4a25c6586..cee23daf3 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -2006,9 +2006,7 @@ "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.", - "statusCode": 500, - "type": "BAD_REQUEST" + "message": "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon." } } }, @@ -2238,9 +2236,7 @@ "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.", - "statusCode": 500, - "type": "BAD_REQUEST" + "message": "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon." } } }, diff --git a/docs/api/pages/06-quotes-and-pricing.md b/docs/api/pages/06-quotes-and-pricing.md index 4e5300a06..5bbac3f3b 100644 --- a/docs/api/pages/06-quotes-and-pricing.md +++ b/docs/api/pages/06-quotes-and-pricing.md @@ -85,14 +85,12 @@ 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. The `type: "BAD_REQUEST"` field is provider-style compatibility metadata, not the HTTP status; 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: +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.", - "statusCode": 500, - "type": "BAD_REQUEST" + "message": "This route is temporarily unavailable due to low liquidity. Please try a smaller amount or check back soon." } ``` diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 19a9b45a2..1bc83b240 100644 --- a/docs/security-spec/03-ramp-engine/quote-lifecycle.md +++ b/docs/security-spec/03-ramp-engine/quote-lifecycle.md @@ -5,7 +5,7 @@ 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. - - 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.`). The optional `type: "BAD_REQUEST"` value is provider-style compatibility metadata, not the HTTP status; 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. + - 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. From dd0eced8dea3095b4616181233da34a4e64bf697 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 18:48:50 +0200 Subject: [PATCH 50/59] Address PR review findings --- docs/api/openapi/vortex.openapi.d.ts | 110 +++++++++++++----- docs/api/openapi/vortex.openapi.json | 1 - .../03-ramp-engine/quote-lifecycle.md | 2 +- 3 files changed, 82 insertions(+), 31 deletions(-) diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 63432cd51..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; @@ -2053,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"]; }; }; }; @@ -2148,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 cee23daf3..32c1c2b8f 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -1978,7 +1978,6 @@ "3": { "summary": "Example of invalid ramp type error", "value": { - "code": 400, "message": "Invalid ramp type, must be \"BUY\" or \"SELL\"" } } diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index 1bc83b240..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,7 @@ 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. From dbd7b9a5c7dd765f02a48d6aae94da0f71225a73 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 18:53:00 +0200 Subject: [PATCH 51/59] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/api/src/api/services/quote/core/errors.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/api/services/quote/core/errors.test.ts b/apps/api/src/api/services/quote/core/errors.test.ts index d78b3a08b..f3656f0a9 100644 --- a/apps/api/src/api/services/quote/core/errors.test.ts +++ b/apps/api/src/api/services/quote/core/errors.test.ts @@ -21,6 +21,6 @@ describe("quote error helpers", () => { expect(error.isPublic).toBe(true); expect(error.message).toBe(QuoteError.LowLiquidity); expect(error.status).toBe(httpStatus.INTERNAL_SERVER_ERROR); - expect(error.type).toBe("BAD_REQUEST"); + expect(error.type).toBeUndefined(); }); }); From 9bc49c8b62621b18836a2d95f8764993757a103f Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 19:58:14 +0200 Subject: [PATCH 52/59] Reject AssetHub CBU Alfredpay quotes --- .../quote/routes/route-resolver.test.ts | 25 +++++++++++++++++++ .../services/quote/routes/route-resolver.ts | 7 +++++- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/api/services/quote/routes/route-resolver.test.ts 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..4b00f23be --- /dev/null +++ b/apps/api/src/api/services/quote/routes/route-resolver.test.ts @@ -0,0 +1,25 @@ +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); + }); +}); 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 cb7195f54..61f792453 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 { From 70f3bde3e655f9f43e10390540c004a357cb22ae Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 19:58:37 +0200 Subject: [PATCH 53/59] Fix ramp terminal transaction metadata --- .../api/src/api/services/ramp/helpers.test.ts | 23 ++++++++++++++++--- apps/api/src/api/services/ramp/helpers.ts | 2 +- .../api/src/api/services/ramp/ramp.service.ts | 2 +- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/services/ramp/helpers.test.ts b/apps/api/src/api/services/ramp/helpers.test.ts index 3f664f750..823e5b195 100644 --- a/apps/api/src/api/services/ramp/helpers.test.ts +++ b/apps/api/src/api/services/ramp/helpers.test.ts @@ -1,8 +1,8 @@ -import {describe, expect, it} from "bun:test"; -import {Networks, RampDirection} from "@vortexfi/shared"; +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"; +import { getFinalTransactionHashForRampV2 } from "./helpers"; type RampStateTestOverrides = { currentPhase?: string; @@ -76,4 +76,21 @@ describe("getFinalTransactionHashForRampV2", () => { 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 6d731ef88..595564a7d 100644 --- a/apps/api/src/api/services/ramp/helpers.ts +++ b/apps/api/src/api/services/ramp/helpers.ts @@ -111,7 +111,7 @@ const EXPLORER_LINK_BUILDERS: Record = [TransactionHashKey.MykoboPayoutTxHash]: hash => `${CHAIN_EXPLORERS.base}/${hash}`, - [TransactionHashKey.AlfredpayOfframpTransferTxHash]: hash => `${CHAIN_EXPLORERS.base}/${hash}` + [TransactionHashKey.AlfredpayOfframpTransferTxHash]: hash => `${CHAIN_EXPLORERS.polygon}/${hash}` }; const TRANSACTION_HASH_PRIORITY: readonly TransactionHashKey[] = [ diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 75b2dc0bc..a2f250fc6 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -433,7 +433,7 @@ export class RampService extends BaseRampService { const timeDifferenceSeconds = (currentTime.getTime() - rampStateCreationTime.getTime()) / 1000; if (timeDifferenceSeconds > RAMP_START_EXPIRATION_TIME_SECONDS) { - this.cancelRamp(rampState.id); + await this.cancelRamp(rampState.id); throw new APIError({ message: "Maximum time window to start process exceeded. Ramp invalidated.", status: httpStatus.BAD_REQUEST From d0deec3ea99a78785f7d25ff7e15bab837f88b39 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 19:59:09 +0200 Subject: [PATCH 54/59] Align frontend ramp terminal handling --- .../src/hooks/ramp/useIsQuoteComponentDisplayed.ts | 5 +++-- apps/frontend/src/hooks/ramp/useRampNavigation.ts | 11 +++++++---- apps/frontend/src/pages/progress/index.tsx | 11 ++++++++++- apps/frontend/src/pages/progress/phaseFlows.test.ts | 1 + apps/frontend/src/services/api/ramp.service.ts | 7 ++++++- 5 files changed, 27 insertions(+), 8 deletions(-) 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/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/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; } From 1df2a8ef350bc5ebdfb8290d32bbe413ee151cfe Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 19:59:32 +0200 Subject: [PATCH 55/59] Normalize Argentina KYC validation --- .../src/api/middlewares/validators.test.ts | 52 ++++++++++++++++++- apps/api/src/api/middlewares/validators.ts | 9 +++- .../components/Alfredpay/ArKycFormScreen.tsx | 33 +++++++++--- 3 files changed, 85 insertions(+), 9 deletions(-) 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 4f6fd0f27..77885dfe0 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -30,6 +30,7 @@ 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 { APIError } from "../errors/api-error"; import { buildApiClientRequestMetadata, observeApiClientEvent } from "../observability/apiClientEvent.service"; import { getRequestDurationMs } from "../observability/requestContext"; @@ -546,7 +547,13 @@ export const validateKycSubmission: RequestHandler = (req, res, next) => { const error = validator(body); if (error) { - res.status(httpStatus.BAD_REQUEST).json({ error }); + next( + new APIError({ + errors: [{ message: error }], + message: error, + status: httpStatus.BAD_REQUEST + }) + ); return; } diff --git a/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx index 39be75aee..ada2f1927 100644 --- a/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx @@ -18,7 +18,7 @@ const schema = z lastName: z.string().min(1), nationalities: z.array(z.string().regex(/^[A-Z]{2}$/)).optional(), pep: z.boolean(), - phoneNumber: z.string().regex(/^\+54[\d\s\-()]{7,}$/, "Use Argentina format (+54...)"), + 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) @@ -62,6 +62,30 @@ function DniField({ form }: { form: ArForm }) { ); } +function PhoneNumberField({ form }: { form: ArForm }) { + const error = form.formState.errors.phoneNumber; + return ( +
    + + + { + if (!value) return value; + const digits = value.replace(/\D/g, ""); + return digits.startsWith("54") ? `+${digits}` : `+54${digits}`; + } + })} + /> +
    + ); +} + const config: KycFormConfig = { defaultValues: { countryCode: "AR", @@ -94,13 +118,10 @@ const config: KycFormConfig = { type: "text" }, { - autoComplete: "tel", - inputMode: "tel", - inputType: "tel", labelKey: "components.arKycForm.phoneNumber", name: "phoneNumber", - placeholder: "+54 9 11 1234 5678", - type: "text" + render: form => , + type: "custom" }, { labelKey: "components.arKycForm.documentType", From 48cad309e988824906bf077bc9de8e9c3fec4361 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 11 Jun 2026 20:27:11 +0200 Subject: [PATCH 56/59] Sanitize Argentina KYC phone input --- .../src/components/Alfredpay/ArKycFormScreen.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx index ada2f1927..bdda7d3d6 100644 --- a/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/ArKycFormScreen.tsx @@ -66,12 +66,18 @@ 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", { From f1f23f2dbbccbb9797449de3f14c01d3843dc9f2 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 12 Jun 2026 10:26:35 +0200 Subject: [PATCH 57/59] Remove hydration from ephemeral check (as it's not used atm) --- .../api/services/phases/post-process/index.ts | 4 +- .../services/quote/routes/route-definition.ts | 15 ------ .../quote/routes/route-resolver.test.ts | 46 +++++++++++++++++-- .../services/quote/routes/route-resolver.ts | 3 ++ .../onramp-avenia-to-assethub.strategy.ts | 8 ++-- .../services/ramp/ephemeral-freshness.test.ts | 32 +++++++++---- .../api/services/ramp/ephemeral-freshness.ts | 12 +++-- .../src/api/workers/ramp-recovery.worker.ts | 3 +- 8 files changed, 79 insertions(+), 44 deletions(-) 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/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 index 4b00f23be..d50934d74 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.test.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.test.ts @@ -1,8 +1,8 @@ -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"; +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", () => { @@ -22,4 +22,40 @@ describe("RouteResolver", () => { 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 61f792453..5048b4c0c 100644 --- a/apps/api/src/api/services/quote/routes/route-resolver.ts +++ b/apps/api/src/api/services/quote/routes/route-resolver.ts @@ -44,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) { 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/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 }, From e9b7f7358aae488c005bf9b12a193f4f1f59a539 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 12 Jun 2026 10:26:58 +0200 Subject: [PATCH 58/59] Allow overriding the substrate network WSS urls --- apps/api/.env.example | 4 ++ .../src/services/pendulum/apiManager.ts | 43 +++++++++++++++---- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/apps/api/.env.example b/apps/api/.env.example index 906c4fd22..097eb4eb8 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://hydration.ibp.network +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/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); From e4dc192b112375d236ac081d98823a1ec392c47b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 12 Jun 2026 10:43:03 +0200 Subject: [PATCH 59/59] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/api/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/.env.example b/apps/api/.env.example index 097eb4eb8..aefc6a09f 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -35,7 +35,7 @@ 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://hydration.ibp.network +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