Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
93ee4f7
implement querying Alfredpay currency limits
Sharqiewicz May 11, 2026
2fdcd3d
implement Alfredpay currency limits
Sharqiewicz May 12, 2026
4e6c25b
enforce Alfredpay monthly cumulative limits
Sharqiewicz May 18, 2026
17660a8
tighten Alfredpay monthly usage join cast
Sharqiewicz May 19, 2026
16f980c
add quote logic
gianfra-t May 20, 2026
c785075
quote logic adjustments, computation re-order
gianfra-t May 20, 2026
05bcd67
invert rate before passing to calculateExpectedOutput
gianfra-t May 20, 2026
b866d5c
use alfredpay token constant for token selection in pre-swap handler
gianfra-t May 20, 2026
ae747b9
removing logs, comments. Improve fee estimation operation for discount.
gianfra-t May 22, 2026
9def722
reorder fee computation
gianfra-t May 22, 2026
14c6601
patch fees for alfredpay flows
gianfra-t May 22, 2026
5e536a5
Merge branch 'staging' into add-alfredpay-discounts
gianfra-t May 26, 2026
2aafc68
handle KYB user status
gianfra-t May 26, 2026
d6d1d9e
refresh alfredpay quote before sending operation
gianfra-t May 26, 2026
88af81a
adjust issues with alfredpay onramp flow
gianfra-t May 26, 2026
ace2551
fix: address review comments - scope usage by stablecoin, determinist…
Copilot May 27, 2026
06268ce
Merge pull request #1138 from pendulum-chain/feat/alredpay-limits
ebma May 27, 2026
c675d3e
alfredpay offramp testing
gianfra-t May 27, 2026
1d7fd66
code improvements
gianfra-t May 27, 2026
379bd44
point to standalone BRLA nabla instance
gianfra-t May 27, 2026
d5bd17d
Potential fix for pull request finding
gianfra-t May 27, 2026
e3ca1c4
rollback squidrouter swap handler change
gianfra-t May 27, 2026
0194bb3
round down final evm bridge output
gianfra-t May 27, 2026
8eab58c
remove unused logs
gianfra-t May 27, 2026
5f331ee
Potential fix for pull request finding 'Unused variable, import, func…
gianfra-t May 27, 2026
9ffcf85
harden alfredpay discount/fallback paths and refresh security spec
ebma May 27, 2026
29b0de8
Merge pull request #1163 from pendulum-chain/add-alfredpay-discounts
gianfra-t May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions apps/api/src/api/controllers/alfredpay.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand All @@ -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<AlfredPayCustomer> = {};

if (newStatus && newStatus !== alfredPayCustomer.status) {
Expand Down
156 changes: 156 additions & 0 deletions apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, FiatToken> = {
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<string, RawAmountLimits>();
private intervalHandle: ReturnType<typeof setInterval> | 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<void> {
try {
const { supportedPairs } = await AlfredpayApiService.getInstance().getAllConfigs();
const nextCache = new Map<string, RawAmountLimits>();
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<string, RawAmountLimits>, 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;
}
}
130 changes: 130 additions & 0 deletions apps/api/src/api/services/alfredpay/alfredpay.helpers.ts
Original file line number Diff line number Diff line change
@@ -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<Record<FiatToken, AlfredPayCountry>> = {
[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<AlfredpayCustomerType> {
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<ResolvedAlfredpayLimits | null> {
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<Big> {
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<RampState & { quote: QuoteTicket }>;

let total = new Big(0);
for (const ramp of completedRamps) {
total = total.plus(ramp.quote.inputAmount);
}
return total;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading