From 41db8d6cbb08f9d93a8c9643aa19715dc04b77be Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Thu, 28 May 2026 17:51:36 +0100 Subject: [PATCH] refactor Alfredpay error validation --- apps/api/src/api/services/quote/index.ts | 23 +++- .../src/hooks/ramp/useRampValidation.ts | 125 +++++++++++++++--- .../src/stores/quote/useQuoteStore.ts | 7 +- .../shared/src/endpoints/quote.endpoints.ts | 2 + .../services/alfredpay/alfredpayApiService.ts | 9 +- .../shared/src/services/alfredpay/types.ts | 18 ++- 6 files changed, 147 insertions(+), 37 deletions(-) diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 29b0c6a3f..e4751f20c 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -204,13 +204,7 @@ export class QuoteService extends BaseRampService { // Detect Alfredpay trade limit error and surface it as a user-facing limit error if (error instanceof AlfredpayTradeLimitError) { - const isOnramp = ctx.request.rampType === RampDirection.BUY; - throw new APIError({ - message: isOnramp - ? `${QuoteError.BelowLowerLimitBuy} ${error.minQuantity} ${error.fromCurrency}` - : `${QuoteError.BelowLowerLimitSell} ${error.minQuantity} ${error.fromCurrency}`, - status: httpStatus.BAD_REQUEST - }); + throw mapAlfredpayLimitErrorToApiError(error, ctx.request.rampType === RampDirection.BUY); } // Wrap unexpected errors as generic failure @@ -252,6 +246,21 @@ export class QuoteService extends BaseRampService { } } +function mapAlfredpayLimitErrorToApiError(error: AlfredpayTradeLimitError, isOnramp: boolean): APIError { + const prefix = selectAlfredpayLimitPrefix(error.kind === "above", isOnramp); + return new APIError({ + message: `${prefix} ${error.quantity} ${error.fromCurrency}`, + status: httpStatus.BAD_REQUEST + }); +} + +function selectAlfredpayLimitPrefix(isAboveMax: boolean, isOnramp: boolean): QuoteError { + if (isAboveMax && isOnramp) return QuoteError.AboveUpperLimitBuy; + if (isAboveMax) return QuoteError.AboveUpperLimitSell; + if (isOnramp) return QuoteError.BelowLowerLimitBuy; + return QuoteError.BelowLowerLimitSell; +} + function requiresEvmPartnerPayout(request: CreateQuoteRequest): boolean { if (request.rampType === RampDirection.SELL && request.outputCurrency === FiatToken.BRL) { const fromNetwork = getNetworkFromDestination(request.from); diff --git a/apps/frontend/src/hooks/ramp/useRampValidation.ts b/apps/frontend/src/hooks/ramp/useRampValidation.ts index 39db69077..469f0397c 100644 --- a/apps/frontend/src/hooks/ramp/useRampValidation.ts +++ b/apps/frontend/src/hooks/ramp/useRampValidation.ts @@ -17,7 +17,7 @@ import { config } from "../../config"; import { getTokenDisabledReason, isFiatTokenDisabled } from "../../config/tokenAvailability"; import { TrackableEvent, useEventsContext } from "../../contexts/events"; import { useNetwork } from "../../contexts/network"; -import { multiplyByPowerOfTen, stringifyBigWithSignificantDecimals } from "../../helpers/contracts"; +import { multiplyByPowerOfTen } from "../../helpers/contracts"; import { getEvmTokenConfig } from "../../services/tokens"; import { useQuoteFormStore } from "../../stores/quote/useQuoteFormStore"; import { useQuote, useQuoteError, useQuoteLoading } from "../../stores/quote/useQuoteStore"; @@ -25,17 +25,60 @@ import { useRampDirection } from "../../stores/rampDirectionStore"; import { useOnchainTokenBalance } from "../useOnchainTokenBalance"; import { useVortexAccount } from "../useVortexAccount"; +function formatLimitAmount(amount: Big, locale: string): string { + return amount.toNumber().toLocaleString(locale, { maximumFractionDigits: 2 }); +} + +// Backend limit errors carry the value in the suffix, e.g. "...limit of 10000.00 EUR". +const BACKEND_LIMIT_VALUE_REGEX = /of\s+(\d+(?:\.\d+)?)\s+([A-Z]{3})/; + +function extractBackendLimit(error: string, locale: string): { amount: string; symbol: string } | null { + const match = error.match(BACKEND_LIMIT_VALUE_REGEX); + if (!match) return null; + return { amount: formatLimitAmount(new Big(match[1]), locale), symbol: match[2] }; +} + +type LimitKind = "min" | "max"; +type LimitDirection = "buy" | "sell"; + +function buildLimitMessage( + t: TFunction<"translation", undefined>, + args: { + kind: LimitKind; + direction: LimitDirection; + fallbackRawAmount: string; + fallbackDecimals: number; + fallbackSymbol: string; + locale: string; + quoteError?: string | null; + } +): string { + const { kind, direction, fallbackRawAmount, fallbackDecimals, fallbackSymbol, locale, quoteError } = args; + const parsed = quoteError ? extractBackendLimit(quoteError, locale) : null; + const key = + kind === "min" + ? `pages.swap.error.lessThanMinimumWithdrawal.${direction}` + : `pages.swap.error.moreThanMaximumWithdrawal.${direction}`; + const valueField = kind === "min" ? "minAmountUnits" : "maxAmountUnits"; + return t(key, { + assetSymbol: parsed?.symbol ?? fallbackSymbol, + [valueField]: parsed?.amount ?? formatLimitAmount(multiplyByPowerOfTen(Big(fallbackRawAmount), -fallbackDecimals), locale) + }); +} + function validateOnramp( t: TFunction<"translation", undefined>, { inputAmount, fromToken, limits, + locale, trackEvent }: { inputAmount: Big; fromToken: FiatTokenDetails; limits?: AmountLimits; + locale: string; trackEvent: (event: TrackableEvent) => void; } ): string | null { @@ -55,11 +98,11 @@ function validateOnramp( event: "form_error", input_amount: inputAmount.toString() }); - const key = isTooHigh ? "pages.swap.error.amountOutOfRange.buyTooHigh" : "pages.swap.error.amountOutOfRange.buyTooLow"; + const key = isTooHigh ? "pages.swap.error.moreThanMaximumWithdrawal.buy" : "pages.swap.error.lessThanMinimumWithdrawal.buy"; return t(key, { assetSymbol: fromToken.fiat.symbol, - maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 0), - minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 0) + maxAmountUnits: formatLimitAmount(maxAmountUnits, locale), + minAmountUnits: formatLimitAmount(minAmountUnits, locale) }); } @@ -74,6 +117,7 @@ function validateOfframp( toToken, quote, limits, + locale, userInputTokenBalance, isDisconnected, trackEvent @@ -83,6 +127,7 @@ function validateOfframp( toToken: FiatTokenDetails; quote: QuoteResponse; limits?: AmountLimits; + locale: string; userInputTokenBalance: string | null; isDisconnected: boolean; trackEvent: (event: TrackableEvent) => void; @@ -114,11 +159,13 @@ function validateOfframp( event: "form_error", input_amount: inputAmount.toString() }); - const key = isTooHigh ? "pages.swap.error.amountOutOfRange.sellTooHigh" : "pages.swap.error.amountOutOfRange.sellTooLow"; + const key = isTooHigh + ? "pages.swap.error.moreThanMaximumWithdrawal.sell" + : "pages.swap.error.lessThanMinimumWithdrawal.sell"; return t(key, { assetSymbol: unitSymbol, - maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 0), - minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 0) + maxAmountUnits: formatLimitAmount(maxAmountUnits, locale), + minAmountUnits: formatLimitAmount(minAmountUnits, locale) }); } @@ -160,7 +207,8 @@ function validateTokenAvailability( } export const useRampValidation = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const locale = i18n.language; const { inputAmount: inputAmountString, onChainToken, fiatToken } = useQuoteFormStore(); const quote = useQuote(); @@ -198,21 +246,51 @@ export const useRampValidation = () => { const fiatTokenDetails = getAnyFiatTokenDetails(fiatToken); - 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.sellTooLow", { - assetSymbol: toToken.assetSymbol, - maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 0), - minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 0) + const isBelowSellLimit = quoteError?.includes(QuoteError.BelowLowerLimitSell); + const isBelowBuyLimit = + quoteError?.includes(QuoteError.BelowLowerLimitBuy) || quoteError?.includes(QuoteError.InputAmountTooLow); + const isAboveSellLimit = quoteError?.includes(QuoteError.AboveUpperLimitSell); + const isAboveBuyLimit = quoteError?.includes(QuoteError.AboveUpperLimitBuy); + + if (isBelowSellLimit) { + return buildLimitMessage(t, { + direction: "sell", + fallbackDecimals: toToken.decimals, + fallbackRawAmount: fiatTokenDetails.minSellAmountRaw, + fallbackSymbol: toToken.assetSymbol, + kind: "min", + locale, + quoteError + }); + } else if (isBelowBuyLimit) { + return buildLimitMessage(t, { + direction: "buy", + fallbackDecimals: fromToken.decimals, + fallbackRawAmount: fiatTokenDetails.minBuyAmountRaw, + fallbackSymbol: fromToken.assetSymbol, + kind: "min", + locale, + quoteError + }); + } else if (isAboveSellLimit) { + return buildLimitMessage(t, { + direction: "sell", + fallbackDecimals: toToken.decimals, + fallbackRawAmount: fiatTokenDetails.maxSellAmountRaw, + fallbackSymbol: toToken.assetSymbol, + kind: "max", + locale, + quoteError }); - } 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.buyTooLow", { - assetSymbol: fromToken.assetSymbol, - maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 0), - minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 0) + } else if (isAboveBuyLimit) { + return buildLimitMessage(t, { + direction: "buy", + fallbackDecimals: fromToken.decimals, + fallbackRawAmount: fiatTokenDetails.maxBuyAmountRaw, + fallbackSymbol: fromToken.assetSymbol, + kind: "max", + locale, + quoteError }); } else if (quoteError) return t(quoteError); @@ -223,6 +301,7 @@ export const useRampValidation = () => { fromToken: fromToken as FiatTokenDetails, inputAmount, limits, + locale, trackEvent }); } else { @@ -231,6 +310,7 @@ export const useRampValidation = () => { inputAmount, isDisconnected, limits, + locale, quote: quote as QuoteResponse, toToken: toToken as FiatTokenDetails, trackEvent, @@ -246,6 +326,7 @@ export const useRampValidation = () => { isDisconnected, isOnramp, t, + locale, inputAmount, fromToken, trackEvent, diff --git a/apps/frontend/src/stores/quote/useQuoteStore.ts b/apps/frontend/src/stores/quote/useQuoteStore.ts index 7238378e1..444eddd87 100644 --- a/apps/frontend/src/stores/quote/useQuoteStore.ts +++ b/apps/frontend/src/stores/quote/useQuoteStore.ts @@ -62,8 +62,11 @@ const friendlyErrorMessages: Record = { [QuoteError.InputAmountForSwapMustBeGreaterThanZero]: "pages.swap.error.tryLargerAmount", [QuoteError.InputAmountTooLow]: "pages.swap.error.tryLargerAmount", [QuoteError.InputAmountTooLowToCoverCalculatedFees]: "pages.swap.error.tryLargerAmount", - [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 + // Limit errors pass through; useRampValidation rewrites them with the actual min/max. + [QuoteError.BelowLowerLimitSell]: QuoteError.BelowLowerLimitSell, + [QuoteError.BelowLowerLimitBuy]: QuoteError.BelowLowerLimitBuy, + [QuoteError.AboveUpperLimitSell]: QuoteError.AboveUpperLimitSell, + [QuoteError.AboveUpperLimitBuy]: QuoteError.AboveUpperLimitBuy, [QuoteError.LowLiquidity]: "pages.swap.error.lowLiquidity", // Calculation failures - suggest different amount [QuoteError.UnableToGetPendulumTokenDetails]: "pages.swap.error.tryDifferentAmount", diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 19a28c404..283b1655b 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -103,6 +103,8 @@ export enum QuoteError { LowLiquidity = "Low liquidity for this route. Please try a smaller amount.", BelowLowerLimitSell = "Output amount below minimum SELL limit of", BelowLowerLimitBuy = "Input amount below minimum BUY limit of", + AboveUpperLimitSell = "Output amount exceeds maximum SELL limit of", + AboveUpperLimitBuy = "Input amount exceeds maximum BUY limit of", // Availability errors UnsupportedCurrency = "Currency not supported", diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index 97074f09f..e8725830d 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -109,8 +109,13 @@ export class AlfredpayApiService { try { const parsed = JSON.parse(errorText); if (parsed.errorCode === 111426 && parsed.errorMetadata) { - const { minQuantity, fromCurrency } = parsed.errorMetadata; - throw new AlfredpayTradeLimitError(minQuantity, fromCurrency); + const { minQuantity, maxQuantity, fromCurrency } = parsed.errorMetadata; + logger.current.warn( + `Alfredpay trade limit hit: minQuantity=${minQuantity} maxQuantity=${maxQuantity} fromCurrency=${fromCurrency}` + ); + throw maxQuantity !== undefined + ? AlfredpayTradeLimitError.above(maxQuantity, fromCurrency) + : AlfredpayTradeLimitError.below(minQuantity, fromCurrency); } } catch (parseError) { if (parseError instanceof AlfredpayTradeLimitError) { diff --git a/packages/shared/src/services/alfredpay/types.ts b/packages/shared/src/services/alfredpay/types.ts index 3b0b02d88..b4a4c2dd3 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -376,15 +376,25 @@ export interface GetAllConfigsResponse { } export class AlfredpayTradeLimitError extends Error { - readonly minQuantity: string; + readonly kind: "above" | "below"; + readonly quantity: string; readonly fromCurrency: string; - constructor(minQuantity: string, fromCurrency: string) { - super(`Trade below minimum: ${minQuantity} ${fromCurrency}`); + private constructor(kind: "above" | "below", quantity: string, fromCurrency: string) { + super(`Trade ${kind === "below" ? "below minimum" : "above maximum"}: ${quantity} ${fromCurrency}`); this.name = "AlfredpayTradeLimitError"; - this.minQuantity = minQuantity; + this.kind = kind; + this.quantity = quantity; this.fromCurrency = fromCurrency; } + + static below(minQuantity: string, fromCurrency: string): AlfredpayTradeLimitError { + return new AlfredpayTradeLimitError("below", minQuantity, fromCurrency); + } + + static above(maxQuantity: string, fromCurrency: string): AlfredpayTradeLimitError { + return new AlfredpayTradeLimitError("above", maxQuantity, fromCurrency); + } } // MXN KYC form submission types