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/alfredpay/alfredpay-limits.service.ts b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts new file mode 100644 index 000000000..ab9931583 --- /dev/null +++ b/apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts @@ -0,0 +1,156 @@ +import { + AlfredpayApiService, + AlfredpayConfigPair, + AlfredpayCustomerType, + AlfredpayStablecoinKey, + FiatToken, + getAnyFiatTokenDetails, + RampDirection, + RawAmountLimits +} from "@vortexfi/shared"; +import Big from "big.js"; +import logger from "../../../config/logger"; + +/** 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: AlfredpayCustomerType[] = [AlfredpayCustomerType.INDIVIDUAL, AlfredpayCustomerType.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: RampDirection, + fiat: FiatToken, + stablecoin: AlfredpayStablecoinKey, + customer: AlfredpayCustomerType +): 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); +} + +interface DerivedAxes { + direction: RampDirection; + fiat: FiatToken; + stablecoin: AlfredpayStablecoinKey; +} + +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); + this.intervalHandle.unref(); + } + + public stop(): void { + if (this.intervalHandle) { + clearInterval(this.intervalHandle); + this.intervalHandle = null; + } + } + + /** + * 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). + */ + public getLimits( + fiat: FiatToken, + stablecoin: AlfredpayStablecoinKey, + 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); + } + + private fallback( + fiat: FiatToken, + stablecoin: AlfredpayStablecoinKey, + customerType: AlfredpayCustomerType, + direction: RampDirection + ): RawAmountLimits { + const hardcoded = getAnyFiatTokenDetails(fiat).alfredpayLimits; + if (!hardcoded) { + throw new Error(`AlfredPay limits missing for ${fiat} — token config is out of sync`); + } + 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(); + 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 axes = this.deriveAxes(pair); + if (!axes) return; + + const { direction, fiat, stablecoin } = axes; + const limits: RawAmountLimits = { + maxRaw: toRaw(pair.maxQuantity, decimals), + minRaw: toRaw(pair.minQuantity, decimals) + }; + + const customers: AlfredpayCustomerType[] = pair.typeCustomer ? [pair.typeCustomer] : CUSTOMER_TYPES; + const isWildcard = !pair.typeCustomer; + for (const customer of customers) { + 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 deriveAxes(pair: AlfredpayConfigPair): DerivedAxes | null { + const fromFiat = ALFREDPAY_FIATS[pair.fromCurrency]; + const toFiat = ALFREDPAY_FIATS[pair.toCurrency]; + + if (fromFiat && isStablecoinSymbol(pair.toCurrency)) { + return { direction: RampDirection.BUY, fiat: fromFiat, stablecoin: pair.toCurrency }; + } + if (toFiat && isStablecoinSymbol(pair.fromCurrency)) { + return { direction: RampDirection.SELL, fiat: toFiat, stablecoin: pair.fromCurrency }; + } + return null; + } +} 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..7a241ccae --- /dev/null +++ b/apps/api/src/api/services/alfredpay/alfredpay.helpers.ts @@ -0,0 +1,130 @@ +import { + AlfredPayCountry, + AlfredpayCustomerType, + AlfredpayStablecoinKey, + AmountLimits, + FiatToken, + getAnyFiatTokenDetails, + isAlfredpayToken, + RampCurrency, + 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"; + +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 { + if (!userId) return AlfredpayCustomerType.INDIVIDUAL; + const country = alfredpayCountryForFiat(fiat); + if (!country) return AlfredpayCustomerType.INDIVIDUAL; + const customer = await AlfredPayCustomer.findOne({ order: [["type", "ASC"]], where: { country, userId } }); + return customer?.type === AlfredpayCustomerType.BUSINESS ? AlfredpayCustomerType.BUSINESS : AlfredpayCustomerType.INDIVIDUAL; +} + +/** + * 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 { + return currency === "USDC" || currency === "USDT" ? currency : null; +} + +/** AlfredPay limits resolved for a specific quote — includes the axes used to pick them. */ +export interface ResolvedAlfredpayLimits extends AmountLimits { + fiat: FiatToken; + stablecoin: AlfredpayStablecoinKey; + 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 { + const { rampType, inputCurrency, outputCurrency, userId } = args; + const isOnramp = rampType === RampDirection.BUY; + const fiatCandidate = isOnramp ? inputCurrency : outputCurrency; + const onchainCurrency = isOnramp ? outputCurrency : inputCurrency; + if (!isAlfredpayToken(fiatCandidate)) return null; + + const stablecoin = stablecoinFromCurrency(onchainCurrency); + if (!stablecoin) { + throw new Error(`Unsupported AlfredPay stablecoin: ${onchainCurrency}`); + } + + const customer = await lookupAlfredpayCustomerType(userId, fiatCandidate); + const raw = AlfredpayLimitsService.getInstance().getLimits(fiatCandidate, stablecoin, customer, rampType); + const decimals = isOnramp ? getAnyFiatTokenDetails(fiatCandidate).decimals : 6; + + return { + customer, + direction: rampType, + fiat: fiatCandidate, + max: multiplyByPowerOfTen(new Big(raw.maxRaw), -decimals).toFixed(), + min: multiplyByPowerOfTen(new Big(raw.minRaw), -decimals).toFixed(), + 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, + 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, ...stablecoinSide } }], + where: { + createdAt: { [Op.gte]: startOfCurrentUtcMonth() }, + currentPhase: "complete", + type: direction, + userId + } + })) as Array; + + let total = new Big(0); + for (const ramp of completedRamps) { + total = total.plus(ramp.quote.inputAmount); + } + return total; +} 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/phases/handlers/squid-router-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-phase-handler.ts index 89ea84e5a..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 @@ -63,9 +63,9 @@ 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"); + return this.transitionToNextPhase(state, "finalSettlementSubsidy"); } const bridgeMeta = quote.metadata.evmToEvm || quote.metadata.moonbeamToEvm; @@ -84,7 +84,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; @@ -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/phases/handlers/subsidize-pre-swap-handler.ts b/apps/api/src/api/services/phases/handlers/subsidize-pre-swap-handler.ts index a5fec4089..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 @@ -1,4 +1,7 @@ import { + ALFREDPAY_ERC20_DECIMALS, + ALFREDPAY_ERC20_TOKEN, + ALFREDPAY_EVM_TOKEN, ApiManager, checkEvmBalanceForToken, EvmClientManager, @@ -7,9 +10,11 @@ import { EvmTokenDetails, FiatToken, getOnChainTokenDetails, + isAlfredpayToken, Networks, nativeToDecimal, RampCurrency, + RampDirection, RampPhase, waitUntilTrueWithTimeout } from "@vortexfi/shared"; @@ -46,9 +51,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, ALFREDPAY_EVM_TOKEN) as EvmTokenDetails; + if (!inputTokenDetails) { + 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, + inputToken: ALFREDPAY_EVM_TOKEN, + inputTokenDetails, + logLabel: "Alfredpay", + nextPhase: "squidRouterSwap" as RampPhase, + subsidyToken: ALFREDPAY_EVM_TOKEN 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 +199,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 +228,11 @@ 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()}`); 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 +275,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 +292,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/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index bc174bd76..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, @@ -266,6 +267,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 amount limits in human units of `inputCurrency`. + * Set by the finalize engine during validation for AlfredPay quotes; surfaced on the QuoteResponse. + */ + 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 e13419f16..97bc584e9 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,13 @@ import { FiatToken, getAnyFiatTokenDetails, RampDirection } from "@vortexfi/shar import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../errors/api-error"; +import { + getAlfredpayMonthlyUsage, + 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 @@ -47,3 +53,65 @@ 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: ResolvedAlfredpayLimits): void { + const amountBig = new Big(amount); + 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 ${verb} limit of ${min.toFixed(2)} ${unitSymbol}`, + status: httpStatus.BAD_REQUEST + }); + } + if (amountBig.gt(max)) { + throw new APIError({ + message: `Input amount exceeds monthly ${verb} limit of ${max.toFixed(2)} ${unitSymbol}`, + status: httpStatus.BAD_REQUEST + }); + } +} + +/** + * 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({ + 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); + + const { userId } = ctx.request; + if (!userId) return true; + + 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; + + 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 + }); +} 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..cff901312 --- /dev/null +++ b/apps/api/src/api/services/quote/engines/discount/offramp-alfredpay.ts @@ -0,0 +1,94 @@ +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"; + +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.evmToEvm) { + throw new Error("OffRampAlfredpayDiscountEngine requires evmToEvm 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, outputCurrency, rampType } = ctx.request; + + const partner = await resolveDiscountPartner(ctx, rampType); + const targetDiscount = partner?.targetDiscount ?? 0; + const maxSubsidy = partner?.maxSubsidy ?? 0; + + // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate + 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. + // 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 effectiveRateStr = await priceFeedService.convertCurrency( + "1", + outputCurrency as RampCurrency, + ALFREDPAY_ONCHAIN_CURRENCY as unknown as RampCurrency + ); + 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); + + 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: multiplyByPowerOfTen(finalOutput, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), + adjustedDifference, + adjustedTargetDiscount, + expectedOutputAmountDecimal: expectedOutputDecimal, + expectedOutputAmountRaw: multiplyByPowerOfTen(expectedOutputDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), + idealSubsidyAmountInOutputTokenDecimal: idealSubsidyDecimal, + idealSubsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(idealSubsidyDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), + partnerId: partner ? partner.id : null, + subsidyAmountInOutputTokenDecimal: actualSubsidyDecimal, + subsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(actualSubsidyDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), + subsidyRate, + targetOutputAmountDecimal: targetOutputDecimal, + 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 new file mode 100644 index 000000000..1e4d20c7f --- /dev/null +++ b/apps/api/src/api/services/quote/engines/discount/onramp-alfredpay.ts @@ -0,0 +1,77 @@ +import { ALFREDPAY_ERC20_DECIMALS, multiplyByPowerOfTen, 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"); + } + + if (!ctx.fees?.usd) { + throw new Error("OnRampAlfredpayDiscountEngine requires fees.usd 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); + + // 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); + + 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: multiplyByPowerOfTen(finalOutput, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), + adjustedDifference, + adjustedTargetDiscount, + expectedOutputAmountDecimal: expectedOutputDecimal, + expectedOutputAmountRaw: multiplyByPowerOfTen(expectedOutputDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), + idealSubsidyAmountInOutputTokenDecimal: idealSubsidyDecimal, + idealSubsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(idealSubsidyDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), + partnerId: partner ? partner.id : null, + subsidyAmountInOutputTokenDecimal: actualSubsidyDecimal, + subsidyAmountInOutputTokenRaw: multiplyByPowerOfTen(actualSubsidyDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0), + subsidyRate, + targetOutputAmountDecimal: targetOutputDecimal, + targetOutputAmountRaw: multiplyByPowerOfTen(targetOutputDecimal, ALFREDPAY_ERC20_DECIMALS).toFixed(0, 0) + }; + } +} 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 dd954456c..6d25bc285 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, @@ -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); @@ -109,6 +110,7 @@ export abstract class BaseFinalizeEngine implements Stage { const expiresAt = getExpirationDate(ctx); ctx.builtResponse = { + alfredpayInputLimits: ctx.alfredpayInputLimits, anchorFeeFiat: fiatFees.anchor, anchorFeeUsd: usdFees.anchor, createdAt: new Date(), @@ -169,7 +171,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 38b91465e..d881bc31e 100644 --- a/apps/api/src/api/services/quote/engines/finalize/offramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/offramp.ts @@ -3,7 +3,7 @@ import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../../errors/api-error"; import { QuoteContext } from "../../core/types"; -import { validateAmountLimits } from "../../core/validation-helpers"; +import { applyAlfredpayLimits, validateAmountLimits } from "../../core/validation-helpers"; import { BaseFinalizeEngine, FinalizeComputation } from "."; export class OffRampFinalizeEngine extends BaseFinalizeEngine { @@ -53,7 +53,8 @@ export class OffRampFinalizeEngine extends BaseFinalizeEngine { }; } - protected validate(ctx: QuoteContext, { amount }: FinalizeComputation): void { + protected async validate(ctx: QuoteContext, { amount }: FinalizeComputation): Promise { + if (await applyAlfredpayLimits(ctx, ctx.request.inputAmount)) return; validateAmountLimits(amount, ctx.request.outputCurrency as FiatToken, "min", ctx.request.rampType); validateAmountLimits(amount, ctx.request.outputCurrency as FiatToken, "max", 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 942a748c5..081043479 100644 --- a/apps/api/src/api/services/quote/engines/finalize/onramp.ts +++ b/apps/api/src/api/services/quote/engines/finalize/onramp.ts @@ -3,7 +3,7 @@ import Big from "big.js"; import httpStatus from "http-status"; import { APIError } from "../../../../errors/api-error"; import { QuoteContext } from "../../core/types"; -import { validateAmountLimits } from "../../core/validation-helpers"; +import { applyAlfredpayLimits, validateAmountLimits } from "../../core/validation-helpers"; import { BaseFinalizeEngine, FinalizeComputation } from "."; export class OnRampFinalizeEngine extends BaseFinalizeEngine { @@ -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) { @@ -86,7 +92,8 @@ export class OnRampFinalizeEngine extends BaseFinalizeEngine { }; } - protected validate(ctx: QuoteContext): void { + protected async validate(ctx: QuoteContext): Promise { + if (await applyAlfredpayLimits(ctx, ctx.request.inputAmount)) return; validateAmountLimits(ctx.request.inputAmount, ctx.request.inputCurrency as FiatToken, "min", ctx.request.rampType); validateAmountLimits(ctx.request.inputAmount, ctx.request.inputCurrency as FiatToken, "max", ctx.request.rampType); } 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..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 @@ -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,24 @@ 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 deductibleFee = ctx.preNabla?.deductibleFeeAmountInSwapCurrency ?? new Big(0); + const inputAmountDecimal = effectiveRate.gt(0) + ? ctx.subsidy.targetOutputAmountDecimal.div(effectiveRate) + : ctx.evmToEvm.outputAmountDecimal.minus(deductibleFee); + + const alfredpayService = AlfredpayApiService.getInstance(); const quoteRequest: CreateAlfredpayOfframpQuoteRequest = { chain: AlfredpayChain.MATIC, fromAmount: inputAmountDecimal.toString(), @@ -45,27 +60,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/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 }; 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..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 @@ -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.targetOutputAmountRaw, + fromNetwork: Networks.Polygon, + fromToken: ALFREDPAY_ERC20_TOKEN, + inputAmountDecimal: subsidy.targetOutputAmountDecimal, + inputAmountRaw: subsidy.targetOutputAmountRaw, + 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.targetOutputAmountRaw, fromNetwork: Networks.Polygon, fromToken: ALFREDPAY_ERC20_TOKEN, - inputAmountDecimal: alfredpayMint.outputAmountDecimal, - inputAmountRaw: alfredpayMint.outputAmountRaw, + 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 c745b609d..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 @@ -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"; @@ -11,9 +12,10 @@ export const offrampEvmToAlfredpayStrategy = defineRouteStrategy({ engines: () => ({ [StageKey.Initialize]: new OffRampFromEvmInitializeEngine(Networks.Polygon), [StageKey.Fee]: new OffRampEvmToAlfredpayFeeEngine(), + [StageKey.Discount]: new OffRampAlfredpayDiscountEngine(), [StageKey.PartnerOperation]: new OfframpTransactionAlfredpayEngine(), [StageKey.Finalize]: new OffRampFinalizeEngine() }), name: "OfframpEvmToAlfredpay", - stages: [StageKey.Initialize, StageKey.PartnerOperation, StageKey.Fee, StageKey.Finalize] + stages: [StageKey.Initialize, StageKey.Discount, StageKey.PartnerOperation, StageKey.Fee, 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] }); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 839d37592..8b5f93986 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, @@ -1452,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/onramp/routes/alfredpay-to-evm.ts b/apps/api/src/api/services/transactions/onramp/routes/alfredpay-to-evm.ts index 3c8f75519..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 @@ -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 diff --git a/apps/api/src/api/services/transactions/validation.ts b/apps/api/src/api/services/transactions/validation.ts index e6bdad999..d432d97c8 100644 --- a/apps/api/src/api/services/transactions/validation.ts +++ b/apps/api/src/api/services/transactions/validation.ts @@ -246,6 +246,8 @@ function getTransactionTypeForPhase(phase: RampPhase | CleanupPhase, network: Ne case "baseCleanupBrla": case "baseCleanupUsdc": case "baseCleanupAxlUsdc": + case "alfredOnrampMintFallback": + case "alfredpayOfframpTransferFallback": return EphemeralAccountType.EVM; default: throw new APIError({ diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e07ec8f11..6e7dc180e 100755 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,6 +9,7 @@ import { config } from "./config/vars"; 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"; @@ -68,6 +69,9 @@ const initializeApp = async () => { new RampRecoveryWorker().start(); new UnhandledPaymentWorker().start(); + // Start AlfredPay limits refresh loop (daily; falls back to hardcoded if stale) + AlfredpayLimitsService.getInstance().start(); + // Register phase handlers registerPhaseHandlers(); 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/hooks/ramp/useRampValidation.ts b/apps/frontend/src/hooks/ramp/useRampValidation.ts index b70d52dc1..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,24 +30,30 @@ function validateOnramp( { inputAmount, fromToken, + limits, trackEvent }: { inputAmount: Big; fromToken: FiatTokenDetails; + limits?: AmountLimits; trackEvent: (event: TrackableEvent) => void; } ): string | null { - const maxAmountUnits = multiplyByPowerOfTen(Big(fromToken.maxBuyAmountRaw), -fromToken.decimals); - const minAmountUnits = multiplyByPowerOfTen(Big(fromToken.minBuyAmountRaw), -fromToken.decimals); + const maxAmountUnits = limits + ? new Big(limits.max) + : multiplyByPowerOfTen(Big(fromToken.maxBuyAmountRaw), -fromToken.decimals); + 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, { @@ -66,6 +73,7 @@ function validateOfframp( fromToken, toToken, quote, + limits, userInputTokenBalance, isDisconnected, trackEvent @@ -74,27 +82,41 @@ function validateOfframp( fromToken: OnChainTokenDetails; toToken: FiatTokenDetails; quote: QuoteResponse; + limits?: AmountLimits; 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); - const amountOut = quote ? Big(quote.outputAmount) : Big(0); + // 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 isTooHigh = inputAmount && quote && maxAmountUnits.lt(amountOut); - const isTooLow = !amountOut.eq(0) && !config.test.overwriteMinimumTransferAmount && minAmountUnits.gt(amountOut); + 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, { - assetSymbol: toToken.fiat.symbol, + assetSymbol: unitSymbol, maxAmountUnits: stringifyBigWithSignificantDecimals(maxAmountUnits, 0), minAmountUnits: stringifyBigWithSignificantDecimals(minAmountUnits, 0) }); @@ -102,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, @@ -194,11 +216,13 @@ export const useRampValidation = () => { }); } else if (quoteError) return t(quoteError); + const limits = quote?.alfredpayInputLimits; let validationError = null; if (isOnramp) { validationError = validateOnramp(t, { fromToken: fromToken as FiatTokenDetails, inputAmount, + limits, trackEvent }); } else { @@ -206,6 +230,7 @@ export const useRampValidation = () => { fromToken: fromToken as OnChainTokenDetails, inputAmount, isDisconnected, + limits, quote: quote as QuoteResponse, toToken: toToken as FiatTokenDetails, trackEvent, diff --git a/apps/frontend/src/machines/ramp.machine.ts b/apps/frontend/src/machines/ramp.machine.ts index 4e442c052..9e7afe611 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: { @@ -560,7 +576,7 @@ export const rampMachine = setup({ }, SET_RAMP_STATE: { actions: assign({ - rampState: ({ event }) => event.rampState + rampState: ({ event, context }) => mergeRampStatePreservingPaymentInfo(context.rampState, event.rampState) }) } } @@ -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" 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", 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. diff --git a/packages/shared/src/endpoints/quote.endpoints.ts b/packages/shared/src/endpoints/quote.endpoints.ts index 7c1fca7ba..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 { @@ -73,6 +74,9 @@ export interface QuoteResponse { expiresAt: Date; createdAt: Date; sessionId?: 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/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..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,27 @@ 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: RampCurrency): token is FiatToken => ALFREDPAY_FIAT_TOKEN_SET.has(token); -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; 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; diff --git a/packages/shared/src/tokens/freeTokens/config.ts b/packages/shared/src/tokens/freeTokens/config.ts index 3f4bbec72..ab612f85b 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 { AlfredpayLimitsTable, 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: AlfredpayLimitsTable = { + offramp: { + USDC: { + BUSINESS: { maxRaw: "300000000000", minRaw: "1000000" }, + INDIVIDUAL: { maxRaw: "300000000000", minRaw: "1000000" } + }, + USDT: { + BUSINESS: { maxRaw: "300000000000", minRaw: "1000000" }, + INDIVIDUAL: { maxRaw: "100000000000", minRaw: "1000000" } + } + }, + onramp: { + USDC: { + BUSINESS: { maxRaw: "30000000", minRaw: "100" }, + INDIVIDUAL: { maxRaw: "10000000", minRaw: "100" } + }, + USDT: { + BUSINESS: { maxRaw: "30000000", minRaw: "100" }, + INDIVIDUAL: { maxRaw: "10000000", minRaw: "100" } + } + } +}; + +const MXN_LIMITS: AlfredpayLimitsTable = { + 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: AlfredpayLimitsTable = { + offramp: { + USDC: { + BUSINESS: { maxRaw: "300000000000", minRaw: "1000000" }, + INDIVIDUAL: { maxRaw: "300000000000", minRaw: "1000000" } + }, + USDT: { + BUSINESS: { maxRaw: "300000000000", minRaw: "1000000" }, + INDIVIDUAL: { maxRaw: "100000000000", minRaw: "1000000" } + } + }, + onramp: { + USDC: { + BUSINESS: { maxRaw: "110596799945", minRaw: "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,16 @@ export const freeTokenConfig: Partial> = name: "US Dollar", symbol: "USD" }, - maxBuyAmountRaw: "10000000000", - maxSellAmountRaw: "100000000000000000000", + // 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: "100", + minSellAmountRaw: "1000000", type: TokenType.Fiat }, [FiatToken.MXN]: { + alfredpayLimits: MXN_LIMITS, assetSymbol: "MXN", decimals: 2, fiat: { @@ -27,13 +108,14 @@ export const freeTokenConfig: Partial> = name: "Mexican Peso", symbol: "MXN" }, - maxBuyAmountRaw: "10000000000", - maxSellAmountRaw: "100000000000000000000", - minBuyAmountRaw: "15000", - minSellAmountRaw: "2000", + maxBuyAmountRaw: "8695217304", + maxSellAmountRaw: "5000000000000", + minBuyAmountRaw: "20000", + minSellAmountRaw: "1000000", type: TokenType.Fiat }, [FiatToken.COP]: { + alfredpayLimits: COP_LIMITS, assetSymbol: "COP", decimals: 2, fiat: { @@ -41,10 +123,10 @@ export const freeTokenConfig: Partial> = name: "Colombian Peso", symbol: "COP" }, - maxBuyAmountRaw: "10000000000", - maxSellAmountRaw: "100000000000000000000", + maxBuyAmountRaw: "36865599982", + maxSellAmountRaw: "100000000000", 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..126e20322 100644 --- a/packages/shared/src/tokens/types/base.ts +++ b/packages/shared/src/tokens/types/base.ts @@ -45,6 +45,35 @@ export interface FiatDetails { name: string; } +export enum AlfredpayCustomerType { + INDIVIDUAL = "INDIVIDUAL", + BUSINESS = "BUSINESS" +} + +export type AlfredpayStablecoinKey = "USDC" | "USDT"; + +/** 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; +} + +/** + * 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 AlfredpayLimitsTable { + onramp: Record>; + offramp: Record>; +} + export interface BaseFiatTokenDetails { fiat: FiatDetails; minSellAmountRaw: string; @@ -53,6 +82,8 @@ export interface BaseFiatTokenDetails { maxBuyAmountRaw: string; buyFeesBasisPoints?: number; buyFeesFixedComponent?: number; + /** Multi-axis AlfredPay limits; populated only for AlfredPay-routed fiats (USD/MXN/COP). */ + alfredpayLimits?: AlfredpayLimitsTable; } export interface FiatCurrencyDetails extends BaseTokenDetails, BaseFiatTokenDetails {