Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f5c3566
feat(shared): call Alfredpay through Alfred's Penny adapter
ebma Sep 29, 2026
5201eca
fix(api): stop sending a business id in Alfredpay quote metadata
ebma Sep 29, 2026
f0e93c0
fix(api): keep approved Alfredpay customers on an upstream 404
ebma Sep 29, 2026
cc24eeb
test(api): assert Alfredpay quote amounts stay decimal strings
ebma Sep 29, 2026
023b330
docs(repo): document the Alfredpay Penny adapter in the security spec
ebma Sep 29, 2026
e73647c
fix(api): keep configured Alfredpay limits where Alfred sets none
ebma Sep 30, 2026
8a9f393
fix(shared): accept the adapter's flat onramp order response
ebma Sep 30, 2026
d32cf56
fix(kyc): require the CURP and CUIT the Alfred adapter validates
ebma Sep 30, 2026
41696fb
test(api): re-provision the Alfredpay contract fixtures on the adapter
ebma Sep 30, 2026
588742a
fix(api): retry a failed provider order read before the offramp transfer
ebma Sep 30, 2026
4f147ee
fix(shared): validate the adapter's onramp order and limit bounds
ebma Sep 30, 2026
b70c909
fix(kyc): verify the CUIT check digit the provider enforces
ebma Sep 30, 2026
9b7a730
fix(api): reject invalid CURP and CUIT at the KYC API boundary
ebma Sep 30, 2026
4ebae39
fix(api): skip unusable rows in the Alfredpay limits listing
ebma Sep 30, 2026
cdac70d
fix(api): keep approved customers on an empty KYC status read
ebma Sep 30, 2026
3ceb46e
fix(api): reject Alfredpay onramp quotes that do not echo the input
ebma Sep 30, 2026
4c19f45
test(api): run the live Alfredpay contract only against the sandbox
ebma Sep 30, 2026
07b0cad
fix(frontend): label the Mexican KYC ID field CURP
ebma Sep 30, 2026
fcfba07
docs(repo): record the Alfredpay review fixes in the spec and env
ebma Sep 30, 2026
6f2d847
fix(api): keep approved Alfredpay customers on an UPDATE_REQUIRED read
ebma Oct 1, 2026
24b8371
fix(api): pay Alfredpay offramps only to the customer's listed accounts
ebma Oct 1, 2026
abecd90
feat(shared): pause MX/CO business verification in onboarding discovery
ebma Oct 1, 2026
8db833d
fix(kyc): stop MX/CO business verification before the provider
ebma Oct 1, 2026
992ee6c
fix(api): answer 503 for new MX/CO Alfredpay business customers
ebma Oct 1, 2026
9affe4d
docs(api): record the Alfredpay status, payout and KYB pause invariants
ebma Oct 1, 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
1 change: 0 additions & 1 deletion .github/workflows/contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ jobs:
env:
ALFREDPAY_BASE_URL: ${{ secrets.CONTRACT_ALFREDPAY_BASE_URL }}
ALFREDPAY_API_KEY: ${{ secrets.CONTRACT_ALFREDPAY_API_KEY }}
ALFREDPAY_API_SECRET: ${{ secrets.CONTRACT_ALFREDPAY_API_SECRET }}
ALFREDPAY_CONTRACT_CUSTOMER_ID: ${{ secrets.CONTRACT_ALFREDPAY_CUSTOMER_ID }}
ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID: ${{ secrets.CONTRACT_ALFREDPAY_FIAT_ACCOUNT_ID }}
ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID: ${{ secrets.CONTRACT_ALFREDPAY_KYC_SUBMISSION_ID }}
Expand Down
10 changes: 6 additions & 4 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,12 @@ RECIPIENT_INVITE_MAX_DISCOUNT_BPS=300
# Only the private key is needed - public key is derived from it
WEBHOOK_PRIVATE_KEY=your-webhook-private-key

# AlfredPay
ALFREDPAY_BASE_URL=your-alfredpay-base-url
ALFREDPAY_API_KEY=your-alfredpay-api-key
ALFREDPAY_API_SECRET=your-alfredpay-api-secret
# AlfredPay, through Alfred's Penny adapter. Unset, the base URL follows SANDBOX_ENABLED (only true
# on DEPLOYMENT_ENV=sandbox): the sandbox adapter there, the production adapter everywhere else. So
# local and staging setups with a sandbox key must set it explicitly, as below.
# The API key is an Alfred partner key (alfk_...) from dashboard.alfredpay.io; there is no secret.
ALFREDPAY_BASE_URL=https://api.sandbox.alfredpay.io/adapters/penny
ALFREDPAY_API_KEY=your-alfred-sandbox-api-key

# Monerium OAuth (the redirect URI must exactly match the dashboard callback registered with Monerium)
MONERIUM_CLIENT_ID=your-monerium-auth-code-client-id
Expand Down
29 changes: 21 additions & 8 deletions apps/api/src/api/controllers/alfredpay.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ALFREDPAY_BUSINESS_KYB_PAUSED_MESSAGE,
AlfredPayStatus,
AlfredpayApiError,
AlfredpayApiService,
Expand All @@ -19,6 +20,7 @@ import {
DomesticGetKycStatusResponse,
DomesticStatusRequest,
DomesticStatusResponse,
isAlfredpayBusinessKybPaused,
SubmitKybInformationRequest,
SubmitKycInformationRequest
} from "@vortexfi/shared";
Expand Down Expand Up @@ -341,9 +343,12 @@ export class AlfredpayController {
logger.error("Error refreshing Alfredpay status:", error);

// If the upstream API returns 404 (KYC submission not found), the local status is stale.
// Reset to Consulted so the frontend re-triggers the KYC flow.
// Reset to Consulted so the frontend re-triggers the KYC flow. Never for an approved
// customer: a lookup can also 404 for data Alfred has not moved to its new platform (US is
// not served there yet), and a status read must not force an approved customer through KYC.
const errorMessage = AlfredpayController.getErrorMessage(error).toLowerCase();
if (errorMessage.includes("404") || errorMessage.includes("not found")) {
const isNotFound = errorMessage.includes("404") || errorMessage.includes("not found");
if (isNotFound && alfredPayCustomer.status !== AlfredPayStatus.Success) {
logger.info("Resetting stale AlfredPay status to pending due to upstream 404");
await alfredPayCustomer.update({
status: AlfredPayStatus.Consulted,
Expand Down Expand Up @@ -523,11 +528,15 @@ export class AlfredpayController {
: (await alfredpayService.getLastKycSubmission(alfredPayCustomer.alfredPayId))?.submissionId;

if (!submissionId) {
await alfredPayCustomer.update({
status: AlfredPayStatus.Consulted,
statusExternal: null,
verificationStatus: VerificationStatus.Pending
});
// Same rule as /alfredpayStatus: a read that finds nothing must not send an approved
// customer back through KYC (invariant 33).
if (alfredPayCustomer.status !== AlfredPayStatus.Success) {
await alfredPayCustomer.update({
status: AlfredPayStatus.Consulted,
statusExternal: null,
verificationStatus: VerificationStatus.Pending
});
}
return res.status(404).json({ error: "No KYC attempt found" });
}

Expand Down Expand Up @@ -581,7 +590,7 @@ export class AlfredpayController {
alfred_pay_id: alfredPayCustomer.alfredPayId,
country: alfredPayCustomer.country,
lastFailure: updateData.lastFailureReasons?.[0] || alfredPayCustomer.lastFailureReasons?.[0], // Get the latest failure reason
status: (newStatus || alfredPayCustomer.status) as AlfredPayStatus,
status: alfredPayCustomer.status,
updated_at: alfredPayCustomer.updatedAt.toISOString()
};

Expand Down Expand Up @@ -667,6 +676,10 @@ export class AlfredpayController {
return res.status(400).json({ error: "Business customer already exists" });
}

if (isAlfredpayBusinessKybPaused(country)) {
return res.status(httpStatus.SERVICE_UNAVAILABLE).json({ error: ALFREDPAY_BUSINESS_KYB_PAUSED_MESSAGE });
}

const alfredpayService = AlfredpayApiService.getInstance();

let customerId: string;
Expand Down
23 changes: 22 additions & 1 deletion apps/api/src/api/middlewares/validators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ describe("validateKycSubmission", () => {
const req = {
body: {
country: "AR",
cuit: "20123456789",
cuit: "20-12345678-6",
nationalities: ["AR"],
pep: false,
phoneNumber: "+5491112345678"
Expand All @@ -282,4 +282,25 @@ describe("validateKycSubmission", () => {
expect(nextMock.mock.calls[0]?.[0]).toBeUndefined();
expect(res.statusCode).toBeUndefined();
});

// Integrators reach this route without our forms; these are the values the provider rejects with
// an opaque 422, so they must stop here with a 400 instead.
function kycError(body: Record<string, unknown>): string | undefined {
const nextMock = mock((_error?: unknown) => undefined);
validateKycSubmission({ body } as unknown as Request, buildRes(), nextMock as unknown as NextFunction);
return (nextMock.mock.calls[0]?.[0] as APIError | undefined)?.message;
}

it("requires a CUIT with a valid check digit for Argentina", () => {
const ar = { country: "AR", nationalities: ["AR"], pep: false, phoneNumber: "+5491112345678" };
expect(kycError(ar)).toBe("CUIT must be 11 digits with a valid check digit");
expect(kycError({ ...ar, cuit: "20123456789" })).toBe("CUIT must be 11 digits with a valid check digit");
expect(kycError({ ...ar, cuit: "20123456786" })).toBeUndefined();
});

it("requires a CURP with a valid check digit as the Mexican dni", () => {
expect(kycError({ country: "MX", dni: "1234567890123" })).toBe("dni must be a valid 18-character CURP");
expect(kycError({ country: "MX", dni: "OEAF771012HMCRGR09" })).toBe("dni must be a valid 18-character CURP");
expect(kycError({ country: "MX", dni: "OEAF771012HMCRGR08" })).toBeUndefined();
});
});
9 changes: 7 additions & 2 deletions apps/api/src/api/middlewares/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
isSupportedFiatCurrency,
isValidAveniaAccountType,
isValidCpf,
isValidCuit,
isValidCurp,
isValidCurrencyForDirection,
isValidDirection,
isValidKYCDocType,
Expand Down Expand Up @@ -562,11 +564,14 @@ const countryValidators: Record<string, (body: SubmitKycInformationRequest) => s
AR: ({ phoneNumber, cuit, nationalities, pep }) => {
if (!phoneNumber) return "Phone number is required for Argentina";
if (!phoneNumber.startsWith("+54")) return "Phone number must use Argentina country code (+54)";
if (cuit && !/^\d{11}$/.test(cuit)) return "CUIT must be exactly 11 digits";
// The provider rejects an Argentine individual without a CUIT or with a wrong check digit.
if (!cuit || !isValidCuit(cuit.replace(/\D/g, ""))) return "CUIT must be 11 digits with a valid check digit";
if (nationalities && !nationalities.every(n => /^[A-Z]{2}$/.test(n))) return "Nationalities must use alpha-2 country codes";
if (typeof pep !== "boolean") return "PEP declaration is required for Argentina";
return null;
}
},
// The provider accepts only a CURP with a valid check digit as the Mexican `dni`.
MX: ({ dni }) => (typeof dni === "string" && isValidCurp(dni) ? null : "dni must be a valid 18-character CURP")
};

/**
Expand Down
12 changes: 9 additions & 3 deletions apps/api/src/api/routes/v1/onboarding-requirements.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,23 @@ describe("GET /v1/onboarding/requirements", () => {

try {
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}/v1/onboarding/requirements?country=MX&customerType=business`);
const response = await fetch(`http://127.0.0.1:${port}/v1/onboarding/requirements?country=MX&customerType=individual`);

expect(response.status).toBe(200);
const body = (await response.json()) as Record<string, unknown>;
expect(body).toMatchObject({
country: "MX",
customerType: "business",
flow: "mx-business-api-kyb",
customerType: "individual",
flow: "mx-individual-api-kyc",
family: "domestic"
});
expect(body).not.toHaveProperty("fields");

// Business verification is paused in MX and CO, so discovery must not advertise it.
for (const country of ["MX", "CO"]) {
const paused = await fetch(`http://127.0.0.1:${port}/v1/onboarding/requirements?country=${country}&customerType=business`);
expect(paused.status).toBe(404);
}
} finally {
server.close();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ function toView(record: ProviderCustomer): AlfredpayCustomerView {
status: toAlfredPayStatus(record),
type: customerTypeToAlfredpayType(record.customerType),
async update(changes) {
// Alfred's new platform reports UPDATE_REQUIRED for customers it lists as ACTIVE (2026-09-30), and
// /retryKyc only reopens FAILED, so a status read must not move an approved customer there.
if (
this.status === AlfredPayStatus.Success &&
changes.statusExternal?.toUpperCase() === AlfredpayKycStatus.UPDATE_REQUIRED
) {
return;
}
await record.update({
...(changes.verificationStatus !== undefined
? { status: changes.verificationStatus }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DomesticCustomerType,
FiatToken,
type GetAllConfigsResponse,
getAnyFiatTokenDetails,
RampDirection
} from "@vortexfi/shared";
import { AlfredpayLimitsService } from "./alfredpay-limits.service";
Expand Down Expand Up @@ -88,4 +89,73 @@ describe("AlfredpayLimitsService.refresh", () => {
minRaw: "100000"
});
});

async function refreshWith(...batches: AlfredpayConfigPair[][]): Promise<AlfredpayLimitsService> {
const service = new (AlfredpayLimitsService as unknown as { new (): AlfredpayLimitsService })();
for (const supportedPairs of batches) {
AlfredpayApiService.getInstance = () => ({ getAllConfigs: async () => ({ supportedPairs }) }) as unknown as AlfredpayApiService;
await (service as unknown as { refresh(): Promise<void> }).refresh();
}
return service;
}

const configured = (fiat: FiatToken) => {
const limits = getAnyFiatTokenDetails(fiat).alfredpayLimits;
if (!limits) throw new Error(`no configured Alfredpay limits for ${fiat}`);
return limits;
};
const arsMin = pair({ fromCurrency: "ARS", maxQuantity: null, minQuantity: "1234.56", toCurrency: "USDT" });

/**
* The Penny adapter serves null quantities on most pairs (2026-09-30). `new Big(null)` threw and
* aborted the whole refresh, so no provider bound was ever applied.
*/
test("keeps each customer type's configured bound where Alfred sets no limit", async () => {
const service = await refreshWith([arsMin]);

for (const customer of [DomesticCustomerType.INDIVIDUAL, DomesticCustomerType.BUSINESS]) {
expect(service.getLimits(FiatToken.ARS, "USDT", customer, RampDirection.BUY)).toEqual({
maxRaw: configured(FiatToken.ARS).onramp.USDT[customer].maxRaw,
minRaw: "123456"
});
}
});

test("a malformed bound or an unknown customer type does not lose the other rows", async () => {
const service = await refreshWith([
pair({ maxQuantity: undefined as unknown as null, minQuantity: "" }),
pair({ maxQuantity: null, minQuantity: "10.00", typeCustomer: "COMPANY" as DomesticCustomerType }),
arsMin
]);

expect(service.getLimits(FiatToken.ARS, "USDT", DomesticCustomerType.INDIVIDUAL, RampDirection.BUY).minRaw).toBe("123456");
});

test("a row without any bound does not shadow a real row for the same pair", async () => {
const real = pair({ maxQuantity: "1000.00", minQuantity: "99.00" });
const service = await refreshWith([
pair({ maxQuantity: null, minQuantity: null }),
pair({ maxQuantity: null, minQuantity: null, typeCustomer: DomesticCustomerType.INDIVIDUAL }),
real
]);

for (const customer of [DomesticCustomerType.INDIVIDUAL, DomesticCustomerType.BUSINESS]) {
expect(service.getLimits(FiatToken.MXN, "USDC", customer, RampDirection.BUY)).toEqual({ maxRaw: "100000", minRaw: "9900" });
}
});

test("an empty listing keeps the previous limits", async () => {
const service = await refreshWith([arsMin], []);

expect(service.getLimits(FiatToken.ARS, "USDT", DomesticCustomerType.INDIVIDUAL, RampDirection.BUY).minRaw).toBe("123456");
});

test("a row scaled against the currency's convention is skipped", async () => {
// BUY limits are read back with the fiat's 2 decimals; a "6" row would be 10^4 off.
const service = await refreshWith([pair({ decimals: "6", maxQuantity: null, minQuantity: "150.00" })]);

expect(service.getLimits(FiatToken.MXN, "USDC", DomesticCustomerType.INDIVIDUAL, RampDirection.BUY)).toEqual(
configured(FiatToken.MXN).onramp.USDC[DomesticCustomerType.INDIVIDUAL]
);
});
});
42 changes: 26 additions & 16 deletions apps/api/src/api/services/alfredpay/alfredpay-limits.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ function cacheKey(
return `${direction}:${fiat}:${stablecoin}:${customer}`;
}

/** A provider bound is only a decimal string; null, absent or "" means Alfred sets no limit on that side. */
function isDecimalQuantity(value: unknown): value is string {
return typeof value === "string" && /^\d+(\.\d+)?$/.test(value);
}

function toRaw(quantityDecimal: string, decimals: number): string {
return new Big(quantityDecimal).mul(new Big(10).pow(decimals)).round(0, Big.roundDown).toFixed(0);
}
Expand Down Expand Up @@ -107,6 +112,8 @@ export class AlfredpayLimitsService {
private async refresh(): Promise<void> {
try {
const { supportedPairs } = await AlfredpayApiService.getInstance().getAllConfigs();
// An unparseable 2xx body arrives as an empty listing; it must not wipe the limits we have.
if (supportedPairs.length === 0) throw new Error("allConfigs returned no pairs");
const nextCache = new Map<string, RawAmountLimits>();
for (const pair of supportedPairs) {
this.indexPair(nextCache, pair);
Expand All @@ -119,31 +126,34 @@ export class AlfredpayLimitsService {
}

private indexPair(target: Map<string, RawAmountLimits>, pair: AlfredpayConfigPair): void {
// The /allConfigs listing contains junk rows: decimals null/"" and even null
// currencies. Only digit-string decimals are trustworthy — Number(null) is 0 and
// would silently shrink the raw limits by 10^decimals. Capped at two digits (sane
// currency precision): an oversized exponent would make Big(10).pow throw and
// abort the whole refresh on one bad row.
if (typeof pair.decimals !== "string" || !/^\d{1,2}$/.test(pair.decimals)) return;
const decimals = Number(pair.decimals);

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)
};

// Limits are read back scaled by the fiat's decimals for BUY and the stablecoin's (6) for SELL
// (resolveAlfredpayQuoteLimits). The listing also carries junk rows (decimals null, "" or huge);
// a row scaled any other way would mix scales with our configured bound, so it is skipped.
const decimals = direction === RampDirection.BUY ? getAnyFiatTokenDetails(fiat).decimals : 6;
if (pair.decimals !== String(decimals)) return;
if (pair.typeCustomer && !CUSTOMER_TYPES.includes(pair.typeCustomer)) return;

// A row without any bound says nothing and must not shadow another row for the same pair.
const minQuantity = isDecimalQuantity(pair.minQuantity) ? pair.minQuantity : null;
const maxQuantity = isDecimalQuantity(pair.maxQuantity) ? pair.maxQuantity : null;
if (minQuantity === null && maxQuantity === null) return;

const customers: DomesticCustomerType[] = 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);
}
if (isWildcard && target.has(key)) continue;
// Where Alfred sets no limit on a side, keep our configured bound rather than reading it as unlimited.
const configured = this.fallback(fiat, stablecoin, customer, direction);
target.set(key, {
maxRaw: maxQuantity === null ? configured.maxRaw : toRaw(maxQuantity, decimals),
minRaw: minQuantity === null ? configured.minRaw : toRaw(minQuantity, decimals)
});
}
}

Expand Down
Loading