Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions apps/api/src/api/services/quote/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
125 changes: 103 additions & 22 deletions apps/frontend/src/hooks/ramp/useRampValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,68 @@ 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";
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 {
Expand All @@ -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)
});
}

Expand All @@ -74,6 +117,7 @@ function validateOfframp(
toToken,
quote,
limits,
locale,
userInputTokenBalance,
isDisconnected,
trackEvent
Expand All @@ -83,6 +127,7 @@ function validateOfframp(
toToken: FiatTokenDetails;
quote: QuoteResponse;
limits?: AmountLimits;
locale: string;
userInputTokenBalance: string | null;
isDisconnected: boolean;
trackEvent: (event: TrackableEvent) => void;
Expand Down Expand Up @@ -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)
});
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand All @@ -223,6 +301,7 @@ export const useRampValidation = () => {
fromToken: fromToken as FiatTokenDetails,
inputAmount,
limits,
locale,
trackEvent
});
} else {
Expand All @@ -231,6 +310,7 @@ export const useRampValidation = () => {
inputAmount,
isDisconnected,
limits,
locale,
quote: quote as QuoteResponse,
toToken: toToken as FiatTokenDetails,
trackEvent,
Expand All @@ -246,6 +326,7 @@ export const useRampValidation = () => {
isDisconnected,
isOnramp,
t,
locale,
inputAmount,
fromToken,
trackEvent,
Expand Down
7 changes: 5 additions & 2 deletions apps/frontend/src/stores/quote/useQuoteStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,11 @@ const friendlyErrorMessages: Record<QuoteError, string> = {
[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",
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/endpoints/quote.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 7 additions & 2 deletions packages/shared/src/services/alfredpay/alfredpayApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 14 additions & 4 deletions packages/shared/src/services/alfredpay/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading