From 704a6515eb81e50b66280afb7913a8d659c32440 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 5 Jan 2026 16:07:40 -0300 Subject: [PATCH 1/9] improving input validation for various forms --- .../components/Avenia/AveniaField/index.tsx | 16 +---- .../AveniaKycEligibilityFields/index.tsx | 1 + .../Avenia/DocumentUpload/index.tsx | 13 +++-- .../RampSubmitButton/RampSubmitButton.tsx | 7 ++- .../DetailsStep/DetailsStepActions.tsx | 15 ++++- .../widget-steps/DetailsStep/index.tsx | 38 ++++++++++-- .../src/hooks/brla/useKYCForm/index.tsx | 13 ++++- apps/frontend/src/hooks/ramp/schema.ts | 1 + .../src/hooks/ramp/useRampValidation.ts | 58 ++++++------------- apps/frontend/src/translations/en.json | 9 ++- apps/frontend/src/translations/pt.json | 8 ++- packages/shared/src/services/brla/helpers.ts | 58 ++++++++++++++++++- packages/shared/src/tokens/stellar/config.ts | 2 +- 13 files changed, 163 insertions(+), 76 deletions(-) diff --git a/apps/frontend/src/components/Avenia/AveniaField/index.tsx b/apps/frontend/src/components/Avenia/AveniaField/index.tsx index 297710cd3..9e32a4710 100644 --- a/apps/frontend/src/components/Avenia/AveniaField/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaField/index.tsx @@ -69,21 +69,7 @@ export const AveniaField: FC = ({ id, label, index, validation - + {errorMessage && {errorMessage}} ); diff --git a/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx b/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx index 4450045ec..9f160fb82 100644 --- a/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx @@ -31,6 +31,7 @@ const StandardBrlaFieldOptionsValidationPatterns: Partial = ({ aveniaKycActor, label: string, onChange: React.ChangeEventHandler | undefined, valid: boolean, - Icon: React.ComponentType> + Icon: React.ComponentType>, + fileName?: string ) => ( @@ -190,13 +192,15 @@ export const DocumentUpload: React.FC = ({ aveniaKycActor, t("components.documentUpload.fields.rgFront"), e => handleFileChange(e, setFront, setFrontValid), frontValid, - DocumentTextIcon + DocumentTextIcon, + front?.name )} {renderField( t("components.documentUpload.fields.rgBack"), e => handleFileChange(e, setBack, setBackValid), backValid, - DocumentTextIcon + DocumentTextIcon, + back?.name )} )} @@ -205,7 +209,8 @@ export const DocumentUpload: React.FC = ({ aveniaKycActor, t("components.documentUpload.fields.cnhDocument"), e => handleFileChange(e, setFront, setFrontValid), frontValid, - DocumentTextIcon + DocumentTextIcon, + front?.name )} diff --git a/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx b/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx index 4f33d2c1c..ea8691ba8 100644 --- a/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx +++ b/apps/frontend/src/components/RampSubmitButton/RampSubmitButton.tsx @@ -177,7 +177,7 @@ const useButtonContent = ({ toToken, submitButtonDisabled }: UseButtonContentPro ]); }; -export const RampSubmitButton = ({ className }: { className?: string }) => { +export const RampSubmitButton = ({ className, hasValidationErrors }: { className?: string; hasValidationErrors?: boolean }) => { const rampActor = useRampActor(); const { onRampConfirm } = useRampSubmission(); const stellarData = useStellarKycSelector(); @@ -208,6 +208,10 @@ export const RampSubmitButton = ({ className }: { className?: string }) => { const toToken = isOnramp ? getOnChainTokenDetailsOrDefault(selectedNetwork, onChainToken) : getAnyFiatTokenDetails(fiatToken); const submitButtonDisabled = useMemo(() => { + if (hasValidationErrors) { + return true; + } + if ( walletLocked && (isOfframp || quote?.from === "sepa") && @@ -242,6 +246,7 @@ export const RampSubmitButton = ({ className }: { className?: string }) => { return false; }, [ + hasValidationErrors, executionInput, isQuoteExpired, isOfframp, diff --git a/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepActions.tsx b/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepActions.tsx index 0f8a38039..6d097382a 100644 --- a/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepActions.tsx +++ b/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepActions.tsx @@ -1,4 +1,6 @@ import { Networks } from "@vortexfi/shared"; +import { FieldErrors } from "react-hook-form"; +import { RampFormValues } from "../../../hooks/ramp/schema"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { ConnectWalletSection } from "../../ConnectWalletSection"; import { RampSubmitButton } from "../../RampSubmitButton/RampSubmitButton"; @@ -10,9 +12,16 @@ export interface DetailsStepActionsProps { requiresConnection: boolean; className?: string; forceNetwork?: Networks; + formErrors?: FieldErrors; } -export const DetailsStepActions = ({ signingState, className, requiresConnection, forceNetwork }: DetailsStepActionsProps) => { +export const DetailsStepActions = ({ + signingState, + className, + requiresConnection, + forceNetwork, + formErrors +}: DetailsStepActionsProps) => { const { shouldDisplay: signingBoxVisible, signatureState, confirmations } = signingState; const { isConnected } = useVortexAccount(forceNetwork); @@ -28,7 +37,9 @@ export const DetailsStepActions = ({ signingState, className, requiresConnection return (
{requiresConnection && } - {displayRampSubmitButton && } + {displayRampSubmitButton && ( + 0} /> + )}
); }; diff --git a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx index fdbfd6d2f..6365f009d 100644 --- a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx @@ -1,10 +1,11 @@ import { InformationCircleIcon } from "@heroicons/react/24/outline"; import { FiatToken, Networks } from "@vortexfi/shared"; import { useSelector } from "@xstate/react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useRampActor } from "../../../contexts/rampState"; +import { RampFormValues } from "../../../hooks/ramp/schema"; import { useRampForm } from "../../../hooks/ramp/useRampForm"; import { useRampSubmission } from "../../../hooks/ramp/useRampSubmission"; import { useSigningBoxState } from "../../../hooks/useSigningBoxState"; @@ -32,6 +33,7 @@ export interface FormData { taxId?: string; moneriumWalletAddress?: string; walletAddress?: string; + fiatToken?: FiatToken; } export const DetailsStep = ({ className }: DetailsStepProps) => { @@ -58,6 +60,7 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { const walletForm = walletLockedFromState || address || undefined; const { form } = useRampForm({ + fiatToken: quote?.rampType === "BUY" ? (quote.inputCurrency as FiatToken) : (quote?.outputCurrency as FiatToken), moneriumWalletAddress: evmAddress, pixId, taxId, @@ -72,12 +75,31 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { if (isMoneriumToAssethubRamp && substrateAddress) { form.setValue("walletAddress", substrateAddress); - } else if (walletLockedFromState) { - form.setValue("walletAddress", walletLockedFromState); } else if (!isMoneriumToAssethubRamp && address) { form.setValue("walletAddress", address); + } else if (walletLockedFromState) { + form.setValue("walletAddress", walletLockedFromState); + } + + const fiatToken = quote?.rampType === "BUY" ? (quote.inputCurrency as FiatToken) : (quote?.outputCurrency as FiatToken); + form.setValue("fiatToken", fiatToken); + }, [form, evmAddress, isMoneriumRamp, address, walletLockedFromState, isMoneriumToAssethubRamp, substrateAddress, quote]); + + const previousValues = useRef({}); + const currentValues = form.watch(); + + useEffect(() => { + const valuesChanged = JSON.stringify(currentValues) !== JSON.stringify(previousValues.current); + const hasErrors = Object.keys(form.formState.errors).length > 0; + + if (valuesChanged && hasErrors) { + form.clearErrors(); } - }, [form, evmAddress, isMoneriumRamp, address, walletLockedFromState, isMoneriumToAssethubRamp, substrateAddress]); + + if (valuesChanged) { + previousValues.current = currentValues; + } + }, [currentValues, form]); const { onRampConfirm } = useRampSubmission(); @@ -92,6 +114,7 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { const canSkipConnection = quote?.from === "pix"; const handleFormSubmit = (data: FormData) => { + console.log("form errors: ", form.formState.errors); rampActor.send({ address: data.walletAddress, type: "SET_ADDRESS" @@ -118,7 +141,12 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { )} - + diff --git a/apps/frontend/src/hooks/brla/useKYCForm/index.tsx b/apps/frontend/src/hooks/brla/useKYCForm/index.tsx index 47d881848..ca90fa158 100644 --- a/apps/frontend/src/hooks/brla/useKYCForm/index.tsx +++ b/apps/frontend/src/hooks/brla/useKYCForm/index.tsx @@ -60,12 +60,19 @@ const createKycFormSchema = (t: (key: string) => string) => [ExtendedAveniaFieldOptions.BIRTHDATE]: yup .date() - .transform((value, originalValue) => { + .transform((value: Date | undefined, originalValue: any) => { return originalValue === "" ? undefined : value; }) .required(t("components.brlaExtendedForm.validation.birthdate.required")) .max(new Date(), t("components.brlaExtendedForm.validation.birthdate.future")) - .min(new Date(1900, 0, 1), t("components.brlaExtendedForm.validation.birthdate.tooOld")), + .min(new Date(1900, 0, 1), t("components.brlaExtendedForm.validation.birthdate.tooOld")) + .test("is-18-or-older", t("components.brlaExtendedForm.validation.birthdate.tooYoung"), value => { + if (!value) return true; + const birthDate = new Date(value); + const ageDate = new Date(birthDate); + ageDate.setFullYear(ageDate.getFullYear() + 18); + return ageDate <= new Date(); + }), [ExtendedAveniaFieldOptions.COMPANY_NAME]: yup .string() @@ -73,7 +80,7 @@ const createKycFormSchema = (t: (key: string) => string) => [ExtendedAveniaFieldOptions.START_DATE]: yup .date() - .transform((value, originalValue) => { + .transform((value: Date | undefined, originalValue: any) => { return originalValue === "" ? undefined : value; }) .max(new Date(), t("components.brlaExtendedForm.validation.startDate.future")) diff --git a/apps/frontend/src/hooks/ramp/schema.ts b/apps/frontend/src/hooks/ramp/schema.ts index 59e772fba..106a4931a 100644 --- a/apps/frontend/src/hooks/ramp/schema.ts +++ b/apps/frontend/src/hooks/ramp/schema.ts @@ -11,6 +11,7 @@ export type RampFormValues = { pixId?: string; walletAddress?: string; moneriumWalletAddress?: string; + fiatToken?: FiatToken; }; export const PHONE_REGEX = /^\+[1-9][0-9]\d{1,14}$/; diff --git a/apps/frontend/src/hooks/ramp/useRampValidation.ts b/apps/frontend/src/hooks/ramp/useRampValidation.ts index 82c002043..458d13f59 100644 --- a/apps/frontend/src/hooks/ramp/useRampValidation.ts +++ b/apps/frontend/src/hooks/ramp/useRampValidation.ts @@ -35,30 +35,20 @@ function validateOnramp( } ): string | null { const maxAmountUnits = multiplyByPowerOfTen(Big(fromToken.maxBuyAmountRaw), -fromToken.decimals); - // Set minimum amount for EURC to 1 unit as an arbitrary limit. - const minAmountUnits = - fromToken.assetSymbol === "EURC" ? new Big(1) : multiplyByPowerOfTen(Big(fromToken.minBuyAmountRaw), -fromToken.decimals); + const minAmountUnits = multiplyByPowerOfTen(Big(fromToken.minBuyAmountRaw), -fromToken.decimals); - if (inputAmount && maxAmountUnits.lt(inputAmount)) { - trackEvent({ - error_message: "more_than_maximum_withdrawal", - event: "form_error", - input_amount: inputAmount ? inputAmount.toString() : "0" - }); - return t("pages.swap.error.moreThanMaximumWithdrawal.buy", { - assetSymbol: fromToken.fiat.symbol, - maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2) - }); - } + const isTooHigh = inputAmount && maxAmountUnits.lt(inputAmount); + const isTooLow = inputAmount && !inputAmount.eq(0) && minAmountUnits.gt(inputAmount); - if (inputAmount && !inputAmount.eq(0) && minAmountUnits.gt(inputAmount)) { + if (isTooHigh || isTooLow) { trackEvent({ - error_message: "less_than_minimum_withdrawal", + error_message: isTooHigh ? "more_than_maximum_withdrawal" : "less_than_minimum_withdrawal", event: "form_error", input_amount: inputAmount ? inputAmount.toString() : "0" }); - return t("pages.swap.error.lessThanMinimumWithdrawal.buy", { + return t("pages.swap.error.amountOutOfRange.buy", { assetSymbol: fromToken.fiat.symbol, + maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2), minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2) }); } @@ -104,36 +94,24 @@ function validateOfframp( const maxAmountUnits = multiplyByPowerOfTen(Big(toToken.maxSellAmountRaw), -toToken.decimals); const minAmountUnits = multiplyByPowerOfTen(Big(toToken.minSellAmountRaw), -toToken.decimals); + const amountOut = quote ? Big(quote.outputAmount) : Big(0); + + const isTooHigh = inputAmount && quote && maxAmountUnits.lt(amountOut); + const isTooLow = !amountOut.eq(0) && !config.test.overwriteMinimumTransferAmount && minAmountUnits.gt(amountOut); - if (inputAmount && quote && maxAmountUnits.lt(Big(quote.outputAmount))) { + if (isTooHigh || isTooLow) { trackEvent({ - error_message: "more_than_maximum_withdrawal", + error_message: isTooHigh ? "more_than_maximum_withdrawal" : "less_than_minimum_withdrawal", event: "form_error", input_amount: inputAmount ? inputAmount.toString() : "0" }); - return t("pages.swap.error.moreThanMaximumWithdrawal.sell", { + return t("pages.swap.error.amountOutOfRange.sell", { assetSymbol: toToken.fiat.symbol, - maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2) + maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2), + minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2) }); } - const amountOut = quote ? Big(quote.outputAmount) : Big(0); - - if (!amountOut.eq(0)) { - if (!config.test.overwriteMinimumTransferAmount && minAmountUnits.gt(amountOut)) { - trackEvent({ - error_message: "less_than_minimum_withdrawal", - event: "form_error", - input_amount: inputAmount ? inputAmount.toString() : "0" - }); - - return t("pages.swap.error.lessThanMinimumWithdrawal.sell", { - assetSymbol: toToken.fiat.symbol, - minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2) - }); - } - } - return null; } @@ -178,8 +156,6 @@ export const useRampValidation = () => { }); const getCurrentErrorMessage = useCallback(() => { - if (quoteError) return t(quoteError); - if (isDisconnected) return; // First check if the fiat token is enabled @@ -207,6 +183,8 @@ export const useRampValidation = () => { if (validationError) return validationError; + if (quoteError) return t(quoteError); + return null; }, [ quoteError, diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index d0eee065b..c77cf544a 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -106,7 +106,8 @@ "birthdate": { "future": "Birthdate cannot be in the future", "required": "Birthdate is required", - "tooOld": "Invalid birthdate" + "tooOld": "Invalid birthdate", + "tooYoung": "You must be at least 18 years old" }, "cep": { "minLength": "CEP must be at least 3 characters", @@ -219,6 +220,7 @@ "rgFront": "RG Front", "uploadSelfie": "Upload Selfie" }, + "helperText": "Click here to upload", "title": "Fast-Track Verification", "uploadBug": "There was an error uploading the files for verification. Please try again later.", "uploadFailed": "Upload failed. Please try again.", @@ -542,7 +544,6 @@ "titlePart2": "inside your APP", "widgetIntegration": "Widget integration" }, - "whyVortexApi": { "cta": { "npmPackage": "NPM package", @@ -919,6 +920,10 @@ "developedBy": "Developed by", "error": { "ARS_tokenUnavailable": "Improving your ARS exit - back shortly! ", + "amountOutOfRange": { + "buy": "{{assetSymbol}} orders must be between {{minAmountUnits}} and {{maxAmountUnits}}", + "sell": "{{assetSymbol}} orders must be between {{minAmountUnits}} and {{maxAmountUnits}}" + }, "BRL_tokenUnavailable": "Improving your BRL exit - back shortly! ", "EURC_tokenUnavailable": "Improving your EUR exit - back shortly! ", "feeComponents": "Failed to calculate the fees. Please try again with a different amount.", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 0e5605ab0..9447743dc 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -107,7 +107,8 @@ "birthdate": { "future": "Data de nascimento não pode ser no futuro", "required": "Data de nascimento é obrigatória", - "tooOld": "Data de nascimento inválida" + "tooOld": "Data de nascimento inválida", + "tooYoung": "Você deve ter pelo menos 18 anos de idade" }, "cep": { "minLength": "CEP deve ter pelo menos 3 caracteres", @@ -220,6 +221,7 @@ "rgFront": "Frente do RG", "uploadSelfie": "Enviar selfie" }, + "helperText": "Clique aqui para enviar", "title": "Verificação Rápida", "uploadBug": "Ocorreu um erro ao enviar os arquivos para verificação. Por favor, tente novamente mais tarde.", "uploadFailed": "Falha no envio. Por favor, tente novamente.", @@ -912,6 +914,10 @@ "developedBy": "Desenvolvido por", "error": { "ARS_tokenUnavailable": "Ajustando sua saída ARS - em breve!", + "amountOutOfRange": { + "buy": "Pedidos em {{assetSymbol}} devem estar entre {{minAmountUnits}} e {{maxAmountUnits}}", + "sell": "Pedidos em {{assetSymbol}} devem estar entre {{minAmountUnits}} e {{maxAmountUnits}}" + }, "BRL_tokenUnavailable": "Ajustando sua saída BRL - em breve!", "EURC_tokenUnavailable": "Ajustando sua saída EUR - em breve!", "feeComponents": "Falha ao calcular as taxas. Por favor, tente novamente com um valor diferente.", diff --git a/packages/shared/src/services/brla/helpers.ts b/packages/shared/src/services/brla/helpers.ts index 6b70b4104..18df512fa 100644 --- a/packages/shared/src/services/brla/helpers.ts +++ b/packages/shared/src/services/brla/helpers.ts @@ -18,10 +18,64 @@ export function generateReferenceLabel(quote: Quote): string { export const CPF_REGEX = /^\d{3}(\.\d{3}){2}-\d{2}$|^\d{11}$/; export const CNPJ_REGEX = /^(\d{2}\.?\d{3}\.?\d{3}\/?\d{4}-?\d{2})$/; +/** + * Checks if all digits in a string are the same (e.g., "11111111111") + */ +function hasAllSameDigits(digits: string): boolean { + if (digits.length === 0) return false; + const firstDigit = digits[0]; + return digits.split("").every(d => d === firstDigit); +} + +/** + * Checks if digits form an ascending sequence (e.g., "12345678901" or "01234567890") + */ +function isAscendingSequence(digits: string): boolean { + for (let i = 1; i < digits.length; i++) { + const prev = parseInt(digits[i - 1], 10); + const curr = parseInt(digits[i], 10); + // Allow wrap-around from 9 to 0 + if (curr !== (prev + 1) % 10) { + return false; + } + } + return true; +} + +/** + * Checks if digits form a descending sequence (e.g., "98765432109" or "10987654321") + */ +function isDescendingSequence(digits: string): boolean { + for (let i = 1; i < digits.length; i++) { + const prev = parseInt(digits[i - 1], 10); + const curr = parseInt(digits[i], 10); + // Allow wrap-around from 0 to 9 + if (curr !== (prev - 1 + 10) % 10) { + return false; + } + } + return true; +} + +/** + * Checks if the input contains a trivial pattern (all same digits or sequential) + */ +function isTrivialPattern(input: string): boolean { + // Extract only digits + const digits = input.replace(/\D/g, ""); + if (digits.length === 0) return false; + + return hasAllSameDigits(digits) || isAscendingSequence(digits) || isDescendingSequence(digits); +} + export function isValidCnpj(cnpj: string): boolean { - return CNPJ_REGEX.test(cnpj); + if (!CNPJ_REGEX.test(cnpj)) return false; + if (isTrivialPattern(cnpj)) return false; + return true; } export function isValidCpf(cpf: string): boolean { - return CPF_REGEX.test(cpf); + if (!CPF_REGEX.test(cpf)) return false; + if (isTrivialPattern(cpf)) return false; + return true; } diff --git a/packages/shared/src/tokens/stellar/config.ts b/packages/shared/src/tokens/stellar/config.ts index c174e6726..e7edc486b 100644 --- a/packages/shared/src/tokens/stellar/config.ts +++ b/packages/shared/src/tokens/stellar/config.ts @@ -18,7 +18,7 @@ export const stellarTokenConfig: Partial> }, maxBuyAmountRaw: "10000000000000000", maxSellAmountRaw: "10000000000000000", - minBuyAmountRaw: "1000000000000", + minBuyAmountRaw: "10000000000000", minSellAmountRaw: "25000000000000", pendulumRepresentative: { assetSymbol: "EURC", From 1f8dce6cb0a5a388c7ffb1ff49c87f362bbfacde Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 6 Jan 2026 10:45:14 -0300 Subject: [PATCH 2/9] show different datepicker icon --- apps/frontend/src/components/Avenia/AveniaField/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/Avenia/AveniaField/index.tsx b/apps/frontend/src/components/Avenia/AveniaField/index.tsx index 9e32a4710..207c6d3b3 100644 --- a/apps/frontend/src/components/Avenia/AveniaField/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaField/index.tsx @@ -1,3 +1,4 @@ +import { CalendarDaysIcon } from "@heroicons/react/24/outline"; import { motion } from "motion/react"; import { FC } from "react"; import { useFormContext, useFormState } from "react-hook-form"; @@ -69,7 +70,12 @@ export const AveniaField: FC = ({ id, label, index, validation - +
+ + {id === ExtendedAveniaFieldOptions.BIRTHDATE && ( + + )} +
{errorMessage && {errorMessage}} ); From 27b1eebfc357d541ce53d030bec042128a88366c Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 6 Jan 2026 11:55:09 -0300 Subject: [PATCH 3/9] remove spinner on ramp confirm button with validation errors --- apps/frontend/src/components/QuoteSubmitButtons/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/components/QuoteSubmitButtons/index.tsx b/apps/frontend/src/components/QuoteSubmitButtons/index.tsx index b2916c480..cb3ac4fd5 100644 --- a/apps/frontend/src/components/QuoteSubmitButtons/index.tsx +++ b/apps/frontend/src/components/QuoteSubmitButtons/index.tsx @@ -106,7 +106,7 @@ export const QuoteSubmitButton: FC = ({ className, disab return (
From f43f63aba03a3f0b83f95f8182f96023a4ce3f02 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Thu, 8 Jan 2026 17:15:14 -0300 Subject: [PATCH 4/9] validate fields are not empty to enable continue button --- .../DetailsStep/DetailsStepActions.tsx | 29 +++++++++++++++---- .../widget-steps/DetailsStep/index.tsx | 16 +--------- packages/shared/src/tokens/stellar/config.ts | 2 +- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepActions.tsx b/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepActions.tsx index 6d097382a..5c9cb283a 100644 --- a/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepActions.tsx +++ b/apps/frontend/src/components/widget-steps/DetailsStep/DetailsStepActions.tsx @@ -1,5 +1,5 @@ import { Networks } from "@vortexfi/shared"; -import { FieldErrors } from "react-hook-form"; +import { useFormContext } from "react-hook-form"; import { RampFormValues } from "../../../hooks/ramp/schema"; import { useVortexAccount } from "../../../hooks/useVortexAccount"; import { ConnectWalletSection } from "../../ConnectWalletSection"; @@ -12,7 +12,7 @@ export interface DetailsStepActionsProps { requiresConnection: boolean; className?: string; forceNetwork?: Networks; - formErrors?: FieldErrors; + isBrazilLanding: boolean; } export const DetailsStepActions = ({ @@ -20,11 +20,30 @@ export const DetailsStepActions = ({ className, requiresConnection, forceNetwork, - formErrors + isBrazilLanding }: DetailsStepActionsProps) => { const { shouldDisplay: signingBoxVisible, signatureState, confirmations } = signingState; const { isConnected } = useVortexAccount(forceNetwork); + const { + formState: { errors }, + watch + } = useFormContext(); + const formValues = watch(); + + const hasFormErrors = Object.keys(errors).length > 0; + + let hasEmptyForm = false; + + if (isBrazilLanding) { + const allRelevantFieldsEmpty = !formValues.taxId || !formValues.walletAddress; + hasEmptyForm = allRelevantFieldsEmpty; + } else { + hasEmptyForm = !formValues.walletAddress; + } + + const hasValidationErrors = hasFormErrors || hasEmptyForm; + if (signingBoxVisible) { return (
@@ -37,9 +56,7 @@ export const DetailsStepActions = ({ return (
{requiresConnection && } - {displayRampSubmitButton && ( - 0} /> - )} + {displayRampSubmitButton && }
); }; diff --git a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx index 6365f009d..a2291a00a 100644 --- a/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/DetailsStep/index.tsx @@ -88,19 +88,6 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { const previousValues = useRef({}); const currentValues = form.watch(); - useEffect(() => { - const valuesChanged = JSON.stringify(currentValues) !== JSON.stringify(previousValues.current); - const hasErrors = Object.keys(form.formState.errors).length > 0; - - if (valuesChanged && hasErrors) { - form.clearErrors(); - } - - if (valuesChanged) { - previousValues.current = currentValues; - } - }, [currentValues, form]); - const { onRampConfirm } = useRampSubmission(); const signingState: SigningState = { @@ -114,7 +101,6 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { const canSkipConnection = quote?.from === "pix"; const handleFormSubmit = (data: FormData) => { - console.log("form errors: ", form.formState.errors); rampActor.send({ address: data.walletAddress, type: "SET_ADDRESS" @@ -143,7 +129,7 @@ export const DetailsStep = ({ className }: DetailsStepProps) => { )} diff --git a/packages/shared/src/tokens/stellar/config.ts b/packages/shared/src/tokens/stellar/config.ts index e7edc486b..c174e6726 100644 --- a/packages/shared/src/tokens/stellar/config.ts +++ b/packages/shared/src/tokens/stellar/config.ts @@ -18,7 +18,7 @@ export const stellarTokenConfig: Partial> }, maxBuyAmountRaw: "10000000000000000", maxSellAmountRaw: "10000000000000000", - minBuyAmountRaw: "10000000000000", + minBuyAmountRaw: "1000000000000", minSellAmountRaw: "25000000000000", pendulumRepresentative: { assetSymbol: "EURC", From 3d45c22b51001ac5ea95ee57372dc199f738f4a8 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 9 Jan 2026 11:14:23 -0300 Subject: [PATCH 5/9] prioritize limits vs balance warning --- .../AveniaKycEligibilityFields/index.tsx | 1 - .../src/hooks/ramp/useRampValidation.ts | 36 +++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx b/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx index 9f160fb82..4450045ec 100644 --- a/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKycEligibilityFields/index.tsx @@ -31,7 +31,6 @@ const StandardBrlaFieldOptionsValidationPatterns: Partial void; } ): string | null { - if (typeof userInputTokenBalance === "string") { - const isNativeToken = fromToken.isNative; - if (Big(userInputTokenBalance).lt(inputAmount ?? 0)) { - trackEvent({ - error_message: "insufficient_balance", - event: "form_error", - input_amount: inputAmount ? inputAmount.toString() : "0" - }); - return t("pages.swap.error.insufficientFunds", { - assetSymbol: fromToken?.assetSymbol, - userInputTokenBalance - }); - // If the user chose the max amount, show a warning for native tokens due to gas fees - } else if (isNativeToken && Big(userInputTokenBalance).eq(inputAmount)) { - return t("pages.swap.error.gasWarning"); - } - } - const maxAmountUnits = multiplyByPowerOfTen(Big(toToken.maxSellAmountRaw), -toToken.decimals); const minAmountUnits = multiplyByPowerOfTen(Big(toToken.minSellAmountRaw), -toToken.decimals); const amountOut = quote ? Big(quote.outputAmount) : Big(0); @@ -112,6 +94,24 @@ function validateOfframp( }); } + if (typeof userInputTokenBalance === "string") { + const isNativeToken = fromToken.isNative; + if (Big(userInputTokenBalance).lt(inputAmount ?? 0)) { + trackEvent({ + error_message: "insufficient_balance", + event: "form_error", + input_amount: inputAmount ? inputAmount.toString() : "0" + }); + return t("pages.swap.error.insufficientFunds", { + assetSymbol: fromToken?.assetSymbol, + userInputTokenBalance + }); + // If the user chose the max amount, show a warning for native tokens due to gas fees + } else if (isNativeToken && Big(userInputTokenBalance).eq(inputAmount)) { + return t("pages.swap.error.gasWarning"); + } + } + return null; } From 906705772995b3f278c07453a3382cb3104c707f Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 9 Jan 2026 17:01:27 -0300 Subject: [PATCH 6/9] improve error messaging for limits --- apps/api/src/api/services/quote/index.ts | 6 ++++- .../src/hooks/ramp/useRampValidation.ts | 27 ++++++++++++++++--- .../src/stores/quote/useQuoteStore.ts | 3 ++- .../shared/src/endpoints/quote.endpoints.ts | 2 ++ 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index c3dfeeba4..c3695cc69 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -178,7 +178,11 @@ export class QuoteService extends BaseRampService { await orchestrator.run(strategy, ctx); } catch (error) { logger.error(error instanceof Error ? error.message : String(error)); - throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR }); + if (error instanceof APIError) { + throw error; + } else { + throw new APIError({ message: QuoteError.FailedToCalculateQuote, status: httpStatus.INTERNAL_SERVER_ERROR }); + } } if (!ctx.builtResponse) { diff --git a/apps/frontend/src/hooks/ramp/useRampValidation.ts b/apps/frontend/src/hooks/ramp/useRampValidation.ts index 242f1a4ea..f00923753 100644 --- a/apps/frontend/src/hooks/ramp/useRampValidation.ts +++ b/apps/frontend/src/hooks/ramp/useRampValidation.ts @@ -4,6 +4,7 @@ import { getAnyFiatTokenDetails, getOnChainTokenDetailsOrDefault, OnChainTokenDetails, + QuoteError, QuoteResponse, RampDirection } from "@vortexfi/shared"; @@ -162,8 +163,30 @@ export const useRampValidation = () => { const tokenAvailabilityError = validateTokenAvailability(t, fiatToken, trackEvent); if (tokenAvailabilityError) return tokenAvailabilityError; - let validationError = null; + // For offramps, we must also show a valid error message, when backend refuses to calculate a quote + // due to limits. + + const fiatTokenDetails = getAnyFiatTokenDetails(fiatToken); + if (quoteError?.includes(QuoteError.BellowLowerLimitSell)) { + const maxAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.maxSellAmountRaw), -toToken.decimals); + const minAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.minSellAmountRaw), -toToken.decimals); + return t("pages.swap.error.amountOutOfRange.sell", { + assetSymbol: toToken.assetSymbol, + maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2), + minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2) + }); + } else if (quoteError?.includes(QuoteError.BellowLowerLimitBuy)) { + const maxAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.maxBuyAmountRaw), -fromToken.decimals); + const minAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.minBuyAmountRaw), -fromToken.decimals); + return t("pages.swap.error.amountOutOfRange.buy", { + assetSymbol: fromToken.assetSymbol, + maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2), + minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2) + }); + } else if (quoteError) return t(quoteError); + + let validationError = null; if (isOnramp) { validationError = validateOnramp(t, { fromToken: fromToken as FiatTokenDetails, @@ -183,8 +206,6 @@ export const useRampValidation = () => { if (validationError) return validationError; - if (quoteError) return t(quoteError); - return null; }, [ quoteError, diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index 76fd47f89..f9a8e1118 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -75,7 +75,8 @@ const friendlyErrorMessages: Record = { [QuoteError.InputAmountForSwapMustBeGreaterThanZero]: "pages.swap.error.tryLargerAmount", [QuoteError.InputAmountTooLow]: "pages.swap.error.tryLargerAmount", [QuoteError.InputAmountTooLowToCoverCalculatedFees]: "pages.swap.error.tryLargerAmount", - + [QuoteError.BellowLowerLimitSell]: QuoteError.BellowLowerLimitSell, // We leave this as-is, as the replacement string depends on the context + [QuoteError.BellowLowerLimitBuy]: QuoteError.BellowLowerLimitBuy, // We leave this as-is, as the replacement string depends on the context // Calculation failures - suggest different amount [QuoteError.UnableToGetPendulumTokenDetails]: "pages.swap.error.tryDifferentAmount", [QuoteError.FailedToCalculateQuote]: "pages.swap.error.tryDifferentAmount", diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 924b72c61..f121621c1 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -95,6 +95,8 @@ 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.", + BellowLowerLimitSell = "Output amount below minimum SELL limit of", + BellowLowerLimitBuy = "Input amount below minimum BUY limit of", // Token/calculation errors UnableToGetPendulumTokenDetails = "Unable to get Pendulum token details", From bd8b748996424ee97b853a3037b70ccd74c5e6f9 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 12 Jan 2026 10:56:43 -0300 Subject: [PATCH 7/9] more improvements to error display in swap form --- apps/frontend/src/hooks/ramp/useRampValidation.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/hooks/ramp/useRampValidation.ts b/apps/frontend/src/hooks/ramp/useRampValidation.ts index f00923753..8434840a6 100644 --- a/apps/frontend/src/hooks/ramp/useRampValidation.ts +++ b/apps/frontend/src/hooks/ramp/useRampValidation.ts @@ -18,7 +18,7 @@ import { TrackableEvent, useEventsContext } from "../../contexts/events"; import { useNetwork } from "../../contexts/network"; import { multiplyByPowerOfTen, stringifyBigWithSignificantDecimals } from "../../helpers/contracts"; import { useQuoteFormStore } from "../../stores/quote/useQuoteFormStore"; -import { useQuote, useQuoteError } from "../../stores/quote/useQuoteStore"; +import { useQuote, useQuoteError, useQuoteLoading } from "../../stores/quote/useQuoteStore"; import { useRampDirection } from "../../stores/rampDirectionStore"; import { useOnchainTokenBalance } from "../useOnchainTokenBalance"; import { useVortexAccount } from "../useVortexAccount"; @@ -65,6 +65,7 @@ function validateOfframp( toToken, quote, userInputTokenBalance, + isDisconnected, trackEvent }: { inputAmount: Big; @@ -72,6 +73,7 @@ function validateOfframp( toToken: FiatTokenDetails; quote: QuoteResponse; userInputTokenBalance: string | null; + isDisconnected: boolean; trackEvent: (event: TrackableEvent) => void; } ): string | null { @@ -95,7 +97,7 @@ function validateOfframp( }); } - if (typeof userInputTokenBalance === "string") { + if (typeof userInputTokenBalance === "string" && !isDisconnected) { const isNativeToken = fromToken.isNative; if (Big(userInputTokenBalance).lt(inputAmount ?? 0)) { trackEvent({ @@ -137,6 +139,7 @@ export const useRampValidation = () => { const { inputAmount: inputAmountString, onChainToken, fiatToken } = useQuoteFormStore(); const quote = useQuote(); + const quoteLoading = useQuoteLoading(); const quoteError = useQuoteError(); const { selectedNetwork } = useNetwork(); const { trackEvent } = useEventsContext(); @@ -157,7 +160,7 @@ export const useRampValidation = () => { }); const getCurrentErrorMessage = useCallback(() => { - if (isDisconnected) return; + if (quoteLoading) return null; // First check if the fiat token is enabled const tokenAvailabilityError = validateTokenAvailability(t, fiatToken, trackEvent); @@ -197,6 +200,7 @@ export const useRampValidation = () => { validationError = validateOfframp(t, { fromToken: fromToken as OnChainTokenDetails, inputAmount, + isDisconnected, quote: quote as QuoteResponse, toToken: toToken as FiatTokenDetails, trackEvent, @@ -217,6 +221,7 @@ export const useRampValidation = () => { trackEvent, toToken, quote, + quoteLoading, userInputTokenBalance?.balance, fiatToken ]); From 9e34f4a47f00533158bc466a6653777dcab4ae72 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 12 Jan 2026 11:21:50 -0300 Subject: [PATCH 8/9] add error variant to catch --- apps/frontend/src/hooks/ramp/useRampValidation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/hooks/ramp/useRampValidation.ts b/apps/frontend/src/hooks/ramp/useRampValidation.ts index 8434840a6..ae2ab81f8 100644 --- a/apps/frontend/src/hooks/ramp/useRampValidation.ts +++ b/apps/frontend/src/hooks/ramp/useRampValidation.ts @@ -179,7 +179,7 @@ export const useRampValidation = () => { maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2), minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2) }); - } else if (quoteError?.includes(QuoteError.BellowLowerLimitBuy)) { + } else if (quoteError?.includes(QuoteError.BellowLowerLimitBuy) || quoteError?.includes(QuoteError.InputAmountTooLow)) { const maxAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.maxBuyAmountRaw), -fromToken.decimals); const minAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.minBuyAmountRaw), -fromToken.decimals); return t("pages.swap.error.amountOutOfRange.buy", { From 1ae4e7cd2b1f4d43c2652da8bc66f6b28b3e9756 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Mon, 12 Jan 2026 13:47:06 -0300 Subject: [PATCH 9/9] fix typo --- apps/frontend/src/hooks/ramp/useRampValidation.ts | 4 ++-- apps/frontend/src/stores/quote/useQuoteStore.ts | 4 ++-- packages/shared/src/endpoints/quote.endpoints.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/frontend/src/hooks/ramp/useRampValidation.ts b/apps/frontend/src/hooks/ramp/useRampValidation.ts index ae2ab81f8..49e380969 100644 --- a/apps/frontend/src/hooks/ramp/useRampValidation.ts +++ b/apps/frontend/src/hooks/ramp/useRampValidation.ts @@ -171,7 +171,7 @@ export const useRampValidation = () => { const fiatTokenDetails = getAnyFiatTokenDetails(fiatToken); - if (quoteError?.includes(QuoteError.BellowLowerLimitSell)) { + if (quoteError?.includes(QuoteError.BelowLowerLimitSell)) { const maxAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.maxSellAmountRaw), -toToken.decimals); const minAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.minSellAmountRaw), -toToken.decimals); return t("pages.swap.error.amountOutOfRange.sell", { @@ -179,7 +179,7 @@ export const useRampValidation = () => { maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 2), minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 2) }); - } else if (quoteError?.includes(QuoteError.BellowLowerLimitBuy) || quoteError?.includes(QuoteError.InputAmountTooLow)) { + } else if (quoteError?.includes(QuoteError.BelowLowerLimitBuy) || quoteError?.includes(QuoteError.InputAmountTooLow)) { const maxAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.maxBuyAmountRaw), -fromToken.decimals); const minAmountUnits = multiplyByPowerOfTen(Big(fiatTokenDetails.minBuyAmountRaw), -fromToken.decimals); return t("pages.swap.error.amountOutOfRange.buy", { diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index f9a8e1118..4ef47cf17 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -75,8 +75,8 @@ const friendlyErrorMessages: Record = { [QuoteError.InputAmountForSwapMustBeGreaterThanZero]: "pages.swap.error.tryLargerAmount", [QuoteError.InputAmountTooLow]: "pages.swap.error.tryLargerAmount", [QuoteError.InputAmountTooLowToCoverCalculatedFees]: "pages.swap.error.tryLargerAmount", - [QuoteError.BellowLowerLimitSell]: QuoteError.BellowLowerLimitSell, // We leave this as-is, as the replacement string depends on the context - [QuoteError.BellowLowerLimitBuy]: QuoteError.BellowLowerLimitBuy, // We leave this as-is, as the replacement string depends on the context + [QuoteError.BelowLowerLimitSell]: QuoteError.BelowLowerLimitSell, // We leave this as-is, as the replacement string depends on the context + [QuoteError.BelowLowerLimitBuy]: QuoteError.BelowLowerLimitBuy, // We leave this as-is, as the replacement string depends on the context // Calculation failures - suggest different amount [QuoteError.UnableToGetPendulumTokenDetails]: "pages.swap.error.tryDifferentAmount", [QuoteError.FailedToCalculateQuote]: "pages.swap.error.tryDifferentAmount", diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index f121621c1..265fe0550 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -95,8 +95,8 @@ 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.", - BellowLowerLimitSell = "Output amount below minimum SELL limit of", - BellowLowerLimitBuy = "Input amount below minimum BUY limit of", + BelowLowerLimitSell = "Output amount below minimum SELL limit of", + BelowLowerLimitBuy = "Input amount below minimum BUY limit of", // Token/calculation errors UnableToGetPendulumTokenDetails = "Unable to get Pendulum token details",