From 93ee4f7834679c07165585bf4769f4d9009d8345 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Mon, 11 May 2026 17:38:34 +0100 Subject: [PATCH 01/24] implement querying Alfredpay currency limits --- .../alfredpay/alfredpay-limits.service.ts | 174 ++++++++++++++++++ .../services/alfredpay/alfredpay.helpers.ts | 101 ++++++++++ apps/api/src/api/services/quote/core/types.ts | 6 + .../services/quote/core/validation-helpers.ts | 25 +++ .../services/quote/engines/finalize/index.ts | 6 +- .../quote/engines/finalize/offramp.ts | 17 +- .../services/quote/engines/finalize/onramp.ts | 17 +- apps/api/src/index.ts | 4 + .../src/hooks/ramp/useRampValidation.ts | 34 +++- .../shared/src/endpoints/quote.endpoints.ts | 6 + .../services/alfredpay/alfredpayApiService.ts | 10 + .../shared/src/services/alfredpay/types.ts | 18 ++ .../shared/src/tokens/freeTokens/config.ts | 102 ++++++++-- packages/shared/src/tokens/types/base.ts | 21 +++ 14 files changed, 517 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts create mode 100644 apps/api/src/api/services/alfredpay/alfredpay.helpers.ts diff --git a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts new file mode 100644 index 000000000..cbc9164ee --- /dev/null +++ b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts @@ -0,0 +1,174 @@ +import { + AlfredpayApiService, + AlfredpayConfigPair, + AlfredpayCustomerKey, + AlfredpayCustomerType, + AlfredpayLimitsBucket, + AlfredpayStablecoinKey, + FiatToken, + getAnyFiatTokenDetails, + isAlfredpayToken +} from "@vortexfi/shared"; +import Big from "big.js"; +import logger from "../../../config/logger"; + +export type AlfredpayLimitsDirection = "onramp" | "offramp"; + +/** Refreshed once on startup, then daily. Limits don't change often, so this avoids beating the API. */ +const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; + +const CUSTOMER_TYPES: AlfredpayCustomerKey[] = ["INDIVIDUAL", "BUSINESS"]; + +const ALFREDPAY_FIATS: Record = { + COP: FiatToken.COP, + MXN: FiatToken.MXN, + USD: FiatToken.USD +}; + +function isStablecoinSymbol(symbol: string): symbol is AlfredpayStablecoinKey { + return symbol === "USDC" || symbol === "USDT"; +} + +function cacheKey( + direction: AlfredpayLimitsDirection, + fiat: FiatToken, + stablecoin: AlfredpayStablecoinKey, + customer: AlfredpayCustomerKey +): string { + return `${direction}:${fiat}:${stablecoin}:${customer}`; +} + +function toRaw(quantityDecimal: string, decimals: number): string { + return new Big(quantityDecimal).mul(new Big(10).pow(decimals)).round(0, Big.roundDown).toFixed(0); +} + +export class AlfredpayLimitsService { + private static instance: AlfredpayLimitsService; + + private cache = new Map(); + private intervalHandle: ReturnType | null = null; + + public static getInstance(): AlfredpayLimitsService { + if (!AlfredpayLimitsService.instance) { + AlfredpayLimitsService.instance = new AlfredpayLimitsService(); + } + return AlfredpayLimitsService.instance; + } + + public start(): void { + if (this.intervalHandle) return; + void this.refresh(); + this.intervalHandle = setInterval(() => void this.refresh(), REFRESH_INTERVAL_MS); + } + + public stop(): void { + if (this.intervalHandle) { + clearInterval(this.intervalHandle); + this.intervalHandle = null; + } + } + + /** + * Returns the limits bucket for the given key. Falls back to hardcoded values when the cache is empty + * (first fetch hasn't succeeded yet) or doesn't contain a matching entry. + * + * Onramp raw values are scaled by the fiat's decimals; offramp raw values by the stablecoin's decimals (6). + */ + public getLimits( + fiat: FiatToken, + stablecoin: AlfredpayStablecoinKey, + customerType: AlfredpayCustomerKey, + direction: AlfredpayLimitsDirection + ): AlfredpayLimitsBucket { + const cached = this.cache.get(cacheKey(direction, fiat, stablecoin, customerType)); + if (cached) return cached; + return this.fallback(fiat, stablecoin, customerType, direction); + } + + private fallback( + fiat: FiatToken, + stablecoin: AlfredpayStablecoinKey, + customerType: AlfredpayCustomerKey, + direction: AlfredpayLimitsDirection + ): AlfredpayLimitsBucket { + const hardcoded = getAnyFiatTokenDetails(fiat).alfredpayLimits; + if (!hardcoded) { + // Should never happen for AlfredPay tokens. Return permissive sentinel so we don't reject quotes outright. + return { maxRaw: "0", minRaw: "0" }; + } + return hardcoded[direction][stablecoin][customerType]; + } + + private async refresh(): Promise { + try { + const { supportedPairs } = await AlfredpayApiService.getInstance().getAllConfigs(); + const nextCache = new Map(); + for (const pair of supportedPairs) { + this.indexPair(nextCache, pair); + } + this.cache = nextCache; + logger.info(`[AlfredpayLimits] refreshed: ${supportedPairs.length} pairs, ${nextCache.size} cache entries`); + } catch (err) { + logger.warn("[AlfredpayLimits] refresh failed, retaining previous cache (or hardcoded fallback if empty)", err); + } + } + + private indexPair(target: Map, pair: AlfredpayConfigPair): void { + const decimals = Number(pair.decimals); + if (!Number.isFinite(decimals)) return; + + const direction = this.deriveDirection(pair); + if (!direction) return; + + const { fiat, stablecoin } = direction; + const bucket: AlfredpayLimitsBucket = { + maxRaw: toRaw(pair.maxQuantity, decimals), + minRaw: toRaw(pair.minQuantity, decimals) + }; + + const customers: AlfredpayCustomerKey[] = pair.typeCustomer ? [pair.typeCustomer as AlfredpayCustomerKey] : CUSTOMER_TYPES; + for (const customer of customers) { + const key = cacheKey(direction.direction, fiat, stablecoin, customer); + // The API can return overlapping rows (typeCustomer=null + a specific BUSINESS row). Specific wins by sorting last. + target.set(key, bucket); + } + } + + private deriveDirection( + pair: AlfredpayConfigPair + ): { direction: AlfredpayLimitsDirection; fiat: FiatToken; stablecoin: AlfredpayStablecoinKey } | null { + const fromFiat = ALFREDPAY_FIATS[pair.fromCurrency]; + const toFiat = ALFREDPAY_FIATS[pair.toCurrency]; + + if (fromFiat && isStablecoinSymbol(pair.toCurrency)) { + return { direction: "onramp", fiat: fromFiat, stablecoin: pair.toCurrency }; + } + if (toFiat && isStablecoinSymbol(pair.fromCurrency)) { + return { direction: "offramp", fiat: toFiat, stablecoin: pair.fromCurrency }; + } + return null; + } + + /** + * Test helper: pre-seed the cache without going through the API. + */ + public _setForTesting(entries: Iterable<[string, AlfredpayLimitsBucket]>): void { + this.cache = new Map(entries); + } +} + +export function resolveAlfredpayLimits( + fiat: FiatToken, + stablecoin: AlfredpayStablecoinKey, + customerType: AlfredpayCustomerKey, + direction: AlfredpayLimitsDirection +): AlfredpayLimitsBucket { + if (!isAlfredpayToken(fiat)) { + throw new Error(`resolveAlfredpayLimits called with non-AlfredPay fiat: ${fiat}`); + } + return AlfredpayLimitsService.getInstance().getLimits(fiat, stablecoin, customerType, direction); +} + +export function normalizeCustomerType(type: AlfredpayCustomerType | null | undefined): AlfredpayCustomerKey { + return type === AlfredpayCustomerType.BUSINESS ? "BUSINESS" : "INDIVIDUAL"; +} diff --git a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts new file mode 100644 index 000000000..eb58a0ee8 --- /dev/null +++ b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts @@ -0,0 +1,101 @@ +import { + AlfredPayCountry, + AlfredpayCustomerKey, + AlfredpayCustomerType, + AlfredpayStablecoinKey, + FiatToken, + getAnyFiatTokenDetails, + isAlfredpayToken, + RampCurrency, + RampDirection +} from "@vortexfi/shared"; +import Big from "big.js"; +import AlfredPayCustomer from "../../../models/alfredPayCustomer.model"; +import { multiplyByPowerOfTen } from "../pendulum/helpers"; +import { AlfredpayLimitsDirection, AlfredpayLimitsService, normalizeCustomerType } from "./alfredpay-limits.service"; + +const FIAT_TO_COUNTRY: Partial> = { + [FiatToken.COP]: AlfredPayCountry.CO, + [FiatToken.MXN]: AlfredPayCountry.MX, + [FiatToken.USD]: AlfredPayCountry.US +}; + +export function alfredpayCountryForFiat(fiat: FiatToken): AlfredPayCountry | undefined { + return FIAT_TO_COUNTRY[fiat]; +} + +/** + * Returns the AlfredPay customer type for a user+country, defaulting to INDIVIDUAL when: + * - no userId is available (unauthenticated quote) + * - the user has no alfredpay_customer row for that country (KYC not started) + * + * Defaulting to INDIVIDUAL is intentional — it's the more restrictive bucket on USD/COP, so an + * anonymous quote that would later route through a Business customer just sees tighter limits at first. + */ +export async function lookupAlfredpayCustomerType( + userId: string | undefined, + fiat: FiatToken +): Promise<"INDIVIDUAL" | "BUSINESS"> { + if (!userId) return "INDIVIDUAL"; + const country = alfredpayCountryForFiat(fiat); + if (!country) return "INDIVIDUAL"; + const customer = await AlfredPayCustomer.findOne({ where: { country, userId } }); + return normalizeCustomerType(customer?.type as AlfredpayCustomerType | undefined); +} + +/** + * Resolves the stablecoin axis (USDC vs USDT) from the on-chain currency in a quote request. + * Returns null if the currency isn't a recognized AlfredPay stablecoin. + */ +export function stablecoinFromCurrency(currency: RampCurrency): AlfredpayStablecoinKey | null { + const symbol = String(currency); + if (symbol === "USDC" || symbol === "USDT") return symbol; + return null; +} + +export interface AlfredpayQuoteLimitsContext { + fiat: FiatToken; + stablecoin: AlfredpayStablecoinKey; + customerType: AlfredpayCustomerKey; + direction: AlfredpayLimitsDirection; + /** Limits expressed in human units of `inputCurrency` (the side the validator checks). */ + inputLimits: { min: string; max: string }; +} + +/** + * Resolves AlfredPay limits for a quote request, returning null when the quote isn't an AlfredPay quote. + * Throws when the on-chain side isn't a recognized AlfredPay stablecoin. + */ +export async function resolveAlfredpayQuoteLimits(args: { + rampType: RampDirection; + inputCurrency: RampCurrency; + outputCurrency: RampCurrency; + userId?: string; +}): Promise { + const { rampType, inputCurrency, outputCurrency, userId } = args; + const direction: AlfredpayLimitsDirection = rampType === RampDirection.BUY ? "onramp" : "offramp"; + const fiatCandidate = (direction === "onramp" ? inputCurrency : outputCurrency) as FiatToken; + if (!isAlfredpayToken(fiatCandidate)) return null; + + const stablecoin = stablecoinFromCurrency(direction === "onramp" ? outputCurrency : inputCurrency); + if (!stablecoin) { + throw new Error( + `Unsupported AlfredPay ${direction} stablecoin: ${direction === "onramp" ? outputCurrency : inputCurrency}` + ); + } + + const customerType = await lookupAlfredpayCustomerType(userId, fiatCandidate); + const bucket = AlfredpayLimitsService.getInstance().getLimits(fiatCandidate, stablecoin, customerType, direction); + const decimals = direction === "onramp" ? getAnyFiatTokenDetails(fiatCandidate).decimals : 6; + + return { + customerType, + direction, + fiat: fiatCandidate, + inputLimits: { + max: multiplyByPowerOfTen(new Big(bucket.maxRaw), -decimals).toFixed(), + min: multiplyByPowerOfTen(new Big(bucket.minRaw), -decimals).toFixed() + }, + stablecoin + }; +} diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index bc174bd76..fd67cc817 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -266,6 +266,12 @@ export interface QuoteContext { // Allow engines to supply a ready response (used by special-case engine and finalize stage) builtResponse?: QuoteResponse; + /** + * Resolved AlfredPay input-side limits in human units of `inputCurrency`. + * Set by the finalize engine during validation for AlfredPay quotes; surfaced on the QuoteResponse. + */ + alfredpayInputLimits?: { min: string; max: string }; + // Flag to skip database persistence (for best quote comparison) skipPersistence?: boolean; diff --git a/apps/api/src/api/services/quote/core/validation-helpers.ts b/apps/api/src/api/services/quote/core/validation-helpers.ts index e13419f16..459e57124 100644 --- a/apps/api/src/api/services/quote/core/validation-helpers.ts +++ b/apps/api/src/api/services/quote/core/validation-helpers.ts @@ -2,6 +2,7 @@ import { FiatToken, getAnyFiatTokenDetails, RampDirection } from "@vortexfi/shar import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../errors/api-error"; +import { AlfredpayQuoteLimitsContext } from "../../alfredpay/alfredpay.helpers"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; /** @@ -47,3 +48,27 @@ export function validateAmountLimits( }); } } + +/** + * Validate an amount against precomputed AlfredPay limits. The amount is in the same units as the limits: + * onramp → fiat units; offramp → stablecoin units. + */ +export function validateAlfredpayLimits(amount: Big.BigSource, limits: AlfredpayQuoteLimitsContext): void { + const amountBig = new Big(amount); + const min = new Big(limits.inputLimits.min); + const max = new Big(limits.inputLimits.max); + const unitSymbol = limits.direction === "onramp" ? getAnyFiatTokenDetails(limits.fiat).fiat.symbol : limits.stablecoin; + + if (amountBig.lt(min)) { + throw new APIError({ + message: `Input amount below minimum ${limits.direction} limit of ${min.toFixed(2)} ${unitSymbol}`, + status: httpStatus.BAD_REQUEST + }); + } + if (amountBig.gt(max)) { + throw new APIError({ + message: `Input amount exceeds maximum ${limits.direction} limit of ${max.toFixed(2)} ${unitSymbol}`, + status: httpStatus.BAD_REQUEST + }); + } +} diff --git a/apps/api/src/api/services/quote/engines/finalize/index.ts b/apps/api/src/api/services/quote/engines/finalize/index.ts index 3c829aa09..0d83dea09 100644 --- a/apps/api/src/api/services/quote/engines/finalize/index.ts +++ b/apps/api/src/api/services/quote/engines/finalize/index.ts @@ -48,6 +48,7 @@ export function buildQuoteResponse(quoteTicket: QuoteTicket): QuoteResponse { from: quoteTicket.from, id: quoteTicket.id, inputAmount: trimTrailingZeros(quoteTicket.inputAmount), + inputAmountLimits: quoteTicket.metadata.alfredpayInputLimits, inputCurrency: quoteTicket.inputCurrency, network: quoteTicket.network, networkFeeFiat: fiatFees.network, @@ -87,7 +88,7 @@ export abstract class BaseFinalizeEngine implements Stage { } const computation = await this.computeOutput(ctx); - this.validate(ctx, computation); + await this.validate(ctx, computation); const outputAmountStr = computation.amount.toFixed(computation.decimals, 0); @@ -117,6 +118,7 @@ export abstract class BaseFinalizeEngine implements Stage { from: request.from, id: "temp-" + Date.now(), // Temporary ID for comparison inputAmount: trimTrailingZeros(request.inputAmount), + inputAmountLimits: ctx.alfredpayInputLimits, inputCurrency: request.inputCurrency, network: request.network, networkFeeFiat: fiatFees.network, @@ -170,7 +172,7 @@ export abstract class BaseFinalizeEngine implements Stage { protected abstract computeOutput(ctx: QuoteContext): Promise; - protected validate(ctx: QuoteContext, result: FinalizeComputation): void { + protected async validate(_ctx: QuoteContext, _result: FinalizeComputation): Promise { // Implemented by subclasses when necessary } } diff --git a/apps/api/src/api/services/quote/engines/finalize/offramp.ts b/apps/api/src/api/services/quote/engines/finalize/offramp.ts index da1353ad6..8d3eee437 100644 --- a/apps/api/src/api/services/quote/engines/finalize/offramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/offramp.ts @@ -2,8 +2,9 @@ import { FiatToken, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../../errors/api-error"; +import { resolveAlfredpayQuoteLimits } from "../../../alfredpay/alfredpay.helpers"; import { QuoteContext } from "../../core/types"; -import { validateAmountLimits } from "../../core/validation-helpers"; +import { validateAlfredpayLimits, validateAmountLimits } from "../../core/validation-helpers"; import { BaseFinalizeEngine, FinalizeComputation } from "."; export class OffRampFinalizeEngine extends BaseFinalizeEngine { @@ -53,7 +54,19 @@ export class OffRampFinalizeEngine extends BaseFinalizeEngine { }; } - protected validate(ctx: QuoteContext, { amount }: FinalizeComputation): void { + protected async validate(ctx: QuoteContext, { amount }: FinalizeComputation): Promise { + const alfredpayLimits = await resolveAlfredpayQuoteLimits({ + inputCurrency: ctx.request.inputCurrency, + outputCurrency: ctx.request.outputCurrency, + rampType: ctx.request.rampType, + userId: ctx.request.userId + }); + + if (alfredpayLimits) { + ctx.alfredpayInputLimits = alfredpayLimits.inputLimits; + validateAlfredpayLimits(ctx.request.inputAmount, alfredpayLimits); + return; + } validateAmountLimits(amount, ctx.request.outputCurrency as FiatToken, "min", ctx.request.rampType); } } 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..18e0930fc 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.ts @@ -2,8 +2,9 @@ import { AssetHubToken, FiatToken, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../../errors/api-error"; +import { resolveAlfredpayQuoteLimits } from "../../../alfredpay/alfredpay.helpers"; import { QuoteContext } from "../../core/types"; -import { validateAmountLimits } from "../../core/validation-helpers"; +import { validateAlfredpayLimits, validateAmountLimits } from "../../core/validation-helpers"; import { BaseFinalizeEngine, FinalizeComputation } from "."; export class OnRampFinalizeEngine extends BaseFinalizeEngine { @@ -86,7 +87,19 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { }; } - protected validate(ctx: QuoteContext): void { + protected async validate(ctx: QuoteContext): Promise { + const alfredpayLimits = await resolveAlfredpayQuoteLimits({ + inputCurrency: ctx.request.inputCurrency, + outputCurrency: ctx.request.outputCurrency, + rampType: ctx.request.rampType, + userId: ctx.request.userId + }); + + if (alfredpayLimits) { + ctx.alfredpayInputLimits = alfredpayLimits.inputLimits; + validateAlfredpayLimits(ctx.request.inputAmount, alfredpayLimits); + return; + } validateAmountLimits(ctx.request.inputAmount, ctx.request.inputCurrency as FiatToken, "min", ctx.request.rampType); } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d23c6685c..a79bf99ff 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -18,6 +18,7 @@ import { } from "./constants/constants"; import { runMigrations } from "./database/migrator"; import "./models"; // Initialize models +import { AlfredpayLimitsService } from "./api/services/alfredpay/alfredpay-limits.service"; import registerPhaseHandlers from "./api/services/phases/register-handlers"; import CleanupWorker from "./api/workers/cleanup.worker"; import RampRecoveryWorker from "./api/workers/ramp-recovery.worker"; @@ -73,6 +74,9 @@ const initializeApp = async () => { new RampRecoveryWorker().start(); new UnhandledPaymentWorker().start(); + // Start AlfredPay limits refresh loop (10 min TTL; falls back to hardcoded if stale) + AlfredpayLimitsService.getInstance().start(); + // Register phase handlers registerPhaseHandlers(); diff --git a/apps/frontend/src/hooks/ramp/useRampValidation.ts b/apps/frontend/src/hooks/ramp/useRampValidation.ts index b70d52dc1..28cb0f0b8 100644 --- a/apps/frontend/src/hooks/ramp/useRampValidation.ts +++ b/apps/frontend/src/hooks/ramp/useRampValidation.ts @@ -29,15 +29,21 @@ function validateOnramp( { inputAmount, fromToken, + quoteLimits, trackEvent }: { inputAmount: Big; fromToken: FiatTokenDetails; + quoteLimits?: { min: string; max: string }; trackEvent: (event: TrackableEvent) => void; } ): string | null { - const maxAmountUnits = multiplyByPowerOfTen(Big(fromToken.maxBuyAmountRaw), -fromToken.decimals); - const minAmountUnits = multiplyByPowerOfTen(Big(fromToken.minBuyAmountRaw), -fromToken.decimals); + const maxAmountUnits = quoteLimits + ? new Big(quoteLimits.max) + : multiplyByPowerOfTen(Big(fromToken.maxBuyAmountRaw), -fromToken.decimals); + const minAmountUnits = quoteLimits + ? new Big(quoteLimits.min) + : multiplyByPowerOfTen(Big(fromToken.minBuyAmountRaw), -fromToken.decimals); const isTooHigh = inputAmount && maxAmountUnits.lt(inputAmount); const isTooLow = inputAmount && !inputAmount.eq(0) && minAmountUnits.gt(inputAmount); @@ -66,6 +72,7 @@ function validateOfframp( fromToken, toToken, quote, + quoteLimits, userInputTokenBalance, isDisconnected, trackEvent @@ -74,17 +81,27 @@ function validateOfframp( fromToken: OnChainTokenDetails; toToken: FiatTokenDetails; quote: QuoteResponse; + quoteLimits?: { min: string; max: string }; userInputTokenBalance: string | null; isDisconnected: boolean; trackEvent: (event: TrackableEvent) => void; } ): string | null { - const maxAmountUnits = multiplyByPowerOfTen(Big(toToken.maxSellAmountRaw), -toToken.decimals); - const minAmountUnits = multiplyByPowerOfTen(Big(toToken.minSellAmountRaw), -toToken.decimals); + // AlfredPay quotes return stablecoin-denominated input limits; compare against `inputAmount` (stablecoin units). + // Legacy path (BRL/EURC) compares against fiat `outputAmount` against fiat min/max. + const maxAmountUnits = quoteLimits + ? new Big(quoteLimits.max) + : multiplyByPowerOfTen(Big(toToken.maxSellAmountRaw), -toToken.decimals); + const minAmountUnits = quoteLimits + ? new Big(quoteLimits.min) + : multiplyByPowerOfTen(Big(toToken.minSellAmountRaw), -toToken.decimals); + const amountOut = quote ? Big(quote.outputAmount) : Big(0); + const amountToCheck = quoteLimits ? inputAmount : amountOut; + const unitSymbol = quoteLimits ? fromToken.assetSymbol : toToken.fiat.symbol; - const isTooHigh = inputAmount && quote && maxAmountUnits.lt(amountOut); - const isTooLow = !amountOut.eq(0) && !config.test.overwriteMinimumTransferAmount && minAmountUnits.gt(amountOut); + const isTooHigh = inputAmount && quote && maxAmountUnits.lt(amountToCheck); + const isTooLow = !amountToCheck.eq(0) && !config.test.overwriteMinimumTransferAmount && minAmountUnits.gt(amountToCheck); if (isTooHigh || isTooLow) { trackEvent({ @@ -94,7 +111,7 @@ function validateOfframp( }); const key = isTooHigh ? "pages.swap.error.amountOutOfRange.sellTooHigh" : "pages.swap.error.amountOutOfRange.sellTooLow"; return t(key, { - assetSymbol: toToken.fiat.symbol, + assetSymbol: unitSymbol, maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 0), minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 0) }); @@ -194,11 +211,13 @@ export const useRampValidation = () => { }); } else if (quoteError) return t(quoteError); + const quoteLimits = quote?.inputAmountLimits; let validationError = null; if (isOnramp) { validationError = validateOnramp(t, { fromToken: fromToken as FiatTokenDetails, inputAmount, + quoteLimits, trackEvent }); } else { @@ -207,6 +226,7 @@ export const useRampValidation = () => { inputAmount, isDisconnected, quote: quote as QuoteResponse, + quoteLimits, toToken: toToken as FiatTokenDetails, trackEvent, userInputTokenBalance: userInputTokenBalance?.balance || "0" diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 7c1fca7ba..912e51de5 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -73,6 +73,12 @@ export interface QuoteResponse { expiresAt: Date; createdAt: Date; sessionId?: string; + + /** + * Resolved input-side amount limits for this quote. Decimal-string values in human units of `inputCurrency`. + * Currently populated only for AlfredPay quotes (USD/MXN/COP). + */ + inputAmountLimits?: { min: string; max: string }; } // GET /quotes/:id diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index eef4ec2f7..97074f09f 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -25,6 +25,7 @@ import { CreateAlfredpayOnrampResponse, FindAlfredpayCustomerResponse, GetAlfredpayOnrampTransactionResponse, + GetAllConfigsResponse, GetKybRedirectLinkResponse, GetKybStatusResponse, GetKybSubmissionResponse, @@ -143,6 +144,15 @@ export class AlfredpayApiService { )) as CreateAlfredpayCustomerResponse; } + /** + * Fetch all supported trading pairs and their per-pair / per-customer-type quantity limits. + * Docs: https://alfredpay.readme.io/v2.0/reference/configurationscontroller_getallconfigs-3 + */ + public async getAllConfigs(): Promise { + const path = "/api/v1/third-party-service/penny/configurations"; + return (await this.executeRequest(path, "GET")) ?? { supportedPairs: [] }; + } + public async findCustomer(email: string, country: string): Promise { const encodedEmail = encodeURIComponent(email); const path = `/api/v1/third-party-service/penny/customers/find/${encodedEmail}/${country}`; diff --git a/packages/shared/src/services/alfredpay/types.ts b/packages/shared/src/services/alfredpay/types.ts index 1e58e4466..75ef98b7a 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -360,6 +360,24 @@ const ALFREDPAY_FIAT_TOKEN_SET = new Set([FiatToken.USD, FiatToken.MX export const isAlfredpayToken = (token: FiatToken): boolean => ALFREDPAY_FIAT_TOKEN_SET.has(token); +/** Raw shape returned by `GET …/configurations`. `typeCustomer: null` means the pair applies to both customer types. */ +export interface AlfredpayConfigPair { + id: string; + fromCurrency: string; + toCurrency: string; + businessId: string | null; + maxQuantity: string; + minQuantity: string; + decimals: string; + typeCustomer: AlfredpayCustomerType | null; + createdAt: string; + updatedAt: string; +} + +export interface GetAllConfigsResponse { + supportedPairs: AlfredpayConfigPair[]; +} + export class AlfredpayTradeLimitError extends Error { readonly minQuantity: string; readonly fromCurrency: string; diff --git a/packages/shared/src/tokens/freeTokens/config.ts b/packages/shared/src/tokens/freeTokens/config.ts index 3f4bbec72..dbffb572f 100644 --- a/packages/shared/src/tokens/freeTokens/config.ts +++ b/packages/shared/src/tokens/freeTokens/config.ts @@ -2,10 +2,88 @@ * Free token configuration (not bound to any network) */ -import { FiatCurrencyDetails, FiatToken, TokenType } from "../types/base"; +import { AlfredpayCurrencyLimits, FiatCurrencyDetails, FiatToken, TokenType } from "../types/base"; + +/** + * Hardcoded fallback AlfredPay limits derived from limits.md (May 11 2026 snapshot). + * + * Storage convention: + * - onramp raw values are scaled by the parent fiat's decimals (USD/MXN/COP = 2). + * - offramp raw values are scaled by stablecoin decimals (USDC/USDT = 6). + */ + +const USD_LIMITS: AlfredpayCurrencyLimits = { + 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: "30000000", minRaw: "100" }, + INDIVIDUAL: { maxRaw: "10000000", minRaw: "100" } + }, + USDT: { + BUSINESS: { maxRaw: "30000000", minRaw: "100" }, + INDIVIDUAL: { maxRaw: "10000000", minRaw: "100" } + } + } +}; + +const MXN_LIMITS: AlfredpayCurrencyLimits = { + offramp: { + USDC: { + BUSINESS: { maxRaw: "5000000000000", minRaw: "1000000" }, + INDIVIDUAL: { maxRaw: "5000000000000", minRaw: "1000000" } + }, + USDT: { + BUSINESS: { maxRaw: "5000000000000", minRaw: "1000000" }, + INDIVIDUAL: { maxRaw: "5000000000000", minRaw: "1000000" } + } + }, + onramp: { + USDC: { + BUSINESS: { maxRaw: "8699689121", minRaw: "20000" }, + INDIVIDUAL: { maxRaw: "8699689121", minRaw: "20000" } + }, + USDT: { + BUSINESS: { maxRaw: "8695217304", minRaw: "20000" }, + INDIVIDUAL: { maxRaw: "8695217304", minRaw: "20000" } + } + } +}; + +const COP_LIMITS: AlfredpayCurrencyLimits = { + 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: "3500000" }, + INDIVIDUAL: { maxRaw: "36865599982", minRaw: "3500000" } + }, + USDT: { + BUSINESS: { maxRaw: "110596799945", minRaw: "3500000" }, + INDIVIDUAL: { maxRaw: "36865599982", minRaw: "3500000" } + } + } +}; export const freeTokenConfig: Partial> = { [FiatToken.USD]: { + alfredpayLimits: USD_LIMITS, assetSymbol: "USD", decimals: 2, fiat: { @@ -13,13 +91,14 @@ export const freeTokenConfig: Partial> = name: "US Dollar", symbol: "USD" }, - maxBuyAmountRaw: "10000000000", - maxSellAmountRaw: "100000000000000000000", + maxBuyAmountRaw: "30000000", + maxSellAmountRaw: "300000000000", minBuyAmountRaw: "100", - minSellAmountRaw: "100", + minSellAmountRaw: "1000000", type: TokenType.Fiat }, [FiatToken.MXN]: { + alfredpayLimits: MXN_LIMITS, assetSymbol: "MXN", decimals: 2, fiat: { @@ -27,13 +106,14 @@ export const freeTokenConfig: Partial> = name: "Mexican Peso", symbol: "MXN" }, - maxBuyAmountRaw: "10000000000", - maxSellAmountRaw: "100000000000000000000", - minBuyAmountRaw: "15000", - minSellAmountRaw: "2000", + maxBuyAmountRaw: "8699689121", + maxSellAmountRaw: "5000000000000", + minBuyAmountRaw: "20000", + minSellAmountRaw: "1000000", type: TokenType.Fiat }, [FiatToken.COP]: { + alfredpayLimits: COP_LIMITS, assetSymbol: "COP", decimals: 2, fiat: { @@ -41,10 +121,10 @@ export const freeTokenConfig: Partial> = name: "Colombian Peso", symbol: "COP" }, - maxBuyAmountRaw: "10000000000", - maxSellAmountRaw: "100000000000000000000", + maxBuyAmountRaw: "110596799945", + maxSellAmountRaw: "300000000000", minBuyAmountRaw: "3500000", - minSellAmountRaw: "100", + minSellAmountRaw: "1000000", type: TokenType.Fiat } }; diff --git a/packages/shared/src/tokens/types/base.ts b/packages/shared/src/tokens/types/base.ts index 73f4d13a4..990c6cb0a 100644 --- a/packages/shared/src/tokens/types/base.ts +++ b/packages/shared/src/tokens/types/base.ts @@ -45,6 +45,25 @@ export interface FiatDetails { name: string; } +/** String-literal values match `AlfredpayCustomerType` enum in services/alfredpay/types.ts. */ +export type AlfredpayCustomerKey = "INDIVIDUAL" | "BUSINESS"; +export type AlfredpayStablecoinKey = "USDC" | "USDT"; + +export interface AlfredpayLimitsBucket { + minRaw: string; + maxRaw: string; +} + +/** + * Multi-axis AlfredPay limits. + * - `onramp` raw values are scaled by the FIAT decimals of the parent token. + * - `offramp` raw values are scaled by the STABLECOIN decimals (USDC/USDT = 6). + */ +export interface AlfredpayCurrencyLimits { + onramp: Record>; + offramp: Record>; +} + export interface BaseFiatTokenDetails { fiat: FiatDetails; minSellAmountRaw: string; @@ -53,6 +72,8 @@ export interface BaseFiatTokenDetails { maxBuyAmountRaw: string; buyFeesBasisPoints?: number; buyFeesFixedComponent?: number; + /** Multi-axis AlfredPay limits; populated only for AlfredPay-routed fiats (USD/MXN/COP). */ + alfredpayLimits?: AlfredpayCurrencyLimits; } export interface FiatCurrencyDetails extends BaseTokenDetails, BaseFiatTokenDetails { From 2fdcd3d91f35a3241ddecd05210f3734eaf10da9 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 12 May 2026 09:47:10 +0100 Subject: [PATCH 02/24] implement Alfredpay currency limits --- .../alfredpay/alfredpay-limits.service.ts | 96 ++++++++----------- .../services/alfredpay/alfredpay.helpers.ts | 59 +++++------- apps/api/src/api/services/quote/core/types.ts | 5 +- .../services/quote/core/validation-helpers.ts | 34 +++++-- .../services/quote/engines/finalize/index.ts | 8 +- .../quote/engines/finalize/offramp.ts | 16 +--- .../services/quote/engines/finalize/onramp.ts | 16 +--- .../src/hooks/ramp/useRampValidation.ts | 65 +++++++------ .../shared/src/endpoints/quote.endpoints.ts | 8 +- .../shared/src/services/alfredpay/types.ts | 11 +-- .../shared/src/tokens/freeTokens/config.ts | 20 ++-- packages/shared/src/tokens/types/base.ts | 24 +++-- 12 files changed, 173 insertions(+), 189 deletions(-) diff --git a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts index cbc9164ee..ab9931583 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts @@ -1,23 +1,20 @@ import { AlfredpayApiService, AlfredpayConfigPair, - AlfredpayCustomerKey, AlfredpayCustomerType, - AlfredpayLimitsBucket, AlfredpayStablecoinKey, FiatToken, getAnyFiatTokenDetails, - isAlfredpayToken + RampDirection, + RawAmountLimits } from "@vortexfi/shared"; import Big from "big.js"; import logger from "../../../config/logger"; -export type AlfredpayLimitsDirection = "onramp" | "offramp"; - /** Refreshed once on startup, then daily. Limits don't change often, so this avoids beating the API. */ const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; -const CUSTOMER_TYPES: AlfredpayCustomerKey[] = ["INDIVIDUAL", "BUSINESS"]; +const CUSTOMER_TYPES: AlfredpayCustomerType[] = [AlfredpayCustomerType.INDIVIDUAL, AlfredpayCustomerType.BUSINESS]; const ALFREDPAY_FIATS: Record = { COP: FiatToken.COP, @@ -30,10 +27,10 @@ function isStablecoinSymbol(symbol: string): symbol is AlfredpayStablecoinKey { } function cacheKey( - direction: AlfredpayLimitsDirection, + direction: RampDirection, fiat: FiatToken, stablecoin: AlfredpayStablecoinKey, - customer: AlfredpayCustomerKey + customer: AlfredpayCustomerType ): string { return `${direction}:${fiat}:${stablecoin}:${customer}`; } @@ -42,10 +39,16 @@ function toRaw(quantityDecimal: string, decimals: number): string { return new Big(quantityDecimal).mul(new Big(10).pow(decimals)).round(0, Big.roundDown).toFixed(0); } +interface DerivedAxes { + direction: RampDirection; + fiat: FiatToken; + stablecoin: AlfredpayStablecoinKey; +} + export class AlfredpayLimitsService { private static instance: AlfredpayLimitsService; - private cache = new Map(); + private cache = new Map(); private intervalHandle: ReturnType | null = null; public static getInstance(): AlfredpayLimitsService { @@ -59,6 +62,7 @@ export class AlfredpayLimitsService { if (this.intervalHandle) return; void this.refresh(); this.intervalHandle = setInterval(() => void this.refresh(), REFRESH_INTERVAL_MS); + this.intervalHandle.unref(); } public stop(): void { @@ -69,7 +73,7 @@ export class AlfredpayLimitsService { } /** - * Returns the limits bucket for the given key. Falls back to hardcoded values when the cache is empty + * Returns raw limits for the given key. Falls back to hardcoded values when the cache is empty * (first fetch hasn't succeeded yet) or doesn't contain a matching entry. * * Onramp raw values are scaled by the fiat's decimals; offramp raw values by the stablecoin's decimals (6). @@ -77,9 +81,9 @@ export class AlfredpayLimitsService { public getLimits( fiat: FiatToken, stablecoin: AlfredpayStablecoinKey, - customerType: AlfredpayCustomerKey, - direction: AlfredpayLimitsDirection - ): AlfredpayLimitsBucket { + customerType: AlfredpayCustomerType, + direction: RampDirection + ): RawAmountLimits { const cached = this.cache.get(cacheKey(direction, fiat, stablecoin, customerType)); if (cached) return cached; return this.fallback(fiat, stablecoin, customerType, direction); @@ -88,21 +92,21 @@ export class AlfredpayLimitsService { private fallback( fiat: FiatToken, stablecoin: AlfredpayStablecoinKey, - customerType: AlfredpayCustomerKey, - direction: AlfredpayLimitsDirection - ): AlfredpayLimitsBucket { + customerType: AlfredpayCustomerType, + direction: RampDirection + ): RawAmountLimits { const hardcoded = getAnyFiatTokenDetails(fiat).alfredpayLimits; if (!hardcoded) { - // Should never happen for AlfredPay tokens. Return permissive sentinel so we don't reject quotes outright. - return { maxRaw: "0", minRaw: "0" }; + throw new Error(`AlfredPay limits missing for ${fiat} — token config is out of sync`); } - return hardcoded[direction][stablecoin][customerType]; + const table = direction === RampDirection.BUY ? hardcoded.onramp : hardcoded.offramp; + return table[stablecoin][customerType]; } private async refresh(): Promise { try { const { supportedPairs } = await AlfredpayApiService.getInstance().getAllConfigs(); - const nextCache = new Map(); + const nextCache = new Map(); for (const pair of supportedPairs) { this.indexPair(nextCache, pair); } @@ -113,62 +117,40 @@ export class AlfredpayLimitsService { } } - private indexPair(target: Map, pair: AlfredpayConfigPair): void { + private indexPair(target: Map, pair: AlfredpayConfigPair): void { const decimals = Number(pair.decimals); if (!Number.isFinite(decimals)) return; - const direction = this.deriveDirection(pair); - if (!direction) return; + const axes = this.deriveAxes(pair); + if (!axes) return; - const { fiat, stablecoin } = direction; - const bucket: AlfredpayLimitsBucket = { + const { direction, fiat, stablecoin } = axes; + const limits: RawAmountLimits = { maxRaw: toRaw(pair.maxQuantity, decimals), minRaw: toRaw(pair.minQuantity, decimals) }; - const customers: AlfredpayCustomerKey[] = pair.typeCustomer ? [pair.typeCustomer as AlfredpayCustomerKey] : CUSTOMER_TYPES; + const customers: AlfredpayCustomerType[] = pair.typeCustomer ? [pair.typeCustomer] : CUSTOMER_TYPES; + const isWildcard = !pair.typeCustomer; for (const customer of customers) { - const key = cacheKey(direction.direction, fiat, stablecoin, customer); - // The API can return overlapping rows (typeCustomer=null + a specific BUSINESS row). Specific wins by sorting last. - target.set(key, bucket); + const key = cacheKey(direction, fiat, stablecoin, customer); + // Specific customer rows take precedence over the wildcard (null) row, regardless of response order. + if (!isWildcard || !target.has(key)) { + target.set(key, limits); + } } } - private deriveDirection( - pair: AlfredpayConfigPair - ): { direction: AlfredpayLimitsDirection; fiat: FiatToken; stablecoin: AlfredpayStablecoinKey } | null { + private deriveAxes(pair: AlfredpayConfigPair): DerivedAxes | null { const fromFiat = ALFREDPAY_FIATS[pair.fromCurrency]; const toFiat = ALFREDPAY_FIATS[pair.toCurrency]; if (fromFiat && isStablecoinSymbol(pair.toCurrency)) { - return { direction: "onramp", fiat: fromFiat, stablecoin: pair.toCurrency }; + return { direction: RampDirection.BUY, fiat: fromFiat, stablecoin: pair.toCurrency }; } if (toFiat && isStablecoinSymbol(pair.fromCurrency)) { - return { direction: "offramp", fiat: toFiat, stablecoin: pair.fromCurrency }; + return { direction: RampDirection.SELL, fiat: toFiat, stablecoin: pair.fromCurrency }; } return null; } - - /** - * Test helper: pre-seed the cache without going through the API. - */ - public _setForTesting(entries: Iterable<[string, AlfredpayLimitsBucket]>): void { - this.cache = new Map(entries); - } -} - -export function resolveAlfredpayLimits( - fiat: FiatToken, - stablecoin: AlfredpayStablecoinKey, - customerType: AlfredpayCustomerKey, - direction: AlfredpayLimitsDirection -): AlfredpayLimitsBucket { - if (!isAlfredpayToken(fiat)) { - throw new Error(`resolveAlfredpayLimits called with non-AlfredPay fiat: ${fiat}`); - } - return AlfredpayLimitsService.getInstance().getLimits(fiat, stablecoin, customerType, direction); -} - -export function normalizeCustomerType(type: AlfredpayCustomerType | null | undefined): AlfredpayCustomerKey { - return type === AlfredpayCustomerType.BUSINESS ? "BUSINESS" : "INDIVIDUAL"; } diff --git a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts index eb58a0ee8..369180b29 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts @@ -1,8 +1,8 @@ import { AlfredPayCountry, - AlfredpayCustomerKey, AlfredpayCustomerType, AlfredpayStablecoinKey, + AmountLimits, FiatToken, getAnyFiatTokenDetails, isAlfredpayToken, @@ -12,7 +12,7 @@ import { import Big from "big.js"; import AlfredPayCustomer from "../../../models/alfredPayCustomer.model"; import { multiplyByPowerOfTen } from "../pendulum/helpers"; -import { AlfredpayLimitsDirection, AlfredpayLimitsService, normalizeCustomerType } from "./alfredpay-limits.service"; +import { AlfredpayLimitsService } from "./alfredpay-limits.service"; const FIAT_TO_COUNTRY: Partial> = { [FiatToken.COP]: AlfredPayCountry.CO, @@ -32,15 +32,12 @@ export function alfredpayCountryForFiat(fiat: FiatToken): AlfredPayCountry | und * Defaulting to INDIVIDUAL is intentional — it's the more restrictive bucket on USD/COP, so an * anonymous quote that would later route through a Business customer just sees tighter limits at first. */ -export async function lookupAlfredpayCustomerType( - userId: string | undefined, - fiat: FiatToken -): Promise<"INDIVIDUAL" | "BUSINESS"> { - if (!userId) return "INDIVIDUAL"; +export async function lookupAlfredpayCustomerType(userId: string | undefined, fiat: FiatToken): Promise { + if (!userId) return AlfredpayCustomerType.INDIVIDUAL; const country = alfredpayCountryForFiat(fiat); - if (!country) return "INDIVIDUAL"; + if (!country) return AlfredpayCustomerType.INDIVIDUAL; const customer = await AlfredPayCustomer.findOne({ where: { country, userId } }); - return normalizeCustomerType(customer?.type as AlfredpayCustomerType | undefined); + return customer?.type === AlfredpayCustomerType.BUSINESS ? AlfredpayCustomerType.BUSINESS : AlfredpayCustomerType.INDIVIDUAL; } /** @@ -48,54 +45,50 @@ export async function lookupAlfredpayCustomerType( * Returns null if the currency isn't a recognized AlfredPay stablecoin. */ export function stablecoinFromCurrency(currency: RampCurrency): AlfredpayStablecoinKey | null { - const symbol = String(currency); - if (symbol === "USDC" || symbol === "USDT") return symbol; - return null; + return currency === "USDC" || currency === "USDT" ? currency : null; } -export interface AlfredpayQuoteLimitsContext { +/** AlfredPay limits resolved for a specific quote — includes the axes used to pick them. */ +export interface ResolvedAlfredpayLimits extends AmountLimits { fiat: FiatToken; stablecoin: AlfredpayStablecoinKey; - customerType: AlfredpayCustomerKey; - direction: AlfredpayLimitsDirection; - /** Limits expressed in human units of `inputCurrency` (the side the validator checks). */ - inputLimits: { min: string; max: string }; + customer: AlfredpayCustomerType; + direction: RampDirection; } /** * Resolves AlfredPay limits for a quote request, returning null when the quote isn't an AlfredPay quote. * Throws when the on-chain side isn't a recognized AlfredPay stablecoin. + * + * Returned limits are in human units of `inputCurrency` (the side the validator checks). */ export async function resolveAlfredpayQuoteLimits(args: { rampType: RampDirection; inputCurrency: RampCurrency; outputCurrency: RampCurrency; userId?: string; -}): Promise { +}): Promise { const { rampType, inputCurrency, outputCurrency, userId } = args; - const direction: AlfredpayLimitsDirection = rampType === RampDirection.BUY ? "onramp" : "offramp"; - const fiatCandidate = (direction === "onramp" ? inputCurrency : outputCurrency) as FiatToken; + const isOnramp = rampType === RampDirection.BUY; + const fiatCandidate = isOnramp ? inputCurrency : outputCurrency; + const onchainCurrency = isOnramp ? outputCurrency : inputCurrency; if (!isAlfredpayToken(fiatCandidate)) return null; - const stablecoin = stablecoinFromCurrency(direction === "onramp" ? outputCurrency : inputCurrency); + const stablecoin = stablecoinFromCurrency(onchainCurrency); if (!stablecoin) { - throw new Error( - `Unsupported AlfredPay ${direction} stablecoin: ${direction === "onramp" ? outputCurrency : inputCurrency}` - ); + throw new Error(`Unsupported AlfredPay stablecoin: ${onchainCurrency}`); } - const customerType = await lookupAlfredpayCustomerType(userId, fiatCandidate); - const bucket = AlfredpayLimitsService.getInstance().getLimits(fiatCandidate, stablecoin, customerType, direction); - const decimals = direction === "onramp" ? getAnyFiatTokenDetails(fiatCandidate).decimals : 6; + const customer = await lookupAlfredpayCustomerType(userId, fiatCandidate); + const raw = AlfredpayLimitsService.getInstance().getLimits(fiatCandidate, stablecoin, customer, rampType); + const decimals = isOnramp ? getAnyFiatTokenDetails(fiatCandidate).decimals : 6; return { - customerType, - direction, + customer, + direction: rampType, fiat: fiatCandidate, - inputLimits: { - max: multiplyByPowerOfTen(new Big(bucket.maxRaw), -decimals).toFixed(), - min: multiplyByPowerOfTen(new Big(bucket.minRaw), -decimals).toFixed() - }, + max: multiplyByPowerOfTen(new Big(raw.maxRaw), -decimals).toFixed(), + min: multiplyByPowerOfTen(new Big(raw.minRaw), -decimals).toFixed(), stablecoin }; } diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index fd67cc817..a0b98a0e4 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -2,6 +2,7 @@ // Shared types and contracts used by the quote pipeline. import { + AmountLimits, CreateQuoteRequest, DestinationType, EvmToken, @@ -267,10 +268,10 @@ export interface QuoteContext { builtResponse?: QuoteResponse; /** - * Resolved AlfredPay input-side limits in human units of `inputCurrency`. + * Resolved AlfredPay input-side amount limits in human units of `inputCurrency`. * Set by the finalize engine during validation for AlfredPay quotes; surfaced on the QuoteResponse. */ - alfredpayInputLimits?: { min: string; max: string }; + alfredpayInputLimits?: AmountLimits; // Flag to skip database persistence (for best quote comparison) skipPersistence?: boolean; diff --git a/apps/api/src/api/services/quote/core/validation-helpers.ts b/apps/api/src/api/services/quote/core/validation-helpers.ts index 459e57124..fbc63c3a3 100644 --- a/apps/api/src/api/services/quote/core/validation-helpers.ts +++ b/apps/api/src/api/services/quote/core/validation-helpers.ts @@ -2,8 +2,9 @@ import { FiatToken, getAnyFiatTokenDetails, RampDirection } from "@vortexfi/shar import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../errors/api-error"; -import { AlfredpayQuoteLimitsContext } from "../../alfredpay/alfredpay.helpers"; +import { ResolvedAlfredpayLimits, resolveAlfredpayQuoteLimits } from "../../alfredpay/alfredpay.helpers"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; +import { QuoteContext } from "./types"; /** * Get token limit units for a given fiat token, limit type, and operation type @@ -53,22 +54,41 @@ export function validateAmountLimits( * Validate an amount against precomputed AlfredPay limits. The amount is in the same units as the limits: * onramp → fiat units; offramp → stablecoin units. */ -export function validateAlfredpayLimits(amount: Big.BigSource, limits: AlfredpayQuoteLimitsContext): void { +export function validateAlfredpayLimits(amount: Big.BigSource, limits: ResolvedAlfredpayLimits): void { const amountBig = new Big(amount); - const min = new Big(limits.inputLimits.min); - const max = new Big(limits.inputLimits.max); - const unitSymbol = limits.direction === "onramp" ? getAnyFiatTokenDetails(limits.fiat).fiat.symbol : limits.stablecoin; + const min = new Big(limits.min); + const max = new Big(limits.max); + const isOnramp = limits.direction === RampDirection.BUY; + const verb = isOnramp ? "onramp" : "offramp"; + const unitSymbol = isOnramp ? getAnyFiatTokenDetails(limits.fiat).fiat.symbol : limits.stablecoin; if (amountBig.lt(min)) { throw new APIError({ - message: `Input amount below minimum ${limits.direction} limit of ${min.toFixed(2)} ${unitSymbol}`, + message: `Input amount below minimum ${verb} limit of ${min.toFixed(2)} ${unitSymbol}`, status: httpStatus.BAD_REQUEST }); } if (amountBig.gt(max)) { throw new APIError({ - message: `Input amount exceeds maximum ${limits.direction} limit of ${max.toFixed(2)} ${unitSymbol}`, + message: `Input amount exceeds maximum ${verb} limit of ${max.toFixed(2)} ${unitSymbol}`, status: httpStatus.BAD_REQUEST }); } } + +/** + * Resolves AlfredPay limits for the quote, records them on the context, and validates the given amount. + * Returns true when the quote routes through AlfredPay (caller should skip generic validation). + */ +export async function applyAlfredpayLimits(ctx: QuoteContext, amount: Big.BigSource): Promise { + const alfredpayLimits = await resolveAlfredpayQuoteLimits({ + inputCurrency: ctx.request.inputCurrency, + outputCurrency: ctx.request.outputCurrency, + rampType: ctx.request.rampType, + userId: ctx.request.userId + }); + if (!alfredpayLimits) return false; + ctx.alfredpayInputLimits = { max: alfredpayLimits.max, min: alfredpayLimits.min }; + validateAlfredpayLimits(amount, alfredpayLimits); + return true; +} diff --git a/apps/api/src/api/services/quote/engines/finalize/index.ts b/apps/api/src/api/services/quote/engines/finalize/index.ts index 0d83dea09..af8d83bd0 100644 --- a/apps/api/src/api/services/quote/engines/finalize/index.ts +++ b/apps/api/src/api/services/quote/engines/finalize/index.ts @@ -40,6 +40,7 @@ export function buildQuoteResponse(quoteTicket: QuoteTicket): QuoteResponse { const processingFeeUsd = new Big(usdFees.anchor).plus(usdFees.vortex).toFixed(); return { + alfredpayInputLimits: quoteTicket.metadata.alfredpayInputLimits, anchorFeeFiat: fiatFees.anchor, anchorFeeUsd: usdFees.anchor, createdAt: quoteTicket.createdAt, @@ -48,7 +49,6 @@ export function buildQuoteResponse(quoteTicket: QuoteTicket): QuoteResponse { from: quoteTicket.from, id: quoteTicket.id, inputAmount: trimTrailingZeros(quoteTicket.inputAmount), - inputAmountLimits: quoteTicket.metadata.alfredpayInputLimits, inputCurrency: quoteTicket.inputCurrency, network: quoteTicket.network, networkFeeFiat: fiatFees.network, @@ -110,15 +110,15 @@ export abstract class BaseFinalizeEngine implements Stage { const expiresAt = getExpirationDate(ctx); ctx.builtResponse = { + alfredpayInputLimits: ctx.alfredpayInputLimits, anchorFeeFiat: fiatFees.anchor, anchorFeeUsd: usdFees.anchor, createdAt: new Date(), expiresAt, feeCurrency: fiatFees.currency, - from: request.from, - id: "temp-" + Date.now(), // Temporary ID for comparison + from: request.from, // Temporary ID for comparison + id: "temp-" + Date.now(), inputAmount: trimTrailingZeros(request.inputAmount), - inputAmountLimits: ctx.alfredpayInputLimits, inputCurrency: request.inputCurrency, network: request.network, networkFeeFiat: fiatFees.network, diff --git a/apps/api/src/api/services/quote/engines/finalize/offramp.ts b/apps/api/src/api/services/quote/engines/finalize/offramp.ts index 8d3eee437..00ad2d745 100644 --- a/apps/api/src/api/services/quote/engines/finalize/offramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/offramp.ts @@ -2,9 +2,8 @@ import { FiatToken, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../../errors/api-error"; -import { resolveAlfredpayQuoteLimits } from "../../../alfredpay/alfredpay.helpers"; import { QuoteContext } from "../../core/types"; -import { validateAlfredpayLimits, validateAmountLimits } from "../../core/validation-helpers"; +import { applyAlfredpayLimits, validateAmountLimits } from "../../core/validation-helpers"; import { BaseFinalizeEngine, FinalizeComputation } from "."; export class OffRampFinalizeEngine extends BaseFinalizeEngine { @@ -55,18 +54,7 @@ export class OffRampFinalizeEngine extends BaseFinalizeEngine { } protected async validate(ctx: QuoteContext, { amount }: FinalizeComputation): Promise { - const alfredpayLimits = await resolveAlfredpayQuoteLimits({ - inputCurrency: ctx.request.inputCurrency, - outputCurrency: ctx.request.outputCurrency, - rampType: ctx.request.rampType, - userId: ctx.request.userId - }); - - if (alfredpayLimits) { - ctx.alfredpayInputLimits = alfredpayLimits.inputLimits; - validateAlfredpayLimits(ctx.request.inputAmount, alfredpayLimits); - return; - } + if (await applyAlfredpayLimits(ctx, ctx.request.inputAmount)) return; validateAmountLimits(amount, ctx.request.outputCurrency as FiatToken, "min", ctx.request.rampType); } } 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 18e0930fc..e51fa0202 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.ts @@ -2,9 +2,8 @@ import { AssetHubToken, FiatToken, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../../errors/api-error"; -import { resolveAlfredpayQuoteLimits } from "../../../alfredpay/alfredpay.helpers"; import { QuoteContext } from "../../core/types"; -import { validateAlfredpayLimits, validateAmountLimits } from "../../core/validation-helpers"; +import { applyAlfredpayLimits, validateAmountLimits } from "../../core/validation-helpers"; import { BaseFinalizeEngine, FinalizeComputation } from "."; export class OnRampFinalizeEngine extends BaseFinalizeEngine { @@ -88,18 +87,7 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { } protected async validate(ctx: QuoteContext): Promise { - const alfredpayLimits = await resolveAlfredpayQuoteLimits({ - inputCurrency: ctx.request.inputCurrency, - outputCurrency: ctx.request.outputCurrency, - rampType: ctx.request.rampType, - userId: ctx.request.userId - }); - - if (alfredpayLimits) { - ctx.alfredpayInputLimits = alfredpayLimits.inputLimits; - validateAlfredpayLimits(ctx.request.inputAmount, alfredpayLimits); - return; - } + if (await applyAlfredpayLimits(ctx, ctx.request.inputAmount)) return; validateAmountLimits(ctx.request.inputAmount, ctx.request.inputCurrency as FiatToken, "min", ctx.request.rampType); } } diff --git a/apps/frontend/src/hooks/ramp/useRampValidation.ts b/apps/frontend/src/hooks/ramp/useRampValidation.ts index 28cb0f0b8..39db69077 100644 --- a/apps/frontend/src/hooks/ramp/useRampValidation.ts +++ b/apps/frontend/src/hooks/ramp/useRampValidation.ts @@ -1,4 +1,5 @@ import { + AmountLimits, FiatToken, FiatTokenDetails, getAnyFiatTokenDetails, @@ -29,30 +30,30 @@ function validateOnramp( { inputAmount, fromToken, - quoteLimits, + limits, trackEvent }: { inputAmount: Big; fromToken: FiatTokenDetails; - quoteLimits?: { min: string; max: string }; + limits?: AmountLimits; trackEvent: (event: TrackableEvent) => void; } ): string | null { - const maxAmountUnits = quoteLimits - ? new Big(quoteLimits.max) + const maxAmountUnits = limits + ? new Big(limits.max) : multiplyByPowerOfTen(Big(fromToken.maxBuyAmountRaw), -fromToken.decimals); - const minAmountUnits = quoteLimits - ? new Big(quoteLimits.min) + const minAmountUnits = limits + ? new Big(limits.min) : multiplyByPowerOfTen(Big(fromToken.minBuyAmountRaw), -fromToken.decimals); - const isTooHigh = inputAmount && maxAmountUnits.lt(inputAmount); - const isTooLow = inputAmount && !inputAmount.eq(0) && minAmountUnits.gt(inputAmount); + const isTooHigh = maxAmountUnits.lt(inputAmount); + const isTooLow = !inputAmount.eq(0) && minAmountUnits.gt(inputAmount); if (isTooHigh || isTooLow) { trackEvent({ error_message: isTooHigh ? "more_than_maximum_withdrawal" : "less_than_minimum_withdrawal", event: "form_error", - input_amount: inputAmount ? inputAmount.toString() : "0" + input_amount: inputAmount.toString() }); const key = isTooHigh ? "pages.swap.error.amountOutOfRange.buyTooHigh" : "pages.swap.error.amountOutOfRange.buyTooLow"; return t(key, { @@ -72,7 +73,7 @@ function validateOfframp( fromToken, toToken, quote, - quoteLimits, + limits, userInputTokenBalance, isDisconnected, trackEvent @@ -81,33 +82,37 @@ function validateOfframp( fromToken: OnChainTokenDetails; toToken: FiatTokenDetails; quote: QuoteResponse; - quoteLimits?: { min: string; max: string }; + limits?: AmountLimits; userInputTokenBalance: string | null; isDisconnected: boolean; trackEvent: (event: TrackableEvent) => void; } ): string | null { - // AlfredPay quotes return stablecoin-denominated input limits; compare against `inputAmount` (stablecoin units). - // Legacy path (BRL/EURC) compares against fiat `outputAmount` against fiat min/max. - const maxAmountUnits = quoteLimits - ? new Big(quoteLimits.max) - : multiplyByPowerOfTen(Big(toToken.maxSellAmountRaw), -toToken.decimals); - const minAmountUnits = quoteLimits - ? new Big(quoteLimits.min) - : multiplyByPowerOfTen(Big(toToken.minSellAmountRaw), -toToken.decimals); + // AlfredPay path compares the stablecoin-denominated `inputAmount` against the resolved input limits. + // Legacy path (BRL/EURC) compares the fiat `outputAmount` against the fiat min/max on the destination token. + const check = limits + ? { + amount: inputAmount, + max: new Big(limits.max), + min: new Big(limits.min), + symbol: fromToken.assetSymbol + } + : { + amount: quote ? Big(quote.outputAmount) : Big(0), + max: multiplyByPowerOfTen(Big(toToken.maxSellAmountRaw), -toToken.decimals), + min: multiplyByPowerOfTen(Big(toToken.minSellAmountRaw), -toToken.decimals), + symbol: toToken.fiat.symbol + }; + const { max: maxAmountUnits, min: minAmountUnits, amount: amountToCheck, symbol: unitSymbol } = check; - const amountOut = quote ? Big(quote.outputAmount) : Big(0); - const amountToCheck = quoteLimits ? inputAmount : amountOut; - const unitSymbol = quoteLimits ? fromToken.assetSymbol : toToken.fiat.symbol; - - const isTooHigh = inputAmount && quote && maxAmountUnits.lt(amountToCheck); + const isTooHigh = !!quote && maxAmountUnits.lt(amountToCheck); const isTooLow = !amountToCheck.eq(0) && !config.test.overwriteMinimumTransferAmount && minAmountUnits.gt(amountToCheck); if (isTooHigh || isTooLow) { trackEvent({ error_message: isTooHigh ? "more_than_maximum_withdrawal" : "less_than_minimum_withdrawal", event: "form_error", - input_amount: inputAmount ? inputAmount.toString() : "0" + input_amount: inputAmount.toString() }); const key = isTooHigh ? "pages.swap.error.amountOutOfRange.sellTooHigh" : "pages.swap.error.amountOutOfRange.sellTooLow"; return t(key, { @@ -119,11 +124,11 @@ function validateOfframp( if (typeof userInputTokenBalance === "string" && !isDisconnected) { const isNativeToken = fromToken.isNative; - if (Big(userInputTokenBalance).lt(inputAmount ?? 0)) { + if (Big(userInputTokenBalance).lt(inputAmount)) { trackEvent({ error_message: "insufficient_balance", event: "form_error", - input_amount: inputAmount ? inputAmount.toString() : "0" + input_amount: inputAmount.toString() }); return t("pages.swap.error.insufficientFunds", { assetSymbol: fromToken?.assetSymbol, @@ -211,13 +216,13 @@ export const useRampValidation = () => { }); } else if (quoteError) return t(quoteError); - const quoteLimits = quote?.inputAmountLimits; + const limits = quote?.alfredpayInputLimits; let validationError = null; if (isOnramp) { validationError = validateOnramp(t, { fromToken: fromToken as FiatTokenDetails, inputAmount, - quoteLimits, + limits, trackEvent }); } else { @@ -225,8 +230,8 @@ export const useRampValidation = () => { fromToken: fromToken as OnChainTokenDetails, inputAmount, isDisconnected, + limits, quote: quote as QuoteResponse, - quoteLimits, toToken: toToken as FiatTokenDetails, trackEvent, userInputTokenBalance: userInputTokenBalance?.balance || "0" diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 912e51de5..19a28c404 100644 --- a/packages/shared/src/endpoints/quote.endpoints.ts +++ b/packages/shared/src/endpoints/quote.endpoints.ts @@ -1,4 +1,5 @@ import { DestinationType, Networks, PaymentMethod, RampCurrency, RampDirection } from "../index"; +import { AmountLimits } from "../tokens/types/base"; // Fee structure export interface QuoteFeeStructure { @@ -74,11 +75,8 @@ export interface QuoteResponse { createdAt: Date; sessionId?: string; - /** - * Resolved input-side amount limits for this quote. Decimal-string values in human units of `inputCurrency`. - * Currently populated only for AlfredPay quotes (USD/MXN/COP). - */ - inputAmountLimits?: { min: string; max: string }; + /** Resolved AlfredPay input-side amount limits in human units of `inputCurrency`. Populated for USD/MXN/COP quotes. */ + alfredpayInputLimits?: AmountLimits; } // GET /quotes/:id diff --git a/packages/shared/src/services/alfredpay/types.ts b/packages/shared/src/services/alfredpay/types.ts index 75ef98b7a..3b0b02d88 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -1,9 +1,6 @@ -import { FiatToken } from "../../tokens/types/base"; +import { AlfredpayCustomerType, FiatToken, RampCurrency } from "../../tokens/types/base"; -export enum AlfredpayCustomerType { - INDIVIDUAL = "INDIVIDUAL", - BUSINESS = "BUSINESS" -} +export { AlfredpayCustomerType }; export type AlfredPayType = AlfredpayCustomerType; export const AlfredPayType = AlfredpayCustomerType; @@ -356,9 +353,9 @@ 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: ReadonlySet = new Set([FiatToken.USD, FiatToken.MXN, FiatToken.COP]); -export const isAlfredpayToken = (token: FiatToken): boolean => ALFREDPAY_FIAT_TOKEN_SET.has(token); +export const isAlfredpayToken = (token: RampCurrency): token is FiatToken => ALFREDPAY_FIAT_TOKEN_SET.has(token); /** Raw shape returned by `GET …/configurations`. `typeCustomer: null` means the pair applies to both customer types. */ export interface AlfredpayConfigPair { diff --git a/packages/shared/src/tokens/freeTokens/config.ts b/packages/shared/src/tokens/freeTokens/config.ts index dbffb572f..ab612f85b 100644 --- a/packages/shared/src/tokens/freeTokens/config.ts +++ b/packages/shared/src/tokens/freeTokens/config.ts @@ -2,7 +2,7 @@ * Free token configuration (not bound to any network) */ -import { AlfredpayCurrencyLimits, FiatCurrencyDetails, FiatToken, TokenType } from "../types/base"; +import { AlfredpayLimitsTable, FiatCurrencyDetails, FiatToken, TokenType } from "../types/base"; /** * Hardcoded fallback AlfredPay limits derived from limits.md (May 11 2026 snapshot). @@ -12,7 +12,7 @@ import { AlfredpayCurrencyLimits, FiatCurrencyDetails, FiatToken, TokenType } fr * - offramp raw values are scaled by stablecoin decimals (USDC/USDT = 6). */ -const USD_LIMITS: AlfredpayCurrencyLimits = { +const USD_LIMITS: AlfredpayLimitsTable = { offramp: { USDC: { BUSINESS: { maxRaw: "300000000000", minRaw: "1000000" }, @@ -35,7 +35,7 @@ const USD_LIMITS: AlfredpayCurrencyLimits = { } }; -const MXN_LIMITS: AlfredpayCurrencyLimits = { +const MXN_LIMITS: AlfredpayLimitsTable = { offramp: { USDC: { BUSINESS: { maxRaw: "5000000000000", minRaw: "1000000" }, @@ -58,7 +58,7 @@ const MXN_LIMITS: AlfredpayCurrencyLimits = { } }; -const COP_LIMITS: AlfredpayCurrencyLimits = { +const COP_LIMITS: AlfredpayLimitsTable = { offramp: { USDC: { BUSINESS: { maxRaw: "300000000000", minRaw: "1000000" }, @@ -91,8 +91,10 @@ export const freeTokenConfig: Partial> = name: "US Dollar", symbol: "USD" }, - maxBuyAmountRaw: "30000000", - maxSellAmountRaw: "300000000000", + // Legacy fields = floor across customer types & stablecoins (Individual caps), used as fallback when + // the dynamic AlfredPay limit hasn't been resolved yet. Never above the strictest real limit. + maxBuyAmountRaw: "10000000", + maxSellAmountRaw: "100000000000", minBuyAmountRaw: "100", minSellAmountRaw: "1000000", type: TokenType.Fiat @@ -106,7 +108,7 @@ export const freeTokenConfig: Partial> = name: "Mexican Peso", symbol: "MXN" }, - maxBuyAmountRaw: "8699689121", + maxBuyAmountRaw: "8695217304", maxSellAmountRaw: "5000000000000", minBuyAmountRaw: "20000", minSellAmountRaw: "1000000", @@ -121,8 +123,8 @@ export const freeTokenConfig: Partial> = name: "Colombian Peso", symbol: "COP" }, - maxBuyAmountRaw: "110596799945", - maxSellAmountRaw: "300000000000", + maxBuyAmountRaw: "36865599982", + maxSellAmountRaw: "100000000000", minBuyAmountRaw: "3500000", minSellAmountRaw: "1000000", type: TokenType.Fiat diff --git a/packages/shared/src/tokens/types/base.ts b/packages/shared/src/tokens/types/base.ts index 990c6cb0a..126e20322 100644 --- a/packages/shared/src/tokens/types/base.ts +++ b/packages/shared/src/tokens/types/base.ts @@ -45,11 +45,21 @@ export interface FiatDetails { name: string; } -/** String-literal values match `AlfredpayCustomerType` enum in services/alfredpay/types.ts. */ -export type AlfredpayCustomerKey = "INDIVIDUAL" | "BUSINESS"; +export enum AlfredpayCustomerType { + INDIVIDUAL = "INDIVIDUAL", + BUSINESS = "BUSINESS" +} + export type AlfredpayStablecoinKey = "USDC" | "USDT"; -export interface AlfredpayLimitsBucket { +/** Min/max pair in human decimal units. */ +export interface AmountLimits { + min: string; + max: string; +} + +/** Min/max pair in raw integer-string units (storage form). */ +export interface RawAmountLimits { minRaw: string; maxRaw: string; } @@ -59,9 +69,9 @@ export interface AlfredpayLimitsBucket { * - `onramp` raw values are scaled by the FIAT decimals of the parent token. * - `offramp` raw values are scaled by the STABLECOIN decimals (USDC/USDT = 6). */ -export interface AlfredpayCurrencyLimits { - onramp: Record>; - offramp: Record>; +export interface AlfredpayLimitsTable { + onramp: Record>; + offramp: Record>; } export interface BaseFiatTokenDetails { @@ -73,7 +83,7 @@ export interface BaseFiatTokenDetails { buyFeesBasisPoints?: number; buyFeesFixedComponent?: number; /** Multi-axis AlfredPay limits; populated only for AlfredPay-routed fiats (USD/MXN/COP). */ - alfredpayLimits?: AlfredpayCurrencyLimits; + alfredpayLimits?: AlfredpayLimitsTable; } export interface FiatCurrencyDetails extends BaseTokenDetails, BaseFiatTokenDetails { From 4e6c25b6c3affcc38eb5758ae5f85fb81193fa17 Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Mon, 18 May 2026 16:17:29 +0100 Subject: [PATCH 03/24] enforce Alfredpay monthly cumulative limits --- .../services/alfredpay/alfredpay.helpers.ts | 31 +++++++++++++++++++ .../services/quote/core/validation-helpers.ts | 26 +++++++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts index 369180b29..d74099f99 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts @@ -10,7 +10,10 @@ import { RampDirection } from "@vortexfi/shared"; import Big from "big.js"; +import { Op } from "sequelize"; import AlfredPayCustomer from "../../../models/alfredPayCustomer.model"; +import QuoteTicket from "../../../models/quoteTicket.model"; +import RampState from "../../../models/rampState.model"; import { multiplyByPowerOfTen } from "../pendulum/helpers"; import { AlfredpayLimitsService } from "./alfredpay-limits.service"; @@ -92,3 +95,31 @@ export async function resolveAlfredpayQuoteLimits(args: { stablecoin }; } + +function startOfCurrentUtcMonth(): Date { + const now = new Date(); + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); +} + +/** Returned in input-currency human units: fiat on onramp, stablecoin on offramp. */ +export async function getAlfredpayMonthlyUsage(userId: string, direction: RampDirection, fiat: FiatToken): Promise { + const isOnramp = direction === RampDirection.BUY; + const fiatSide = isOnramp ? { inputCurrency: fiat } : { outputCurrency: fiat }; + + const completedRamps = await RampState.findAll({ + include: [{ as: "quote", model: QuoteTicket, required: true, where: fiatSide }], + where: { + createdAt: { [Op.gte]: startOfCurrentUtcMonth() }, + currentPhase: "complete", + type: direction, + userId + } + }); + + let total = new Big(0); + for (const ramp of completedRamps) { + const quote = (ramp as RampState & { quote: QuoteTicket }).quote; + total = total.plus(quote.inputAmount); + } + return total; +} diff --git a/apps/api/src/api/services/quote/core/validation-helpers.ts b/apps/api/src/api/services/quote/core/validation-helpers.ts index fbc63c3a3..6fbbe2747 100644 --- a/apps/api/src/api/services/quote/core/validation-helpers.ts +++ b/apps/api/src/api/services/quote/core/validation-helpers.ts @@ -2,7 +2,11 @@ import { FiatToken, getAnyFiatTokenDetails, RampDirection } from "@vortexfi/shar import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../errors/api-error"; -import { ResolvedAlfredpayLimits, resolveAlfredpayQuoteLimits } from "../../alfredpay/alfredpay.helpers"; +import { + getAlfredpayMonthlyUsage, + ResolvedAlfredpayLimits, + resolveAlfredpayQuoteLimits +} from "../../alfredpay/alfredpay.helpers"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; import { QuoteContext } from "./types"; @@ -70,15 +74,15 @@ export function validateAlfredpayLimits(amount: Big.BigSource, limits: ResolvedA } if (amountBig.gt(max)) { throw new APIError({ - message: `Input amount exceeds maximum ${verb} limit of ${max.toFixed(2)} ${unitSymbol}`, + message: `Input amount exceeds monthly ${verb} limit of ${max.toFixed(2)} ${unitSymbol}`, status: httpStatus.BAD_REQUEST }); } } /** - * Resolves AlfredPay limits for the quote, records them on the context, and validates the given amount. * Returns true when the quote routes through AlfredPay (caller should skip generic validation). + * The max is a monthly cap; unauthenticated quotes only get per-tx checks. */ export async function applyAlfredpayLimits(ctx: QuoteContext, amount: Big.BigSource): Promise { const alfredpayLimits = await resolveAlfredpayQuoteLimits({ @@ -90,5 +94,19 @@ export async function applyAlfredpayLimits(ctx: QuoteContext, amount: Big.BigSou if (!alfredpayLimits) return false; ctx.alfredpayInputLimits = { max: alfredpayLimits.max, min: alfredpayLimits.min }; validateAlfredpayLimits(amount, alfredpayLimits); - return true; + + const { userId } = ctx.request; + if (!userId) return true; + + const used = await getAlfredpayMonthlyUsage(userId, alfredpayLimits.direction, alfredpayLimits.fiat); + const max = new Big(alfredpayLimits.max); + if (used.plus(new Big(amount)).lte(max)) return true; + + const isOnramp = alfredpayLimits.direction === RampDirection.BUY; + const verb = isOnramp ? "onramp" : "offramp"; + const unitSymbol = isOnramp ? getAnyFiatTokenDetails(alfredpayLimits.fiat).fiat.symbol : alfredpayLimits.stablecoin; + throw new APIError({ + message: `Monthly ${verb} limit of ${max.toFixed(2)} ${unitSymbol} would be exceeded (already used ${used.toFixed(2)} ${unitSymbol} this month).`, + status: httpStatus.BAD_REQUEST + }); } From 17660a890bbd4f561fea543dc6eeea898f1f266b Mon Sep 17 00:00:00 2001 From: Kacper Szarkiewicz Date: Tue, 19 May 2026 10:33:10 +0100 Subject: [PATCH 04/24] tighten Alfredpay monthly usage join cast --- apps/api/src/api/services/alfredpay/alfredpay.helpers.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts index d74099f99..cddb0549e 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts @@ -106,7 +106,7 @@ export async function getAlfredpayMonthlyUsage(userId: string, direction: RampDi const isOnramp = direction === RampDirection.BUY; const fiatSide = isOnramp ? { inputCurrency: fiat } : { outputCurrency: fiat }; - const completedRamps = await RampState.findAll({ + const completedRamps = (await RampState.findAll({ include: [{ as: "quote", model: QuoteTicket, required: true, where: fiatSide }], where: { createdAt: { [Op.gte]: startOfCurrentUtcMonth() }, @@ -114,12 +114,11 @@ export async function getAlfredpayMonthlyUsage(userId: string, direction: RampDi type: direction, userId } - }); + })) as Array; let total = new Big(0); for (const ramp of completedRamps) { - const quote = (ramp as RampState & { quote: QuoteTicket }).quote; - total = total.plus(quote.inputAmount); + total = total.plus(ramp.quote.inputAmount); } return total; } From 16f980cb69f45df82a86cac884ecc3bd4dae26dd Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 20 May 2026 11:18:53 -0300 Subject: [PATCH 05/24] add quote logic --- .../phases/handlers/fund-ephemeral-handler.ts | 2 +- .../engines/discount/offramp-alfredpay.ts | 69 +++++++++++++++++++ .../engines/discount/onramp-alfredpay.ts | 69 +++++++++++++++++++ .../onramp-polygon-to-evm-alfredpay.ts | 26 +++++-- .../offramp-evm-to-alfredpay.strategy.ts | 4 +- .../onramp-alfredpay-to-evm.strategy.ts | 6 +- 6 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts create mode 100644 apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts diff --git a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts index fd39d4a9b..0e413f750 100644 --- a/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts +++ b/apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts @@ -254,7 +254,7 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler { } // alfredpay onramp case if (isOnramp(state) && isAlfredpayToken(quote.inputCurrency as FiatToken)) { - return "squidRouterSwap"; + return "subsidizePreSwap"; } // monerium onramp case if (isOnramp(state) && quote.inputCurrency === FiatToken.EURC) { diff --git a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts new file mode 100644 index 000000000..dbad2a690 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts @@ -0,0 +1,69 @@ +import { RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { QuoteContext } from "../../core/types"; +import { BaseDiscountEngine, DiscountComputation } from "."; +import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; + +export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { + readonly config = { + direction: RampDirection.SELL, + isOfframp: true, + skipNote: "Skipped for on-ramp request" + } as const; + + protected validate(ctx: QuoteContext): void { + if (!ctx.alfredpayOfframp) { + throw new Error("OffRampAlfredpayDiscountEngine requires alfredpayOfframp to be defined"); + } + + if (!ctx.request.inputAmount) { + throw new Error("OffRampAlfredpayDiscountEngine requires request.inputAmount to be defined"); + } + } + + protected async compute(ctx: QuoteContext): Promise { + const { inputAmount, rampType } = ctx.request; + + const partner = await resolveDiscountPartner(ctx, rampType); + const targetDiscount = partner?.targetDiscount ?? 0; + const maxSubsidy = partner?.maxSubsidy ?? 0; + + const alfredpayOfframp = ctx.alfredpayOfframp!; + + const effectiveRate = alfredpayOfframp.outputAmountDecimal.div(alfredpayOfframp.inputAmountDecimal); + + const finalOutput = alfredpayOfframp.outputAmountDecimal; + + const { + expectedOutput: expectedOutputDecimal, + adjustedDifference, + adjustedTargetDiscount + } = calculateExpectedOutput(inputAmount, effectiveRate, targetDiscount, this.config.isOfframp, partner); + + const idealSubsidyDecimal = expectedOutputDecimal.gt(finalOutput) ? expectedOutputDecimal.minus(finalOutput) : new Big(0); + + const actualSubsidyDecimal = + targetDiscount > 0 ? calculateSubsidyAmount(expectedOutputDecimal, finalOutput, maxSubsidy) : new Big(0); + + const targetOutputDecimal = finalOutput.plus(actualSubsidyDecimal); + + const subsidyRate = expectedOutputDecimal.gt(0) ? actualSubsidyDecimal.div(expectedOutputDecimal) : new Big(0); + + return { + actualOutputAmountDecimal: finalOutput, + actualOutputAmountRaw: finalOutput.toFixed(6, 0), + adjustedDifference, + adjustedTargetDiscount, + expectedOutputAmountDecimal: expectedOutputDecimal, + expectedOutputAmountRaw: expectedOutputDecimal.toFixed(6, 0), + idealSubsidyAmountInOutputTokenDecimal: idealSubsidyDecimal, + idealSubsidyAmountInOutputTokenRaw: idealSubsidyDecimal.toFixed(6, 0), + partnerId: partner ? partner.id : null, + subsidyAmountInOutputTokenDecimal: actualSubsidyDecimal, + subsidyAmountInOutputTokenRaw: actualSubsidyDecimal.toFixed(6, 0), + subsidyRate, + targetOutputAmountDecimal: targetOutputDecimal, + targetOutputAmountRaw: targetOutputDecimal.toFixed(6, 0) + }; + } +} diff --git a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts new file mode 100644 index 000000000..e67357ea5 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts @@ -0,0 +1,69 @@ +import { RampDirection } from "@vortexfi/shared"; +import Big from "big.js"; +import { QuoteContext } from "../../core/types"; +import { BaseDiscountEngine, DiscountComputation } from "."; +import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; + +export class OnRampAlfredpayDiscountEngine extends BaseDiscountEngine { + readonly config = { + direction: RampDirection.BUY, + isOfframp: false, + skipNote: "Skipped for off-ramp request" + } as const; + + protected validate(ctx: QuoteContext): void { + if (!ctx.alfredpayMint) { + throw new Error("OnRampAlfredpayDiscountEngine requires alfredpayMint to be defined"); + } + + if (!ctx.request.inputAmount) { + throw new Error("OnRampAlfredpayDiscountEngine requires request.inputAmount to be defined"); + } + } + + protected async compute(ctx: QuoteContext): Promise { + const { inputAmount, rampType } = ctx.request; + + const partner = await resolveDiscountPartner(ctx, rampType); + const targetDiscount = partner?.targetDiscount ?? 0; + const maxSubsidy = partner?.maxSubsidy ?? 0; + + const alfredpayMint = ctx.alfredpayMint!; + + const effectiveRate = alfredpayMint.outputAmountDecimal.div(alfredpayMint.inputAmountDecimal); + + const finalOutput = ctx.evmToEvm?.outputAmountDecimal ?? alfredpayMint.outputAmountDecimal; + + const { + expectedOutput: expectedOutputDecimal, + adjustedDifference, + adjustedTargetDiscount + } = calculateExpectedOutput(inputAmount, effectiveRate, targetDiscount, this.config.isOfframp, partner); + + const idealSubsidyDecimal = expectedOutputDecimal.gt(finalOutput) ? expectedOutputDecimal.minus(finalOutput) : new Big(0); + + const actualSubsidyDecimal = + targetDiscount > 0 ? calculateSubsidyAmount(expectedOutputDecimal, finalOutput, maxSubsidy) : new Big(0); + + const targetOutputDecimal = finalOutput.plus(actualSubsidyDecimal); + + const subsidyRate = expectedOutputDecimal.gt(0) ? actualSubsidyDecimal.div(expectedOutputDecimal) : new Big(0); + + return { + actualOutputAmountDecimal: finalOutput, + actualOutputAmountRaw: finalOutput.toFixed(6, 0), + adjustedDifference, + adjustedTargetDiscount, + expectedOutputAmountDecimal: expectedOutputDecimal, + expectedOutputAmountRaw: expectedOutputDecimal.toFixed(6, 0), + idealSubsidyAmountInOutputTokenDecimal: idealSubsidyDecimal, + idealSubsidyAmountInOutputTokenRaw: idealSubsidyDecimal.toFixed(6, 0), + partnerId: partner ? partner.id : null, + subsidyAmountInOutputTokenDecimal: actualSubsidyDecimal, + subsidyAmountInOutputTokenRaw: actualSubsidyDecimal.toFixed(6, 0), + subsidyRate, + targetOutputAmountDecimal: targetOutputDecimal, + targetOutputAmountRaw: targetOutputDecimal.toFixed(6, 0) + }; + } +} diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts index d60c27f0f..47a709dcf 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts @@ -30,14 +30,28 @@ export class OnRampSquidRouterUsdToEvmEngine extends BaseSquidRouterEngine { "OnRampSquidRouterUsdToEvmEngine: Missing alfredpayMint.amountOut in context - ensure initialize stage ran successfully" ); } + + if (!ctx.subsidy) { + throw new Error("OnRampSquidRouterUsdToEvmEngine: Missing subsidy in context - ensure discount stage ran successfully"); + } } protected compute(ctx: QuoteContext): SquidRouterComputation { if (ctx.to === Networks.Polygon && ctx.request.outputCurrency === ALFREDPAY_EVM_TOKEN) { + // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate + const subsidy = ctx.subsidy!; return { data: { - skipRouteCalculation: true - } as SquidRouterData, + amountRaw: subsidy.actualOutputAmountRaw, + fromNetwork: Networks.Polygon, + fromToken: ALFREDPAY_ERC20_TOKEN, + inputAmountDecimal: subsidy.actualOutputAmountDecimal, + inputAmountRaw: subsidy.actualOutputAmountRaw, + outputDecimals: 6, + skipRouteCalculation: true, + toNetwork: Networks.Polygon, + toToken: ALFREDPAY_EVM_TOKEN + } as unknown as SquidRouterData, type: "evm-to-evm" }; } @@ -53,15 +67,15 @@ export class OnRampSquidRouterUsdToEvmEngine extends BaseSquidRouterEngine { const toTokenDetails = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to); // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const alfredpayMint = ctx.alfredpayMint!; + const subsidy = ctx.subsidy!; return { data: { - amountRaw: alfredpayMint.outputAmountRaw, + amountRaw: subsidy.actualOutputAmountRaw, fromNetwork: Networks.Polygon, fromToken: ALFREDPAY_ERC20_TOKEN, - inputAmountDecimal: alfredpayMint.outputAmountDecimal, - inputAmountRaw: alfredpayMint.outputAmountRaw, + inputAmountDecimal: subsidy.actualOutputAmountDecimal, + inputAmountRaw: subsidy.actualOutputAmountRaw, outputDecimals: toTokenDetails.decimals, toNetwork, toToken: toTokenDetails.erc20AddressSourceChain diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts index c745b609d..fed989ad7 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts @@ -1,5 +1,6 @@ import { Networks } from "@vortexfi/shared"; import { StageKey } from "../../core/types"; +import { OffRampAlfredpayDiscountEngine } from "../../engines/discount/offramp-alfredpay"; import { OffRampEvmToAlfredpayFeeEngine } from "../../engines/fee/offramp-evm-to-alfredpay"; import { OffRampFinalizeEngine } from "../../engines/finalize/offramp"; @@ -12,8 +13,9 @@ export const offrampEvmToAlfredpayStrategy = defineRouteStrategy({ [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Polygon), [StageKey.Fee]: new OffRampEvmToAlfredpayFeeEngine(), [StageKey.PartnerOperation]: new OfframpTransactionAlfredpayEngine(), + [StageKey.Discount]: new OffRampAlfredpayDiscountEngine(), [StageKey.Finalize]: new OffRampFinalizeEngine() }), name: "OfframpEvmToAlfredpay", - stages: [StageKey.Initialize, StageKey.PartnerOperation, StageKey.Fee, StageKey.Finalize] + stages: [StageKey.Initialize, StageKey.PartnerOperation, StageKey.Fee, StageKey.Discount, StageKey.Finalize] }); diff --git a/apps/api/src/api/services/quote/routes/strategies/onramp-alfredpay-to-evm.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/onramp-alfredpay-to-evm.strategy.ts index 2317a0ab6..9d9753cb3 100644 --- a/apps/api/src/api/services/quote/routes/strategies/onramp-alfredpay-to-evm.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/onramp-alfredpay-to-evm.strategy.ts @@ -1,4 +1,5 @@ import { StageKey } from "../../core/types"; +import { OnRampAlfredpayDiscountEngine } from "../../engines/discount/onramp-alfredpay"; import { OnRampAlfredpayToEvmFeeEngine } from "../../engines/fee/onramp-alfredpay-to-evm"; import { OnRampFinalizeEngine } from "../../engines/finalize/onramp"; import { OnRampInitializeAlfredpayEngine } from "../../engines/initialize/onramp-alfredpay"; @@ -9,9 +10,10 @@ export const onrampAlfredpayToEvmStrategy = defineRouteStrategy({ engines: () => ({ [StageKey.Initialize]: new OnRampInitializeAlfredpayEngine(), [StageKey.Fee]: new OnRampAlfredpayToEvmFeeEngine(), - [StageKey.SquidRouter]: new OnRampSquidRouterUsdToEvmEngine(), // Uses same engine as monerium's. (Polygon ephemeral -> destination) + [StageKey.Discount]: new OnRampAlfredpayDiscountEngine(), + [StageKey.SquidRouter]: new OnRampSquidRouterUsdToEvmEngine(), [StageKey.Finalize]: new OnRampFinalizeEngine() }), name: "OnrampAlfredpayToEvm", - stages: [StageKey.Initialize, StageKey.Fee, StageKey.SquidRouter, StageKey.Finalize] + stages: [StageKey.Initialize, StageKey.Fee, StageKey.Discount, StageKey.SquidRouter, StageKey.Finalize] }); From c785075422b4af7a23c7063300eb1e1279bac1cf Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 20 May 2026 16:46:29 -0300 Subject: [PATCH 06/24] quote logic adjustments, computation re-order --- .../handlers/subsidize-pre-swap-handler.ts | 97 ++++++++++++++----- .../engines/discount/offramp-alfredpay.ts | 49 +++++++--- .../engines/discount/onramp-alfredpay.ts | 17 ++-- .../engines/partners/offramp-alfredpay.ts | 32 ++++-- .../onramp-polygon-to-evm-alfredpay.ts | 14 ++- .../offramp-evm-to-alfredpay.strategy.ts | 2 +- 6 files changed, 157 insertions(+), 54 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts index a5fec4089..50c488bb1 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts @@ -1,4 +1,6 @@ import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_ERC20_TOKEN, ApiManager, checkEvmBalanceForToken, EvmClientManager, @@ -7,9 +9,11 @@ import { EvmTokenDetails, FiatToken, getOnChainTokenDetails, + isAlfredpayToken, Networks, nativeToDecimal, RampCurrency, + RampDirection, RampPhase, waitUntilTrueWithTimeout } from "@vortexfi/shared"; @@ -46,9 +50,60 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { return this.executeEvmSubsidize(state, quote); } + if (state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken)) { + return this.executeEvmSubsidize(state, quote); + } + return this.executeSubstrateSubsidize(state, quote); } + private getEvmSubsidyConfig(state: RampState, quote: QuoteTicket) { + if (state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken)) { + if (!quote.metadata.evmToEvm) { + throw new Error("Missing evmToEvm information in quote metadata"); + } + + const inputTokenDetails = getOnChainTokenDetails(Networks.Polygon, EvmToken.USDT) as EvmTokenDetails; + if (!inputTokenDetails) { + throw new Error("Could not find token details for USDT on Polygon. Invalid quote metadata."); + } + + return { + expectedInputAmountForSwapRaw: quote.metadata.evmToEvm.inputAmountRaw, + inputAmountDecimals: ALFREDPAY_ERC20_DECIMALS, // TODO no need to keep this constant, let's identify simply by token/chain itself. + inputToken: EvmToken.USDT, + inputTokenDetails, + logLabel: "Alfredpay", + nextPhase: "squidRouterSwap" as RampPhase, + subsidyToken: EvmToken.USDT as unknown as SubsidyToken, + tokenContract: ALFREDPAY_ERC20_TOKEN + }; + } + + if (!quote.metadata.nablaSwapEvm) { + throw new Error("Missing nablaSwapEvm information in quote metadata"); + } + + const inputToken = quote.metadata.nablaSwapEvm.inputCurrency as EvmToken; + const inputTokenDetails = getOnChainTokenDetails(Networks.Base, inputToken) as EvmTokenDetails; + if (!inputTokenDetails) { + throw new Error( + `Could not find token details for input token ${inputToken} on network ${Networks.Base}. Invalid quote metadata.` + ); + } + + return { + expectedInputAmountForSwapRaw: quote.metadata.nablaSwapEvm.inputAmountForSwapRaw, + inputAmountDecimals: quote.metadata.nablaSwapEvm.inputDecimals, + inputToken, + inputTokenDetails, + logLabel: "EVM", + nextPhase: "nablaApprove" as RampPhase, + subsidyToken: quote.metadata.nablaSwapEvm.inputCurrency as unknown as SubsidyToken, + tokenContract: inputTokenDetails.erc20AddressSourceChain as `0x${string}` + }; + } + private async executeSubstrateSubsidize(state: RampState, quote: QuoteTicket): Promise { const apiManager = ApiManager.getInstance(); const networkName = "pendulum"; @@ -143,24 +198,21 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { throw new Error("SubsidizePreSwapPhaseHandler: State metadata corrupted. This is a bug."); } - if (!quote.metadata.nablaSwapEvm) { - throw new Error("Missing nablaSwapEvm information in quote metadata"); - } - try { + const { + inputAmountDecimals, + inputToken, + inputTokenDetails, + logLabel, + nextPhase, + expectedInputAmountForSwapRaw, + subsidyToken, + tokenContract + } = this.getEvmSubsidyConfig(state, quote); + // Wait for token settlement before checking balance await new Promise(resolve => setTimeout(resolve, 15000)); - // Get token details for the input token - const inputToken = quote.metadata.nablaSwapEvm.inputCurrency as EvmToken; - - const inputTokenDetails = getOnChainTokenDetails(Networks.Base, inputToken) as EvmTokenDetails; - if (!inputTokenDetails) { - throw new Error( - `Could not find token details for input token ${inputToken} on network ${Networks.Base}. Invalid quote metadata.` - ); - } - // Check current balance on EVM const currentBalance = await checkEvmBalanceForToken({ amountDesiredRaw: "1", @@ -175,13 +227,15 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { throw new Error("Invalid phase: input token did not arrive yet on EVM"); } - const expectedInputAmountForSwapRaw = quote.metadata.nablaSwapEvm.inputAmountForSwapRaw; - const requiredAmount = Big(expectedInputAmountForSwapRaw).sub(currentBalance); - logger.debug(`SubsidizePreSwapHandler (EVM): requiredAmount ${requiredAmount.toString()}`); + logger.debug(`SubsidizePreSwapHandler (${logLabel}): requiredAmount ${requiredAmount.toString()}`); + + console.log( + `[SubsidizePreSwapPhaseHandler] ${logLabel} ephemeral=${evmEphemeralAddress}, expected=${expectedInputAmountForSwapRaw}, currentBalance=${currentBalance.toString()}, required=${requiredAmount.toString()}` + ); if (requiredAmount.gt(Big(0))) { - const subsidyDecimal = nativeToDecimal(requiredAmount, quote.metadata.nablaSwapEvm.inputDecimals).toString(); + const subsidyDecimal = nativeToDecimal(requiredAmount, inputAmountDecimals).toString(); const subsidyUsd = await priceFeedService.convertCurrency( subsidyDecimal, inputToken as RampCurrency, @@ -224,12 +278,11 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { data, maxFeePerGas, maxPriorityFeePerGas, - to: inputTokenDetails.erc20AddressSourceChain as `0x${string}`, + to: tokenContract, value: 0n }); - const subsidyAmount = nativeToDecimal(requiredAmount, quote.metadata.nablaSwapEvm.inputDecimals).toNumber(); - const subsidyToken = quote.metadata.nablaSwapEvm.inputCurrency as unknown as SubsidyToken; + const subsidyAmount = nativeToDecimal(requiredAmount, inputAmountDecimals).toNumber(); await this.createSubsidy(state, subsidyAmount, subsidyToken, fundingAccount.address, txHash); @@ -242,7 +295,7 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { } } - return this.transitionToNextPhase(state, "nablaApprove"); + return this.transitionToNextPhase(state, nextPhase); } catch (e) { logger.error("Error in subsidizePreSwap (EVM):", e); if (e instanceof PhaseError) { diff --git a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts index dbad2a690..92953c3ae 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts @@ -1,5 +1,12 @@ -import { RampDirection } from "@vortexfi/shared"; +import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_ONCHAIN_CURRENCY, + multiplyByPowerOfTen, + RampCurrency, + RampDirection +} from "@vortexfi/shared"; import Big from "big.js"; +import { priceFeedService } from "../../../priceFeed.service"; import { QuoteContext } from "../../core/types"; import { BaseDiscountEngine, DiscountComputation } from "."; import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; @@ -12,8 +19,8 @@ export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { } as const; protected validate(ctx: QuoteContext): void { - if (!ctx.alfredpayOfframp) { - throw new Error("OffRampAlfredpayDiscountEngine requires alfredpayOfframp to be defined"); + if (!ctx.evmToEvm) { + throw new Error("OffRampAlfredpayDiscountEngine requires evmToEvm to be defined"); } if (!ctx.request.inputAmount) { @@ -22,17 +29,31 @@ export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { } protected async compute(ctx: QuoteContext): Promise { - const { inputAmount, rampType } = ctx.request; + const { inputAmount, outputCurrency, rampType } = ctx.request; const partner = await resolveDiscountPartner(ctx, rampType); const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - const alfredpayOfframp = ctx.alfredpayOfframp!; + // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate + const usdOnPolygon = ctx.evmToEvm!.outputAmountDecimal; - const effectiveRate = alfredpayOfframp.outputAmountDecimal.div(alfredpayOfframp.inputAmountDecimal); + // Oracle rate USD -> fiat. + // This block is required to avoid calling the Alfredpay API twice for a quote. + // Since the input amount for the Alfredpay operations comes after this, and uses the output of the + // discounted rate, we need to know or estimate the rate in advance. + const oneUnitInFiat = await priceFeedService.convertCurrency( + "1", + ALFREDPAY_ONCHAIN_CURRENCY as unknown as RampCurrency, + outputCurrency as RampCurrency + ); + const effectiveRate = new Big(oneUnitInFiat); - const finalOutput = alfredpayOfframp.outputAmountDecimal; + const finalOutput = usdOnPolygon.mul(effectiveRate); + + console.log( + `[OffRampAlfredpayDiscountEngine] input=${inputAmount} ${outputCurrency}, usdOnPolygon=${usdOnPolygon.toString()}, oracleRate=${effectiveRate.toString()}, finalOutput=${finalOutput.toString()} ${outputCurrency}` + ); const { expectedOutput: expectedOutputDecimal, @@ -49,21 +70,25 @@ export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { const subsidyRate = expectedOutputDecimal.gt(0) ? actualSubsidyDecimal.div(expectedOutputDecimal) : new Big(0); + console.log( + `[OffRampAlfredpayDiscountEngine] partner=${partner?.id ?? "none"}, targetDiscount=${targetDiscount}, maxSubsidy=${maxSubsidy}, expectedOutput=${expectedOutputDecimal.toString()}, idealSubsidy=${idealSubsidyDecimal.toString()}, actualSubsidy=${actualSubsidyDecimal.toString()}, targetOutput=${targetOutputDecimal.toString()}, subsidyRate=${subsidyRate.toString()}` + ); + return { actualOutputAmountDecimal: finalOutput, - actualOutputAmountRaw: finalOutput.toFixed(6, 0), + actualOutputAmountRaw: multiplyByPowerOfTen(finalOutput, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), adjustedDifference, adjustedTargetDiscount, expectedOutputAmountDecimal: expectedOutputDecimal, - expectedOutputAmountRaw: expectedOutputDecimal.toFixed(6, 0), + expectedOutputAmountRaw: multiplyByPowerOfTen(expectedOutputDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), idealSubsidyAmountInOutputTokenDecimal: idealSubsidyDecimal, - idealSubsidyAmountInOutputTokenRaw: idealSubsidyDecimal.toFixed(6, 0), + idealSubsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(idealSubsidyDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), partnerId: partner ? partner.id : null, subsidyAmountInOutputTokenDecimal: actualSubsidyDecimal, - subsidyAmountInOutputTokenRaw: actualSubsidyDecimal.toFixed(6, 0), + subsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(actualSubsidyDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), subsidyRate, targetOutputAmountDecimal: targetOutputDecimal, - targetOutputAmountRaw: targetOutputDecimal.toFixed(6, 0) + targetOutputAmountRaw: multiplyByPowerOfTen(targetOutputDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0) }; } } diff --git a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts index e67357ea5..480c82c41 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts @@ -1,4 +1,5 @@ -import { RampDirection } from "@vortexfi/shared"; +import { ALFREDPAY_ERC20_TOKEN } from "@packages/shared"; +import { ALFREDPAY_ERC20_DECIMALS, multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import { QuoteContext } from "../../core/types"; import { BaseDiscountEngine, DiscountComputation } from "."; @@ -34,6 +35,10 @@ export class OnRampAlfredpayDiscountEngine extends BaseDiscountEngine { const finalOutput = ctx.evmToEvm?.outputAmountDecimal ?? alfredpayMint.outputAmountDecimal; + console.log( + `[OnRampAlfredpayDiscountEngine] input=${inputAmount} ${ctx.request.outputCurrency}, alfredpayMintIn=${alfredpayMint.inputAmountDecimal.toString()} ${alfredpayMint.currency}, alfredpayMintOut=${alfredpayMint.outputAmountDecimal.toString()} ${ctx.request.outputCurrency}, effectiveRate=${effectiveRate.toString()}, finalOutput=${finalOutput.toString()}` + ); + const { expectedOutput: expectedOutputDecimal, adjustedDifference, @@ -51,19 +56,19 @@ export class OnRampAlfredpayDiscountEngine extends BaseDiscountEngine { return { actualOutputAmountDecimal: finalOutput, - actualOutputAmountRaw: finalOutput.toFixed(6, 0), + actualOutputAmountRaw: multiplyByPowerOfTen(finalOutput, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), adjustedDifference, adjustedTargetDiscount, expectedOutputAmountDecimal: expectedOutputDecimal, - expectedOutputAmountRaw: expectedOutputDecimal.toFixed(6, 0), + expectedOutputAmountRaw: multiplyByPowerOfTen(expectedOutputDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), idealSubsidyAmountInOutputTokenDecimal: idealSubsidyDecimal, - idealSubsidyAmountInOutputTokenRaw: idealSubsidyDecimal.toFixed(6, 0), + idealSubsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(idealSubsidyDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), partnerId: partner ? partner.id : null, subsidyAmountInOutputTokenDecimal: actualSubsidyDecimal, - subsidyAmountInOutputTokenRaw: actualSubsidyDecimal.toFixed(6, 0), + subsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(actualSubsidyDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), subsidyRate, targetOutputAmountDecimal: targetOutputDecimal, - targetOutputAmountRaw: targetOutputDecimal.toFixed(6, 0) + targetOutputAmountRaw: multiplyByPowerOfTen(targetOutputDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0) }; } } 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 07431935f..59b9952fe 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 @@ -7,9 +7,11 @@ import { AlfredpayPaymentMethodType, CreateAlfredpayOfframpQuoteRequest, multiplyByPowerOfTen, + RampCurrency, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; +import { priceFeedService } from "../../../priceFeed.service"; import { QuoteContext } from "../../core/types"; import { BaseInitializeEngine } from "./../initialize/index"; @@ -26,11 +28,23 @@ export class OfframpTransactionAlfredpayEngine extends BaseInitializeEngine { throw new Error("OfframpTransactionAlfredpayEngine: No evmToEvm quote"); } - const usdTokenDecimals = ALFREDPAY_ERC20_DECIMALS; - const inputAmountDecimal = new Big(ctx.evmToEvm.outputAmountDecimal); + if (!ctx.subsidy) { + throw new Error("OfframpTransactionAlfredpayEngine: Missing ctx.subsidy (Discount stage must run first)"); + } - const alfredpayService = AlfredpayApiService.getInstance(); + // Use the same oracle rate as Discount to back-solve the subsidized USD input. + const oneUnitInFiat = await priceFeedService.convertCurrency( + "1", + ALFREDPAY_ONCHAIN_CURRENCY as unknown as RampCurrency, + req.outputCurrency as RampCurrency + ); + const effectiveRate = new Big(oneUnitInFiat); + const inputAmountDecimal = effectiveRate.gt(0) + ? ctx.subsidy.targetOutputAmountDecimal.div(effectiveRate) + : ctx.evmToEvm.outputAmountDecimal; + + const alfredpayService = AlfredpayApiService.getInstance(); const quoteRequest: CreateAlfredpayOfframpQuoteRequest = { chain: AlfredpayChain.MATIC, fromAmount: inputAmountDecimal.toString(), @@ -45,27 +59,25 @@ export class OfframpTransactionAlfredpayEngine extends BaseInitializeEngine { const quote = await alfredpayService.createOfframpQuote(quoteRequest); - const fromAmount = new Big(ctx.evmToEvm.outputAmountDecimal); const toAmount = new Big(quote.toAmount); - const alfredpayFee = AlfredpayApiService.sumFeesByCurrency( quote.fees, req.outputCurrency as unknown as AlfredpayFiatCurrency ); ctx.alfredpayOfframp = { - currency: ctx.request.outputCurrency, + currency: req.outputCurrency, expirationDate: new Date(quote.expiration), fee: alfredpayFee, - inputAmountDecimal: fromAmount, - inputAmountRaw: multiplyByPowerOfTen(fromAmount, usdTokenDecimals).toFixed(0, 0), + inputAmountDecimal, + inputAmountRaw: multiplyByPowerOfTen(inputAmountDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), outputAmountDecimal: toAmount, - outputAmountRaw: multiplyByPowerOfTen(toAmount, 2).toFixed(0, 0), // Assuming 2 decimals for fiat + outputAmountRaw: multiplyByPowerOfTen(toAmount, 2).toFixed(0, 0), quoteId: quote.quoteId }; ctx.addNote?.( - `Initialized: ${inputAmountDecimal.toString()} ${req.inputCurrency} -> ${toAmount.toString()} ${req.outputCurrency}` + `OfframpTransactionAlfredpayEngine: ${inputAmountDecimal.toString()} ${ALFREDPAY_ONCHAIN_CURRENCY} -> ${toAmount.toString()} ${req.outputCurrency} (fee ${alfredpayFee.toString()}, rate ${effectiveRate.toString()})` ); } } diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts index 47a709dcf..29a481ca7 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts @@ -68,14 +68,22 @@ export class OnRampSquidRouterUsdToEvmEngine extends BaseSquidRouterEngine { const toTokenDetails = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to); // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const subsidy = ctx.subsidy!; + console.log("Inputs for evm-to-evm computation", { + amountRaw: subsidy.targetOutputAmountRaw, + fromNetwork: Networks.Polygon, + fromToken: ALFREDPAY_ERC20_TOKEN, + inputAmountDecimal: subsidy.targetOutputAmountDecimal.toString(), + inputAmountRaw: subsidy.targetOutputAmountRaw, + outputDecimals: toTokenDetails.decimals + }); return { data: { - amountRaw: subsidy.actualOutputAmountRaw, + amountRaw: subsidy.targetOutputAmountRaw, fromNetwork: Networks.Polygon, fromToken: ALFREDPAY_ERC20_TOKEN, - inputAmountDecimal: subsidy.actualOutputAmountDecimal, - inputAmountRaw: subsidy.actualOutputAmountRaw, + inputAmountDecimal: subsidy.targetOutputAmountDecimal, + inputAmountRaw: subsidy.targetOutputAmountRaw, outputDecimals: toTokenDetails.decimals, toNetwork, toToken: toTokenDetails.erc20AddressSourceChain diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts index fed989ad7..3090460f3 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts @@ -17,5 +17,5 @@ export const offrampEvmToAlfredpayStrategy = defineRouteStrategy({ [StageKey.Finalize]: new OffRampFinalizeEngine() }), name: "OfframpEvmToAlfredpay", - stages: [StageKey.Initialize, StageKey.PartnerOperation, StageKey.Fee, StageKey.Discount, StageKey.Finalize] + stages: [StageKey.Initialize, StageKey.Discount, StageKey.PartnerOperation, StageKey.Fee, StageKey.Finalize] }); From 05bcd6765df972f4a67f70997fde4672b063eba3 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 20 May 2026 17:02:47 -0300 Subject: [PATCH 07/24] invert rate before passing to calculateExpectedOutput --- .../services/quote/engines/discount/offramp-alfredpay.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts index 92953c3ae..6856e0366 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts @@ -47,9 +47,12 @@ export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { ALFREDPAY_ONCHAIN_CURRENCY as unknown as RampCurrency, outputCurrency as RampCurrency ); - const effectiveRate = new Big(oneUnitInFiat); + // priceFeedService returns USD-FIAT (e.g., 3764.67), but calculateExpectedOutput expects FIAT-USD + const effectiveRate = new Big(1).div(oneUnitInFiat); - const finalOutput = usdOnPolygon.mul(effectiveRate); + // finalOutput uses the non-inverted rate for display/logging + const usdToFiatRate = new Big(oneUnitInFiat); + const finalOutput = usdOnPolygon.mul(usdToFiatRate); console.log( `[OffRampAlfredpayDiscountEngine] input=${inputAmount} ${outputCurrency}, usdOnPolygon=${usdOnPolygon.toString()}, oracleRate=${effectiveRate.toString()}, finalOutput=${finalOutput.toString()} ${outputCurrency}` From b866d5c8789a95ce2d5e062cbc04044233a81127 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 20 May 2026 18:04:37 -0300 Subject: [PATCH 08/24] use alfredpay token constant for token selection in pre-swap handler --- .../phases/handlers/subsidize-pre-swap-handler.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts index 50c488bb1..1aabfae24 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts @@ -1,6 +1,7 @@ import { ALFREDPAY_ERC20_DECIMALS, ALFREDPAY_ERC20_TOKEN, + ALFREDPAY_EVM_TOKEN, ApiManager, checkEvmBalanceForToken, EvmClientManager, @@ -63,19 +64,19 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { throw new Error("Missing evmToEvm information in quote metadata"); } - const inputTokenDetails = getOnChainTokenDetails(Networks.Polygon, EvmToken.USDT) as EvmTokenDetails; + const inputTokenDetails = getOnChainTokenDetails(Networks.Polygon, ALFREDPAY_EVM_TOKEN) as EvmTokenDetails; if (!inputTokenDetails) { - throw new Error("Could not find token details for USDT on Polygon. Invalid quote metadata."); + throw new Error("Could not find token details for Alfredpay token on Polygon. Invalid quote metadata."); } return { expectedInputAmountForSwapRaw: quote.metadata.evmToEvm.inputAmountRaw, inputAmountDecimals: ALFREDPAY_ERC20_DECIMALS, // TODO no need to keep this constant, let's identify simply by token/chain itself. - inputToken: EvmToken.USDT, + inputToken: ALFREDPAY_EVM_TOKEN, inputTokenDetails, logLabel: "Alfredpay", nextPhase: "squidRouterSwap" as RampPhase, - subsidyToken: EvmToken.USDT as unknown as SubsidyToken, + subsidyToken: ALFREDPAY_EVM_TOKEN as unknown as SubsidyToken, tokenContract: ALFREDPAY_ERC20_TOKEN }; } From ae747b9fd6495d5f6b2f3ba6e90ca487076a704a Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 22 May 2026 17:07:52 -0300 Subject: [PATCH 09/24] removing logs, comments. Improve fee estimation operation for discount. --- .../handlers/subsidize-pre-swap-handler.ts | 6 +---- .../engines/discount/offramp-alfredpay.ts | 25 ++++++------------- .../onramp-polygon-to-evm-alfredpay.ts | 8 ------ .../offramp-evm-to-alfredpay.strategy.ts | 4 +-- 4 files changed, 11 insertions(+), 32 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts index 1aabfae24..e60b74b2d 100644 --- a/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts +++ b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts @@ -71,7 +71,7 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { return { expectedInputAmountForSwapRaw: quote.metadata.evmToEvm.inputAmountRaw, - inputAmountDecimals: ALFREDPAY_ERC20_DECIMALS, // TODO no need to keep this constant, let's identify simply by token/chain itself. + inputAmountDecimals: ALFREDPAY_ERC20_DECIMALS, inputToken: ALFREDPAY_EVM_TOKEN, inputTokenDetails, logLabel: "Alfredpay", @@ -231,10 +231,6 @@ export class SubsidizePreSwapPhaseHandler extends BasePhaseHandler { const requiredAmount = Big(expectedInputAmountForSwapRaw).sub(currentBalance); logger.debug(`SubsidizePreSwapHandler (${logLabel}): requiredAmount ${requiredAmount.toString()}`); - console.log( - `[SubsidizePreSwapPhaseHandler] ${logLabel} ephemeral=${evmEphemeralAddress}, expected=${expectedInputAmountForSwapRaw}, currentBalance=${currentBalance.toString()}, required=${requiredAmount.toString()}` - ); - if (requiredAmount.gt(Big(0))) { const subsidyDecimal = nativeToDecimal(requiredAmount, inputAmountDecimals).toString(); const subsidyUsd = await priceFeedService.convertCurrency( diff --git a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts index 6856e0366..9b550f67b 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts @@ -38,26 +38,21 @@ export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const usdOnPolygon = ctx.evmToEvm!.outputAmountDecimal; - // Oracle rate USD -> fiat. + // Oracle rate FIAT -> USD (e.g., 1 ARS = 0.0002657 USD). // This block is required to avoid calling the Alfredpay API twice for a quote. - // Since the input amount for the Alfredpay operations comes after this, and uses the output of the + // Since setting the input amount for the Alfredpay operations comes after this, and uses the output of the // discounted rate, we need to know or estimate the rate in advance. - const oneUnitInFiat = await priceFeedService.convertCurrency( + const effectiveRateStr = await priceFeedService.convertCurrency( "1", - ALFREDPAY_ONCHAIN_CURRENCY as unknown as RampCurrency, - outputCurrency as RampCurrency + outputCurrency as RampCurrency, + ALFREDPAY_ONCHAIN_CURRENCY as unknown as RampCurrency ); - // priceFeedService returns USD-FIAT (e.g., 3764.67), but calculateExpectedOutput expects FIAT-USD - const effectiveRate = new Big(1).div(oneUnitInFiat); + const effectiveRate = new Big(effectiveRateStr); - // finalOutput uses the non-inverted rate for display/logging - const usdToFiatRate = new Big(oneUnitInFiat); + // finalOutput uses the inverted rate (USD -> FIAT) for display/logging + const usdToFiatRate = new Big(1).div(effectiveRate); const finalOutput = usdOnPolygon.mul(usdToFiatRate); - console.log( - `[OffRampAlfredpayDiscountEngine] input=${inputAmount} ${outputCurrency}, usdOnPolygon=${usdOnPolygon.toString()}, oracleRate=${effectiveRate.toString()}, finalOutput=${finalOutput.toString()} ${outputCurrency}` - ); - const { expectedOutput: expectedOutputDecimal, adjustedDifference, @@ -73,10 +68,6 @@ export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { const subsidyRate = expectedOutputDecimal.gt(0) ? actualSubsidyDecimal.div(expectedOutputDecimal) : new Big(0); - console.log( - `[OffRampAlfredpayDiscountEngine] partner=${partner?.id ?? "none"}, targetDiscount=${targetDiscount}, maxSubsidy=${maxSubsidy}, expectedOutput=${expectedOutputDecimal.toString()}, idealSubsidy=${idealSubsidyDecimal.toString()}, actualSubsidy=${actualSubsidyDecimal.toString()}, targetOutput=${targetOutputDecimal.toString()}, subsidyRate=${subsidyRate.toString()}` - ); - return { actualOutputAmountDecimal: finalOutput, actualOutputAmountRaw: multiplyByPowerOfTen(finalOutput, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts index 29a481ca7..ac2ae7700 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts @@ -68,14 +68,6 @@ export class OnRampSquidRouterUsdToEvmEngine extends BaseSquidRouterEngine { const toTokenDetails = getTokenDetailsForEvmDestination(req.outputCurrency as OnChainToken, req.to); // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const subsidy = ctx.subsidy!; - console.log("Inputs for evm-to-evm computation", { - amountRaw: subsidy.targetOutputAmountRaw, - fromNetwork: Networks.Polygon, - fromToken: ALFREDPAY_ERC20_TOKEN, - inputAmountDecimal: subsidy.targetOutputAmountDecimal.toString(), - inputAmountRaw: subsidy.targetOutputAmountRaw, - outputDecimals: toTokenDetails.decimals - }); return { data: { diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts index 3090460f3..918459613 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts @@ -11,9 +11,9 @@ import { defineRouteStrategy } from "../route-definition"; export const offrampEvmToAlfredpayStrategy = defineRouteStrategy({ engines: () => ({ [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Polygon), - [StageKey.Fee]: new OffRampEvmToAlfredpayFeeEngine(), - [StageKey.PartnerOperation]: new OfframpTransactionAlfredpayEngine(), [StageKey.Discount]: new OffRampAlfredpayDiscountEngine(), + [StageKey.PartnerOperation]: new OfframpTransactionAlfredpayEngine(), + [StageKey.Fee]: new OffRampEvmToAlfredpayFeeEngine(), [StageKey.Finalize]: new OffRampFinalizeEngine() }), name: "OfframpEvmToAlfredpay", From 9def722053c8aa2199a519034a6bbb28d50ea87f Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 22 May 2026 17:21:18 -0300 Subject: [PATCH 10/24] reorder fee computation --- .../routes/strategies/offramp-evm-to-alfredpay.strategy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts index 918459613..21dde5ff8 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts @@ -11,11 +11,11 @@ import { defineRouteStrategy } from "../route-definition"; export const offrampEvmToAlfredpayStrategy = defineRouteStrategy({ engines: () => ({ [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Polygon), + [StageKey.Fee]: new OffRampEvmToAlfredpayFeeEngine(), [StageKey.Discount]: new OffRampAlfredpayDiscountEngine(), [StageKey.PartnerOperation]: new OfframpTransactionAlfredpayEngine(), - [StageKey.Fee]: new OffRampEvmToAlfredpayFeeEngine(), [StageKey.Finalize]: new OffRampFinalizeEngine() }), name: "OfframpEvmToAlfredpay", - stages: [StageKey.Initialize, StageKey.Discount, StageKey.PartnerOperation, StageKey.Fee, StageKey.Finalize] + stages: [StageKey.Initialize, StageKey.Fee, StageKey.Discount, StageKey.PartnerOperation, StageKey.Finalize] }); From 14c6601dec7d8f526d28a85aac854233a0be0661 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Fri, 22 May 2026 18:12:02 -0300 Subject: [PATCH 11/24] patch fees for alfredpay flows --- .../services/quote/engines/discount/offramp-alfredpay.ts | 3 ++- .../services/quote/engines/discount/onramp-alfredpay.ts | 8 +++++--- .../api/src/api/services/quote/engines/finalize/onramp.ts | 8 +++++++- .../services/quote/engines/partners/offramp-alfredpay.ts | 3 ++- .../strategies/offramp-evm-to-alfredpay.strategy.ts | 2 +- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts index 9b550f67b..515d130e2 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts @@ -36,7 +36,8 @@ export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { const maxSubsidy = partner?.maxSubsidy ?? 0; // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const usdOnPolygon = ctx.evmToEvm!.outputAmountDecimal; + const deductibleFee = ctx.preNabla?.deductibleFeeAmountInSwapCurrency ?? new Big(0); + const usdOnPolygon = ctx.evmToEvm!.outputAmountDecimal.minus(deductibleFee); // Oracle rate FIAT -> USD (e.g., 1 ARS = 0.0002657 USD). // This block is required to avoid calling the Alfredpay API twice for a quote. diff --git a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts index 480c82c41..ba2c90467 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts @@ -1,5 +1,4 @@ -import { ALFREDPAY_ERC20_TOKEN } from "@packages/shared"; -import { ALFREDPAY_ERC20_DECIMALS, multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; +import { ALFREDPAY_ERC20_DECIMALS, ALFREDPAY_ERC20_TOKEN, multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; import { QuoteContext } from "../../core/types"; import { BaseDiscountEngine, DiscountComputation } from "."; @@ -33,7 +32,10 @@ export class OnRampAlfredpayDiscountEngine extends BaseDiscountEngine { const effectiveRate = alfredpayMint.outputAmountDecimal.div(alfredpayMint.inputAmountDecimal); - const finalOutput = ctx.evmToEvm?.outputAmountDecimal ?? alfredpayMint.outputAmountDecimal; + const usdFees = ctx.fees?.usd; + const feesToDeduct = usdFees ? new Big(usdFees.vortex).plus(usdFees.partnerMarkup) : new Big(0); + + const finalOutput = ctx.evmToEvm?.outputAmountDecimal ?? alfredpayMint.outputAmountDecimal.minus(feesToDeduct); console.log( `[OnRampAlfredpayDiscountEngine] input=${inputAmount} ${ctx.request.outputCurrency}, alfredpayMintIn=${alfredpayMint.inputAmountDecimal.toString()} ${alfredpayMint.currency}, alfredpayMintOut=${alfredpayMint.outputAmountDecimal.toString()} ${ctx.request.outputCurrency}, effectiveRate=${effectiveRate.toString()}, finalOutput=${finalOutput.toString()}` 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 942a748c5..dbcceccd9 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.ts @@ -61,7 +61,13 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { status: httpStatus.INTERNAL_SERVER_ERROR }); } - finalOutputAmountDecimal = new Big(output); + let amount = new Big(output); + if (!ctx.evmToEvm && ctx.alfredpayMint) { + const usdFees = ctx.fees?.usd; + const feesToDeduct = usdFees ? new Big(usdFees.vortex).plus(usdFees.partnerMarkup) : new Big(0); + amount = amount.minus(feesToDeduct); + } + finalOutputAmountDecimal = amount; } else { const output = ctx.moonbeamToEvm?.outputAmountDecimal; if (!output) { 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 59b9952fe..7bd96c5a5 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 @@ -40,9 +40,10 @@ export class OfframpTransactionAlfredpayEngine extends BaseInitializeEngine { ); const effectiveRate = new Big(oneUnitInFiat); + const deductibleFee = ctx.preNabla?.deductibleFeeAmountInSwapCurrency ?? new Big(0); const inputAmountDecimal = effectiveRate.gt(0) ? ctx.subsidy.targetOutputAmountDecimal.div(effectiveRate) - : ctx.evmToEvm.outputAmountDecimal; + : ctx.evmToEvm.outputAmountDecimal.minus(deductibleFee); const alfredpayService = AlfredpayApiService.getInstance(); const quoteRequest: CreateAlfredpayOfframpQuoteRequest = { diff --git a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts index 21dde5ff8..ecce16594 100644 --- a/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts +++ b/apps/api/src/api/services/quote/routes/strategies/offramp-evm-to-alfredpay.strategy.ts @@ -17,5 +17,5 @@ export const offrampEvmToAlfredpayStrategy = defineRouteStrategy({ [StageKey.Finalize]: new OffRampFinalizeEngine() }), name: "OfframpEvmToAlfredpay", - stages: [StageKey.Initialize, StageKey.Fee, StageKey.Discount, StageKey.PartnerOperation, StageKey.Finalize] + stages: [StageKey.Initialize, StageKey.Discount, StageKey.PartnerOperation, StageKey.Fee, StageKey.Finalize] }); From 2aafc689fede7f60a56a4082ae99d1604b19d909 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 26 May 2026 13:43:16 -0300 Subject: [PATCH 12/24] handle KYB user status --- .../api/controllers/alfredpay.controller.ts | 34 +++++++++++++++---- .../api/services/transactions/validation.ts | 1 + 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index ecca314c4..a745a1ba6 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -38,7 +38,23 @@ export class AlfredpayController { return AlfredPayStatus.UpdateRequired; case AlfredpayKycStatus.CREATED: default: - return null; // Do nothing + return null; + } + } + + private static mapKybStatus(status: AlfredpayKybStatus): AlfredPayStatus | null { + switch (status) { + case AlfredpayKybStatus.IN_REVIEW: + return AlfredPayStatus.Verifying; + case AlfredpayKybStatus.FAILED: + return AlfredPayStatus.Failed; + case AlfredpayKybStatus.COMPLETED: + return AlfredPayStatus.Success; + case AlfredpayKybStatus.UPDATE_REQUIRED: + return AlfredPayStatus.UpdateRequired; + case AlfredpayKybStatus.CREATED: + default: + return null; } } @@ -57,17 +73,21 @@ export class AlfredpayController { } const alfredpayService = AlfredpayApiService.getInstance(); + const isBusiness = alfredPayCustomer.type === AlfredpayCustomerType.BUSINESS; try { - const lastSubmission = await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); + const lastSubmission = isBusiness + ? await alfredpayService.getLastKybSubmission(alfredPayCustomer.alfredPayId) + : await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId); if (lastSubmission && lastSubmission.submissionId) { - const statusResponse = await alfredpayService.getKycStatus( - alfredPayCustomer.alfredPayId, - lastSubmission.submissionId - ); + const statusResponse = isBusiness + ? await alfredpayService.getKybStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId) + : await alfredpayService.getKycStatus(alfredPayCustomer.alfredPayId, lastSubmission.submissionId); - const newStatus = AlfredpayController.mapKycStatus(statusResponse.status); + const newStatus = isBusiness + ? AlfredpayController.mapKybStatus(statusResponse.status) + : AlfredpayController.mapKycStatus(statusResponse.status); const updateData: Partial = {}; if (newStatus && newStatus !== alfredPayCustomer.status) { diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index e6bdad999..bf80ef4dc 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -246,6 +246,7 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase, network: Ne case "baseCleanupBrla": case "baseCleanupUsdc": case "baseCleanupAxlUsdc": + case "alfredOnrampMintFallback": return EphemeralAccountType.EVM; default: throw new APIError({ From d6d1d9e8c4165cde392a61751273fcfbaf567105 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 26 May 2026 17:16:52 -0300 Subject: [PATCH 13/24] refresh alfredpay quote before sending operation --- .../api/src/api/services/ramp/ramp.service.ts | 86 ++++++++++++++++++- .../SummaryStep/TransactionTokensDisplay.tsx | 7 +- apps/frontend/src/machines/ramp.machine.ts | 27 +++++- 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 839d37592..a427ff3cd 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -1356,9 +1356,10 @@ export class RampService extends BaseRampService { } const alfredpayService = AlfredpayApiService.getInstance(); - const alfredpayQuoteId = quote.metadata.alfredpayMint?.quoteId; + const originalAlfredpayMint = quote.metadata.alfredpayMint; + const originalQuoteId = originalAlfredpayMint?.quoteId; - if (!alfredpayQuoteId) { + if (!originalQuoteId || !originalAlfredpayMint) { throw new APIError({ message: "Missing Alfredpay quote ID in metadata", status: httpStatus.BAD_REQUEST @@ -1386,14 +1387,24 @@ export class RampService extends BaseRampService { }); } + // Alfredpay quotes expire ~30s after creation, which is often shorter than the time the + // user needs to sign ephemeral txs in the UI. Try refreshing the Alfredpay quote + const fromCurrency = quote.inputCurrency as unknown as AlfredpayFiatCurrency; + const effectiveQuoteId = await this.refreshAlfredpayOnrampQuoteIfMatching( + quote, + originalAlfredpayMint, + fromCurrency, + transaction + ); + const orderRequest: CreateAlfredpayOnrampRequest = { amount: quote.inputAmount, chain: AlfredpayChain.MATIC, customerId: rampState.state.alfredpayUserId, depositAddress: rampState.state.evmEphemeralAddress, - fromCurrency: quote.inputCurrency as unknown as AlfredpayFiatCurrency, + fromCurrency, paymentMethodType: AlfredpayPaymentMethodType.BANK, - quoteId: alfredpayQuoteId, + quoteId: effectiveQuoteId, toCurrency: ALFREDPAY_ONCHAIN_CURRENCY }; @@ -1413,6 +1424,73 @@ export class RampService extends BaseRampService { return order.fiatPaymentInstructions; } + private async refreshAlfredpayOnrampQuoteIfMatching( + quote: QuoteTicket, + originalAlfredpayMint: NonNullable, + fromCurrency: AlfredpayFiatCurrency, + transaction: Transaction + ): Promise { + const alfredpayService = AlfredpayApiService.getInstance(); + const originalQuoteId = originalAlfredpayMint.quoteId; + + try { + const freshQuote = await alfredpayService.createOnrampQuote({ + chain: AlfredpayChain.MATIC, + fromAmount: new Big(quote.inputAmount).toString(), + fromCurrency, + metadata: { + businessId: "vortex", + customerId: quote.userId || "unknown" + }, + paymentMethodType: AlfredpayPaymentMethodType.BANK, + toCurrency: ALFREDPAY_ONCHAIN_CURRENCY + }); + + // outputAmountDecimal arrives as a serialized Big after JSONB roundtrip; normalize via Big(). + const originalToAmount = new Big(originalAlfredpayMint.outputAmountDecimal as unknown as string); + const freshToAmount = new Big(freshQuote.toAmount); + + const originalFee = new Big(originalAlfredpayMint.fee as unknown as string); + const freshFee = AlfredpayApiService.sumFeesByCurrency(freshQuote.fees, fromCurrency); + + if (!freshToAmount.eq(originalToAmount) || !freshFee.eq(originalFee)) { + logger.warn( + `[refreshAlfredpayOnrampQuote] Quote ${quote.id}: refreshed Alfredpay quote drifted. ` + + `toAmount original=${originalToAmount.toString()} fresh=${freshToAmount.toString()}, ` + + `fee original=${originalFee.toString()} fresh=${freshFee.toString()}. ` + + `Falling back to original quoteId ${originalQuoteId}.` + ); + return originalQuoteId; + } + + await quote.update( + { + metadata: { + ...quote.metadata, + alfredpayMint: { + ...originalAlfredpayMint, + expirationDate: new Date(freshQuote.expiration), + quoteId: freshQuote.quoteId + } + } + }, + { transaction } + ); + + logger.info( + `[refreshAlfredpayOnrampQuote] Quote ${quote.id}: swapped Alfredpay quote ${originalQuoteId} -> ${freshQuote.quoteId}.` + ); + return freshQuote.quoteId; + } catch (error) { + logger.warn( + `[refreshAlfredpayOnrampQuote] Quote ${quote.id}: refresh failed (${ + error instanceof Error ? error.message : String(error) + }). Falling back to original quoteId ${originalQuoteId}.` + ); + return originalQuoteId; + } + } + private async processAlfredpayOfframpStart( rampState: RampState, quote: QuoteTicket, diff --git a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx index ebb9dac52..261baa29d 100644 --- a/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx +++ b/apps/frontend/src/components/widget-steps/SummaryStep/TransactionTokensDisplay.tsx @@ -55,7 +55,10 @@ export const TransactionTokensDisplay: FC = ({ ex })); const targetTimestampMs = quote ? new Date(quote.expiresAt).getTime() : null; - const { minutes, seconds } = useCountdown(targetTimestampMs, () => rampActor.send({ type: "EXPIRE_QUOTE" })); + + const isAlfredpayFlow = isAlfredpayToken(executionInput.fiatToken); + const countdownTarget = isAlfredpayFlow ? null : targetTimestampMs; + const { minutes, seconds } = useCountdown(countdownTarget, () => rampActor.send({ type: "EXPIRE_QUOTE" })); const formattedTime = `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`; @@ -135,7 +138,7 @@ export const TransactionTokensDisplay: FC = ({ ex {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.USD && } {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.MXN && } {rampDirection === RampDirection.BUY && executionInput.fiatToken === FiatToken.COP && } - {quoteLocked && targetTimestampMs !== null && !isQuoteExpired && ( + {quoteLocked && !isAlfredpayFlow && targetTimestampMs !== null && !isQuoteExpired && (
{t("components.SummaryPage.BRLOnrampDetails.timerLabel")} {formattedTime}
diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index 4e442c052..3de558b49 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -25,6 +25,22 @@ import { RampContext, RampMachineActor, RampMachineEvents, RampState } from "./t export const SUCCESS_CALLBACK_DELAY_MS = 5000; // 5 seconds +function mergeRampStatePreservingPaymentInfo(prev: RampState | undefined, next: RampState): RampState { + if (!prev?.ramp) return next; + const prevRamp = prev.ramp; + const nextRamp = next.ramp; + if (!nextRamp) return next; + return { + ...next, + ramp: { + ...nextRamp, + achPaymentData: nextRamp.achPaymentData ?? prevRamp.achPaymentData, + depositQrCode: nextRamp.depositQrCode ?? prevRamp.depositQrCode, + ibanPaymentData: nextRamp.ibanPaymentData ?? prevRamp.ibanPaymentData + } + }; +} + function getActorErrorMessage(event: unknown): string { if (typeof event !== "object" || event === null || !("error" in event)) { return "An unexpected error occurred."; @@ -103,7 +119,7 @@ export const rampMachine = setup({ }, EXPIRE_QUOTE: { actions: assign({ - isQuoteExpired: true + isQuoteExpired: ({ context }) => (context.quoteLocked ? context.isQuoteExpired : true) }) }, LOGOUT: { @@ -602,11 +618,12 @@ export const rampMachine = setup({ } }, RegisterRamp: { + entry: assign({ quoteLocked: true }), invoke: { input: ({ context }) => context, onDone: { actions: assign({ - rampState: ({ event }) => event.output + rampState: ({ event, context }) => mergeRampStatePreservingPaymentInfo(context.rampState, event.output) }), target: "UpdateRamp" }, @@ -689,13 +706,15 @@ export const rampMachine = setup({ onDone: [ { actions: assign({ - rampState: ({ event }) => event.output as RampState + rampState: ({ event, context }) => + mergeRampStatePreservingPaymentInfo(context.rampState, event.output as RampState) }), guard: ({ context }) => context.rampDirection === RampDirection.BUY }, { actions: assign({ - rampState: ({ event }) => event.output as RampState + rampState: ({ event, context }) => + mergeRampStatePreservingPaymentInfo(context.rampState, event.output as RampState) }), guard: ({ context }) => context.rampDirection === RampDirection.SELL, target: "StartRamp" From 88af81a01d26452db660ebb0fc40c218d0a069b9 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 26 May 2026 17:25:30 -0300 Subject: [PATCH 14/24] adjust issues with alfredpay onramp flow --- .../services/phases/handlers/squid-router-phase-handler.ts | 4 ++-- .../services/transactions/onramp/routes/alfredpay-to-evm.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index 89ea84e5a..b5c97e960 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -63,7 +63,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { const isAlfredpayOnramp = state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken) && !!quote.metadata.alfredpayMint; - if (isAlfredpayOnramp) { + if (isAlfredpayOnramp && quote.metadata.to === Networks.Polygon) { logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for Alfredpay onramp (ramp ${state.id})`); return this.transitionToNextPhase(state, "destinationTransfer"); } @@ -195,7 +195,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { throw new Error(`Quote not found for ramp ${state.id}`); } - if (quote.inputCurrency === FiatToken.EURC || quote.inputCurrency === FiatToken.USD) { + if (quote.inputCurrency === FiatToken.EURC || isAlfredpayToken(quote.inputCurrency as FiatToken)) { return this.polygonClient; } else if (quote.inputCurrency === FiatToken.BRL) { return this.baseClient; 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 3c8f75519..52e90de4d 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 @@ -55,6 +55,10 @@ export async function prepareAlfredpayToEvmOnrampTransactions({ throw new Error("Missing alfredpay raw mint amount in quote metadata"); } + if (!quote.metadata.evmToEvm?.outputAmountRaw) { + throw new Error("Missing evmToEvm raw output amount in quote metadata"); + } + const toNetwork = getNetworkFromDestination(quote.to); if (!toNetwork || toNetwork === Networks.AssetHub) { throw new Error(`Invalid network for destination ${quote.to}`); @@ -104,7 +108,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.alfredpayMint.outputAmountRaw, + amountRaw: quote.metadata.evmToEvm?.outputAmountRaw, destinationNetwork: toNetwork as EvmNetworks, toAddress: destinationAddress, toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain From ace25517944910b13649322a9214c5df290b52ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 08:50:28 +0000 Subject: [PATCH 15/24] fix: address review comments - scope usage by stablecoin, deterministic customer type, fix comments --- .../src/api/services/alfredpay/alfredpay.helpers.ts | 12 +++++++++--- .../api/services/quote/core/validation-helpers.ts | 7 ++++++- .../src/api/services/quote/engines/finalize/index.ts | 4 ++-- apps/api/src/index.ts | 2 +- bun.lock | 1 + 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts index cddb0549e..7a241ccae 100644 --- a/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts +++ b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts @@ -39,7 +39,7 @@ export async function lookupAlfredpayCustomerType(userId: string | undefined, fi if (!userId) return AlfredpayCustomerType.INDIVIDUAL; const country = alfredpayCountryForFiat(fiat); if (!country) return AlfredpayCustomerType.INDIVIDUAL; - const customer = await AlfredPayCustomer.findOne({ where: { country, userId } }); + const customer = await AlfredPayCustomer.findOne({ order: [["type", "ASC"]], where: { country, userId } }); return customer?.type === AlfredpayCustomerType.BUSINESS ? AlfredpayCustomerType.BUSINESS : AlfredpayCustomerType.INDIVIDUAL; } @@ -102,12 +102,18 @@ function startOfCurrentUtcMonth(): Date { } /** Returned in input-currency human units: fiat on onramp, stablecoin on offramp. */ -export async function getAlfredpayMonthlyUsage(userId: string, direction: RampDirection, fiat: FiatToken): Promise { +export async function getAlfredpayMonthlyUsage( + userId: string, + direction: RampDirection, + fiat: FiatToken, + stablecoin: AlfredpayStablecoinKey +): Promise { const isOnramp = direction === RampDirection.BUY; const fiatSide = isOnramp ? { inputCurrency: fiat } : { outputCurrency: fiat }; + const stablecoinSide = isOnramp ? { outputCurrency: stablecoin } : { inputCurrency: stablecoin }; const completedRamps = (await RampState.findAll({ - include: [{ as: "quote", model: QuoteTicket, required: true, where: fiatSide }], + include: [{ as: "quote", model: QuoteTicket, required: true, where: { ...fiatSide, ...stablecoinSide } }], where: { createdAt: { [Op.gte]: startOfCurrentUtcMonth() }, currentPhase: "complete", diff --git a/apps/api/src/api/services/quote/core/validation-helpers.ts b/apps/api/src/api/services/quote/core/validation-helpers.ts index 6fbbe2747..97bc584e9 100644 --- a/apps/api/src/api/services/quote/core/validation-helpers.ts +++ b/apps/api/src/api/services/quote/core/validation-helpers.ts @@ -98,7 +98,12 @@ export async function applyAlfredpayLimits(ctx: QuoteContext, amount: Big.BigSou const { userId } = ctx.request; if (!userId) return true; - const used = await getAlfredpayMonthlyUsage(userId, alfredpayLimits.direction, alfredpayLimits.fiat); + const used = await getAlfredpayMonthlyUsage( + userId, + alfredpayLimits.direction, + alfredpayLimits.fiat, + alfredpayLimits.stablecoin + ); const max = new Big(alfredpayLimits.max); if (used.plus(new Big(amount)).lte(max)) return true; diff --git a/apps/api/src/api/services/quote/engines/finalize/index.ts b/apps/api/src/api/services/quote/engines/finalize/index.ts index af8d83bd0..599b87ab1 100644 --- a/apps/api/src/api/services/quote/engines/finalize/index.ts +++ b/apps/api/src/api/services/quote/engines/finalize/index.ts @@ -116,8 +116,8 @@ export abstract class BaseFinalizeEngine implements Stage { createdAt: new Date(), expiresAt, feeCurrency: fiatFees.currency, - from: request.from, // Temporary ID for comparison - id: "temp-" + Date.now(), + from: request.from, + id: "temp-" + Date.now(), // Temporary ID for comparison inputAmount: trimTrailingZeros(request.inputAmount), inputCurrency: request.inputCurrency, network: request.network, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a79bf99ff..dc286aa6d 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -74,7 +74,7 @@ const initializeApp = async () => { new RampRecoveryWorker().start(); new UnhandledPaymentWorker().start(); - // Start AlfredPay limits refresh loop (10 min TTL; falls back to hardcoded if stale) + // Start AlfredPay limits refresh loop (daily; falls back to hardcoded if stale) AlfredpayLimitsService.getInstance().start(); // Register phase handlers diff --git a/bun.lock b/bun.lock index 5117a5b2e..d0798ddf1 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "vortex-monorepo", From c675d3e6ec6d2cbb46ae34cdf35ee1103319b0a3 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 27 May 2026 10:52:40 -0300 Subject: [PATCH 16/24] alfredpay offramp testing --- .../api/src/api/services/ramp/ramp.service.ts | 23 ------------------- .../api/services/transactions/validation.ts | 1 + 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index a427ff3cd..8b5f93986 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -1530,29 +1530,6 @@ export class RampService extends BaseRampService { status: httpStatus.BAD_REQUEST }); } - - const orderRequest: CreateAlfredpayOfframpRequest = { - amount: quote.inputAmount, - chain: AlfredpayChain.MATIC, - customerId: rampState.state.alfredpayUserId, - fiatAccountId: rampState.state.fiatAccountId, - fromCurrency: ALFREDPAY_ONCHAIN_CURRENCY, - originAddress: rampState.state.walletAddress, - quoteId: alfredpayQuoteId, - toCurrency: quote.outputCurrency as unknown as AlfredpayFiatCurrency - }; - - const order = await alfredpayService.createOfframp(orderRequest); - - await rampState.update( - { - state: { - ...rampState.state, - alfredpayTransactionId: order.transactionId - } - }, - { transaction } - ); } } diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index bf80ef4dc..d432d97c8 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -247,6 +247,7 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase, network: Ne case "baseCleanupUsdc": case "baseCleanupAxlUsdc": case "alfredOnrampMintFallback": + case "alfredpayOfframpTransferFallback": return EphemeralAccountType.EVM; default: throw new APIError({ From 1d7fd668d915bd222f9f85b8ac95d88c12e3c853 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 27 May 2026 11:37:40 -0300 Subject: [PATCH 17/24] code improvements --- .../phases/handlers/squid-router-phase-handler.ts | 12 +----------- .../quote/engines/discount/onramp-alfredpay.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index b5c97e960..2b81c1e89 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -58,16 +58,6 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { return state; } - // Alfredpay onramps mint directly to Polygon in the alfredpay token (e.g. USDT), - // so no squidRouter swap is needed — skip straight to destination transfer. - const isAlfredpayOnramp = - state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken) && !!quote.metadata.alfredpayMint; - - if (isAlfredpayOnramp && quote.metadata.to === Networks.Polygon) { - logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for Alfredpay onramp (ramp ${state.id})`); - return this.transitionToNextPhase(state, "destinationTransfer"); - } - const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; if ( !bridgeMeta?.inputAmountRaw || @@ -84,7 +74,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { bridgeMeta.fromToken.toLowerCase() === bridgeMeta.toToken.toLowerCase(); if (isSameChainSameTokenPassthrough) { logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for same-chain same-token passthrough (ramp ${state.id})`); - return this.transitionToNextPhase(state, "destinationTransfer"); + return this.transitionToNextPhase(state, "finalSettlementSubsidy"); } const evmEphemeralAddress = state.state.evmEphemeralAddress; diff --git a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts index ba2c90467..c939aa35d 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts @@ -1,5 +1,6 @@ -import { ALFREDPAY_ERC20_DECIMALS, ALFREDPAY_ERC20_TOKEN, multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; +import { ALFREDPAY_ERC20_DECIMALS, multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; +import logger from "../../../../../config/logger"; import { QuoteContext } from "../../core/types"; import { BaseDiscountEngine, DiscountComputation } from "."; import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; @@ -19,6 +20,10 @@ export class OnRampAlfredpayDiscountEngine extends BaseDiscountEngine { if (!ctx.request.inputAmount) { throw new Error("OnRampAlfredpayDiscountEngine requires request.inputAmount to be defined"); } + + if (!ctx.fees?.usd) { + throw new Error("OnRampAlfredpayDiscountEngine requires fees.usd to be defined"); + } } protected async compute(ctx: QuoteContext): Promise { @@ -32,12 +37,13 @@ export class OnRampAlfredpayDiscountEngine extends BaseDiscountEngine { const effectiveRate = alfredpayMint.outputAmountDecimal.div(alfredpayMint.inputAmountDecimal); - const usdFees = ctx.fees?.usd; - const feesToDeduct = usdFees ? new Big(usdFees.vortex).plus(usdFees.partnerMarkup) : new Big(0); + // biome-ignore lint/style/noNonNullAssertion: validated in validate() + const usdFees = ctx.fees!.usd!; + const feesToDeduct = new Big(usdFees.vortex).plus(usdFees.partnerMarkup); const finalOutput = ctx.evmToEvm?.outputAmountDecimal ?? alfredpayMint.outputAmountDecimal.minus(feesToDeduct); - console.log( + logger.debug( `[OnRampAlfredpayDiscountEngine] input=${inputAmount} ${ctx.request.outputCurrency}, alfredpayMintIn=${alfredpayMint.inputAmountDecimal.toString()} ${alfredpayMint.currency}, alfredpayMintOut=${alfredpayMint.outputAmountDecimal.toString()} ${ctx.request.outputCurrency}, effectiveRate=${effectiveRate.toString()}, finalOutput=${finalOutput.toString()}` ); From 379bd444b1f16942685844241fb5f77ab68d05e1 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 27 May 2026 11:42:53 -0300 Subject: [PATCH 18/24] point to standalone BRLA nabla instance --- packages/shared/src/tokens/constants/misc.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/tokens/constants/misc.ts b/packages/shared/src/tokens/constants/misc.ts index 3165bc5fa..66aeb6dfa 100644 --- a/packages/shared/src/tokens/constants/misc.ts +++ b/packages/shared/src/tokens/constants/misc.ts @@ -12,8 +12,8 @@ export const ASSETHUB_WSS = "wss://dot-rpc.stakeworld.io/assethub"; export const MOONBEAM_WSS = "wss://wss.api.moonbeam.network"; export const WALLETCONNECT_ASSETHUB_ID = "polkadot:68d56f15f85d3136970ec16946040bc1"; export const NABLA_ROUTER = "6gAVVw13mQgzzKk4yEwScMmWiCNyMAunXFJUZonbgKrym81N"; // AssetHub USDC instance -export const NABLA_ROUTER_BASE: `0x${string}` = "0x58E5Cb2dA15f01CB8FAefef202aa25238efCBdcf"; -export const NABLA_QUOTER_BASE: `0x${string}` = "0x94C2F795358170a92271bF2490a56135E3fBA58A"; +export const NABLA_ROUTER_BASE: `0x${string}` = "0x8EF01C38e3261901e382A66bEbFa35E8B96c750C"; +export const NABLA_QUOTER_BASE: `0x${string}` = "0x2A7989993335b31A3133CDA93bc1a095e7b178Ff"; export const SPACEWALK_REDEEM_SAFETY_MARGIN = 0.05; export const AMM_MINIMUM_OUTPUT_SOFT_MARGIN = 0.02; From d5bd17da47d0f8f83e883f8c0f3546d46eacefdc Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 27 May 2026 11:44:01 -0300 Subject: [PATCH 19/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts index ac2ae7700..baa611b17 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts @@ -42,11 +42,11 @@ export class OnRampSquidRouterUsdToEvmEngine extends BaseSquidRouterEngine { const subsidy = ctx.subsidy!; return { data: { - amountRaw: subsidy.actualOutputAmountRaw, + amountRaw: subsidy.targetOutputAmountRaw, fromNetwork: Networks.Polygon, fromToken: ALFREDPAY_ERC20_TOKEN, - inputAmountDecimal: subsidy.actualOutputAmountDecimal, - inputAmountRaw: subsidy.actualOutputAmountRaw, + inputAmountDecimal: subsidy.targetOutputAmountDecimal, + inputAmountRaw: subsidy.targetOutputAmountRaw outputDecimals: 6, skipRouteCalculation: true, toNetwork: Networks.Polygon, From e3ca1c4692e9639e39e2a2d069113aee2dbebb88 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 27 May 2026 11:57:13 -0300 Subject: [PATCH 20/24] rollback squidrouter swap handler change --- .../phases/handlers/squid-router-phase-handler.ts | 10 ++++++++++ .../squidrouter/onramp-polygon-to-evm-alfredpay.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index 2b81c1e89..678722bba 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -58,6 +58,16 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { return state; } + // Alfredpay onramps mint directly to Polygon in the alfredpay token (e.g. USDT), + // so no squidRouter swap is needed — skip straight to destination transfer. + const isAlfredpayOnramp = + state.type === RampDirection.BUY && isAlfredpayToken(quote.inputCurrency as FiatToken) && !!quote.metadata.alfredpayMint; + + if (isAlfredpayOnramp && quote.metadata.to === Networks.Polygon) { + logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for Alfredpay onramp (ramp ${state.id})`); + return this.transitionToNextPhase(state, "destinationTransfer"); + } + const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; if ( !bridgeMeta?.inputAmountRaw || diff --git a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts index baa611b17..85cf03598 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/onramp-polygon-to-evm-alfredpay.ts @@ -46,7 +46,7 @@ export class OnRampSquidRouterUsdToEvmEngine extends BaseSquidRouterEngine { fromNetwork: Networks.Polygon, fromToken: ALFREDPAY_ERC20_TOKEN, inputAmountDecimal: subsidy.targetOutputAmountDecimal, - inputAmountRaw: subsidy.targetOutputAmountRaw + inputAmountRaw: subsidy.targetOutputAmountRaw, outputDecimals: 6, skipRouteCalculation: true, toNetwork: Networks.Polygon, From 0194bb3ce26caf60f8c2c9726cfe81cbf7a0ed81 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 27 May 2026 12:37:51 -0300 Subject: [PATCH 21/24] round down final evm bridge output --- .../api/services/phases/handlers/squid-router-phase-handler.ts | 2 +- apps/api/src/api/services/quote/engines/squidrouter/index.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index 678722bba..5119bbaf1 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts @@ -65,7 +65,7 @@ export class SquidRouterPhaseHandler extends BasePhaseHandler { if (isAlfredpayOnramp && quote.metadata.to === Networks.Polygon) { logger.info(`SquidRouterPhaseHandler: Skipping squidRouter for Alfredpay onramp (ramp ${state.id})`); - return this.transitionToNextPhase(state, "destinationTransfer"); + return this.transitionToNextPhase(state, "finalSettlementSubsidy"); } const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; diff --git a/apps/api/src/api/services/quote/engines/squidrouter/index.ts b/apps/api/src/api/services/quote/engines/squidrouter/index.ts index f6ae27d27..4c0dd63e2 100644 --- a/apps/api/src/api/services/quote/engines/squidrouter/index.ts +++ b/apps/api/src/api/services/quote/engines/squidrouter/index.ts @@ -96,7 +96,6 @@ export abstract class BaseSquidRouterEngine implements Stage { private async calculateBridge(bridgeRequest: EvmBridgeRequest): Promise { return calculateEvmBridgeAndNetworkFee(bridgeRequest); } - private assignContext( type: SquidRouterComputation["type"], ctx: QuoteContext, @@ -113,7 +112,7 @@ export abstract class BaseSquidRouterEngine implements Stage { outputAmountDecimal: bridgeResult.finalGrossOutputAmountDecimal, outputAmountRaw: new Big(bridgeResult.finalGrossOutputAmountDecimal) .times(new Big(10).pow(data.outputDecimals)) - .toFixed(0), + .toFixed(0, 0), toNetwork: data.toNetwork, toToken: data.toToken }; From 8eab58cec8680cd9b6df827016380f8ea6a994e8 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Wed, 27 May 2026 14:04:48 -0300 Subject: [PATCH 22/24] remove unused logs --- .../api/services/quote/engines/discount/onramp-alfredpay.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts index c939aa35d..f76bb1362 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts @@ -43,10 +43,6 @@ export class OnRampAlfredpayDiscountEngine extends BaseDiscountEngine { const finalOutput = ctx.evmToEvm?.outputAmountDecimal ?? alfredpayMint.outputAmountDecimal.minus(feesToDeduct); - logger.debug( - `[OnRampAlfredpayDiscountEngine] input=${inputAmount} ${ctx.request.outputCurrency}, alfredpayMintIn=${alfredpayMint.inputAmountDecimal.toString()} ${alfredpayMint.currency}, alfredpayMintOut=${alfredpayMint.outputAmountDecimal.toString()} ${ctx.request.outputCurrency}, effectiveRate=${effectiveRate.toString()}, finalOutput=${finalOutput.toString()}` - ); - const { expectedOutput: expectedOutputDecimal, adjustedDifference, From 5f331ee29bfa5c6efe04dde3ff05c8070992c0b1 Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 27 May 2026 14:36:34 -0300 Subject: [PATCH 23/24] 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> --- .../src/api/services/quote/engines/discount/onramp-alfredpay.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts index f76bb1362..1e4d20c7f 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts @@ -1,6 +1,5 @@ import { ALFREDPAY_ERC20_DECIMALS, multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; -import logger from "../../../../../config/logger"; import { QuoteContext } from "../../core/types"; import { BaseDiscountEngine, DiscountComputation } from "."; import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; From 9ffcf85a972e938a12cd6796b49d6099d2e5f647 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 27 May 2026 21:09:50 +0200 Subject: [PATCH 24/24] harden alfredpay discount/fallback paths and refresh security spec Code fixes: - offramp-alfredpay discount engine: reject non-positive oracle rate and drop dead pre-Nabla deductibleFee subtraction - ramp.machine: apply payment-info preserving merge inside SET_RAMP_STATE so future polling additions cannot wipe achPaymentData / depositQrCode / ibanPaymentData - alfredpay-to-evm onramp route: remove dead optional chain on evmToEvm.outputAmountRaw (guarded by top-level check) Spec updates: - alfredpay.md: rewrite to match current code (KYB/KYC branch, subsidizePreSwap, Polygon passthrough, provider-quote refresh, expired-quote recovery, fallback phase emissions) - discount-mechanism.md: 9 of 10 strategies now register discount; document AlfredPay onramp fallback + offramp inverted-rate guard - quote-lifecycle.md: fix endpoint path nit; add AlfredPay 30s provider TTL + refresh policy section --- .../engines/discount/offramp-alfredpay.ts | 9 ++- .../onramp/routes/alfredpay-to-evm.ts | 2 +- apps/frontend/src/machines/ramp.machine.ts | 2 +- .../03-ramp-engine/discount-mechanism.md | 15 ++++- .../03-ramp-engine/quote-lifecycle.md | 12 +++- .../05-integrations/alfredpay.md | 67 ++++++++++++------- 6 files changed, 74 insertions(+), 33 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts index 515d130e2..cff901312 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts @@ -36,8 +36,7 @@ export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { const maxSubsidy = partner?.maxSubsidy ?? 0; // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate - const deductibleFee = ctx.preNabla?.deductibleFeeAmountInSwapCurrency ?? new Big(0); - const usdOnPolygon = ctx.evmToEvm!.outputAmountDecimal.minus(deductibleFee); + const usdOnPolygon = ctx.evmToEvm!.outputAmountDecimal; // Oracle rate FIAT -> USD (e.g., 1 ARS = 0.0002657 USD). // This block is required to avoid calling the Alfredpay API twice for a quote. @@ -50,6 +49,12 @@ export class OffRampAlfredpayDiscountEngine extends BaseDiscountEngine { ); const effectiveRate = new Big(effectiveRateStr); + if (!effectiveRate.gt(0)) { + throw new Error( + `OffRampAlfredpayDiscountEngine: oracle returned non-positive rate (${effectiveRateStr}) for ${outputCurrency} -> ${ALFREDPAY_ONCHAIN_CURRENCY}` + ); + } + // finalOutput uses the inverted rate (USD -> FIAT) for display/logging const usdToFiatRate = new Big(1).div(effectiveRate); const finalOutput = usdOnPolygon.mul(usdToFiatRate); 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 52e90de4d..b86a476eb 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 @@ -108,7 +108,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: quote.metadata.evmToEvm.outputAmountRaw, destinationNetwork: toNetwork as EvmNetworks, toAddress: destinationAddress, toToken: (outputTokenDetails as EvmTokenDetails).erc20AddressSourceChain diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index 3de558b49..9e7afe611 100644 --- a/apps/frontend/src/machines/ramp.machine.ts +++ b/apps/frontend/src/machines/ramp.machine.ts @@ -576,7 +576,7 @@ export const rampMachine = setup({ }, SET_RAMP_STATE: { actions: assign({ - rampState: ({ event }) => event.rampState + rampState: ({ event, context }) => mergeRampStatePreservingPaymentInfo(context.rampState, event.rampState) }) } } diff --git a/docs/security-spec/03-ramp-engine/discount-mechanism.md b/docs/security-spec/03-ramp-engine/discount-mechanism.md index 831a4248c..5e786f09b 100644 --- a/docs/security-spec/03-ramp-engine/discount-mechanism.md +++ b/docs/security-spec/03-ramp-engine/discount-mechanism.md @@ -16,7 +16,12 @@ For each quote, the discount engine: 6. Calculates `idealSubsidy = max(0, expectedOutput − actualOutput)` and `actualSubsidy = min(idealSubsidy, maxSubsidy × expectedOutput)` (only when `targetDiscount > 0`). 7. Writes a `ctx.subsidy` record consumed by downstream merge-subsidy and finalize stages and ultimately by the subsidy phase handlers. -The engine is wired by strategy configuration. Of the 10 route strategies in `apps/api/src/api/services/quote/routes/strategies/`, 7 register a discount engine and 3 do **not**: `offramp-evm-to-alfredpay`, `onramp-alfredpay-to-evm`, and `onramp-monerium-to-evm`. On those three routes, no subsidy is computed regardless of partner configuration. +The engine is wired by strategy configuration. Of the 10 route strategies in `apps/api/src/api/services/quote/routes/strategies/`, 9 register a discount engine and 1 does **not**: `onramp-monerium-to-evm`. On that single route, no subsidy is computed regardless of partner configuration. + +The two AlfredPay strategies use dedicated discount engines (`OnRampAlfredpayDiscountEngine`, `OffRampAlfredpayDiscountEngine`) that compute the subsidy in the AlfredPay-side currency: + +- **Onramp**: subsidy denominated in the AlfredPay on-chain currency (USDC on Polygon). When the AlfredPay quote API later returns a worse `finalOutput` than the discount-projected `expectedOutput`, the partner engine falls back to the discount engine's `expectedOutput` (see `onramp-alfredpay-to-evm` strategy + `alfredOnrampMintFallback` phase emission). The fallback is bounded by `maxSubsidy × expectedOutput`. +- **Offramp**: subsidy denominated in the AlfredPay on-chain currency (USD on Polygon), computed by inverting the oracle's `outputCurrency -> ALFREDPAY_ONCHAIN_CURRENCY` rate. The engine rejects a non-positive oracle rate. There is no pre-Nabla stage on this route, so the conventional `deductibleFee` is always zero and is omitted. For onramps to EVM destinations other than AssetHub, the engine also probes Squid Router (`getEvmBridgeQuote`) to convert the oracle-expected amount into the equivalent amount of the *pre-bridge* token (USDC on Base or axlUSDC on Moonbeam) so the subsidy is denominated in the token the ramp actually holds on the source chain. @@ -45,7 +50,9 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi | **Misleading `[CAPPED]` log on zero-discount partners** | `formatPartnerNote` appends `[CAPPED]` whenever `actualSubsidy < idealSubsidy`. When `targetDiscount=0`, line 79 of `offramp.ts` (and 211 of `onramp.ts`) force `actualSubsidy=0`, but `idealSubsidy` can still be positive whenever Nabla undershoots the oracle. Operators reading logs may interpret a flood of `[CAPPED]` notes as `maxSubsidy` exhaustion when the real reason is `targetDiscount=0`. | **OPEN (F-DISC-03).** `formatPartnerNote` SHOULD distinguish "no discount configured" (`targetDiscount=0`) from "discount configured but cap hit" (`targetDiscount>0 && actual 0` (otherwise `actualSubsidy = 0`). Provider quote TTL (30 seconds) limits the timing window; quote refresh at start (`refreshAlfredpayOnrampQuoteIfMatching`) only re-binds when the new provider quote is byte-identical on `toAmount` and `fee`. | +| **AlfredPay offramp inverted-rate misconfiguration** | If the oracle returns a non-positive rate for `outputCurrency -> ALFREDPAY_ONCHAIN_CURRENCY`, dividing by it would yield `+∞` or NaN, corrupting downstream `expectedOutput` and subsidy math. | `OffRampAlfredpayDiscountEngine` throws when `effectiveRate ≤ 0`. The partner engine (`OfframpTransactionAlfredpayEngine`) has a symmetric `effectiveRate.gt(0)` guard with a fallback that uses `evmToEvm.outputAmountDecimal`. | ## Audit Checklist @@ -64,7 +71,9 @@ For onramps to EVM destinations other than AssetHub, the engine also probes Squi - [x] `isWithinStateTimeout` gates the increment branch in `getAdjustedDifference`. **PASS** — `helpers.ts:115-125`. - [x] `handleQuoteConsumptionForDiscountState` only decrements when the timestamp is within the state-timeout window, then nulls the timestamp. **PASS** — `helpers.ts:127-150`. - [x] All discount-stage math uses `Big.js`. No native `number` arithmetic on monetary values. **PASS** — verified across all four files. -- [x] Strategies that wire a discount engine: 7 of 10. The three opt-outs (`offramp-evm-to-alfredpay`, `onramp-alfredpay-to-evm`, `onramp-monerium-to-evm`) are intentional. **PASS** — verified via strategy file inspection. +- [x] Strategies that wire a discount engine: 9 of 10. The single opt-out (`onramp-monerium-to-evm`) is intentional. **PASS** — verified via strategy file inspection. +- [x] `OffRampAlfredpayDiscountEngine` rejects non-positive oracle rates with an explicit error; `OfframpTransactionAlfredpayEngine` guards `effectiveRate.gt(0)` symmetrically. **PASS**. +- [x] `OnRampAlfredpayDiscountEngine` produces an `expectedOutput` that the `alfredOnrampMintFallback` phase consumes when the provider quote degrades; fallback subsidy stays bounded by `maxSubsidy × expectedOutput`. **PASS**. - [ ] **F-DISC-01 (OPEN)**: `partnerDiscountState` is process-local. Either constrain deployment to a single API replica or migrate the state to a shared store (e.g. PostgreSQL row with `SELECT ... FOR UPDATE`, or Redis with optimistic locking) before horizontal scaling. - [ ] **F-DISC-02 (OPEN)**: `getAdjustedDifference` mutates state on read. Split into a pure reader and an explicit `recordQuoteIssued()` mutator to make retry-safety explicit. - [ ] **F-DISC-03 (OPEN)**: `formatPartnerNote` emits `[CAPPED]` whenever `actual < ideal`, including the `targetDiscount=0` case where capping is forced. Disambiguate the log so operators do not misread it as a `maxSubsidy` exhaustion signal. diff --git a/docs/security-spec/03-ramp-engine/quote-lifecycle.md b/docs/security-spec/03-ramp-engine/quote-lifecycle.md index f9d6fd27c..ea8c10717 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/ramp/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 (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. 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. @@ -42,6 +42,16 @@ The system maintains an **in-memory** `Map 0`. +### AlfredPay Provider Quote TTL + +AlfredPay's upstream provider quote is short-lived (~30 seconds) — much shorter than the Vortex 10-minute quote expiry. To keep the two reconciled: + +1. **At quote time** (`OnRampAlfredpayDiscountEngine` / `OfframpTransactionAlfredpayEngine`): the platform calls the AlfredPay provider, stores the provider `quoteId` and amounts in `ctx.alfredpayOnramp` / `ctx.alfredpayOfframp`, and freezes them in the Vortex quote metadata. +2. **At ramp start** (`refreshAlfredpayOnrampQuoteIfMatching` in `ramp.service.ts`): the API re-fetches a fresh AlfredPay provider quote. If the new provider response is byte-identical on `toAmount` and `fee` to the stored values, the platform substitutes the new provider `quoteId` so the downstream mint/transfer hits an unexpired provider quote. If amounts diverge, the original `quoteId` is kept and downstream handlers may fall back (see `alfredOnrampMintFallback` / `alfredpayOfframpTransferFallback`). +3. **Offramp expired-quote recovery** (`alfredpay-offramp-transfer-handler.ts`): if the provider rejects the stored `quoteId` as expired, the handler requests a fresh provider quote at execute time and reattempts. + +The refresh policy is intentionally strict (byte-identical `toAmount` and `fee` only). Any drift in amounts forces the route into the fallback path, which is bounded by the discount engine's `expectedOutput` and the partner's `maxSubsidy`. + ## Security Invariants 1. **Quotes MUST expire** — A quote older than 10 minutes MUST be rejected when a ramp attempts to bind to it. The expiry is checked via `quote.expiresAt < new Date()` at registration time. Exchange rates change; stale quotes expose the platform to unfavorable rates. diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index ac49fb101..96c472d4c 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -2,29 +2,33 @@ ## What This Does -Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations across multiple currencies and countries. It is used for ramps where BRLA and Monerium do not cover the target market. +Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations across multiple currencies and countries. It is used for ramps where BRLA and Monerium do not cover the target market (e.g. ARS, MXN, COP, USD via ACH). + +**Provider type:** Both (on-ramp and off-ramp) +**Fiat currencies:** Multiple (varies by country, validated via `AlfredPayCountry` enum) +**Chains involved:** Polygon (Alfredpay-side, USDC), EVM destinations via SquidRouter (Polygon → Base/other) +**Customer types:** Individual (KYC) and Business (KYB) — selected via `AlfredpayCustomerType`. The controller maps Alfredpay's KYB status to the platform's `AlfredPayStatus` via `mapKybStatus`; KYC is handled by `mapKycStatus`. Branch in `alfredpay.controller.ts` on `AlfredpayCustomerType.BUSINESS`. -**Provider type:** Both (on-ramp and off-ramp) -**Fiat currencies:** Multiple (varies by country, validated via `AlfredPayCountry` enum) -**Chains involved:** Polygon (primary), EVM chains via SquidRouter **Phase handlers:** -- `alfredpay-onramp-mint-handler.ts` — On-ramp: Initiates Alfredpay on-ramp, receives tokens after fiat payment -- `alfredpay-offramp-transfer-handler.ts` — Off-ramp: Sends tokens to Alfredpay for fiat payout -- `squidRouter-permit-execution-handler.ts` — Off-ramp: Executes SquidRouter permit for the off-ramp swap +- `alfredpay-onramp-mint-handler.ts` — On-ramp: waits for Alfredpay payment confirmation and credits USDC to the ephemeral on Polygon. +- `alfredpay-offramp-transfer-handler.ts` — Off-ramp: transfers USDC to Alfredpay's settlement address for fiat payout. Recovers from expired upstream quotes by re-quoting at execute time (see [Quote Lifecycle — AlfredPay Provider Quote TTL](../03-ramp-engine/quote-lifecycle.md)). +- `subsidize-pre-swap-handler.ts` — Subsidy: tops up the ephemeral's USDC balance on Polygon to the discount-engine's `targetOutputAmountRaw` before the next stage. Uses `getEvmSubsidyConfig` to pick the Alfredpay-specific funding account and token (`ALFREDPAY_EVM_TOKEN`). +- `squid-router-phase-handler.ts` — Cross-chain bridge for non-Polygon EVM destinations. Same-chain same-token routes short-circuit via `isSameChainSameTokenPassthrough` (no SquidRouter call when source and destination are both Polygon USDC). **On-ramp flow:** -1. User initiates on-ramp → receives fiat payment instructions from Alfredpay -2. User makes fiat payment -3. `alfredpayOnrampMint` phase: Alfredpay confirms payment and mints tokens on Polygon -4. `fundEphemeral` phase: Fund ephemeral with POL for gas -5. `squidRouterSwap` → `squidRouterPay` → `finalSettlementSubsidy` → `destinationTransfer` → `complete` +1. Quote stage emits `ctx.alfredpayOnramp` with provider `quoteId` (30s upstream TTL) and `ctx.subsidy` with the discount-engine target. +2. User initiates on-ramp → receives Alfredpay payment instructions. +3. User makes fiat payment. +4. `alfredpayOnrampMint` phase: confirms Alfredpay payment, credits USDC to the ephemeral on Polygon. If the provider quote is degraded or expired and the discount engine's `expectedOutput` exceeds the provider's, the phase emits `alfredOnrampMintFallback` to record the substitution. +5. `subsidizePreSwap` phase: tops up ephemeral USDC balance to the subsidy target (Polygon, `ALFREDPAY_EVM_TOKEN`). +6. `squidRouterSwap` phase: bridges USDC to the destination EVM chain. For same-chain same-token (Polygon USDC → Polygon USDC), the passthrough shortcut sends the funds directly without invoking SquidRouter. +7. `destinationTransfer` → `polygonCleanup` → `complete`. **Off-ramp flow:** -1. `squidRouterPermitExecute` phase: Execute SquidRouter permit (authorized swap + transfer) -2. `fundEphemeral` phase: Fund ephemeral with POL -3. `finalSettlementSubsidy` phase: Top up if needed -4. `alfredpayOfframpTransfer` phase: Transfer tokens to Alfredpay for fiat payout -5. `complete` +1. Quote stage emits `ctx.alfredpayOfframp` with provider `quoteId` and the AlfredPay fiat order is created during `prepareOfframpEvmToAlfredpay...` (see `transactions/offramp/routes/evm-to-alfredpay.ts:229`). The order is authoritative from prep time; `processAlfredpayOfframpStart` only re-validates state before phase execution. +2. `squidRouterPermitExecute` or `squidRouterNoPermitTransfer/Approve/Swap` phase: executes the user-signed permit (or the no-permit equivalent) and lands USDC on Polygon. +3. `alfredpayOfframpTransfer` phase: transfers USDC to Alfredpay's settlement address for fiat payout. If Alfredpay rejects the stored `quoteId` as expired, the handler requests a fresh provider quote at execute time and re-attempts (`alfredpayOfframpTransferFallback` phase records the re-attempt). +4. `polygonCleanupAxlUsdc` → `complete`. **Request validation:** Alfredpay middleware (`alfredpay.middleware.ts`) validates the `country` parameter against the `AlfredPayCountry` enum for all Alfredpay-related requests. @@ -32,33 +36,46 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations 1. **Alfredpay API credentials MUST be stored as environment variables** — Never hardcoded or in database. 2. **Country validation MUST use the `AlfredPayCountry` enum** — The middleware validates that the country parameter is a valid enum value before processing. -3. **Amounts MUST match the quoted values** — On-ramp mint amounts and off-ramp payout amounts must derive from the stored quote. -4. **Off-ramp permit execution MUST verify the signed permit data** — The SquidRouter permit is a user-signed authorization. The execute handler must verify the permit is valid before executing. -5. **Final settlement subsidy MUST ensure the correct amount before Alfredpay transfer** — The subsidy step tops up to the exact amount needed; the transfer step sends that exact amount. +3. **Amounts MUST match the quoted values, or be bounded by the discount engine's `expectedOutput`** — On-ramp mint amounts must derive from the stored quote; if the upstream provider quote degrades, the `alfredOnrampMintFallback` path MUST use the discount engine's `expectedOutput` and the subsidy MUST remain bounded by `maxSubsidy × expectedOutput`. +4. **Off-ramp permit execution MUST verify the signed permit data** — The SquidRouter permit is a user-signed authorization; the execute handler MUST verify the permit is valid before executing. +5. **Subsidy MUST run before the Alfredpay-bound transfer** — `subsidizePreSwap` (onramp) and the Squid-side stages plus `alfredpayOfframpTransfer` (offramp) MUST be ordered so the ephemeral holds the exact subsidized amount before the final transfer step. 6. **Alfredpay API responses MUST be validated** — Status codes, transaction IDs, and amounts confirmed before phase advancement. 7. **Alfredpay interactions MUST be retryable** — Transient failures should use `RecoverablePhaseError`. +8. **Provider quote refresh MUST be strict** — `refreshAlfredpayOnrampQuoteIfMatching` re-binds the provider `quoteId` only when the new provider response is byte-identical on `toAmount` and `fee`. Any drift forces the route into the bounded fallback path. +9. **Off-ramp expired-quote recovery MUST re-create the AlfredPay order, not the Vortex quote** — `alfredpay-offramp-transfer-handler.ts` re-quotes against the provider and re-issues `createOfframp` against the same Vortex quote; it MUST NOT mutate the Vortex `QuoteTicket`. +10. **KYB and KYC status mapping MUST be branched by `AlfredpayCustomerType`** — Business customers use `mapKybStatus`; individuals use `mapKycStatus`. Treating one as the other would allow incomplete due-diligence states to pass as `Success`. +11. **Polygon passthrough MUST preserve amount integrity** — The same-chain same-token shortcut in `squid-router-phase-handler.ts` MUST round down (`toFixed(0, 0)`) and MUST use `evmToEvm.inputAmountRaw` as the source-of-truth amount (matching the subsidy target). ## Threat Vectors & Mitigations | Threat | Attack Scenario | Mitigation | |---|---|---| | **Invalid country injection** | Attacker sends unsupported country code to bypass validation | `validateResultCountry` middleware checks against `AlfredPayCountry` enum; rejects invalid values with 400 | -| **Fiat payment spoofing (on-ramp)** | User claims payment without paying | Wait for Alfredpay payment confirmation; no token minting without confirmation | +| **Fiat payment spoofing (on-ramp)** | User claims payment without paying | Wait for Alfredpay payment confirmation; no token crediting without confirmation | | **Permit replay (off-ramp)** | Attacker replays a previously-used SquidRouter permit | SquidRouter permits include nonces; the permit contract rejects replayed nonces | | **Amount manipulation between subsidy and transfer** | Race condition modifies the balance between subsidy top-up and Alfredpay transfer | Both steps happen sequentially in the phase processor under a single ramp lock | | **Alfredpay API compromise** | Attacker manipulates Alfredpay API responses | Validate response amounts against quote; HTTPS enforcement; monitor for discrepancies | -| **Multi-country regulatory complexity** | Different countries have different KYC/AML requirements | Country-specific validation at Alfredpay level; Vortex passes through validated user data | +| **Multi-country regulatory complexity** | Different countries have different KYC/AML requirements | Country-specific validation at Alfredpay level; KYB vs KYC mapping branched by `AlfredpayCustomerType` | +| **Provider quote-quote-fall fallback abuse** | Attacker times provider quote drift between Vortex quote and ramp start to maximise the discount-engine fallback subsidy | Provider quote TTL is ~30s; `refreshAlfredpayOnrampQuoteIfMatching` only re-binds on byte-identical `toAmount`/`fee`; otherwise the fallback path is bounded by `maxSubsidy × expectedOutput` and only fires when `targetDiscount > 0` | +| **Expired provider quote on offramp transfer** | Provider rejects the stored `quoteId` at transfer time, blocking settlement | `alfredpay-offramp-transfer-handler.ts` re-quotes at execute time and emits `alfredpayOfframpTransferFallback`; the Vortex `QuoteTicket` is untouched | +| **Polygon passthrough rounding** | Same-chain same-token shortcut rounds the bridge output incorrectly, leaking dust or under-funding the destination | `toFixed(0, 0)` round-down in the squid-router finalize; downstream subsidy ensures the destination receives the quoted amount | ## Audit Checklist - [x] Alfredpay API credentials loaded from environment variables. **PASS** — verified: credentials from env vars. - [x] `validateResultCountry` middleware applied to all Alfredpay-related endpoints. **PASS** — middleware applied in route definitions. - [x] Country validation uses `Object.values(AlfredPayCountry).includes()` — not string matching. **PASS** — enum-based validation confirmed. -- [x] `alfredpayOnrampMint` handler verifies Alfredpay payment confirmation before minting. **PASS** — handler waits for Alfredpay confirmation. -- [x] `alfredpayOfframpTransfer` handler sends the correct amount (from stored quote, post-subsidy). **PASS** — amount derived from ramp state. +- [x] `alfredpayOnrampMint` handler verifies Alfredpay payment confirmation before crediting. **PASS** — handler waits for Alfredpay confirmation. +- [x] `alfredpayOfframpTransfer` handler sends the correct amount (from stored quote, post-subsidy) and recovers expired provider quotes via re-quote + `createOfframp`. **PASS** — `alfredpay-offramp-transfer-handler.ts:127-136`. - [x] SquidRouter permit execution validates the permit data before executing. **PASS** — permit data validated via `isSignedTypedDataArray`. - [x] All Alfredpay phase handlers use `RecoverablePhaseError` for transient failures. **PASS** — verified in all handlers. - [x] HTTPS enforced for Alfredpay API calls. **PASS** — base URL uses `https://`. - [x] No Alfredpay credentials or user payment details in logs. **PASS** — no credential leakage observed in log statements. - [FAIL] Timeout configured for Alfredpay API calls. **FAIL F-014** — no explicit HTTP client timeout configured; relies on default system timeouts. -- [x] `finalSettlementSubsidy` runs before `alfredpayOfframpTransfer` in the off-ramp flow. **PASS** — phase ordering confirmed in flow definition. +- [x] `subsidizePreSwap` runs before `squidRouterSwap` on the onramp flow and before `alfredpayOfframpTransfer` on the offramp flow. **PASS** — phase ordering confirmed in `fund-ephemeral-handler.ts` transition and offramp route definition. +- [x] Onramp fallback emits `alfredOnrampMintFallback` when the discount engine's `expectedOutput` supersedes the provider's `finalOutput`. **PASS** — `transactions/onramp/routes/alfredpay-to-evm.ts:269`. Phase is registered as an EVM phase in `transactions/validation.ts:249`. +- [x] Offramp fallback emits `alfredpayOfframpTransferFallback` for expired-quote recovery; phase is registered as an EVM phase in `transactions/validation.ts:250`. **PASS**. +- [x] KYB vs KYC status mapping is branched by `AlfredpayCustomerType.BUSINESS` in `alfredpay.controller.ts`. **PASS** — `mapKybStatus` for business, `mapKycStatus` for individual. +- [x] Polygon same-chain same-token passthrough uses `isSameChainSameTokenPassthrough` shortcut, rounds down (`toFixed(0, 0)`), and uses `evmToEvm.inputAmountRaw` as the source amount. **PASS** — `squid-router-phase-handler.ts` + `squidrouter/index.ts` finalize. +- [x] `refreshAlfredpayOnrampQuoteIfMatching` only re-binds the provider `quoteId` when `toAmount` and `fee` match byte-identically. **PASS** — `ramp.service.ts:1480-1491`. +- [x] AlfredPay offramp order is created at prep time (`evm-to-alfredpay.ts:229`); `processAlfredpayOfframpStart` is a defensive validation-only no-op. **PASS** — verified.