diff --git a/apps/api/src/api/controllers/alfredpay.controller.ts b/apps/api/src/api/controllers/alfredpay.controller.ts index 4c3ce9653..b819580ed 100644 --- a/apps/api/src/api/controllers/alfredpay.controller.ts +++ b/apps/api/src/api/controllers/alfredpay.controller.ts @@ -776,8 +776,11 @@ export class AlfredpayController { const alfredpayService = AlfredpayApiService.getInstance(); const details = await alfredpayService.getKybBusinessDetails(alfredPayCustomer.alfredPayId); + // submissionId is what lets the caller pick the related persons belonging to the submission it is + // actually filing — a customer that retried can carry several businesses here. const minimized = details.map(business => ({ - relatedPersons: (business.relatedPersons ?? []).map(person => ({ idRelatedPerson: person.idRelatedPerson })) + relatedPersons: (business.relatedPersons ?? []).map(person => ({ idRelatedPerson: person.idRelatedPerson })), + submissionId: business.submissionId })); res.json(minimized); diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 77885dfe0..804851c1d 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -17,6 +17,7 @@ import { PriceProvider, QuoteError, RampDirection, + SubmitKybInformationRequest, SubmitKycInformationRequest, TokenConfig, VALID_CRYPTO_CURRENCIES, @@ -537,6 +538,58 @@ const countryValidators: Record s } }; +/** + * Alfredpay refuses to finalize a KYB submission missing any of these (`110002 "Invalid field(s)"`), + * and it only says so at `sendKybSubmission` — after the documents have been uploaded. Rejecting the + * incomplete payload here keeps that failure at the point of entry, and keeps the contract enforced + * for callers that are not our own KYB wizard. Mirrors GET …/penny/kybRequirements?country=, + * including its two `requiredIf` branches. + */ +const validateKybQuestionnaire = (body: SubmitKybInformationRequest): string | null => { + const requiredText: Array = [ + "walletAddresses", + "sourceOfFunds", + "businessActivities", + "accountPurpose" + ]; + for (const field of requiredText) { + const value = body[field]; + if (typeof value !== "string" || !value.trim()) return `${field} is required`; + } + + const requiredBooleans: Array = [ + "transmitsCustomerFunds", + "operatesInSanctionedCountries", + "isRegulatedBusiness" + ]; + for (const field of requiredBooleans) { + if (typeof body[field] !== "boolean") return `${field} is required`; + } + + for (const field of ["expectedMonthlyVolumeUsd", "expectedMonthlyTransactions"] as const) { + const value = body[field]; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return `${field} must be a non-negative number`; + } + if (!Number.isInteger(body.expectedMonthlyTransactions)) return "expectedMonthlyTransactions must be a whole number"; + + if (body.transmitsCustomerFunds && typeof body.conductsComplianceScreening !== "boolean") { + return "conductsComplianceScreening is required when transmitting customer funds"; + } + if (body.conductsComplianceScreening && !body.complianceScreeningDescription?.trim()) { + return "complianceScreeningDescription is required when conducting compliance screening"; + } + return null; +}; + +export const validateKybSubmission: RequestHandler = (req, res, next) => { + const error = validateKybQuestionnaire(req.body as SubmitKybInformationRequest); + if (error) { + next(new APIError({ errors: [{ message: error }], message: error, status: httpStatus.BAD_REQUEST })); + return; + } + next(); +}; + export const validateKycSubmission: RequestHandler = (req, res, next) => { const body = req.body as SubmitKycInformationRequest; const validator = countryValidators[body.country]; diff --git a/apps/api/src/api/routes/v1/alfredpay.route.ts b/apps/api/src/api/routes/v1/alfredpay.route.ts index 5ff0abb55..2a04cd0c6 100644 --- a/apps/api/src/api/routes/v1/alfredpay.route.ts +++ b/apps/api/src/api/routes/v1/alfredpay.route.ts @@ -4,7 +4,7 @@ import { AlfredpayController } from "../../controllers/alfredpay.controller"; import { validateResultCountry } from "../../middlewares/alfredpay.middleware"; import { requirePartnerOrUserAuth } from "../../middlewares/dualAuth"; import { requireAuth } from "../../middlewares/supabaseAuth"; -import { validateKycSubmission } from "../../middlewares/validators"; +import { validateKybSubmission, validateKycSubmission } from "../../middlewares/validators"; const router = Router(); const upload = multer({ limits: { fileSize: 5 * 1024 * 1024 }, storage: multer.memoryStorage() }); @@ -31,7 +31,13 @@ router.post("/submitKycFile", requireAuth, upload.single("file"), validateResult router.post("/sendKycSubmission", requireAuth, validateResultCountry, AlfredpayController.sendKycSubmission); // Business API-based KYB -router.post("/submitKybInformation", requireAuth, validateResultCountry, AlfredpayController.submitKybInformation); +router.post( + "/submitKybInformation", + requireAuth, + validateResultCountry, + validateKybSubmission, + AlfredpayController.submitKybInformation +); router.post("/submitKybFile", requireAuth, upload.single("file"), validateResultCountry, AlfredpayController.submitKybFile); router.get("/findKybCustomerAndBusiness", requireAuth, validateResultCountry, AlfredpayController.findKybCustomerAndBusiness); router.post( diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.timeout.test.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.timeout.test.ts new file mode 100644 index 000000000..cef1033d6 --- /dev/null +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.timeout.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test"; +import QuoteTicket from "../../../../models/quoteTicket.model"; +import RampState from "../../../../models/rampState.model"; +import { RecoverablePhaseError } from "../../../errors/phase-error"; +import { SquidRouterPayPhaseHandler } from "./squid-router-pay-phase-handler"; + +type BridgeStatusChecker = { + checkBridgeStatus(state: RampState, swapHash: string, quote: QuoteTicket, timeoutMs?: number): Promise; +}; + +describe("SquidRouterPayPhaseHandler bridge polling timeout", () => { + it("rejects with a recoverable error when its deadline expires", async () => { + const handler = Object.create(SquidRouterPayPhaseHandler.prototype) as BridgeStatusChecker; + const state = { state: {} } as RampState; + const quote = {} as QuoteTicket; + + const result = handler.checkBridgeStatus(state, "0xswap", quote, 0); + + await expect(result).rejects.toBeInstanceOf(RecoverablePhaseError); + await expect(result).rejects.toThrow("Bridge status check timed out after 0ms"); + }); +}); diff --git a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts index 3973a004c..f40db1522 100644 --- a/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts +++ b/apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts @@ -29,17 +29,12 @@ import RampState from "../../../../models/rampState.model"; import { SubsidyToken } from "../../../../models/subsidy.model"; import { BasePhaseHandler } from "../base-phase-handler"; import { getEvmFundingAccount } from "../evm-funding"; +import { getSquidRouterPayTimeoutMs } from "../phase-processor-config"; const AXELAR_POLLING_INTERVAL_MS = 10000; // 10 seconds const SQUIDROUTER_INITIAL_DELAY_MS = 60000; // 60 seconds const AXL_GAS_SERVICE_EVM = "0x2d5d7d31F671F86C782533cc367F14109a082712"; const BALANCE_POLLING_TIME_MS = 10000; -// NOTE: This timeout is intentionally longer (15 minutes) than the 3–5 minute balance -// checks in other handlers. For SquidRouter/Axelar bridge flows we wait for cross-chain -// settlement and gas payment on the destination chain, which can legitimately take longer -// under network congestion or bridge delays. Reducing this timeout risks premature failure -// of otherwise successful bridge operations. -const EVM_BALANCE_CHECK_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes const DEFAULT_SQUIDROUTER_GAS_ESTIMATE = "1600000"; // Estimate used to calculate part of the gas fee for SquidRouter transactions. /** * Handler for the squidRouter pay phase. Checks the status of the Axelar bridge and pays on native GLMR fee. @@ -128,6 +123,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { } const toChain = quote.to as EvmNetworks; + const pollingTimeoutMs = getSquidRouterPayTimeoutMs(); let balanceCheckPromise: Promise; @@ -141,7 +137,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { chain: toChain, intervalMs: BALANCE_POLLING_TIME_MS, ownerAddress: ephemeralAddress, - timeoutMs: EVM_BALANCE_CHECK_TIMEOUT_MS, + timeoutMs: pollingTimeoutMs, tokenDetails: outTokenDetails }); } else { @@ -179,7 +175,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { if (balanceError instanceof BalanceCheckError) { if (balanceError.type === BalanceCheckErrorType.Timeout) { - errorMessage += ` Balance check timed out after ${EVM_BALANCE_CHECK_TIMEOUT_MS}ms.`; + errorMessage += ` Balance check timed out after ${pollingTimeoutMs}ms.`; } else if (balanceError.type === BalanceCheckErrorType.ReadFailure) { errorMessage += ` Balance check read failure (unexpected infrastructure issue): ${balanceError.message}.`; } @@ -189,7 +185,7 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { errorMessage += ` Bridge check error: ${bridgeError instanceof Error ? bridgeError.message : String(bridgeError)}.`; } - throw new Error(errorMessage); + throw this.createRecoverableError(errorMessage); } throw error; } @@ -199,13 +195,23 @@ export class SquidRouterPayPhaseHandler extends BasePhaseHandler { * Gets the status of the Axelar bridge * @param txHash The swap (bridgeCall) transaction hash */ - private async checkBridgeStatus(state: RampState, swapHash: string, quote: QuoteTicket): Promise { + private async checkBridgeStatus( + state: RampState, + swapHash: string, + quote: QuoteTicket, + timeoutMs = getSquidRouterPayTimeoutMs() + ): Promise { let isExecuted = false; let payTxHash: string | undefined = state.state.squidRouterPayTxHash; + const timeoutAt = Date.now() + timeoutMs; - await new Promise(resolve => setTimeout(resolve, SQUIDROUTER_INITIAL_DELAY_MS)); + await new Promise(resolve => setTimeout(resolve, Math.min(SQUIDROUTER_INITIAL_DELAY_MS, timeoutMs))); while (!isExecuted) { + if (Date.now() >= timeoutAt) { + throw this.createRecoverableError(`SquidRouterPayPhaseHandler: Bridge status check timed out after ${timeoutMs}ms`); + } + try { const squidRouterStatus = await this.getSquidrouterStatus(swapHash, state, quote); diff --git a/apps/api/src/api/services/phases/phase-processor-config.test.ts b/apps/api/src/api/services/phases/phase-processor-config.test.ts new file mode 100644 index 000000000..fde9b5d14 --- /dev/null +++ b/apps/api/src/api/services/phases/phase-processor-config.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { getPhaseProcessorMaxExecutionTimeMs, getSquidRouterPayTimeoutMs } from "./phase-processor-config"; + +const originalProcessorTimeout = process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS; + +describe("phase processor timeout configuration", () => { + afterEach(() => { + if (originalProcessorTimeout === undefined) { + delete process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS; + } else { + process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS = originalProcessorTimeout; + } + }); + + it("limits SquidRouterPay polling to 80% of the processor timeout", () => { + process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS = "1000"; + + expect(getPhaseProcessorMaxExecutionTimeMs()).toBe(1000); + expect(getSquidRouterPayTimeoutMs()).toBe(800); + }); +}); diff --git a/apps/api/src/api/services/phases/phase-processor-config.ts b/apps/api/src/api/services/phases/phase-processor-config.ts new file mode 100644 index 000000000..ee4a6a2ed --- /dev/null +++ b/apps/api/src/api/services/phases/phase-processor-config.ts @@ -0,0 +1,16 @@ +function positiveIntFromEnv(value: string | undefined, fallback: number): number { + const parsed = value ? parseInt(value, 10) : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function getPhaseProcessorMaxExecutionTimeMs(): number { + return positiveIntFromEnv(process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS, 600000); +} + +export function getSquidRouterPayTimeoutMs(): number { + return Math.floor(getPhaseProcessorMaxExecutionTimeMs() * 0.8); +} + +export function getPhaseProcessorRetryDelayMs(): number { + return positiveIntFromEnv(process.env.PHASE_PROCESSOR_RETRY_DELAY_MS, 30000); +} diff --git a/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts b/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts index 53f2c1f22..0456ab0dd 100644 --- a/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts +++ b/apps/api/src/api/services/phases/phase-processor.cancellation.integration.test.ts @@ -29,10 +29,13 @@ const TEST_PHASE = "nablaSwap"; describe("PhaseProcessor execution cancellation", () => { let polls = 0; const receivedSignals: (AbortSignal | undefined)[] = []; + const observedLockTimes: number[] = []; const hangingHandler: PhaseHandler = { - execute: async (_state: RampState, signal?: AbortSignal) => { + execute: async (state: RampState, signal?: AbortSignal) => { receivedSignals.push(signal); + const persistedState = await RampState.findByPk(state.id); + observedLockTimes.push(new Date(persistedState?.processingLock.lockedAt as unknown as string).getTime()); // Poll forever, like a phase waiting for a payment that never arrives. await waitUntilTrue( async () => { @@ -86,6 +89,8 @@ describe("PhaseProcessor execution cancellation", () => { expect(receivedSignals.length).toBeGreaterThanOrEqual(2); expect(receivedSignals.every(signal => signal instanceof AbortSignal)).toBe(true); expect(receivedSignals.every(signal => signal?.aborted)).toBe(true); + expect(observedLockTimes.length).toBe(receivedSignals.length); + expect(observedLockTimes.at(-1)).toBeGreaterThan(observedLockTimes[0]); // Regression: without cancellation the abandoned loops keep polling forever. const pollsAfterGivingUp = polls; diff --git a/apps/api/src/api/services/phases/phase-processor.ts b/apps/api/src/api/services/phases/phase-processor.ts index 37b791c75..315f1241c 100644 --- a/apps/api/src/api/services/phases/phase-processor.ts +++ b/apps/api/src/api/services/phases/phase-processor.ts @@ -5,15 +5,9 @@ import { config } from "../../../config/vars"; import RampState from "../../../models/rampState.model"; import { APIError } from "../../errors/api-error"; import { PhaseError, RecoverablePhaseError } from "../../errors/phase-error"; +import { getPhaseProcessorMaxExecutionTimeMs, getPhaseProcessorRetryDelayMs } from "./phase-processor-config"; import phaseRegistry from "./phase-registry"; -// A malformed env override must fall back to the default: parseInt would yield NaN -// and setTimeout(..., NaN) fires immediately, which would time out every phase instantly. -function positiveIntFromEnv(value: string | undefined, fallback: number): number { - const parsed = value ? parseInt(value, 10) : Number.NaN; - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -} - /** * Process phases for a ramping process */ @@ -22,9 +16,9 @@ export class PhaseProcessor { private retriesMap = new Map(); private readonly MAX_RETRIES = 8; // Overridable so tests don't wait 10 minutes for the execution timeout. - private readonly MAX_EXECUTION_TIME_MS = positiveIntFromEnv(process.env.PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS, 600000); // 10 minutes + private readonly MAX_EXECUTION_TIME_MS = getPhaseProcessorMaxExecutionTimeMs(); // 10 minutes // Overridable so tests don't wait 30s between recoverable-error retries. - private readonly DEFAULT_RETRY_DELAY_MS = positiveIntFromEnv(process.env.PHASE_PROCESSOR_RETRY_DELAY_MS, 30000); + private readonly DEFAULT_RETRY_DELAY_MS = getPhaseProcessorRetryDelayMs(); private lockedRamps = new Set(); /** @@ -137,6 +131,21 @@ export class PhaseProcessor { } } + /** + * Keep the lock fresh while a ramp advances through phases and retries. + */ + private async refreshLock(state: RampState): Promise { + await RampState.update( + { + processingLock: { + locked: true, + lockedAt: new Date() + } + }, + { where: { id: state.id } } + ); + } + /** * Check if the lock has expired. We do this to avoid stale locks that can happen when the service crashes or restarts. * @param state The current ramp state @@ -172,6 +181,10 @@ export class PhaseProcessor { */ private async processPhase(state: RampState): Promise { try { + // The lock is acquired once for the whole processing chain. Refresh it before + // every phase attempt so long phases and retries are not mistaken for crashed work. + await this.refreshLock(state); + const { currentPhase } = state; logger.info(`Processing phase ${currentPhase} for ramp ${state.id}`); diff --git a/apps/api/src/tests/alfredpay-kyb-pending.integration.test.ts b/apps/api/src/tests/alfredpay-kyb-pending.integration.test.ts index 2e3ceac86..561866250 100644 --- a/apps/api/src/tests/alfredpay-kyb-pending.integration.test.ts +++ b/apps/api/src/tests/alfredpay-kyb-pending.integration.test.ts @@ -47,22 +47,34 @@ function authHeaders(token: string): Record { return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; } +// Carries the compliance questionnaire because `validateKybSubmission` rejects a submission without +// it — the same 110002 contract Alfredpay enforces at finalize. const KYB_FORM = { + accountPurpose: "Treasury management", address: "Calle 1 # 2-3", + businessActivities: "Cross-border payments software", businessName: "Prueba SAS", city: "Bogota", country: "CO", + expectedMonthlyTransactions: 120, + expectedMonthlyVolumeUsd: 50000, + isRegulatedBusiness: false, + operatesInSanctionedCountries: false, relatedPersons: [ { dateOfBirth: "1990-01-01", email: "rep@example.com", firstName: "Ana", lastName: "Rep", - nationalities: ["CO"] + nationalities: ["CO"], + pep: false } ], + sourceOfFunds: "Sale of goods/services", state: "DC", taxId: "900123456", + transmitsCustomerFunds: false, + walletAddresses: "N/A", website: "https://prueba.example.com", zipCode: "110111" }; @@ -481,4 +493,64 @@ describe("Alfredpay KYB PENDING submission", () => { const kycCase = await KycCase.findOne({ where: { providerCustomerId: customer?.id } }); expect(kycCase?.providerCaseId).toBe("kyb-sub-new"); }); + + // Alfredpay only reports a missing questionnaire at sendKybSubmission — after every document has + // been uploaded. These keep that contract enforced at the point of entry. + it("rejects a submission missing the compliance questionnaire before reaching Alfredpay", async () => { + const { token } = await createBusinessCustomer("kyb-missing-questionnaire@example.com"); + + const submitKybInformation = mock(async () => ({ submissionId: "should-not-be-created" })); + AlfredpayApiService.getInstance = mock(() => ({ submitKybInformation }) as unknown as AlfredpayApiService); + + const { accountPurpose, ...withoutAccountPurpose } = KYB_FORM; + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify(withoutAccountPurpose), + headers: authHeaders(token), + method: "POST" + }); + + expect(response.status).toBe(400); + expect(submitKybInformation).not.toHaveBeenCalled(); + }); + + it("rejects the conditional screening answer when funds are transmitted", async () => { + const { token } = await createBusinessCustomer("kyb-conditional-questionnaire@example.com"); + + const submitKybInformation = mock(async () => ({ submissionId: "should-not-be-created" })); + AlfredpayApiService.getInstance = mock(() => ({ submitKybInformation }) as unknown as AlfredpayApiService); + + const response = await api.request("/v1/alfredpay/submitKybInformation", { + body: JSON.stringify({ ...KYB_FORM, transmitsCustomerFunds: true }), + headers: authHeaders(token), + method: "POST" + }); + + expect(response.status).toBe(400); + expect(submitKybInformation).not.toHaveBeenCalled(); + }); + + // A customer that retried carries several businesses. Without submissionId the caller cannot tell + // which related persons belong to the submission it is filing, and uploads land on a stale person. + it("findKybCustomerAndBusiness keeps the submission id alongside each business's related persons", async () => { + const { token } = await createBusinessCustomer("kyb-related-persons@example.com"); + + AlfredpayApiService.getInstance = mock( + () => + ({ + getKybBusinessDetails: mock(async () => [ + { relatedPersons: [{ idRelatedPerson: "rp-stale" }], submissionId: "kyb-sub-stale" }, + { relatedPersons: [{ idRelatedPerson: "rp-current" }], submissionId: "kyb-sub-current" } + ]) + }) as unknown as AlfredpayApiService + ); + + const response = await api.request("/v1/alfredpay/findKybCustomerAndBusiness?country=CO", { + headers: authHeaders(token) + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual([ + { relatedPersons: [{ idRelatedPerson: "rp-stale" }], submissionId: "kyb-sub-stale" }, + { relatedPersons: [{ idRelatedPerson: "rp-current" }], submissionId: "kyb-sub-current" } + ]); + }); }); diff --git a/apps/api/src/tests/contracts/alfredpay.contract.test.ts b/apps/api/src/tests/contracts/alfredpay.contract.test.ts index 2f185026a..2847db259 100644 --- a/apps/api/src/tests/contracts/alfredpay.contract.test.ts +++ b/apps/api/src/tests/contracts/alfredpay.contract.test.ts @@ -14,6 +14,12 @@ * progresses past the point where a real payment would be required (an onramp * stays CREATED / awaiting payment). `getQuote` has no production consumers and * is deliberately uncovered. + * + * KYB: the details read runs with the rest of the live half against a hard-coded sandbox + * business customer (KYB_CUSTOMER_ID). The full company-onboarding sequence is opt-in via + * ALFREDPAY_CONTRACT_RUN_KYB_FLOW=1 because it leaves a business customer behind per run: + * + * RUN_LIVE_TESTS=1 ALFREDPAY_CONTRACT_RUN_KYB_FLOW=1 bun test alfredpay.contract */ import { describe, expect, test } from "bun:test"; import { @@ -23,8 +29,12 @@ import { alfredpayCreateOnrampResponseSchema, AlfredpayFeeType, alfredpayFiatAccountsResponseSchema, + AlfredpayCustomerType, AlfredpayFiatAccountType, AlfredpayFiatCurrency, + alfredpayKybBusinessDetailsResponseSchema, + AlfredpayKybFileType, + AlfredpayKybRelatedPersonFileType, alfredpayKycStatusResponseSchema, alfredpayOfframpTransactionSchema, AlfredpayOnChainCurrency, @@ -32,7 +42,8 @@ import { AlfredpayPaymentMethodType, alfredpayQuoteResponseSchema, AlfredpayTradeLimitError, - type CreateAlfredpayOnrampQuoteRequest + type CreateAlfredpayOnrampQuoteRequest, + type SubmitKybInformationRequest } from "@vortexfi/shared"; import { assertLiveCoverage, runLive } from "../../test-utils/contract-support"; import { FakeAlfredpay } from "../../test-utils/fake-world/fake-anchors"; @@ -42,6 +53,68 @@ const HAS_CREDS = !!(process.env.ALFREDPAY_API_KEY && process.env.ALFREDPAY_API_ const CUSTOMER_ID = process.env.ALFREDPAY_CONTRACT_CUSTOMER_ID; const FIAT_ACCOUNT_ID = process.env.ALFREDPAY_CONTRACT_FIAT_ACCOUNT_ID; const KYC_SUBMISSION_ID = process.env.ALFREDPAY_CONTRACT_KYC_SUBMISSION_ID; +const RUN_KYB_FLOW = !!process.env.ALFREDPAY_CONTRACT_RUN_KYB_FLOW; + +// A sandbox MX company put through the full KYB by the flow test below, so the details read +// exercises a fully populated business: four company documents, both representative documents, and a +// stored `questionnaire`. Its submission is FAILED (see the flow test — the sandbox rejects the +// blank placeholder images), which does not affect this read: details are served regardless of +// verification outcome. Re-create it with ALFREDPAY_CONTRACT_RUN_KYB_FLOW=1 and pin the new id here +// if Alfredpay ever clears the sandbox. +const KYB_CUSTOMER_ID = "5f4a1e58-6b74-454c-bc89-defb8df593be"; + +// 1x1 transparent PNG: the uploads only need a well-formed image of an accepted mime type. +const BLANK_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + +function blankPng(name = "blank.png"): File { + const bytes = Uint8Array.from(atob(BLANK_PNG_BASE64), character => character.charCodeAt(0)); + return new File([bytes], name, { type: "image/png" }); +} + +/** + * The name that broke KYB in production. Alfredpay's relate-person upload answers a non-ASCII + * multipart filename with a bare 502 `111301 UNKNOWN_ERROR`, and macOS separates the time from + * AM/PM with U+202F — so every screenshot a user uploads trips it while looking like plain ASCII. + * AlfredpayApiService rewrites the name before sending; driving the flow with this one keeps that + * honest against the real endpoint, since it passes only because of the rewrite. Escaped + * deliberately: the literal character is invisible here. + */ +const MACOS_SCREENSHOT_PNG_NAME = "Screenshot 2026-07-09 at 12.23.56\u202fPM.png"; + +function kybFlowForm(email: string): SubmitKybInformationRequest { + return { + accountPurpose: "Treasury management", + address: "Avenida Paseo de la Reforma 100", + businessActivities: "Cross-border payments software", + businessName: "Vortex Contract Test SA de CV", + city: "Ciudad de Mexico", + country: "MX", + expectedMonthlyTransactions: 120, + expectedMonthlyVolumeUsd: 50000, + // false keeps the run on the unregulated branch, which is what the document set below covers. + isRegulatedBusiness: false, + operatesInSanctionedCountries: false, + relatedPersons: [ + { + dateOfBirth: "1990-01-01", + email, + firstName: "Ana", + lastName: "Rep", + nationalities: ["MX"], + // Not required for MX, but Alfredpay requires it for CO/US. + pep: false + } + ], + sourceOfFunds: "Sale of goods/services", + state: "CDMX", + taxId: "AAA010101AAA", + transmitsCustomerFunds: false, + walletAddresses: "N/A", + website: "https://vortex-contract-test.example.com", + zipCode: "06600" + }; +} if (RUN_LIVE && !HAS_CREDS) { console.warn("[contract:live] Alfredpay live half skipped: ALFREDPAY_API_KEY/ALFREDPAY_API_SECRET not set"); @@ -293,6 +366,97 @@ describe.skipIf(!RUN_LIVE || !HAS_CREDS)("Alfredpay external API contract — li }, 120_000 ); + + /** + * The pair the KYB document uploads depend on: `submissionId` keys the company-document + * uploads, `relatedPersons[].idRelatedPerson` keys the representative's. Both were modelled + * from Alfredpay's docs and asserted only by our own mocks until this test — see the raw + * payload it prints when diagnosing an upload the provider refuses. + */ + test( + "GET /kyb/details response satisfies the KYB business details contract", + async () => { + const details = await runLive("alfredpay getKybBusinessDetails", () => api().getKybBusinessDetails(KYB_CUSTOMER_ID)); + if (!details) return; + console.info(`[contract:live] kyb/details raw payload:\n${JSON.stringify(details, null, 2)}`); + alfredpayKybBusinessDetailsResponseSchema.parse(details); + }, + 60_000 + ); +}); + +/** + * The full MX company KYB sequence the widget and dashboard drive, against the live sandbox: + * create business customer -> submit info -> 3 company documents -> details -> representative's + * document pair -> finalize -> status. + * + * Opt-in (ALFREDPAY_CONTRACT_RUN_KYB_FLOW=1) rather than nightly: unlike the ramp tests, which + * stop before money moves, this leaves a business customer behind on every run. + * + * Deliberately not wrapped in `runLive`: this is a diagnostic probe, so a provider rejection must + * surface as a failure with its body rather than be logged as inconclusive. Each step prints its + * raw response. + * + * "Completes" means every call is accepted and every response shape holds. The submission itself is + * then rejected by the sandbox's verification (FAILED, ~30s) because the uploads are placeholder + * images — see the note on the status step. + */ +describe.skipIf(!RUN_LIVE || !HAS_CREDS || !RUN_KYB_FLOW)("Alfredpay KYB sandbox flow — live", () => { + test( + "a Mexican company KYB completes every step end to end", + async () => { + const api = AlfredpayApiService.getInstance(); + const email = `vortex-kyb-contract-${Date.now()}@example.com`; + + const customer = await api.createCustomer(email, AlfredpayCustomerType.BUSINESS, "MX"); + console.info(`[contract:kyb] createCustomer -> ${JSON.stringify(customer)}`); + const customerId = customer.customerId; + expect(customerId).toBeTruthy(); + + const submission = await api.submitKybInformation(customerId, kybFlowForm(email)); + console.info(`[contract:kyb] submitKybInformation -> ${JSON.stringify(submission)}`); + const submissionId = submission.submissionId; + expect(submissionId).toBeTruthy(); + + for (const fileType of [ + AlfredpayKybFileType.TAX_ID_DOCUMENT, + AlfredpayKybFileType.ARTICLES_INCORPORATION, + AlfredpayKybFileType.PROOF_ADDRESS, + AlfredpayKybFileType.SHAREHOLDER_REGISTRY + ]) { + await api.submitKybFiles(customerId, submissionId, fileType, blankPng()); + console.info(`[contract:kyb] submitKybFiles ${fileType} -> ok`); + } + + const details = await api.getKybBusinessDetails(customerId); + console.info(`[contract:kyb] getKybBusinessDetails -> ${JSON.stringify(details, null, 2)}`); + alfredpayKybBusinessDetailsResponseSchema.parse(details); + + const business = details.find(entry => entry.submissionId === submissionId); + expect(business).toBeDefined(); + const relatedPersonId = business?.relatedPersons[0]?.idRelatedPerson; + expect(relatedPersonId).toBeTruthy(); + + // The step that failed in the dashboard with errorCode 111301 — see MACOS_SCREENSHOT_PNG_NAME. + for (const fileType of [AlfredpayKybRelatedPersonFileType.DOC_FRONT, AlfredpayKybRelatedPersonFileType.DOC_BACK]) { + await api.submitKybRelatedPersonFiles(customerId, relatedPersonId as string, fileType, blankPng(MACOS_SCREENSHOT_PNG_NAME)); + console.info(`[contract:kyb] submitKybRelatedPersonFiles ${fileType} -> ok`); + } + + await api.sendKybSubmission(customerId, submissionId); + console.info("[contract:kyb] sendKybSubmission -> ok"); + + // Alfredpay accepts the submission (IN_REVIEW), then its verification rejects it: the sandbox + // flips this customer to FAILED within ~30s because the uploads are blank 1x1 PNGs rather than + // real identity documents. That is the right outcome for placeholder images, so the contract + // asserted here is that every call is accepted and every response shape holds — not that + // verification approves. Do not "fix" a FAILED status by chasing the payload. + const status = await api.getKybStatus(customerId, submissionId); + console.info(`[contract:kyb] getKybStatus -> ${JSON.stringify(status)}`); + alfredpayKycStatusResponseSchema.parse(status); + }, + 300_000 + ); }); // Not gated on HAS_CREDS: in the nightly (CONTRACT_EXPECT_LIVE=1) missing credentials diff --git a/apps/dashboard/e2e/funding-gate.spec.ts b/apps/dashboard/e2e/funding-gate.spec.ts new file mode 100644 index 000000000..ee4a82ce9 --- /dev/null +++ b/apps/dashboard/e2e/funding-gate.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from "@playwright/test"; +import { mockBackend } from "./support/mockBackend"; +import { injectMockWallet } from "./support/mockWallet"; +import { seedSession } from "./support/session"; + +// Self-custodial crypto deposits are not supported, and self-serve transfers are enabled per +// account on request — so the funding panel offers the connected wallet only, with its submit +// button commented out. This pins that gate; the money-path journeys in transfer-mxn-journey.spec.ts +// are skipped until the button returns. +test("funding panel is gated: no crypto tab, no submit button, support note shown", async ({ page }) => { + await mockBackend(page); + await injectMockWallet(page, { chainIdHex: "0x89" }); + await seedSession(page); + + await page.goto("/transfer"); + + const amountInput = page.locator("#payout-amount"); + await expect(amountInput).toBeVisible({ timeout: 20_000 }); + await amountInput.fill("1000"); + + // The quote lands and the funding panel renders the connected wallet. + await expect(page.getByText("How you'll fund this")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 20_000 }); + + await expect(page.getByText("Send crypto")).toHaveCount(0); + await expect(page.getByRole("button", { name: /^Send/ })).toHaveCount(0); + await expect(page.getByText(/reach out to/)).toBeVisible(); + await expect(page.getByRole("link", { name: "support@vortexfinance.co" })).toBeVisible(); +}); diff --git a/apps/dashboard/e2e/onboarding-alfredpay-mxn-kyb.spec.ts b/apps/dashboard/e2e/onboarding-alfredpay-mxn-kyb.spec.ts index 8f1728fac..cff33a401 100644 --- a/apps/dashboard/e2e/onboarding-alfredpay-mxn-kyb.spec.ts +++ b/apps/dashboard/e2e/onboarding-alfredpay-mxn-kyb.spec.ts @@ -4,7 +4,9 @@ import { seedSession } from "./support/session"; const documentFile = { buffer: Buffer.from("e2e-kyb-document"), mimeType: "application/pdf", name: "document.pdf" }; -test("Alfredpay MX business KYB submits five documents and reaches provider approval", async ({ page }) => { +test("Alfredpay MX business KYB submits the questionnaire and six documents, and reaches provider approval", async ({ + page +}) => { const backend = await mockBackend(page, { alfredpayKyc: {}, companyMode: true }); await seedSession(page); await page.goto("/overview"); @@ -35,10 +37,21 @@ test("Alfredpay MX business KYB submits five documents and reaches provider appr await page.locator('input[name="repDni"]').fill("GOMM900520MDFXYZ01"); await wizard.getByRole("button", { name: "Continue" }).click(); - await expect(wizard.getByText("All five files are required")).toBeVisible({ timeout: 20_000 }); + // Alfredpay's compliance questionnaire — it rejects the submission without these (110002). + await expect(wizard.getByText("Compliance questionnaire")).toBeVisible({ timeout: 20_000 }); + await page.locator('input[name="sourceOfFunds"]').fill("Sale of goods/services"); + await page.locator('input[name="businessActivities"]').fill("Cross-border payments software"); + await page.locator('input[name="accountPurpose"]').fill("Treasury management"); + await page.locator('input[name="walletAddresses"]').fill("N/A"); + await page.locator('input[name="expectedMonthlyVolumeUsd"]').fill("50000"); + await page.locator('input[name="expectedMonthlyTransactions"]').fill("120"); + await wizard.getByRole("button", { name: "Continue" }).click(); + + // Six on the unregulated branch: the four company documents plus the representative's ID pair. + await expect(wizard.getByText("All six files are required")).toBeVisible({ timeout: 20_000 }); const fileInputs = page.locator('input[type="file"]'); - await expect(fileInputs).toHaveCount(5); - for (let index = 0; index < 5; index++) { + await expect(fileInputs).toHaveCount(6); + for (let index = 0; index < 6; index++) { await fileInputs.nth(index).setInputFiles({ ...documentFile, name: `document-${index + 1}.pdf` }); } await wizard.getByRole("button", { name: "Submit documents" }).click(); @@ -46,15 +59,40 @@ test("Alfredpay MX business KYB submits five documents and reaches provider appr await expect(wizard.getByText("Alfredpay is reviewing your submission")).toBeVisible({ timeout: 20_000 }); expect(backend.kybFormSubmissions).toEqual([ expect.objectContaining({ + accountPurpose: "Treasury management", + businessActivities: "Cross-border payments software", businessName: "Vortex Mexico SA de CV", country: "MX", + expectedMonthlyTransactions: 120, + expectedMonthlyVolumeUsd: 50000, + isRegulatedBusiness: false, + operatesInSanctionedCountries: false, relatedPersons: [ - expect.objectContaining({ email: "e2e@vortexfinance.co", firstName: "Maria", lastName: "Gomez", nationalities: ["MX"] }) + expect.objectContaining({ + email: "e2e@vortexfinance.co", + firstName: "Maria", + lastName: "Gomez", + nationalities: ["MX"], + pep: false + }) ], - taxId: "VME260714AB1" + sourceOfFunds: "Sale of goods/services", + taxId: "VME260714AB1", + transmitsCustomerFunds: false, + walletAddresses: "N/A" }) ]); - expect(backend.kybUploads).toEqual({ businessFiles: 3, relatedPersonFiles: 2 }); + // The questionnaire's conditionals stay off the wire while their triggers are false. + const submitted = backend.kybFormSubmissions[0] as Record; + expect(submitted).not.toHaveProperty("conductsComplianceScreening"); + expect(submitted).not.toHaveProperty("complianceScreeningDescription"); + expect(backend.kybUploads).toEqual({ businessFiles: 4, relatedPersonFiles: 2 }); + // Alfredpay keys each document by fileType and rejects an unknown value, so the names matter as + // much as the count. Unregulated: no businessLicense/uploadAmlPolicy. + expect(backend.kybFileTypes).toEqual(["taxIdDocument", "articlesIncorporation", "proofAddress", "shareholderRegistry"]); + expect(backend.kybRelatedPersonFileTypes).toEqual(["docFront", "docBack"]); + // Finalization is a separate call from the polling below; assert it actually happened. + expect(backend.kyc.submitted).toBe(true); backend.kyc.approved = true; await expect(wizard.getByText("Mexico KYB is complete")).toBeVisible({ timeout: 15_000 }); diff --git a/apps/dashboard/e2e/support/mockBackend.ts b/apps/dashboard/e2e/support/mockBackend.ts index 6a06ef983..703a1cbd3 100644 --- a/apps/dashboard/e2e/support/mockBackend.ts +++ b/apps/dashboard/e2e/support/mockBackend.ts @@ -290,6 +290,12 @@ const THIRD_PARTY_BLOCKLIST = [ type RpcRequest = { id?: number; method?: string; params?: unknown[] }; +/** Reads one text field out of a multipart body — the uploads are FormData, not JSON. */ +function multipartField(body: string | null, name: string): string { + const match = body?.match(new RegExp(`name="${name}"\\r?\\n\\r?\\n([^\\r\\n]*)`)); + return match?.[1] ?? ""; +} + function answerRpc(chainIdHex: string) { const answerOne = (req: RpcRequest) => { const hash = (req.params?.[0] as string) ?? `0x${"cd".repeat(32)}`; @@ -362,6 +368,10 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) // onboarding-status refetch) observes it — deterministic, no reliance on poll counts. const kyc = { approved: false, customerCreated: false, submitted: false }; const kybUploads = { businessFiles: 0, relatedPersonFiles: 0 }; + // The fileType is the whole contract of these uploads: Alfredpay keys the document by it and + // rejects an unknown value, so counting requests alone would not catch sending the wrong one. + const kybFileTypes: string[] = []; + const kybRelatedPersonFileTypes: string[] = []; const avenia = { approved: false, statusPolls: 0, submitted: false }; const monerium = { approved: false, @@ -582,15 +592,19 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) } if (path === "/v1/alfredpay/submitKybFile" && method === "POST") { kybUploads.businessFiles += 1; + kybFileTypes.push(multipartField(request.postData(), "fileType")); await fulfillJson({ success: true }); return; } if (path === "/v1/alfredpay/findKybCustomerAndBusiness" && method === "GET") { - await fulfillJson([{ relatedPersons: [{ idRelatedPerson: "related-person-e2e-1" }] }]); + await fulfillJson([ + { relatedPersons: [{ idRelatedPerson: "related-person-e2e-1" }], submissionId: "kyb-submission-e2e-1" } + ]); return; } if (path === "/v1/alfredpay/submitKybRelatedPersonFile" && method === "POST") { kybUploads.relatedPersonFiles += 1; + kybRelatedPersonFileTypes.push(multipartField(request.postData(), "fileType")); await fulfillJson({ success: true }); return; } @@ -748,7 +762,9 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {}) auth, avenia, brlaCreateSubaccountRequests, + kybFileTypes, kybFormSubmissions, + kybRelatedPersonFileTypes, kybUploads, kyc, kycFormSubmissions, diff --git a/apps/dashboard/e2e/transfer-mxn-journey.spec.ts b/apps/dashboard/e2e/transfer-mxn-journey.spec.ts index 81970889b..c2bfa05b8 100644 --- a/apps/dashboard/e2e/transfer-mxn-journey.spec.ts +++ b/apps/dashboard/e2e/transfer-mxn-journey.spec.ts @@ -22,7 +22,10 @@ const EXPECTED_PAYIN_USDC = "54.054054"; // "broadcast" by the wallet stub, and its receipt is answered by the mocked Polygon RPC. // - Only the direct Polygon no-permit path is exercised; the permit/TokenRelayer variant needs // relayer-contract execution that the mock does not model. -test("SELL MXN transfer: quote, register, ephemeral presigning, wallet broadcast, start, tracking", async ({ page }) => { +// +// Skipped while the submit button in FundingMethods is commented out — self-serve transfers are +// enabled per account on request, so there is no button to drive. Un-skip together with it. +test.skip("SELL MXN transfer: quote, register, ephemeral presigning, wallet broadcast, start, tracking", async ({ page }) => { const backend = await mockBackend(page); // The whole journey lives on Polygon, so the wallet connects on chain 137 and // signAndSubmitEvmTransaction never needs a chain switch. @@ -108,7 +111,9 @@ test("SELL MXN transfer: quote, register, ephemeral presigning, wallet broadcast // Each saved payout account is its own self-recipient, and the offramp registers against the // selected one's fiatAccountId. Picking the second account must send the money there — the first // account is auto-selected, so a broken selector would silently pay out to the wrong account. -test("SELL MXN transfer: choosing a different payout account registers against that account", async ({ page }) => { +// +// Skipped for the same reason as the journey above: no submit button to drive. +test.skip("SELL MXN transfer: choosing a different payout account registers against that account", async ({ page }) => { const backend = await mockBackend(page); await injectMockWallet(page, { chainIdHex: "0x89" }); await seedSession(page); diff --git a/apps/dashboard/src/components/onboarding/alfredpay/AlfredpayKycFlow.tsx b/apps/dashboard/src/components/onboarding/alfredpay/AlfredpayKycFlow.tsx index 52a5071a2..d5c60b0fc 100644 --- a/apps/dashboard/src/components/onboarding/alfredpay/AlfredpayKycFlow.tsx +++ b/apps/dashboard/src/components/onboarding/alfredpay/AlfredpayKycFlow.tsx @@ -1,4 +1,4 @@ -import type { AlfredpayKycFormData, KybBusinessFiles, KybFormData, MxnKycFiles } from "@vortexfi/kyc"; +import type { AlfredpayKycFormData, KybBusinessFiles, KybFormData, KybQuestionnaireData, MxnKycFiles } from "@vortexfi/kyc"; import { useMachine } from "@xstate/react"; import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react"; import { useEffect, useRef } from "react"; @@ -10,6 +10,7 @@ import { CORRIDOR_COUNTRY } from "@/services/api/mappers"; import { DocumentUploadScreen } from "./DocumentUploadScreen"; import { KybDocumentUploadScreen } from "./KybDocumentUploadScreen"; import { KybFormScreen } from "./KybFormScreen"; +import { KybQuestionnaireScreen } from "./KybQuestionnaireScreen"; import { type AlfredpayKycCountry, KycFormScreen } from "./KycFormScreen"; interface AlfredpayKycFlowProps { @@ -55,7 +56,7 @@ export function AlfredpayKycFlow({ corridor, onSettled, onClose, userEmail, busi }); const value = String(state.value); - const { error, country } = state.context; + const { error, country, kybFormData, kybQuestionnaireData } = state.context; const reported = useRef(null); useEffect(() => { @@ -111,6 +112,7 @@ export function AlfredpayKycFlow({ corridor, onSettled, onClose, userEmail, busi return ( send({ data, type: "SUBMIT_KYB_FORM" })} userEmail={userEmail} @@ -129,10 +131,21 @@ export function AlfredpayKycFlow({ corridor, onSettled, onClose, userEmail, busi ); } + if (value === "FillingKybQuestionnaire") { + return ( + send({ type: "GO_BACK" })} + onSubmit={(data: KybQuestionnaireData) => send({ data, type: "SUBMIT_KYB_QUESTIONNAIRE" })} + /> + ); + } + if (value === "UploadingKybBusinessDocs") { return ( send({ type: "GO_BACK" })} onSubmit={(files: KybBusinessFiles) => send({ files, type: "SUBMIT_KYB_BUSINESS_FILES" })} /> diff --git a/apps/dashboard/src/components/onboarding/alfredpay/KybDocumentUploadScreen.tsx b/apps/dashboard/src/components/onboarding/alfredpay/KybDocumentUploadScreen.tsx index c08b10707..3d1c8bbf4 100644 --- a/apps/dashboard/src/components/onboarding/alfredpay/KybDocumentUploadScreen.tsx +++ b/apps/dashboard/src/components/onboarding/alfredpay/KybDocumentUploadScreen.tsx @@ -59,21 +59,45 @@ function FileDropZone({ label, file, onChange }: { label: string; file: File | n interface KybDocumentUploadScreenProps { error?: string; + /** Answered on the questionnaire step; Alfredpay then also requires a licence and AML policy. */ + isRegulatedBusiness?: boolean; onBack: () => void; onSubmit: (files: KybBusinessFiles) => void; } -export function KybDocumentUploadScreen({ error, onBack, onSubmit }: KybDocumentUploadScreenProps) { +export function KybDocumentUploadScreen({ error, isRegulatedBusiness, onBack, onSubmit }: KybDocumentUploadScreenProps) { const [taxIdDocument, setTaxIdDocument] = useState(null); const [articlesIncorporation, setArticlesIncorporation] = useState(null); const [proofAddress, setProofAddress] = useState(null); + const [shareholderRegistry, setShareholderRegistry] = useState(null); const [docFront, setDocFront] = useState(null); const [docBack, setDocBack] = useState(null); - const complete = !!taxIdDocument && !!articlesIncorporation && !!proofAddress && !!docFront && !!docBack; + const [businessLicense, setBusinessLicense] = useState(null); + const [uploadAmlPolicy, setUploadAmlPolicy] = useState(null); + const regulatedComplete = !isRegulatedBusiness || (!!businessLicense && !!uploadAmlPolicy); + const complete = + !!taxIdDocument && + !!articlesIncorporation && + !!proofAddress && + !!shareholderRegistry && + !!docFront && + !!docBack && + regulatedComplete; const handleSubmit = () => { - if (!taxIdDocument || !articlesIncorporation || !proofAddress || !docFront || !docBack) return; - onSubmit({ articlesIncorporation, docBack, docFront, proofAddress, taxIdDocument }); + if (!taxIdDocument || !articlesIncorporation || !proofAddress || !shareholderRegistry || !docFront || !docBack) return; + if (!regulatedComplete) return; + onSubmit({ + articlesIncorporation, + // Sent only on the regulated branch — Alfredpay rejects the submission without them there. + businessLicense: isRegulatedBusiness ? (businessLicense ?? undefined) : undefined, + docBack, + docFront, + proofAddress, + shareholderRegistry, + taxIdDocument, + uploadAmlPolicy: isRegulatedBusiness ? (uploadAmlPolicy ?? undefined) : undefined + }); }; return ( @@ -81,11 +105,21 @@ export function KybDocumentUploadScreen({ error, onBack, onSubmit }: KybDocument

Company and representative documents

-

All five files are required. JPG, PNG or PDF, up to 5 MB each.

+

+ {isRegulatedBusiness ? "All eight files are required." : "All six files are required."} JPG, PNG or PDF, up to 5 MB + each. +

+ + {isRegulatedBusiness && ( + <> + + + + )} {error &&

{error}

} diff --git a/apps/dashboard/src/components/onboarding/alfredpay/KybFormScreen.tsx b/apps/dashboard/src/components/onboarding/alfredpay/KybFormScreen.tsx index 368b0466b..51f52e898 100644 --- a/apps/dashboard/src/components/onboarding/alfredpay/KybFormScreen.tsx +++ b/apps/dashboard/src/components/onboarding/alfredpay/KybFormScreen.tsx @@ -1,35 +1,41 @@ import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; -import { type KybFormData, type KybFormValues, kybFormSchema, mapKybFormValues } from "@vortexfi/kyc"; +import { type KybFormData, type KybFormValues, kybFormSchema, mapKybFormValues, toKybFormValues } from "@vortexfi/kyc"; import { useForm } from "react-hook-form"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { DialogFooter } from "@/components/ui/dialog"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; interface KybFormScreenProps { country: "MX" | "CO"; + /** Details already given, so stepping back from the questionnaire does not blank the form. */ + defaults?: KybFormData; onCancel: () => void; onSubmit: (data: KybFormData) => void; userEmail?: string; } -export function KybFormScreen({ country, onCancel, onSubmit, userEmail }: KybFormScreenProps) { +export function KybFormScreen({ country, defaults, onCancel, onSubmit, userEmail }: KybFormScreenProps) { const form = useForm({ - defaultValues: { - address: "", - businessName: "", - city: "", - repDateOfBirth: "", - repDni: "", - repEmail: userEmail ?? "", - repFirstName: "", - repLastName: "", - repNationality: country, - state: "", - taxId: "", - website: "", - zipCode: "" - }, + defaultValues: defaults + ? toKybFormValues(defaults) + : { + address: "", + businessName: "", + city: "", + repDateOfBirth: "", + repDni: "", + repEmail: userEmail ?? "", + repFirstName: "", + repLastName: "", + repNationality: country, + repPep: false, + state: "", + taxId: "", + website: "", + zipCode: "" + }, resolver: standardSchemaResolver(kybFormSchema) }); @@ -46,7 +52,8 @@ export function KybFormScreen({ country, onCancel, onSubmit, userEmail }: KybFor readOnly={readOnly} type={type} {...input} - value={input.value ?? ""} + // repPep is the one boolean in this form and renders as its own checkbox below. + value={typeof input.value === "string" ? input.value : ""} /> @@ -89,6 +96,18 @@ export function KybFormScreen({ country, onCancel, onSubmit, userEmail }: KybFor {field("repDni", "Document number")}
{field("repNationality", "Nationality (2-letter code)")} + ( + + + input.onChange(checked === true)} /> + + This person is a politically exposed person + + )} + /> + + + + + ); +} diff --git a/apps/dashboard/src/components/recipients/RecipientsTable.tsx b/apps/dashboard/src/components/recipients/RecipientsTable.tsx index 645ddec93..ffcdffeb7 100644 --- a/apps/dashboard/src/components/recipients/RecipientsTable.tsx +++ b/apps/dashboard/src/components/recipients/RecipientsTable.tsx @@ -44,7 +44,7 @@ export function RecipientsTable({ recipients }: { recipients: Recipient[] }) { className={ recipient.isSelf ? undefined - : "cursor-pointer focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring" + : "focus-visible:-outline-offset-2 cursor-pointer focus-visible:outline-2 focus-visible:outline-ring" } initial={{ opacity: 0, y: 8 }} key={recipient.id} diff --git a/apps/dashboard/src/components/transfer/FundingMethods.tsx b/apps/dashboard/src/components/transfer/FundingMethods.tsx index a6feb582f..0908cc648 100644 --- a/apps/dashboard/src/components/transfer/FundingMethods.tsx +++ b/apps/dashboard/src/components/transfer/FundingMethods.tsx @@ -1,13 +1,11 @@ import type { QuoteResponse } from "@vortexfi/shared"; import { ConnectKitButton } from "connectkit"; -import { ArrowDownToLine, Check, Copy, Loader2, TriangleAlert, Wallet } from "lucide-react"; -import { toast } from "sonner"; +import { Check, Wallet } from "lucide-react"; import { useAccount } from "wagmi"; import { Button } from "@/components/ui/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { shortenAddress } from "@/domain/transfer"; -export type FundingSource = "wallet" | "crypto"; +export type FundingSource = "wallet"; export interface FundingSubmit { source: FundingSource; @@ -18,171 +16,62 @@ export interface FundingSubmit { interface FundingMethodsProps { quote: QuoteResponse; network: string; - networkLabel: string; submitting: boolean; onSubmit: (submit: FundingSubmit) => void; } /** - * The ramp is signed by the connected wallet, so both funding paths resolve to that - * address — "Connect wallet" signs in-app; "Send crypto" deposits to it manually. + * The ramp is signed by the connected wallet, which is the only funding path — self-custodial + * crypto deposits are not supported. + * + * Submitting is gated off for now: the button below is commented out and accounts are enabled + * on request. `quote`, `submitting` and `onSubmit` stay on the props so restoring it is an + * uncomment plus `const { quote, submitting, onSubmit } = _props`. */ -export function FundingMethods({ quote, networkLabel, submitting, onSubmit }: FundingMethodsProps) { +export function FundingMethods(_props: FundingMethodsProps) { const { address, isConnected } = useAccount(); - const usdcAmount = quote.inputAmount; - return ( -
- How you'll fund this - - - - - Connect wallet - - - - Send crypto - - - - - address && onSubmit({ destAddress: address, label: "Connected wallet", source: "wallet" })} - submitting={submitting} - usdcAmount={usdcAmount} - /> - - - - address && onSubmit({ destAddress: address, label: "Self-custodial deposit", source: "crypto" })} - submitting={submitting} - usdcAmount={usdcAmount} - /> - - -
- ); -} - -function ConnectTab({ - address, - isConnected, - usdcAmount, - submitting, - onSend -}: { - address?: string; - isConnected: boolean; - usdcAmount: string; - submitting: boolean; - onSend: () => void; -}) { if (!isConnected || !address) { return ( -
-

Connect your wallet to send the payin directly.

- - {({ show }) => ( - - )} - +
+ How you'll fund this +
+

Connect your wallet to send the payin directly.

+ + {({ show }) => ( + + )} + +
); } - return ( -
-
- - Connected - {shortenAddress(address)} -
- -
- ); -} -function AddressCard({ address, label, onCopy }: { address: string; label: string; onCopy: () => void }) { return ( -
- {label} -
- {address} - -
-
- ); -} - -function CryptoTab({ - address, - isConnected, - networkLabel, - usdcAmount, - submitting, - onConfirm -}: { - address?: string; - isConnected: boolean; - networkLabel: string; - usdcAmount: string; - submitting: boolean; - onConfirm: () => void; -}) { - if (!isConnected || !address) { - return ( -
-

Connect your wallet to use it as the deposit destination.

- - {({ show }) => ( - - )} - -
- ); - } - return ( -
-
-

What to do

-

- Send ≈ {usdcAmount} USDC on {networkLabel} to your wallet. + */} +

+ To enable this feature on your account, please reach out to{" "} + + support@vortexfinance.co +

- { - navigator.clipboard?.writeText(address); - toast.success("Address copied"); - }} - /> -

- - Crypto transfers are irreversible. Confirm the network and address before sending. -

-
); } diff --git a/apps/dashboard/src/components/transfer/TransferForm.tsx b/apps/dashboard/src/components/transfer/TransferForm.tsx index 24647c3d7..23ac5fa25 100644 --- a/apps/dashboard/src/components/transfer/TransferForm.tsx +++ b/apps/dashboard/src/components/transfer/TransferForm.tsx @@ -63,7 +63,6 @@ export function TransferForm({ account, recipients, preselectRecipientId }: Tran // Only self-recipients are sendable today; third-party sending is coming soon. const isSendable = selected?.isSelf === true && selected.status === "approved"; const corridor = selected ? CORRIDORS[selected.corridorId] : undefined; - const networkLabel = TRANSFER_NETWORKS.find(item => item.id === network)?.label ?? network; const payoutAmount = Number(amount); // BRL offramps pay out to the user's own PIX key; taxId/receiverTaxId are derived server-side. const needsPixKey = selected?.corridorId === "BR" && selected.isSelf === true; @@ -262,13 +261,7 @@ export function TransferForm({ account, recipients, preselectRecipientId }: Tran ) : quote ? ( <> - + {signing && (

Confirm the signature request in your wallet to authorize the transfer… diff --git a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx index 7c9748201..24aba3f20 100644 --- a/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx +++ b/apps/frontend/src/components/Alfredpay/AlfredpayKycFlow.tsx @@ -1,4 +1,11 @@ -import type { AlfredpayKycFormData, KybBusinessFiles, KybFormData, KybPersonFiles, MxnKycFiles } from "@vortexfi/kyc"; +import type { + AlfredpayKycFormData, + KybBusinessFiles, + KybFormData, + KybPersonFiles, + KybQuestionnaireData, + MxnKycFiles +} from "@vortexfi/kyc"; import { useCallback } from "react"; import { useAlfredpayKycActor, useAlfredpayKycSelector, useRampStateSelector } from "../../contexts/rampState"; import { DoneScreen } from "../DoneScreen"; @@ -11,6 +18,7 @@ import { FillingScreen } from "./FillingScreen"; import { KybBusinessDocsScreen } from "./KybBusinessDocsScreen"; import { KybFormScreen } from "./KybFormScreen"; import { KybPersonDocsScreen } from "./KybPersonDocsScreen"; +import { KybQuestionnaireScreen } from "./KybQuestionnaireScreen"; import { LinkReadyScreen } from "./LinkReadyScreen"; import { LoadingScreen } from "./LoadingScreen"; import { MxnDocumentUploadScreen } from "./MxnDocumentUploadScreen"; @@ -37,6 +45,10 @@ export const AlfredpayKycFlow = () => { const submitForm = useCallback((data: AlfredpayKycFormData) => actor?.send({ data, type: "SUBMIT_FORM" }), [actor]); const submitFiles = useCallback((files: MxnKycFiles) => actor?.send({ files, type: "SUBMIT_FILES" }), [actor]); const submitKybForm = useCallback((data: KybFormData) => actor?.send({ data, type: "SUBMIT_KYB_FORM" }), [actor]); + const submitKybQuestionnaire = useCallback( + (data: KybQuestionnaireData) => actor?.send({ data, type: "SUBMIT_KYB_QUESTIONNAIRE" }), + [actor] + ); const submitKybBusinessFiles = useCallback( (files: KybBusinessFiles) => actor?.send({ files, type: "SUBMIT_KYB_BUSINESS_FILES" }), [actor] @@ -99,11 +111,23 @@ export const AlfredpayKycFlow = () => { } if (stateValue === "FillingKybForm") { - return ; + return ( + + ); + } + + if (stateValue === "FillingKybQuestionnaire") { + return ; } if (stateValue === "UploadingKybBusinessDocs") { - return ; + return ( + + ); } if (stateValue === "UploadingKybPersonDocs") { diff --git a/apps/frontend/src/components/Alfredpay/KybBusinessDocsScreen.tsx b/apps/frontend/src/components/Alfredpay/KybBusinessDocsScreen.tsx index 6806a5ccb..7266b06f2 100644 --- a/apps/frontend/src/components/Alfredpay/KybBusinessDocsScreen.tsx +++ b/apps/frontend/src/components/Alfredpay/KybBusinessDocsScreen.tsx @@ -66,24 +66,47 @@ function FileDropZone({ } interface KybBusinessDocsScreenProps { + /** Answered on the questionnaire step; Alfredpay then also requires a licence and AML policy. */ + isRegulatedBusiness?: boolean; onBack: () => void; onSubmit: (files: KybBusinessFiles) => void; } -export function KybBusinessDocsScreen({ onBack, onSubmit }: KybBusinessDocsScreenProps) { +export function KybBusinessDocsScreen({ isRegulatedBusiness, onBack, onSubmit }: KybBusinessDocsScreenProps) { const { t } = useTranslation(); const [taxIdDocument, setTaxIdDocument] = useState(null); const [articlesIncorporation, setArticlesIncorporation] = useState(null); const [proofAddress, setProofAddress] = useState(null); + const [shareholderRegistry, setShareholderRegistry] = useState(null); const [docFront, setDocFront] = useState(null); const [docBack, setDocBack] = useState(null); + const [businessLicense, setBusinessLicense] = useState(null); + const [uploadAmlPolicy, setUploadAmlPolicy] = useState(null); + const regulatedComplete = !isRegulatedBusiness || (businessLicense !== null && uploadAmlPolicy !== null); const isValid = - taxIdDocument !== null && articlesIncorporation !== null && proofAddress !== null && docFront !== null && docBack !== null; + taxIdDocument !== null && + articlesIncorporation !== null && + proofAddress !== null && + shareholderRegistry !== null && + docFront !== null && + docBack !== null && + regulatedComplete; const handleSubmit = () => { - if (!taxIdDocument || !articlesIncorporation || !proofAddress || !docFront || !docBack) return; - onSubmit({ articlesIncorporation, docBack, docFront, proofAddress, taxIdDocument }); + if (!taxIdDocument || !articlesIncorporation || !proofAddress || !shareholderRegistry || !docFront || !docBack) return; + if (!regulatedComplete) return; + onSubmit({ + articlesIncorporation, + // Sent only on the regulated branch — Alfredpay rejects the submission without them there. + businessLicense: isRegulatedBusiness ? (businessLicense ?? undefined) : undefined, + docBack, + docFront, + proofAddress, + shareholderRegistry, + taxIdDocument, + uploadAmlPolicy: isRegulatedBusiness ? (uploadAmlPolicy ?? undefined) : undefined + }); }; return ( @@ -111,6 +134,28 @@ export function KybBusinessDocsScreen({ onBack, onSubmit }: KybBusinessDocsScree label={t("components.kybBusinessDocs.proofAddress")} onChange={setProofAddress} /> + + {isRegulatedBusiness && ( + <> + + + + )} void; country: string; + /** Details already given, so stepping back from the questionnaire does not blank the form. */ + defaults?: KybFormData; userEmail?: string; } -export function KybFormScreen({ onSubmit, country, userEmail }: KybFormScreenProps) { +export function KybFormScreen({ onSubmit, country, defaults, userEmail }: KybFormScreenProps) { const { t } = useTranslation(); const { formState: { errors }, handleSubmit, register - } = useForm({ defaultValues: { repEmail: userEmail ?? "" }, resolver: zodResolver(kybFormSchema) }); + } = useForm({ + defaultValues: defaults ? toKybFormValues(defaults) : { repEmail: userEmail ?? "", repPep: false }, + resolver: zodResolver(kybFormSchema) + }); const inputClass = (hasError: boolean) => `input-vortex-primary input-ghost w-full rounded-lg border p-2 text-base ${hasError ? "border-error" : "border-neutral-300"}`; @@ -212,6 +217,11 @@ export function KybFormScreen({ onSubmit, country, userEmail }: KybFormScreenPro

+ + diff --git a/apps/frontend/src/components/Alfredpay/KybQuestionnaireScreen.tsx b/apps/frontend/src/components/Alfredpay/KybQuestionnaireScreen.tsx new file mode 100644 index 000000000..59f7035c7 --- /dev/null +++ b/apps/frontend/src/components/Alfredpay/KybQuestionnaireScreen.tsx @@ -0,0 +1,221 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { + type KybQuestionnaireData, + type KybQuestionnaireValues, + kybQuestionnaireSchema, + mapKybQuestionnaireValues +} from "@vortexfi/kyc"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { MenuButtons } from "../MenuButtons"; + +interface KybQuestionnaireScreenProps { + /** Answers already given, so stepping back from the documents does not blank the form. */ + defaults?: KybQuestionnaireData; + onBack: () => void; + onSubmit: (data: KybQuestionnaireData) => void; +} + +/** + * Alfredpay's compliance questionnaire. Every question is one Alfredpay requires before it accepts + * the submission (GET …/penny/kybRequirements?country=). The conditionals mirror its `requiredIf`, + * and declaring regulated activities adds two documents to the upload step. + */ +export function KybQuestionnaireScreen({ defaults, onBack, onSubmit }: KybQuestionnaireScreenProps) { + const { t } = useTranslation(); + + const { + formState: { errors }, + handleSubmit, + register, + watch + } = useForm({ + defaultValues: { + ...defaults, + conductsComplianceScreening: defaults?.conductsComplianceScreening ?? false, + isRegulatedBusiness: defaults?.isRegulatedBusiness ?? false, + operatesInSanctionedCountries: defaults?.operatesInSanctionedCountries ?? false, + transmitsCustomerFunds: defaults?.transmitsCustomerFunds ?? false + }, + resolver: zodResolver(kybQuestionnaireSchema) + }); + + const transmitsCustomerFunds = watch("transmitsCustomerFunds"); + const conductsComplianceScreening = watch("conductsComplianceScreening"); + + const inputClass = (hasError: boolean) => + `input-vortex-primary input-ghost w-full rounded-lg border p-2 text-base ${hasError ? "border-error" : "border-neutral-300"}`; + + return ( +
+ +

{t("components.kybQuestionnaire.title")}

+

{t("components.kybQuestionnaire.subtitle")}

+ +
onSubmit(mapKybQuestionnaireValues(fields)))} + > +
+ +

{t("components.kybQuestionnaire.sourceOfFundsHint")}

+ + {errors.sourceOfFunds && {errors.sourceOfFunds.message}} +
+ +
+ + + {errors.businessActivities && ( + {errors.businessActivities.message} + )} +
+ +
+ + + {errors.accountPurpose && {errors.accountPurpose.message}} +
+ +
+ +

{t("components.kybQuestionnaire.walletAddressesHint")}

+ + {errors.walletAddresses && {errors.walletAddresses.message}} +
+ +
+ + + {errors.expectedMonthlyVolumeUsd && ( + {errors.expectedMonthlyVolumeUsd.message} + )} +
+ +
+ + + {errors.expectedMonthlyTransactions && ( + {errors.expectedMonthlyTransactions.message} + )} +
+ +

{t("components.kybQuestionnaire.declarationsTitle")}

+

{t("components.kybQuestionnaire.declarationsSubtitle")}

+ + + + {transmitsCustomerFunds && ( + + )} + + {transmitsCustomerFunds && conductsComplianceScreening && ( +
+ + + {errors.complianceScreeningDescription && ( + {errors.complianceScreeningDescription.message} + )} +
+ )} + + + + + + + +
+
+ ); +} diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 3bc202a82..d43a34852 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -520,10 +520,13 @@ "tryAgain": "Try Again" }, "kybBusinessDocs": { + "amlPolicy": "AML Policy", "articlesIncorporation": "Articles of Incorporation", + "businessLicense": "Business Licence", "docBack": "ID Document – Back", "docFront": "ID Document – Front", "proofAddress": "Proof of Address", + "shareholderRegistry": "Shareholder Registry", "submit": "Submit Documents", "subtitle": "Please upload the required business documents. Max 5 MB per file (JPG, PNG, PDF).", "taxIdDocument": "Tax ID Document", @@ -533,6 +536,7 @@ "businessName": "Business Name", "nationality": "Nationality (2-letter code)", "repDni": "Document Number (optional)", + "repPep": "The representative is a politically exposed person", "representativeTitle": "Authorized Representative", "subtitle": "Please provide your business information to complete KYB.", "taxId": "Tax ID", @@ -544,6 +548,27 @@ "subtitle": "Please upload a photo or scan of the representative's ID document.", "title": "Representative ID ({{current}} of {{total}})" }, + "kybQuestionnaire": { + "accountPurpose": "Primary Account Purpose", + "businessActivities": "Business Activities", + "complianceScreeningDescription": "Describe your compliance screening", + "conductsComplianceScreening": "We conduct compliance screening (KYC, KYB and AML)", + "declarationsSubtitle": "Tick only what applies to your business.", + "declarationsTitle": "Declarations", + "expectedMonthlyTransactions": "Expected Monthly Transactions", + "expectedMonthlyVolumeUsd": "Expected Monthly Volume (USD)", + "isRegulatedBusiness": "We perform regulated activities", + "isRegulatedBusinessHint": "Adds two documents to the next step.", + "operatesInSanctionedCountries": "We operate in Cuba, Iran, Myanmar, North Korea or Syria", + "sourceOfFunds": "Source of Funds", + "sourceOfFundsHint": "The primary source of your company's revenue or the funds used with Alfredpay.", + "submit": "Continue", + "subtitle": "Alfredpay requires these answers before it can review your business.", + "title": "Compliance Questionnaire", + "transmitsCustomerFunds": "We transmit funds on behalf of our customers", + "walletAddresses": "Wallet Addresses", + "walletAddressesHint": "List the wallets that will interact with Alfredpay and their chain. Enter N/A if you will not transact on-chain." + }, "kycDoneScreen": { "accountVerified": "Your account has been verified. You can now proceed.", "completed": "{{kycOrKyb}} Completed!", diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 0b53d7db6..2ec349e98 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -523,7 +523,9 @@ "tryAgain": "Tente novamente" }, "kybBusinessDocs": { + "amlPolicy": "Política de PLD", "articlesIncorporation": "Contrato Social", + "businessLicense": "Licença Comercial", "docBack": "Documento de Identidade – Verso", "docFront": "Documento de Identidade – Frente", "proofAddress": "Comprovante de Endereço", @@ -537,6 +539,7 @@ "businessName": "Nome da Empresa", "nationality": "Nacionalidade (código de 2 letras)", "repDni": "Número do Documento (opcional)", + "repPep": "O representante é uma pessoa politicamente exposta", "representativeTitle": "Representante Autorizado", "subtitle": "Por favor, forneça as informações da sua empresa para concluir o KYB.", "taxId": "CNPJ / Identificação Fiscal", @@ -548,6 +551,27 @@ "subtitle": "Por favor, envie uma foto ou digitalização do documento de identidade do representante.", "title": "Documento do Representante ({{current}} de {{total}})" }, + "kybQuestionnaire": { + "accountPurpose": "Finalidade Principal da Conta", + "businessActivities": "Atividades da Empresa", + "complianceScreeningDescription": "Descreva a sua triagem de conformidade", + "conductsComplianceScreening": "Realizamos triagem de conformidade (KYC, KYB e PLD)", + "declarationsSubtitle": "Marque apenas o que se aplica à sua empresa.", + "declarationsTitle": "Declarações", + "expectedMonthlyTransactions": "Transações Mensais Esperadas", + "expectedMonthlyVolumeUsd": "Volume Mensal Esperado (USD)", + "isRegulatedBusiness": "Exercemos atividades reguladas", + "isRegulatedBusinessHint": "Adiciona dois documentos à próxima etapa.", + "operatesInSanctionedCountries": "Operamos em Cuba, Irã, Mianmar, Coreia do Norte ou Síria", + "sourceOfFunds": "Origem dos Recursos", + "sourceOfFundsHint": "A principal origem da receita da empresa ou dos recursos usados com a Alfredpay.", + "submit": "Continuar", + "subtitle": "A Alfredpay exige estas respostas antes de analisar a sua empresa.", + "title": "Questionário de Conformidade", + "transmitsCustomerFunds": "Transmitimos recursos em nome dos nossos clientes", + "walletAddresses": "Endereços de Carteira", + "walletAddressesHint": "Liste as carteiras que interagirão com a Alfredpay e a respectiva rede. Informe N/A se não houver transações on-chain." + }, "kycDoneScreen": { "accountVerified": "Sua conta foi verificada. Você pode prosseguir agora.", "completed": "{{kycOrKyb}} Concluído!", diff --git a/docs/research/phase-handler-race-incident-2026-07-17.md b/docs/research/phase-handler-race-incident-2026-07-17.md new file mode 100644 index 000000000..e5287b68d --- /dev/null +++ b/docs/research/phase-handler-race-incident-2026-07-17.md @@ -0,0 +1,855 @@ +# Phase-handler race incident report — 2026-07-17 + +Report date: 2026-07-17 +Affected ramp: `eb597373-ed70-4275-a63d-aac172bdfe7a` +Affected flow: BRL onramp, Base-to-destination EVM route +Primary phases: `squidRouterPay`, `finalSettlementSubsidy`, `destinationTransfer` +Status: root cause identified; targeted lock-refresh and `squidRouterPay` polling mitigations implemented after this report + +## Executive summary + +The ramp was processed by multiple overlapping phase executions. The first +`squidRouterPay` execution exceeded the phase processor's 10-minute timeout, but its +internal polling continued. A retry started 30 seconds later and also timed out while +continuing internally. The ramp's database lock was not renewed during this work, so the +recovery worker later treated the active lock as expired and started another execution. + +When the bridge eventually settled, several stale `squidRouterPay` executions completed +at approximately the same time. They independently advanced into +`finalSettlementSubsidy`, where at least two native-to-USDT funding swaps were submitted. +One execution then completed `destinationTransfer` and marked the ramp `complete`. +Another stale execution subsequently persisted an older phase transition and moved the +database state back to `destinationTransfer`. + +The successful destination transfer had already removed the expected token balance from +the ephemeral account. The regressed `destinationTransfer` execution therefore waited +for a balance that could no longer arrive. It exhausted its initial attempt plus eight +retries, remained nonterminal, and was selected again by the recovery worker. This cycle +continued approximately every 45 minutes for the remainder of the supplied logs. + +This was not a database deadlock. It was a phase-state regression followed by an +infinite recovery livelock. + +## Impact + +### Confirmed + +- The intended destination transfer executed successfully once according to the API + logs, and the ramp was temporarily marked `complete`. +- The persisted ramp phase was subsequently regressed to `destinationTransfer`. +- The recovery worker repeatedly executed an impossible balance precondition for more + than six hours. +- The supplied error log contains two `squidRouterPay` timeouts and 81 + `destinationTransfer` balance timeouts through `2026-07-17T18:06:05.842Z`. +- At least two final-settlement native-to-USDT swap transactions were submitted: + - `0xc9572cd1a67a2f50187ca527878319be66f11a8c441af5e853dabc5e3f6e8f2f` + - `0x6bc4adb60cfc770fb66c7a4a98eafdae6734936d2b2246f732b1a65a221792f6` +- Recovery cycles consumed RPC, database, worker, and logging capacity without making + progress. + +### Requiring on-chain reconciliation + +- Whether both final-settlement swap transactions succeeded and their exact output. +- Whether more than one final USDT subsidy transfer succeeded. +- The final token and native balances of the funding and ephemeral accounts. +- Whether the stored `destinationTransferTxHash` was never written, was overwritten by + a stale JSON update, or remained present but was absent from the stale in-memory model. +- The exact net financial loss, if any, from duplicate swap fees, slippage, or duplicate + subsidy transfers. + +The existence of a single `Subsidy` bookkeeping row does not prove that only one +on-chain subsidy occurred. Bookkeeping is written after the side effect, uses a +find-then-create sequence, and is not protected by a unique `(ramp_id, phase)` database +constraint. + +## Expected behavior + +For a non-Base EVM destination, the relevant final phase sequence is: + +```text +squidRouterSwap + -> squidRouterPay + -> finalSettlementSubsidy + -> destinationTransfer + -> complete +``` + +Only one processor should own the ramp. A timed-out execution should stop before a retry +starts. Once `complete` is persisted, no stale execution should be able to move the ramp +back to a nonterminal phase. Financial side effects should be recoverable without being +submitted twice. + +## Relevant implementation + +The report references the source tree as it existed during analysis: + +- Processor and retry loop: + `apps/api/src/api/services/phases/phase-processor.ts` +- Recovery worker: + `apps/api/src/api/workers/ramp-recovery.worker.ts` +- Base handler and phase transition construction: + `apps/api/src/api/services/phases/base-phase-handler.ts` +- Bridge settlement handler: + `apps/api/src/api/services/phases/handlers/squid-router-pay-phase-handler.ts` +- Final settlement subsidy handler: + `apps/api/src/api/services/phases/handlers/final-settlement-subsidy.ts` +- Destination transfer handler: + `apps/api/src/api/services/phases/handlers/destination-transfer-handler.ts` +- Existing processor findings: + `docs/security-spec/03-ramp-engine/state-machine.md` + +The security specification already records two directly relevant known findings: + +- F-003: database lock acquisition is not atomic. +- F-004: recoverable retry exhaustion leaves the ramp nonterminal, and a later + processing cycle receives a fresh retry budget. + +## Log evidence + +### Normal progression into the affected phase + +The ramp progressed normally through minting, swaps, fee distribution, and the Squid +source transaction. The relevant source and bridge transaction hashes were: + +```text +Nabla approve: +0x4d94497f041714aeb6492d4255a26e923e617c37ec36267d2a03440a87bf76ff + +Nabla swap: +0xc7ca43f2a80471d8cb53f6b70398df4686d9bf920f4ddeaf23f0a570adad6073 + +Fee distribution: +0x947144197d12e4e64857686888112dd1752d68ce19ce1b5f4a04d0d2deb34b50 + +Squid approve: +0x77edcb9fc091ff2d9e2797957c240e32ad60fbd2131ec31bbc513f0fc567f113 + +Squid swap / bridge source transaction: +0x141a0356e0db9ca7cf087918ffff9841f17fcb12a9175e70cc27b64fa9fd520f +``` + +The bridge was detected and additional Axelar gas was funded: + +```text +info [...] SquidRouterPayPhaseHandler: Bridge transaction detected on Axelar. Proceeding to fund gas. +info [...] SquidRouterPayPhaseHandler: Base fund transaction sent with hash: 0x404eea351f96c4243286181ea66c3f30fc0c089fe13980dfc23e745fa45e1294 +info [...] Subsidy created successfully with id 51204980-0deb-4342-b9f4-7c1d5600ac00 for ramp eb597373-ed70-4275-a63d-aac172bdfe7a +``` + +### Two processor timeouts + +The persisted failure log contains these two entries: + +```json +{ + "error": "Phase execution timed out", + "phase": "squidRouterPay", + "timestamp": "2026-07-17T11:19:51.503Z", + "recoverable": true, + "isPhaseError": true +} +``` + +```json +{ + "error": "Phase execution timed out", + "phase": "squidRouterPay", + "timestamp": "2026-07-17T11:30:21.528Z", + "recoverable": true, + "isPhaseError": true +} +``` + +The interval is 10 minutes 30 seconds: the configured 10-minute execution timeout plus +the configured 30-second retry delay. The second entry proves that the retry remained in +the same phase for another complete timeout window. + +The operational log then shows recovery taking over the still-active ramp: + +```text +info Attempting recovery in phase squidRouterPay for ramp eb597373-ed70-4275-a63d-aac172bdfe7a +info [...] Lock for ramp eb597373-ed70-4275-a63d-aac172bdfe7a has expired. Ignoring previous lock and continue processing... +info [...] Processing phase squidRouterPay for ramp eb597373-ed70-4275-a63d-aac172bdfe7a +``` + +### Concurrent late completions + +At bridge settlement, the logs contain repeated success messages from executions that +had started at different times: + +```text +info [...] Phase squidRouterPay executed successfully for ramp eb597373-ed70-4275-a63d-aac172bdfe7a +info [...] Phase changed from squidRouterPay to finalSettlementSubsidy for ramp eb597373-ed70-4275-a63d-aac172bdfe7a +info [...] Processing phase finalSettlementSubsidy for ramp eb597373-ed70-4275-a63d-aac172bdfe7a + +info [...] Phase squidRouterPay executed successfully for ramp eb597373-ed70-4275-a63d-aac172bdfe7a +info [...] Phase changed from squidRouterPay to finalSettlementSubsidy for ramp eb597373-ed70-4275-a63d-aac172bdfe7a +info [...] Processing phase finalSettlementSubsidy for ramp eb597373-ed70-4275-a63d-aac172bdfe7a + +info [...] SquidRouterPayPhaseHandler: Transaction 0x141a0356e0db9ca7cf087918ffff9841f17fcb12a9175e70cc27b64fa9fd520f successfully executed on Axelar. +info [...] Phase squidRouterPay executed successfully for ramp eb597373-ed70-4275-a63d-aac172bdfe7a +``` + +Within a single `squidRouterPay` invocation, `Promise.any()` also leaves its losing +promise running. If the destination balance check wins first, the bridge-status polling +promise can continue and emit another late Axelar success message. This adds noise, but +it does not by itself explain duplicate phase transitions. The duplicate transitions +require multiple handler executions. + +### Duplicate final-settlement activity + +The overlapping executions both observed an 84,160-raw-unit USDT settlement shortfall +and an underfunded funding account: + +```text +info [...] FinalSettlementSubsidyHandler: Subsidizing 84160 raw units of USDT to 0xA778a815623892f25235932116637cA0F3BBc0b9 +info [...] FinalSettlementSubsidyHandler: Funding account has insufficient balance. Swapping native token to USDT +info [...] FinalSettlementSubsidyHandler: Swapping 1093793475827218534 native units (approx. rate 8.463755e-14) to get required subsidy. +info [...] FinalSettlementSubsidyHandler: Swap transaction sent: 0xc9572cd1a67a2f50187ca527878319be66f11a8c441af5e853dabc5e3f6e8f2f. Waiting for receipt... + +info [...] FinalSettlementSubsidyHandler: Subsidizing 84160 raw units of USDT to 0xA778a815623892f25235932116637cA0F3BBc0b9 +info [...] FinalSettlementSubsidyHandler: Funding account has insufficient balance. Swapping native token to USDT +info [...] FinalSettlementSubsidyHandler: Swapping 1093793475827218534 native units (approx. rate 8.463755e-14) to get required subsidy. +info [...] FinalSettlementSubsidyHandler: Swap transaction sent: 0x6bc4adb60cfc770fb66c7a4a98eafdae6734936d2b2246f732b1a65a221792f6. Waiting for receipt... +``` + +The handler does not persist the funding-swap hash before waiting for its receipt. Its +idempotency check only covers `finalSettlementSubsidyTxHash`, which is the later subsidy +transfer. It therefore cannot recognize or reconcile a previously submitted funding +swap. + +The log also records a later subsidy-transfer problem: + +```text +error [...] FinalSettlementSubsidyHandler: Transaction 0xac2c732bd0d3af0ea49a6f91c509ea1c6d21f2929839a7d31de5835d725704bd failed or was not found. Retrying... +``` + +This hash must be reconciled on-chain before calculating the incident's financial +impact. + +### Completion followed by state regression + +One execution completed the transfer and the ramp: + +```text +info [...] Phase destinationTransfer executed successfully for ramp eb597373-ed70-4275-a63d-aac172bdfe7a +info [...] Ramp eb597373-ed70-4275-a63d-aac172bdfe7a completed successfully +info Successfully processed ramp state eb597373-ed70-4275-a63d-aac172bdfe7a +``` + +After that completion, another execution continued and wrote the previous transition +again: + +```text +info [...] Subsidy entry already exists for ramp eb597373-ed70-4275-a63d-aac172bdfe7a in phase finalSettlementSubsidy +info [...] Phase finalSettlementSubsidy executed successfully for ramp eb597373-ed70-4275-a63d-aac172bdfe7a +info [...] Phase changed from finalSettlementSubsidy to destinationTransfer for ramp eb597373-ed70-4275-a63d-aac172bdfe7a +info [...] Processing phase destinationTransfer for ramp eb597373-ed70-4275-a63d-aac172bdfe7a +``` + +This ordering is direct evidence that a stale execution survived beyond terminal +completion and was allowed to persist a nonterminal phase afterward. + +### Repeated destination balance failures + +Each destination failure has the same shape: + +```json +{ + "error": "DestinationTransferHandler: Error during phase execution - Balance did not meet the limit within 180000ms", + "phase": "destinationTransfer", + "details": "RecoverablePhaseError: DestinationTransferHandler: Error during phase execution - Balance did not meet the limit within 180000ms", + "recoverable": true, + "isPhaseError": true +} +``` + +The first failure was recorded at `11:40:54.397Z`. Since the balance timeout is exactly +180 seconds, that execution began waiting at approximately `11:37:54Z`, matching the +completion and regression window in the operational logs. + +The supplied failure log contains the following complete timestamp series. Each row is +one processing cycle containing the initial attempt plus eight retries: + +| Cycle | Destination-transfer failure timestamps (UTC) | +|---|---| +| Initial regressed execution | `11:40:54.397`, `11:44:24.951`, `11:47:55.432`, `11:51:25.951`, `11:54:56.506`, `11:58:26.945`, `12:01:57.485`, `12:05:27.960`, `12:08:58.610` | +| Recovery 1 | `12:23:00.780`, `12:26:31.446`, `12:30:02.109`, `12:33:32.877`, `12:37:03.464`, `12:40:34.033`, `12:44:04.816`, `12:47:35.443`, `12:51:06.032` | +| Recovery 2 | `13:08:00.844`, `13:11:31.435`, `13:15:01.970`, `13:18:33.224`, `13:22:03.809`, `13:25:34.417`, `13:29:04.917`, `13:32:35.531`, `13:36:06.192` | +| Recovery 3 | `13:53:00.720`, `13:56:31.250`, `14:00:01.764`, `14:03:32.689`, `14:07:03.395`, `14:10:34.101`, `14:14:04.725`, `14:17:35.317`, `14:21:05.916` | +| Recovery 4 | `14:38:00.728`, `14:41:31.433`, `14:45:02.381`, `14:48:33.360`, `14:52:03.910`, `14:55:34.468`, `14:59:05.088`, `15:02:35.655`, `15:06:06.184` | +| Recovery 5 | `15:23:00.711`, `15:26:31.414`, `15:30:01.887`, `15:33:32.441`, `15:37:03.126`, `15:40:33.636`, `15:44:04.177`, `15:47:34.683`, `15:51:05.468` | +| Recovery 6 | `16:08:00.987`, `16:11:31.619`, `16:15:02.368`, `16:18:32.970`, `16:22:03.539`, `16:25:34.226`, `16:29:04.739`, `16:32:35.844`, `16:36:06.547` | +| Recovery 7 | `16:53:00.716`, `16:56:31.304`, `17:00:01.887`, `17:03:32.583`, `17:07:03.111`, `17:10:33.684`, `17:14:04.332`, `17:17:34.832`, `17:21:05.366` | +| Recovery 8 | `17:38:00.588`, `17:41:31.184`, `17:45:01.717`, `17:48:32.232`, `17:52:03.241`, `17:55:34.029`, `17:59:04.731`, `18:02:35.297`, `18:06:05.842` | + +All timestamps are on 2026-07-17. There are 81 destination failures: nine attempts per +cycle across nine cycles. + +Within a cycle, failures are approximately 210 seconds apart: + +```text +180 seconds balance polling ++ 30 seconds retry delay += 210 seconds per failed attempt +``` + +After the initial cycle, the first failures of later cycles settle into an approximately +45-minute cadence: + +```text +~31 minutes for nine attempts and eight retry delays ++ 10-minute stale-state threshold ++ up to 5 minutes for the recovery cron boundary += approximately 45 minutes between recovery cycles +``` + +This cadence directly demonstrates that the retry budget is bounded only within one +in-memory `processRamp()` call. It is not bounded across recovery cycles. + +## Reconstructed timeline + +| Time (UTC) | Event | Confidence | +|---|---|---| +| ~11:09:51 | `squidRouterPay` execution A begins. | High, derived from the 11:19:51 timeout. | +| 11:10-11:11 | Axelar bridge is detected; Base gas funding transaction is sent and recorded. | Confirmed by operational logs. | +| 11:19:51 | Execution A reaches the processor's 10-minute timeout. Retry 1 is scheduled. | Confirmed by failure log. | +| ~11:20:21 | `squidRouterPay` execution B begins after the 30-second delay. | High, derived from timeout cadence. | +| ~11:29-11:30 | Recovery sees the unrenewed lock as expired and starts another execution. | Confirmed by operational logs; exact second unavailable. | +| 11:30:21 | Execution B reaches its 10-minute processor timeout. | Confirmed by failure log. | +| ~11:37 | Bridge settlement becomes visible. Multiple abandoned/live checks complete close together. | Confirmed by repeated success logs. | +| ~11:37 | Multiple `finalSettlementSubsidy` executions observe the same shortfall and submit at least two funding swaps. | Confirmed by distinct transaction hashes. | +| ~11:37 | One execution completes `destinationTransfer` and writes `complete`. | Confirmed by operational logs. | +| ~11:37 | A stale execution writes `finalSettlementSubsidy -> destinationTransfer` after completion. | Confirmed by log ordering. | +| ~11:37:54 | Regressed `destinationTransfer` starts waiting for the already-spent ephemeral balance. | High, derived from first 180-second timeout. | +| 11:40:54 | First regressed destination attempt times out. | Confirmed by failure log. | +| 12:08:58 | Initial nine-attempt destination budget is exhausted. Ramp remains nonterminal. | Confirmed by cadence and processor configuration. | +| 12:23:00 onward | Recovery repeatedly grants fresh nine-attempt budgets. | Confirmed by complete timestamp series. | +| 18:06:05 | Last supplied failure entry; the loop was still active. | Confirmed by failure log. | + +## Root-cause analysis + +### 1. Processor timeout did not cancel these production handlers + +`PhaseProcessor` races `handler.execute()` against a timeout and sends an +`AbortSignal` when the timeout wins. Shared EVM balance helpers support cancellation, +but cancellation works only if each handler accepts the signal and forwards it. + +The affected handlers do not propagate it: + +- `SquidRouterPayPhaseHandler.executePhase()` does not accept or forward the signal. +- Its destination balance check calls `checkEvmBalanceForToken()` without `signal`. +- Its initial delay, bridge-status loop, and polling delays are not abortable. +- `FinalSettlementSubsidyHandler.executePhase()` does not forward the signal to any of + its balance waits. +- `DestinationTransferHandler.executePhase()` does not forward the signal to its + balance wait. + +The existing cancellation regression test uses a synthetic handler that explicitly +passes its signal into `waitUntilTrue()`. It proves the processor emits a signal, but it +does not prove that real registered handlers stop after timeout. + +Result: the 10-minute timeout scheduled a retry while the timed-out execution remained +alive and capable of late phase transitions and financial side effects. + +### 2. The fixed lock lease expired during active work + +The processor writes `processingLock.lockedAt` once when processing starts. The lock is +considered expired after 15 minutes, but there is no heartbeat or renewal while: + +- a handler polls; +- the processor waits 30 seconds before a retry; +- recursive processing advances through multiple phases; or +- an external network operation takes longer than expected. + +The first `squidRouterPay` timeout occurred after 10 minutes. Its retry then consumed +another 10-minute window while retaining the original lock timestamp. The lock therefore +became eligible for takeover five minutes into the retry even though processing was +active. + +Result: the recovery worker legitimately followed the current lock rules but incorrectly +classified a live processor as crashed. + +### 3. Lock acquisition and release were not ownership-safe + +The database lock is a JSON flag checked on a previously loaded model instance and then +set using a separate unconditional update. This is not an atomic compare-and-swap. +Multiple API instances can observe an unlocked row and both set it to locked. + +The lock has no owner or fencing token. Release is also unconditional, so an old +execution can clear a lock acquired by a newer execution. + +The in-memory `lockedRamps` set protects only one Node.js process and cannot coordinate +multiple instances or an execution that has been removed from the set after its +processor timeout. + +Result: the lock cannot establish durable single ownership. + +### 4. Phase persistence allowed stale and terminal-state regression + +After a handler returns, the processor unconditionally updates `currentPhase` and +`phaseHistory`. The update does not require that: + +- the database remains in the handler's source phase; +- the ramp remains nonterminal; +- the caller still owns the lock; or +- the caller's lease generation is current. + +An execution that started in `finalSettlementSubsidy` can therefore return later and +write `destinationTransfer` even after another execution has written `complete`. + +Result: terminal state was not monotonic. `complete` was regressed to a nonterminal +phase. + +### 5. Whole-JSON metadata writes allowed lost updates + +Affected handlers write metadata using a stale in-memory spread: + +```ts +state: { + ...state.state, + someTransactionHash: txHash +} +``` + +Two concurrent model instances can each contain a different old snapshot. The later +write replaces the entire JSONB value and can remove hashes written by the earlier +execution. In this incident, a stale final-subsidy write could remove +`destinationTransferTxHash` after the successful destination broadcast. + +Even if the database retained the hash, the stale `RampState` passed recursively into +the regressed destination handler might not contain it. Either condition bypasses the +handler's receipt-based completion check. + +Result: recovery could fail to recognize an already-completed destination transaction. + +### 6. Final-settlement financial operations were not durably idempotent + +The final-settlement handler performs two potentially separate financial operations: + +1. Swap funding-account native token into the required output token when necessary. +2. Transfer the output token subsidy to the ephemeral account. + +The funding-swap hash is held only in a local variable while waiting for the receipt. It +is not persisted as an operation intent or recoverable transaction. Two concurrent +executions can both observe the same funding-account shortfall and submit independent +swaps before either balance update is visible. + +The later subsidy-transfer hash is persisted only after receipt confirmation and after +bookkeeping. A process crash or lost lease between broadcast and persistence leaves the +next execution unable to distinguish "not sent" from "sent but not recorded." + +Result: overlapping execution produced at least two funding swaps and exposed the +subsidy transfer to duplicate-submission risk. + +### 7. Retry exhaustion was not durable + +The retry counter is an in-memory `Map`. After the initial attempt plus eight retries, +the processor logs exhaustion, deletes the counter, and returns without moving the ramp +to a terminal or operator-intervention state. + +`processRamp()` catches or absorbs phase failures, so the recovery worker can log +`Successfully processed ramp state` even when the ramp remains stuck in the same phase. +Once `updatedAt` becomes older than ten minutes, recovery invokes `processRamp()` again +and receives a fresh retry budget. + +Result: the failed phase entered a predictable infinite soft loop. + +## Contributing factors + +- `squidRouterPay` intentionally allows a 15-minute destination balance wait, longer + than the processor's 10-minute handler timeout. +- The lock expiry is measured from the start of the entire processing call, not from + recent processor activity. +- `Promise.any()` does not cancel the losing bridge or balance check. +- Recovery cron executions are not explicitly configured with a durable per-ramp + backoff or manual-review cutoff. +- Logging does not include a processor execution ID, lock owner, lease generation, or + attempt-cycle ID, making overlapping workers difficult to distinguish. +- `Successfully processed ramp state` describes function return, not successful phase + advancement or terminal completion. +- The destination handler requires the full expected balance before checking/broadcasting + unless a usable stored hash is present. Once a successful transfer empties the account + and its hash is missing, the precondition can never recover naturally. + +## Why the destination phase could never recover + +The successful execution transferred the expected destination tokens from the ephemeral +account to the user. The stale execution then re-entered `destinationTransfer` with no +usable successful transaction hash and called the balance precondition first. + +Its required condition was effectively: + +```text +ephemeral destination-token balance >= full quoted output amount +``` + +After a successful destination transfer, the expected steady state is the opposite: + +```text +ephemeral destination-token balance ~= 0 +user destination-token balance increased +``` + +No amount of retrying can make the original precondition true unless the ephemeral is +funded again. Automatically funding it again would be unsafe because the user may +already have received the intended payment. + +## Immediate operational response + +Before manually changing this ramp or replaying any transaction: + +1. Stop automated recovery for this ramp, or place it in an operator-review state that + the recovery query excludes. +2. Derive the deterministic hash of the presigned destination transaction and check its + receipt on the destination chain. +3. Confirm the user destination balance and transfer event. +4. Inspect both final-settlement swap hashes and the subsidy-transfer hash + `0xac2c...704bd`. +5. Reconcile the funding account, ephemeral account, and `subsidies` records. +6. If the destination transaction succeeded, restore the ramp to `complete` without + rebroadcasting or re-funding. +7. Record any duplicate swap fees, slippage, or subsidy transfer as incident loss. + +Do not solve this instance by topping up the ephemeral account until the existing signed +destination transaction and recipient balance have been reconciled. A top-up could make +the stale handler pay the user a second time. + +## Recommended improvements + +### Priority 0: prevent stale phase writes + +Make every phase transition a conditional database update. At minimum, it must require +the expected source phase and reject terminal-state regression: + +```sql +UPDATE ramp_states +SET current_phase = :next_phase, + phase_history = :next_history +WHERE id = :ramp_id + AND current_phase = :expected_phase + AND current_phase NOT IN ('complete', 'failed'); +``` + +With lease ownership, it must additionally require the caller's fencing token. Exactly +one row must be affected. If zero rows are affected, the execution is stale and must stop +without invoking another handler. + +This is the strongest immediate containment because it protects terminal state even if +cancellation or locking fails elsewhere. + +### Priority 0: implement fenced leases + +Replace the Boolean JSON lock with ownership-aware lease fields, for example: + +```text +processing_owner_id +processing_generation +processing_lease_expires_at +``` + +Required properties: + +- Acquisition is one atomic conditional update. +- Every acquisition receives a new, monotonically increasing generation or unique + fencing token. +- Active processors renew the lease before expiry. +- Phase and metadata writes require the current owner/generation. +- Release updates only the row owned by that execution. +- An old execution cannot release or write through a newer owner's lease. + +A long-running database transaction or row lock should not be held across external API +and chain waits. A short atomic lease with heartbeat and fencing is better suited to this +workflow. + +### Priority 0: complete cancellation propagation + +Every long-running production handler must accept the processor's `AbortSignal` and pass +it through all waits: + +- `checkEvmBalanceForToken({ signal })` +- bridge status polling +- initial and retry delays +- receipt waits where the client supports cancellation or bounded polling +- other shared `waitUntilTrue*` helpers + +For `squidRouterPay`, create a child abort controller for the balance and bridge checks. +When either branch establishes settlement, abort the other branch before returning. + +Cancellation is cooperative and should not be the only correctness boundary. Fenced +database writes are still required because an RPC call or external library may not stop +immediately. + +### Priority 1: align timeout and lease semantics + +- A phase's internal maximum wait must not exceed the processor timeout unless the + processor timeout is renewed or disabled for that handler. +- Lock lease expiry must be based on missed heartbeats, not total ramp duration. +- Emit explicit metrics when a handler timeout, lease expiry, or takeover occurs. +- A takeover should include the previous owner and generation in logs. + +### Priority 1: make transaction side effects durably idempotent + +For each backend-funded transaction: + +1. Persist an operation record or reserved nonce before broadcast. +2. Broadcast the transaction. +3. Persist the transaction hash immediately after the node accepts it, before waiting + for a receipt. +4. On recovery, reconcile the existing hash/nonce before submitting another transaction. +5. Mark success only after receipt verification. + +Apply this to both the final-settlement funding swap and subsidy transfer. A single +`finalSettlementSubsidyTxHash` is insufficient to represent both operations. + +Where the transaction is presigned, derive its deterministic transaction hash directly +from the serialized signed transaction. The destination handler can check that receipt +without relying exclusively on mutable state metadata. + +### Priority 1: prevent lost JSON metadata updates + +Do not replace the whole `state` JSONB document from stale model snapshots. Use one of: + +- conditional `jsonb_set` updates for individual fields; +- a normalized transaction-operation table; +- row-version optimistic concurrency; or +- reload, merge, and compare-and-swap under the current lease token. + +Transaction hashes should be monotonic: once a valid hash is written, unrelated updates +must not remove it. + +### Priority 1: persist retry state and recovery eligibility + +Move retry policy out of the process-local `Map`. Persist at least: + +```text +phase_attempt_count +next_retry_at +last_phase_error_at +recovery_status +``` + +After the durable maximum is reached, transition to an explicit state such as +`manualReview` or mark the ramp as recovery-ineligible. Do not automatically grant a new +budget merely because another cron cycle begins. + +The recovery worker should report outcomes accurately: + +- `completed` +- `advanced` +- `retry_scheduled` +- `manual_review_required` +- `skipped_lock_held` +- `failed` + +It should not log `Successfully processed` when the phase remained unchanged after +retry exhaustion. + +### Priority 2: strengthen subsidy bookkeeping + +- Add a database uniqueness constraint appropriate to the intended accounting model, + likely `(ramp_id, phase, operation_type)` rather than only `(ramp_id, phase)` if a phase + can legitimately contain multiple operations. +- Replace `findOne()` followed by `create()` with an atomic insert/upsert. +- Treat bookkeeping as evidence of a reconciled transaction, not as the idempotency + mechanism for the transaction itself. + +### Priority 2: improve observability + +Add structured fields to all processor and handler logs: + +```text +rampId +phase +processorExecutionId +attempt +retryCycle +lockOwner +lockGeneration +expectedPhase +persistedPhase +transactionHash +operationType +``` + +Add alerts for: + +- a transition from `complete` or `failed` to a nonterminal phase; +- more than one active execution ID for the same ramp; +- lease takeover while the previous owner is still producing logs; +- more than one funding transaction for the same ramp/phase/operation; +- repeated retry-budget exhaustion; +- a ramp receiving more than a configured number of errors per hour. + +## Verification plan + +The fixes should include regression tests that reproduce the incident rather than only +testing isolated helpers. + +### Processor concurrency tests + +- Start two processors against the same database row and assert only one atomic lease + acquisition succeeds. +- Let owner A's lease expire, let owner B acquire a new generation, then assert A cannot + transition a phase, update metadata, or release B's lease. +- Complete a ramp in owner B and assert a late return from owner A cannot regress + `complete`. +- Run the same test using separate `PhaseProcessor` instances to model separate API + processes; an in-memory set is insufficient for this test. + +### Cancellation tests + +- Execute the real `SquidRouterPayPhaseHandler` with controlled bridge and balance + adapters, trigger processor timeout, and assert all polling stops. +- Assert the losing branch of the settlement race is cancelled after the other succeeds. +- Repeat for real final-settlement and destination balance polling. + +### Financial idempotency tests + +- Crash or abort after funding-swap broadcast but before receipt persistence; recovery + must reconcile rather than broadcast a second swap. +- Crash after subsidy-transfer broadcast but before phase advancement; recovery must + recognize the existing transaction. +- Run two concurrent final-settlement handlers and assert only one operation intent and + one on-chain submission are produced. + +### Recovery tests + +- Exhaust the durable retry budget and assert subsequent recovery cron runs do not reset + it. +- Assert the worker does not report success when no phase progress occurred. +- Given a completed deterministic presigned destination transaction but missing metadata, + assert recovery derives its hash, finds the receipt, and restores `complete` without + requiring the spent ephemeral balance to return. + +## Conclusion + +The incident required several protections to fail together: + +1. `squidRouterPay` exceeded the processor timeout. +2. Timeout cancellation was not propagated into the real polling handler. +3. The unrenewed 15-minute lock expired while legitimate retry work was active. +4. Recovery started another execution without ownership fencing. +5. Multiple stale executions advanced concurrently when bridge settlement arrived. +6. Final-settlement operations were not durably idempotent and at least two funding + swaps were submitted. +7. A stale phase transition was allowed to overwrite terminal `complete`. +8. The destination transaction could not be recognized reliably after its funds had + left the ephemeral account. +9. Process-local retry exhaustion was reset by each recovery cycle. + +The immediate symptom was an endless `destinationTransfer` balance timeout, but changing +that timeout or increasing retries would not address the failure. The primary correctness +boundary must be an atomic, ownership-fenced phase transition that makes terminal states +monotonic. Cooperative cancellation, lease renewal, durable transaction idempotency, +metadata-safe updates, and persistent retry state are the supporting controls required +to prevent recurrence and limit impact when an external bridge is slow. + +## Implemented targeted mitigation + +The initial low-impact production mitigation implements the timing controls identified +in this report without replacing the existing lock model: + +- `PhaseProcessor` refreshes `processingLock.lockedAt` before every phase attempt, + including retries and recursive phase advancement. +- The existing processor timeout remains the outer 10-minute safety boundary and is now + read through shared phase-processor timeout configuration. +- `squidRouterPay` bounds both its destination-balance check and bridge-status loop at + 80% of the processor timeout. +- If neither polling branch detects settlement, both reject and `checkStatus` raises a + recoverable phase error before the processor's outer timeout. + +Under normally bounded external requests, these changes prevent the incident's +lock-expiry-during-retry path and bound unsuccessful `squidRouterPay` polling before the +processor timeout. + +### Timing measurements and resulting envelope + +| Measurement | Incident / default value | Source | +|---|---:|---| +| Processor phase timeout | 10 minutes (`600,000ms`) | `PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS` default | +| SquidRouter polling timeout after mitigation | 8 minutes (`480,000ms`) | 80% of the processor timeout | +| Database lock expiry | 15 minutes | `PhaseProcessor.isLockExpired()` | +| Default retry delay | 30 seconds | `PHASE_PROCESSOR_RETRY_DELAY_MS` default | +| Incident interval between SquidRouter timeouts | 10 minutes 30 seconds | `11:19:51.503Z` to `11:30:21.528Z` | +| Destination retry-attempt interval | approximately 3 minutes 30 seconds | 180-second balance timeout plus 30-second retry delay | +| Repeated recovery-cycle interval | approximately 45 minutes | Supplied failure-log timestamp series | + +The eight-minute value is derived from the same configuration read as the processor +timeout, so test or deployment overrides preserve the 80% relationship. The focused +configuration regression test verifies that a `1,000ms` processor timeout produces an +`800ms` SquidRouter timeout. + +The bridge timer starts before the existing 60-second initial delay. Timeout detection +occurs at a bridge-loop boundary, so the practical bridge rejection can be later than +exactly eight minutes by up to the 10-second polling interval plus the duration of an +in-flight SquidRouter or Axelar status request. The approximately two-minute margin to +the processor timeout is intended to absorb that normal overrun. + +The destination-balance branch uses the same eight-minute timeout. `Promise.any()` only +rejects after both bridge and balance checks reject, at which point `checkStatus` raises +a `RecoverablePhaseError`. Under normally bounded external requests, the handler exits +before the processor's 10-minute outer timeout and the processor starts its retry after +30 seconds. + +At the start of that retry, `processPhase()` refreshes `lockedAt`. The expected lock-age +sequence is therefore: + +```text +00:00 lock refreshed; SquidRouter attempt starts +~08:00 both settlement checks time out recoverably +~08:30 lock refreshed; retry starts +``` + +This remains well below the 15-minute lock expiry and prevents the recovery worker from +creating the second tracked processor through the exact timing path observed in this +incident. + +### Expectations of this mitigation + +- A normally responsive but unsettled SquidRouter/Axelar operation exits recoverably at + approximately eight minutes instead of reaching the processor's 10-minute timeout. +- Every phase attempt and retry refreshes the lock before handler execution. +- A tracked retry should not be mistaken for abandoned work merely because the original + lock timestamp is older than 15 minutes. +- The incident path where recovery took over during the original processor's retry + should no longer produce two tracked `processRamp()` chains. +- The outer processor timeout remains unchanged as a fallback for unexpectedly blocked + code outside the normal polling cadence. + +### Limitations and residual risks + +- This implementation deliberately does not use `AbortSignal` in + `SquidRouterPayPhaseHandler`. A hung external status request cannot be interrupted by + the elapsed-time check. If it remains blocked through the outer 10-minute timeout, the + processor can still abandon that handler invocation. +- `Promise.any()` does not cancel its losing branch. If balance settlement succeeds + before bridge-status polling finishes, the bridge branch can continue until it + succeeds, fails, or reaches its eight-minute deadline while the tracked processor + advances into later phases. The mitigation bounds that branch but does not stop it + immediately. +- Because the bridge branch contains Axelar gas funding, a losing branch can still make + that side effect before its deadline. Existing transaction-hash checks reduce repeat + funding, but this change is not a general financial-idempotency guarantee. +- Timeout checks run between polling iterations. They do not interrupt an in-flight RPC, + HTTP request, transaction submission, or receipt wait. +- Database lock acquisition remains non-atomic (F-003). Two API instances that begin + from an unlocked row at the same time can still create two tracked processors. +- The lock has no owner token. Refresh and release do not prove that the caller owns the + current lock. If an orphan and a replacement processor already coexist, the orphan's + unconditional refresh can extend or overwrite the replacement owner's lease timestamp; + lock refresh must become owner-conditional when fencing is implemented. +- Phase transitions are still not compare-and-swap updates. If concurrent tracked + processors arise through another path, a stale transition can still overwrite a newer + phase, including a terminal phase. +- Recoverable retry counts remain process-local (F-004). Recovery can grant a new retry + budget after one processing cycle exhausts its retries. `squidRouterPay` now reaches + this existing soft-livelock path itself: nine eight-minute attempts plus eight + 30-second delays can consume approximately 76 minutes, after which stale recovery can + grant another complete budget if the bridge never settles. +- Whole-JSON metadata writes and final-settlement transaction idempotency are unchanged. + +Ownership-fenced locking, conditional phase persistence, durable retry state, and +transaction-operation reconciliation therefore remain recommended follow-up work. This +mitigation is intentionally scoped to the observed lock-expiry-during-retry path and to +ensuring normal SquidRouter polling rejects before the outer processor timeout. diff --git a/docs/security-spec/03-ramp-engine/state-machine.md b/docs/security-spec/03-ramp-engine/state-machine.md index 4687298bd..746cd05d8 100644 --- a/docs/security-spec/03-ramp-engine/state-machine.md +++ b/docs/security-spec/03-ramp-engine/state-machine.md @@ -20,7 +20,7 @@ The processor uses a dual-lock approach: - **In-memory lock**: `lockedRamps` Set — prevents the same Node.js process from double-processing - **Database lock**: `processingLock` JSON field on `RampState` — persists lock state across restarts and (in theory) across multiple API instances -Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's considered stale and can be force-released. +Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's considered stale and can be force-released. The processor refreshes `lockedAt` before every phase attempt, including retries and recursive advancement, so active work does not expire merely because the complete ramp chain runs longer than 15 minutes. ## Security Invariants @@ -35,6 +35,7 @@ Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's consi 9. **Error logs MUST be appended, never overwritten** — Each error is pushed to the `errorLogs` array with timestamp, phase, recoverability flag, and stack trace. 10. **Phase handlers MUST NOT directly mutate the database** — Only the processor should call `state.update()` for phase transitions. Handlers return a pending state object. 11. **A user MUST have at most one nonterminal ramp** — Registration locks the authenticated user's `profiles` row and checks `ramp_states` inside the same transaction. A second ramp is rejected with `409` until the existing ramp reaches `complete`, `failed`, or `timedOut`. An `initial` ramp older than the 15-minute start window is changed to `timedOut` before this check so an abandoned registration cannot block the user indefinitely. +12. **`squidRouterPay` polling MUST finish before the processor timeout** — Both the bridge-status loop and destination-balance check use a timeout equal to 80% of `PHASE_PROCESSOR_MAX_EXECUTION_TIME_MS`. If neither detects settlement in time, `Promise.any()` receives two rejected checks and the handler raises a recoverable phase error before the processor's outer timeout. ## Threat Vectors & Mitigations @@ -63,5 +64,8 @@ Lock expiry is set to 15 minutes. If a lock is older than 15 minutes, it's consi - [x] No phase handler directly calls `RampState.update()` for `currentPhase` — only the processor does this - [x] The `lockedRamps` Set is cleaned up in the `finally` block (`this.lockedRamps.delete(state.id)`) - [x] Lock expiry handles edge cases: missing timestamp → expired, invalid date → expired, NaN → expired +- [x] The database lock timestamp is refreshed before every phase attempt, so a 10-minute attempt plus retry cannot age a live lock past the 15-minute expiry +- [x] `squidRouterPay` bounds both bridge-status and destination-balance polling at 80% of the processor timeout +- [ ] Lock refresh/release are not owner-fenced; a surviving stale processor can still overwrite a replacement owner's lock timestamp (F-003 follow-up) - [x] Phase processor is a singleton — `PhaseProcessor.getInstance()` pattern, default export is singleton instance, no production file creates `new PhaseProcessor()` (tests instantiate the class directly) - [EXISTING FINDING] **F-056**: `sandboxEnabled` causes `initial-phase-handler` to skip the entire state machine (transitions directly `initial` → `complete` after a 10-second sleep) — no production guard prevents this. diff --git a/docs/security-spec/05-integrations/alfredpay.md b/docs/security-spec/05-integrations/alfredpay.md index 4e328053e..811372cff 100644 --- a/docs/security-spec/05-integrations/alfredpay.md +++ b/docs/security-spec/05-integrations/alfredpay.md @@ -9,7 +9,11 @@ Alfredpay is a fiat payment provider supporting on-ramp and off-ramp operations **Chains involved:** Polygon (Alfredpay-side, USDT / `ALFREDPAY_EVM_TOKEN`), 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`. -**Verification collection:** MX and CO individual KYC and company KYB are submitted through the authenticated API flow. Company KYB requires tax ID, incorporation, and address documents plus the authorized representative's ID front and back. US individual and company verification use Alfredpay's hosted redirect flow. AR supports individual KYC only; the shared client state machine rejects `country = AR` with `business = true` before making any provider request and does not allow an AR individual flow to toggle to business. +**Verification collection:** MX and CO individual KYC and company KYB are submitted through the authenticated API flow. Company KYB requires tax ID, incorporation, and address documents plus the authorized representative's ID front and back. Company documents are keyed by `submissionId`; the representative's documents are keyed by an Alfredpay-generated `idRelatedPerson`, which only exists once the company record is created — the client therefore fetches it back via `GET /findKybCustomerAndBusiness` (`getKybBusinessDetails`) between the two upload steps. That endpoint returns *every* business the customer has, so the response carries each business's `submissionId` and the client selects the related persons of the submission it is filing. US individual and company verification use Alfredpay's hosted redirect flow. AR supports individual KYC only; the shared client state machine rejects `country = AR` with `business = true` before making any provider request and does not allow an AR individual flow to toggle to business. + +**KYB requirement set (provider-defined, per country):** Alfredpay self-describes what a KYB submission must carry at `GET …/penny/kybRequirements?country=` (`MEX` and `MX` both resolve; every corridor answers). It is the source of truth: `sendKybSubmission` rejects a submission missing any required field with `110002 "Invalid field(s)"` naming them. Beyond the company/representative identity fields, it requires a compliance questionnaire (`walletAddresses`, `sourceOfFunds`, `transmitsCustomerFunds`, `operatesInSanctionedCountries`, `isRegulatedBusiness`, `businessActivities`, `accountPurpose`, `expectedMonthlyVolumeUsd`, `expectedMonthlyTransactions`) sent flat alongside the company fields and stored by Alfredpay nested under `questionnaire`, plus a fourth company document, `shareholderRegistry`. Two branches are conditional: `transmitsCustomerFunds = true` additionally requires `conductsComplianceScreening` (and `complianceScreeningDescription` when that is true), and `isRegulatedBusiness = true` additionally requires the `businessLicense` and `uploadAmlPolicy` documents. `pep` on the representative is required for CO/US/AR but not MX — the only field that differs between corridors, so the form always asks it. + +**Where the KYB set is collected:** the company/representative details and the questionnaire are two screens (`FillingKybForm` → `FillingKybQuestionnaire`), merged into one payload by the shared machine's `submitKybInfo` actor; the upload step then collects the four company documents and the representative's pair, revealing the business licence and AML policy when `isRegulatedBusiness` was answered yes. `submitKybInformation` validates the questionnaire and its conditionals server-side (`validateKybSubmission`), so a client that omits them is rejected with 400 rather than reaching Alfredpay. The live KYB flow contract test (`ALFREDPAY_CONTRACT_RUN_KYB_FLOW=1`) exercises the complete set against the sandbox. **Stuck-submission recovery (PENDING):** Alfredpay reports a created-but-never-finalized (or invalid-data) submission as `PENDING`. It is not a rejection: status sync maps it to the canonical `pending` state (resumable in the dashboard), and `submitKybInformation` probes the last submission first — when it is `PENDING`/`CREATED`, the controller updates it in place via `PUT …/customers/kyb` (`updateKybInformation`) and returns the existing `submissionId` instead of POSTing a new submission, which Alfredpay refuses while one is pending (error `111405 "Customer KYB already exists"`; the controller also recovers from that POST error, but rechecks that Alfredpay still reports `PENDING`/`CREATED` before updating). Submission-id resolution (`resolveAlfredpayKybSubmissionId`) reconciles the persisted `kyc_cases.providerCaseId` with IDs from the last-submission endpoint and KYB details: a persisted ID is preferred only when Alfredpay still returns it, otherwise the first provider ID wins; the persisted ID is used alone whenever discovery yields no IDs (calls failing or answering empty — an empty last-submission response is not authoritative in sandbox). The latest observed Alfredpay `submissionId` is persisted by the submit, status, redirect-link, and retry paths so recovery survives the wizard closing. @@ -63,6 +67,9 @@ For routed Alfredpay onramps (any non-passthrough output), the final quote outpu 18. **`alfredpayOfframpTransfer` MUST verify the ephemeral's token balance before the first broadcast of the presigned transfer** — The presigned final transfer is single-use (its nonce is consumed even on revert), so the handler calls `ensurePresignedTransferFunded` before `sendRawTransaction`: sender/token/amount are decoded from the signed raw tx and the Polygon ephemeral balance is polled (3-minute timeout); a shortfall raises a recoverable error instead of burning the nonce. This complements invariant 14 (`finalSettlementSubsidy` ordering) by also catching capped/failed subsidies. See `03-ramp-engine/ramp-phase-flows.md` invariant 12. 19. **Argentina business onboarding MUST fail before provider access** — Alfredpay does not support AR company KYB. Client hosts using the shared Alfredpay machine MUST pass the account type in machine input; `AR + business` transitions directly to local failure without status, customer creation, or redirect requests. Account selectors MUST not offer the AR business combination. 20. **A provider `PENDING` submission MUST map to canonical `pending`, never to a decided state** — `PENDING` means the submission was never finalized or its data was invalid; treating it as `in_review`/`approved` would let un-reviewed due diligence advance, and treating it as `rejected` would dead-end a recoverable flow. Re-submission against a `PENDING`/`CREATED` submission MUST update it in place (`updateKybInformation`) rather than create a new one. +21. **KYB representative documents MUST be uploaded against the related person of the submission being filed** — `GET …/customers/{customerId}/kyb/details` returns every business the customer has, and a customer that retried after a failed attempt carries several. `findKybCustomerAndBusiness` MUST therefore return each business's `submissionId` alongside its related persons, and the client MUST select only the related persons whose business matches the `submissionId` it is filing, failing the flow when none matches rather than falling back to an arbitrary entry. Uploading the representative's identity documents against a stale business's `idRelatedPerson` attaches due-diligence evidence to the wrong submission — leaving the filed submission without a verified representative — and Alfredpay rejects the upload, dead-ending an otherwise recoverable flow. + +22. **Uploaded filenames MUST be sanitized to ASCII before reaching Alfredpay** — `AlfredpayApiService` rewrites the multipart filename of every KYC/KYB upload to `[A-Za-z0-9._-]` (accents transliterated, everything else replaced) rather than forwarding the name the user's file happened to carry. Alfredpay's relate-person endpoint answers a non-ASCII filename with a bare 502 `111301 UNKNOWN_ERROR` that names no field, which stranded MX company onboardings at the representative's ID upload. The trigger is invisible: macOS separates the time from AM/PM with U+202F, so `Screenshot 2026-07-09 at 12.23.56 PM.png` is rejected while the same name retyped with an ordinary space is accepted, and accented filenames fail for the same reason — the provider stores every upload under a generated `{uuid}.{ext}`, so the submitted name is discarded on arrival and nothing is lost by rewriting it. This also keeps user-controlled text out of a downstream `Content-Disposition` header. The sanitizer MUST copy the bytes into a new `File`: under Bun, `new File([file], name)`, `new Blob([file])` and `FormData.append(field, file, name)` all alias or ignore their way back to the original name, so the guarantee is asserted on the value that reaches the wire (`alfredpayApiService.test.ts`), not on the helper alone. ## Threat Vectors & Mitigations diff --git a/packages/kyc/src/alfredpay/machine.test.ts b/packages/kyc/src/alfredpay/machine.test.ts index 92490649c..2f7ca2698 100644 --- a/packages/kyc/src/alfredpay/machine.test.ts +++ b/packages/kyc/src/alfredpay/machine.test.ts @@ -18,6 +18,7 @@ import { AlfredpayKycMachineErrorType, type KybBusinessFiles, type KybFormData, + type KybQuestionnaireData, type MxnKycFiles } from "./types"; @@ -55,11 +56,13 @@ const kycLink: AlfredpayGetKycRedirectLinkResponse = { const mxnFormData = { firstName: "Frida" } as unknown as AlfredpayKycFormData; const mxnFiles = { back: {} as File, front: {} as File } as MxnKycFiles; const kybFormData = { businessName: "ACME" } as unknown as KybFormData; +const kybQuestionnaireData = { sourceOfFunds: "Sale of goods" } as unknown as KybQuestionnaireData; const kybBusinessFiles = { articlesIncorporation: {} as File, docBack: {} as File, docFront: {} as File, proofAddress: {} as File, + shareholderRegistry: {} as File, taxIdDocument: {} as File } as KybBusinessFiles; @@ -355,7 +358,10 @@ describe("alfredpayKycMachine", () => { { checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)), findKybCustomerAndBusiness: fromPromise( - async () => [{ relatedPersons: [{ idRelatedPerson: "rp-1" }] }] as unknown as AlfredpayKybCustomerAndBusiness[] + async () => + [ + { relatedPersons: [{ idRelatedPerson: "rp-1" }], submissionId: "kyb-sub-1" } + ] as unknown as AlfredpayKybCustomerAndBusiness[] ), pollStatus: fromPromise(() => new Promise(() => {})), sendKybSubmissionActor: fromPromise(async () => undefined), @@ -370,9 +376,13 @@ describe("alfredpayKycMachine", () => { await waitFor(actor, s => s.matches("FillingKybForm")); actor.send({ data: kybFormData, type: "SUBMIT_KYB_FORM" }); - expect(actor.getSnapshot().value).toBe("SubmittingKybInfo"); + expect(actor.getSnapshot().value).toBe("FillingKybQuestionnaire"); expect(actor.getSnapshot().context.kybFormData).toEqual(kybFormData); + actor.send({ data: kybQuestionnaireData, type: "SUBMIT_KYB_QUESTIONNAIRE" }); + expect(actor.getSnapshot().value).toBe("SubmittingKybInfo"); + expect(actor.getSnapshot().context.kybQuestionnaireData).toEqual(kybQuestionnaireData); + await waitFor(actor, s => s.matches("UploadingKybBusinessDocs")); expect(actor.getSnapshot().context.submissionId).toBe("kyb-sub-1"); @@ -393,6 +403,7 @@ describe("alfredpayKycMachine", () => { await waitFor(actor, s => s.matches("FillingKybForm")); actor.send({ data: kybFormData, type: "SUBMIT_KYB_FORM" }); + actor.send({ data: kybQuestionnaireData, type: "SUBMIT_KYB_QUESTIONNAIRE" }); await waitFor(actor, s => s.matches("Failure")); expect(actor.getSnapshot().context.error?.message).toContain("did not return a submission ID"); @@ -412,11 +423,75 @@ describe("alfredpayKycMachine", () => { await waitFor(actor, s => s.matches("FillingKybForm")); actor.send({ data: kybFormData, type: "SUBMIT_KYB_FORM" }); + actor.send({ data: kybQuestionnaireData, type: "SUBMIT_KYB_QUESTIONNAIRE" }); await waitFor(actor, s => s.matches("UploadingKybBusinessDocs")); actor.send({ files: kybBusinessFiles, type: "SUBMIT_KYB_BUSINESS_FILES" }); await waitFor(actor, s => s.matches("Failure")); - expect(actor.getSnapshot().context.error?.message).toContain("did not return relatedPersons[].idRelatedPerson"); + expect(actor.getSnapshot().context.error?.message).toContain("no relatedPersons[].idRelatedPerson"); + }); + + it("uses the related person of the submission being filed, not of a stale earlier business", async () => { + let bundledIds: string[] | undefined; + const actor = createTestActor( + { + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)), + // A customer that retried carries several businesses; Alfredpay returns the stale one first. + findKybCustomerAndBusiness: fromPromise( + async () => + [ + { relatedPersons: [{ idRelatedPerson: "rp-stale" }], submissionId: "kyb-sub-stale" }, + { relatedPersons: [{ idRelatedPerson: "rp-current" }], submissionId: "kyb-sub-current" } + ] as unknown as AlfredpayKybCustomerAndBusiness[] + ), + pollStatus: fromPromise(() => new Promise(() => {})), + sendKybSubmissionActor: fromPromise(async () => undefined), + submitKybBusinessFiles: fromPromise(async () => undefined), + submitKybInfo: fromPromise(async () => ({ submissionId: "kyb-sub-current" }) as SubmitKybInformationResponse), + submitKybRelatedPersonBundleFiles: fromPromise(async ({ input }) => { + bundledIds = input.kybRelatedPersonIds; + }) + }, + kybInput + ); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKybForm")); + actor.send({ data: kybFormData, type: "SUBMIT_KYB_FORM" }); + actor.send({ data: kybQuestionnaireData, type: "SUBMIT_KYB_QUESTIONNAIRE" }); + await waitFor(actor, s => s.matches("UploadingKybBusinessDocs")); + actor.send({ files: kybBusinessFiles, type: "SUBMIT_KYB_BUSINESS_FILES" }); + + await waitFor(actor, s => s.matches("PollingStatus")); + expect(actor.getSnapshot().context.kybRelatedPersonIds).toEqual(["rp-current"]); + expect(bundledIds).toEqual(["rp-current"]); + }); + + it("fails when no business matches the submission being filed", async () => { + const actor = createTestActor( + { + checkStatus: fromPromise(async () => statusOf(AlfredPayStatus.Consulted)), + findKybCustomerAndBusiness: fromPromise( + async () => + [ + { relatedPersons: [{ idRelatedPerson: "rp-stale" }], submissionId: "kyb-sub-stale" } + ] as unknown as AlfredpayKybCustomerAndBusiness[] + ), + submitKybBusinessFiles: fromPromise(async () => undefined), + submitKybInfo: fromPromise(async () => ({ submissionId: "kyb-sub-current" }) as SubmitKybInformationResponse) + }, + kybInput + ); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKybForm")); + actor.send({ data: kybFormData, type: "SUBMIT_KYB_FORM" }); + actor.send({ data: kybQuestionnaireData, type: "SUBMIT_KYB_QUESTIONNAIRE" }); + await waitFor(actor, s => s.matches("UploadingKybBusinessDocs")); + actor.send({ files: kybBusinessFiles, type: "SUBMIT_KYB_BUSINESS_FILES" }); + + await waitFor(actor, s => s.matches("Failure")); + expect(actor.getSnapshot().context.error?.message).toContain("no relatedPersons[].idRelatedPerson"); }); it("rejects AR business before making a provider request", async () => { @@ -458,3 +533,176 @@ describe("alfredpayKycMachine", () => { }); }); }); + +/** + * The suite above replaces the KYB actors, so it never checks what they hand the API. These drive the + * real actors against a recording client — the merge of the two form screens and the document set are + * exactly what Alfredpay validates, and getting either wrong only shows up as a 110002 at finalize. + */ +describe("alfredpayKycMachine KYB actors (real, recording API)", () => { + const companyData = { + address: "Av. Reforma 100", + businessName: "ACME", + city: "CDMX", + relatedPersons: [ + { dateOfBirth: "1990-01-01", email: "rep@acme.example", firstName: "Ana", lastName: "Rep", nationalities: ["MX"], pep: false } + ], + state: "CDMX", + taxId: "AAA010101AAA", + website: "https://acme.example", + zipCode: "06600" + } as KybFormData; + + const questionnaireData = { + accountPurpose: "Treasury management", + businessActivities: "Payments software", + expectedMonthlyTransactions: 120, + expectedMonthlyVolumeUsd: 50000, + isRegulatedBusiness: false, + operatesInSanctionedCountries: false, + sourceOfFunds: "Sale of goods/services", + transmitsCustomerFunds: false, + walletAddresses: "N/A" + } as KybQuestionnaireData; + + const file = (name: string) => new File(["x"], name, { type: "image/png" }); + + function recordingApi() { + const calls = { + businessFiles: [] as string[], + personFiles: [] as string[], + sent: [] as string[], + submitted: [] as unknown[] + }; + const api = { + findKybCustomerAndBusiness: async () => [{ relatedPersons: [{ idRelatedPerson: "rp-1" }], submissionId: "kyb-sub-1" }], + getAlfredpayStatus: async () => statusOf(AlfredPayStatus.Consulted), + getKycStatus: async () => new Promise(() => {}), + sendKybSubmission: async (_country: string, submissionId: string) => { + calls.sent.push(submissionId); + }, + submitKybFile: async (_country: string, _submissionId: string, fileType: string) => { + calls.businessFiles.push(fileType); + }, + submitKybInformation: async (_country: string, data: unknown) => { + calls.submitted.push(data); + return { submissionId: "kyb-sub-1" } as SubmitKybInformationResponse; + }, + submitKybRelatedPersonFile: async (_country: string, relatedPersonId: string, fileType: string) => { + calls.personFiles.push(`${relatedPersonId}:${fileType}`); + } + } as unknown as AlfredpayKycApi; + return { api, calls }; + } + + it("merges the company form and the questionnaire into one Alfredpay payload", async () => { + const { api, calls } = recordingApi(); + const machine = createAlfredpayKycMachine({ api, openVerificationUrl: () => {} }); + const actor = createActor(machine, { input: { business: true, country: "MX" } }); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKybForm")); + actor.send({ data: companyData, type: "SUBMIT_KYB_FORM" }); + actor.send({ data: questionnaireData, type: "SUBMIT_KYB_QUESTIONNAIRE" }); + + await waitFor(actor, s => s.matches("UploadingKybBusinessDocs")); + expect(calls.submitted).toEqual([{ ...companyData, ...questionnaireData }]); + }); + + it("uploads all four company documents and the representative pair against the discovered person", async () => { + const { api, calls } = recordingApi(); + const machine = createAlfredpayKycMachine({ api, openVerificationUrl: () => {} }); + const actor = createActor(machine, { input: { business: true, country: "MX" } }); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKybForm")); + actor.send({ data: companyData, type: "SUBMIT_KYB_FORM" }); + actor.send({ data: questionnaireData, type: "SUBMIT_KYB_QUESTIONNAIRE" }); + await waitFor(actor, s => s.matches("UploadingKybBusinessDocs")); + + actor.send({ + files: { + articlesIncorporation: file("a.png"), + docBack: file("b.png"), + docFront: file("f.png"), + proofAddress: file("p.png"), + shareholderRegistry: file("s.png"), + taxIdDocument: file("t.png") + }, + type: "SUBMIT_KYB_BUSINESS_FILES" + }); + + await waitFor(actor, s => s.matches("PollingStatus")); + expect(calls.businessFiles).toEqual(["taxIdDocument", "articlesIncorporation", "proofAddress", "shareholderRegistry"]); + expect(calls.personFiles).toEqual(["rp-1:docFront", "rp-1:docBack"]); + expect(calls.sent).toEqual(["kyb-sub-1"]); + }); + + it("uploads the licence and AML policy for a regulated business", async () => { + const { api, calls } = recordingApi(); + const machine = createAlfredpayKycMachine({ api, openVerificationUrl: () => {} }); + const actor = createActor(machine, { input: { business: true, country: "MX" } }); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKybForm")); + actor.send({ data: companyData, type: "SUBMIT_KYB_FORM" }); + actor.send({ data: { ...questionnaireData, isRegulatedBusiness: true }, type: "SUBMIT_KYB_QUESTIONNAIRE" }); + await waitFor(actor, s => s.matches("UploadingKybBusinessDocs")); + + actor.send({ + files: { + articlesIncorporation: file("a.png"), + businessLicense: file("l.png"), + docBack: file("b.png"), + docFront: file("f.png"), + proofAddress: file("p.png"), + shareholderRegistry: file("s.png"), + taxIdDocument: file("t.png"), + uploadAmlPolicy: file("m.png") + }, + type: "SUBMIT_KYB_BUSINESS_FILES" + }); + + await waitFor(actor, s => s.matches("PollingStatus")); + expect(calls.businessFiles).toEqual([ + "taxIdDocument", + "articlesIncorporation", + "proofAddress", + "shareholderRegistry", + "businessLicense", + "uploadAmlPolicy" + ]); + }); + + it("refuses to upload a regulated business's documents when the licence or AML policy is missing", async () => { + const { api, calls } = recordingApi(); + const machine = createAlfredpayKycMachine({ api, openVerificationUrl: () => {} }); + const actor = createActor(machine, { input: { business: true, country: "MX" } }); + actor.start(); + + await waitFor(actor, s => s.matches("FillingKybForm")); + actor.send({ data: companyData, type: "SUBMIT_KYB_FORM" }); + actor.send({ data: { ...questionnaireData, isRegulatedBusiness: true }, type: "SUBMIT_KYB_QUESTIONNAIRE" }); + await waitFor(actor, s => s.matches("UploadingKybBusinessDocs")); + + actor.send({ + files: { + articlesIncorporation: file("a.png"), + docBack: file("b.png"), + docFront: file("f.png"), + proofAddress: file("p.png"), + shareholderRegistry: file("s.png"), + taxIdDocument: file("t.png") + }, + type: "SUBMIT_KYB_BUSINESS_FILES" + }); + + // Fails before finalizing rather than filing a submission Alfredpay will reject at the last step. + // Unreachable from either UI (both disable submit until the set is complete) — this guards + // direct machine callers. + await waitFor(actor, s => s.matches("Failure")); + expect(actor.getSnapshot().context.error?.message).toContain("regulated business"); + expect(calls.businessFiles).toEqual(["taxIdDocument", "articlesIncorporation", "proofAddress", "shareholderRegistry"]); + expect(calls.sent).toEqual([]); + }); +}); diff --git a/packages/kyc/src/alfredpay/machine.ts b/packages/kyc/src/alfredpay/machine.ts index 6c00f6820..82dff1beb 100644 --- a/packages/kyc/src/alfredpay/machine.ts +++ b/packages/kyc/src/alfredpay/machine.ts @@ -18,6 +18,7 @@ import { type KybBusinessFiles, type KybFormData, type KybPersonFiles, + type KybQuestionnaireData, type MxnKycFiles } from "./types"; @@ -29,13 +30,19 @@ function extractSubmissionId(payload: SubmitKybInformationResponse | SubmitKycIn /** * Extracts relate-person ids from Alfredpay GET …/customers/{customerId}/kyb/details. - * Response shape: `[{ relatedPersons: [{ idRelatedPerson: "..." }] }]`. + * Response shape: `[{ submissionId: "...", relatedPersons: [{ idRelatedPerson: "..." }] }]`. + * + * The endpoint returns every business the customer has, and a customer that retried after a failed + * attempt carries more than one. Only the business matching the submission we are filing owns the + * related persons our document uploads may target — ids from any other business are stale and + * Alfredpay rejects uploads keyed to them. */ -function extractKybRelatedPersonIds(payload: unknown): string[] { +function extractKybRelatedPersonIds(payload: unknown, submissionId: string): string[] { if (!Array.isArray(payload)) return []; const ids: string[] = []; for (const business of payload) { if (business === null || typeof business !== "object") continue; + if ((business as Record).submissionId !== submissionId) continue; const relatedPersons = (business as Record).relatedPersons; if (!Array.isArray(relatedPersons)) continue; for (const person of relatedPersons) { @@ -188,12 +195,26 @@ export function createAlfredpayKycMachine({ api, openVerificationUrl }: Alfredpa files.articlesIncorporation ); await api.submitKybFile(country, submissionId, AlfredpayKybFileType.PROOF_ADDRESS, files.proofAddress); + await api.submitKybFile(country, submissionId, AlfredpayKybFileType.SHAREHOLDER_REGISTRY, files.shareholderRegistry); + // Alfredpay demands these two only for a regulated business. Checked here rather than trusted + // from the upload screen: skipping them silently would surface as a 110002 at finalize, long + // after the files are gone. + if (input.kybQuestionnaireData?.isRegulatedBusiness) { + if (!files.businessLicense || !files.uploadAmlPolicy) { + throw new Error("A regulated business must supply both the business licence and the AML policy"); + } + await api.submitKybFile(country, submissionId, AlfredpayKybFileType.BUSINESS_LICENSE, files.businessLicense); + await api.submitKybFile(country, submissionId, AlfredpayKybFileType.AML_POLICY, files.uploadAmlPolicy); + } }), submitKybInfo: fromPromise(async ({ input }: { input: AlfredpayKycContext }) => { const country = input.country || "MX"; if (!input.kybFormData) throw new Error("KYB form data missing"); - return api.submitKybInformation(country, input.kybFormData); + if (!input.kybQuestionnaireData) throw new Error("KYB questionnaire data missing"); + // Alfredpay takes the questionnaire flat alongside the company fields and nests it under + // `questionnaire` itself; the two screens are merged here rather than on the wire. + return api.submitKybInformation(country, { ...input.kybFormData, ...input.kybQuestionnaireData }); }), submitKybPersonFiles: fromPromise(async ({ input }: { input: AlfredpayKycContext }) => { @@ -295,6 +316,7 @@ export function createAlfredpayKycMachine({ api, openVerificationUrl }: Alfredpa | { type: "SUBMIT_FORM"; data: AlfredpayKycFormData } | { type: "SUBMIT_FILES"; files: MxnKycFiles } | { type: "SUBMIT_KYB_FORM"; data: KybFormData } + | { type: "SUBMIT_KYB_QUESTIONNAIRE"; data: KybQuestionnaireData } | { type: "SUBMIT_KYB_BUSINESS_FILES"; files: KybBusinessFiles } | { type: "SUBMIT_KYB_PERSON_FILES"; files: KybPersonFiles }, input: {} as AlfredpayKycContext, @@ -445,6 +467,17 @@ export function createAlfredpayKycMachine({ api, openVerificationUrl }: Alfredpa on: { SUBMIT_KYB_FORM: { actions: assign({ kybFormData: ({ event }) => event.data }), + target: "FillingKybQuestionnaire" + } + } + }, + FillingKybQuestionnaire: { + on: { + GO_BACK: { + target: "FillingKybForm" + }, + SUBMIT_KYB_QUESTIONNAIRE: { + actions: assign({ kybQuestionnaireData: ({ event }) => event.data }), target: "SubmittingKybInfo" } } @@ -494,16 +527,18 @@ export function createAlfredpayKycMachine({ api, openVerificationUrl }: Alfredpa actions: assign({ error: () => new AlfredpayKycMachineError( - "Alfredpay GET …/customers/{customerId}/kyb/details did not return relatedPersons[].idRelatedPerson.", + "Alfredpay GET …/customers/{customerId}/kyb/details returned no relatedPersons[].idRelatedPerson for the submission being filed.", AlfredpayKycMachineErrorType.UnknownError ) }), - guard: ({ event }) => extractKybRelatedPersonIds(event.output).length === 0, + guard: ({ context, event }) => + !context.submissionId || extractKybRelatedPersonIds(event.output, context.submissionId).length === 0, target: "Failure" }, { actions: assign({ - kybRelatedPersonIds: ({ event }) => extractKybRelatedPersonIds(event.output) + kybRelatedPersonIds: ({ context, event }) => + extractKybRelatedPersonIds(event.output, context.submissionId as string) }), target: "SubmittingKybRelatedPersonBundle" } @@ -842,7 +877,7 @@ export function createAlfredpayKycMachine({ api, openVerificationUrl }: Alfredpa UploadingKybBusinessDocs: { on: { - GO_BACK: { target: "FillingKybForm" }, + GO_BACK: { target: "FillingKybQuestionnaire" }, SUBMIT_KYB_BUSINESS_FILES: { actions: assign({ kybBusinessFiles: ({ event }) => event.files }), target: "SubmittingKybBusinessFiles" diff --git a/packages/kyc/src/alfredpay/schemas.test.ts b/packages/kyc/src/alfredpay/schemas.test.ts index df33a9181..c0af11417 100644 --- a/packages/kyc/src/alfredpay/schemas.test.ts +++ b/packages/kyc/src/alfredpay/schemas.test.ts @@ -4,7 +4,9 @@ import { arKycSchema, colKycSchema, kybFormSchema, + kybQuestionnaireSchema, mapKybFormValues, + mapKybQuestionnaireValues, mxnKycSchema, toArPhoneNumber, toColPhoneNumber @@ -162,6 +164,7 @@ describe("kybFormSchema", () => { repFirstName: "Ada", repLastName: "Lovelace", repNationality: "MX", + repPep: false, state: "CDMX", taxId: "ACM010101ABC", website: "https://acme.example", @@ -191,7 +194,8 @@ describe("kybFormSchema", () => { email: "owner@acme.example", firstName: "Ada", lastName: "Lovelace", - nationalities: ["MX"] + nationalities: ["MX"], + pep: false } ], state: "CDMX", @@ -202,3 +206,64 @@ describe("kybFormSchema", () => { expect(mapKybFormValues({ ...kyb, repDni: "" }).relatedPersons[0]?.dni).toBeUndefined(); }); }); + +describe("kybQuestionnaireSchema", () => { + const questionnaire = { + accountPurpose: "Treasury management", + businessActivities: "Cross-border payments software", + expectedMonthlyTransactions: 120, + expectedMonthlyVolumeUsd: 50000, + isRegulatedBusiness: false, + operatesInSanctionedCountries: false, + sourceOfFunds: "Sale of goods/services", + transmitsCustomerFunds: false, + walletAddresses: "N/A" + }; + + it("accepts the unregulated, non-transmitting path", () => { + expect(kybQuestionnaireSchema.safeParse(questionnaire).success).toBe(true); + }); + + it("rejects a missing or negative expected volume", () => { + expect(kybQuestionnaireSchema.safeParse({ ...questionnaire, expectedMonthlyVolumeUsd: undefined }).success).toBe(false); + expect(kybQuestionnaireSchema.safeParse({ ...questionnaire, expectedMonthlyVolumeUsd: -1 }).success).toBe(false); + expect(kybQuestionnaireSchema.safeParse({ ...questionnaire, expectedMonthlyTransactions: 1.5 }).success).toBe(false); + }); + + it("mirrors Alfredpay's requiredIf: transmitting funds demands the screening answer, and a claim demands a description", () => { + expect(kybQuestionnaireSchema.safeParse({ ...questionnaire, transmitsCustomerFunds: true }).success).toBe(false); + expect( + kybQuestionnaireSchema.safeParse({ + ...questionnaire, + conductsComplianceScreening: false, + transmitsCustomerFunds: true + }).success + ).toBe(true); + expect( + kybQuestionnaireSchema.safeParse({ + ...questionnaire, + conductsComplianceScreening: true, + transmitsCustomerFunds: true + }).success + ).toBe(false); + expect( + kybQuestionnaireSchema.safeParse({ + ...questionnaire, + complianceScreeningDescription: "KYC, KYB and AML screening on every counterparty", + conductsComplianceScreening: true, + transmitsCustomerFunds: true + }).success + ).toBe(true); + }); + + it("drops conditional answers once their trigger is turned back off", () => { + const mapped = mapKybQuestionnaireValues({ + ...questionnaire, + complianceScreeningDescription: "orphaned", + conductsComplianceScreening: true, + transmitsCustomerFunds: false + }); + expect(mapped.conductsComplianceScreening).toBeUndefined(); + expect(mapped.complianceScreeningDescription).toBeUndefined(); + }); +}); diff --git a/packages/kyc/src/alfredpay/schemas.ts b/packages/kyc/src/alfredpay/schemas.ts index 70b3145fe..aebcca193 100644 --- a/packages/kyc/src/alfredpay/schemas.ts +++ b/packages/kyc/src/alfredpay/schemas.ts @@ -1,6 +1,6 @@ -import type { SubmitKybInformationRequest } from "@vortexfi/shared"; import { AlfredpayArgentinaDocumentType, AlfredpayColombiaDocumentType } from "@vortexfi/shared"; import { z } from "zod"; +import type { KybFormData, KybQuestionnaireData } from "./types"; /** Alfredpay rejects identity documents outside these types, and anything over 5 MB. */ export const KYC_FILE_ACCEPTED_TYPES = ["image/jpeg", "image/png", "application/pdf"]; @@ -71,16 +71,60 @@ export const kybFormSchema = z.object({ repFirstName: z.string().min(1), repLastName: z.string().min(1), repNationality: z.string().regex(/^[A-Z]{2}$/, "Enter a 2-letter country code"), + // Alfredpay requires `pep` for CO/US/AR and ignores it for MX (the only per-corridor difference in + // kybRequirements), so it is always asked rather than branched on country. + repPep: z.boolean(), state: z.string().min(1), taxId: z.string().min(1), website: z.string().url("Enter a valid URL"), zipCode: z.string().min(1) }); +/** + * Alfredpay's compliance questionnaire (GET …/penny/kybRequirements?country= is the source of + * truth). The two conditionals mirror the provider's own `requiredIf`: it demands + * `conductsComplianceScreening` once funds are transmitted on a customer's behalf, and a + * description once screening is claimed. Answering `isRegulatedBusiness` yes additionally requires + * the business licence and AML policy documents, which the upload step collects. + */ +export const kybQuestionnaireSchema = z + .object({ + // Trimmed: these are free-text compliance answers Alfredpay stores verbatim, and whitespace + // satisfies a bare min(1) while telling a reviewer nothing. + accountPurpose: z.string().trim().min(1), + businessActivities: z.string().trim().min(1), + complianceScreeningDescription: z.string().trim().optional(), + conductsComplianceScreening: z.boolean().optional(), + expectedMonthlyTransactions: z.number("Enter a number").int("Enter a whole number").min(0), + expectedMonthlyVolumeUsd: z.number("Enter a number").min(0), + isRegulatedBusiness: z.boolean(), + operatesInSanctionedCountries: z.boolean(), + sourceOfFunds: z.string().trim().min(1), + transmitsCustomerFunds: z.boolean(), + walletAddresses: z.string().trim().min(1, "Enter your wallets, or N/A if the business is not on-chain") + }) + .superRefine((values, ctx) => { + if (values.transmitsCustomerFunds && values.conductsComplianceScreening === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Answer this when transmitting customer funds", + path: ["conductsComplianceScreening"] + }); + } + if (values.conductsComplianceScreening && !values.complianceScreeningDescription?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Describe the screening your business conducts", + path: ["complianceScreeningDescription"] + }); + } + }); + export type MxnKycFormValues = z.infer; export type ColKycFormValues = z.infer; export type ArKycFormValues = z.infer; export type KybFormValues = z.infer; +export type KybQuestionnaireValues = z.infer; /** Argentina fields the form pins rather than asks for. */ export const AR_KYC_DEFAULTS: Partial = { @@ -103,7 +147,7 @@ export function toColPhoneNumber(value: string): string { return value ? `+${value.replace(/^\+/, "").replace(/\D/g, "")}` : value; } -export function mapKybFormValues(fields: KybFormValues): Omit { +export function mapKybFormValues(fields: KybFormValues): KybFormData { return { address: fields.address, businessName: fields.businessName, @@ -115,7 +159,8 @@ export function mapKybFormValues(fields: KybFormValues): Omit; -export type KybFormData = Omit; + +/** + * The KYB payload is collected over two screens and merged at submit: the company/representative + * details, then Alfredpay's compliance questionnaire. Keeping them apart is what lets the + * questionnaire fields stay required on the wire type — a company form alone cannot satisfy it. + */ +export type KybFormData = Omit; +export type KybQuestionnaireData = AlfredpayKybQuestionnaire; export interface MxnKycFiles { front: File; @@ -17,8 +24,12 @@ export interface KybBusinessFiles { taxIdDocument: File; articlesIncorporation: File; proofAddress: File; + shareholderRegistry: File; docFront: File; docBack: File; + /** Both required by Alfredpay only when the questionnaire's `isRegulatedBusiness` is true. */ + businessLicense?: File; + uploadAmlPolicy?: File; } export interface KybPersonFiles { @@ -53,6 +64,7 @@ export interface AlfredpayKycContext { mxnFormData?: AlfredpayKycFormData; mxnFiles?: MxnKycFiles; kybFormData?: KybFormData; + kybQuestionnaireData?: KybQuestionnaireData; kybBusinessFiles?: KybBusinessFiles; kybRelatedPersonFiles?: KybPersonFiles[]; kybRelatedPersonIndex?: number; diff --git a/packages/kyc/src/index.ts b/packages/kyc/src/index.ts index b9462b881..5777ceeab 100644 --- a/packages/kyc/src/index.ts +++ b/packages/kyc/src/index.ts @@ -9,12 +9,16 @@ export { KYC_FILE_ACCEPTED_TYPES, KYC_FILE_MAX_BYTES, type KybFormValues, + type KybQuestionnaireValues, kybFormSchema, + kybQuestionnaireSchema, type MxnKycFormValues, mapKybFormValues, + mapKybQuestionnaireValues, mxnKycSchema, toArPhoneNumber, - toColPhoneNumber + toColPhoneNumber, + toKybFormValues } from "./alfredpay/schemas"; export { type AlfredpayKycApiClient, createAlfredpayKycApi } from "./alfredpay/service"; export { @@ -26,6 +30,7 @@ export { type KybBusinessFiles, type KybFormData, type KybPersonFiles, + type KybQuestionnaireData, type MxnKycFiles } from "./alfredpay/types"; export type { AveniaKycApi, AveniaKycDeps, KybLevel1Response } from "./avenia/api"; diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts new file mode 100644 index 000000000..7a94cdc2f --- /dev/null +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +process.env.ALFREDPAY_API_KEY ||= "test-key"; +process.env.ALFREDPAY_API_SECRET ||= "test-secret"; + +const { AlfredpayApiService, toAsciiFileName } = await import("./alfredpayApiService"); +const { AlfredpayKybRelatedPersonFileType, AlfredpayKycFileType, AlfredpayKybFileType } = await import("./types"); + +describe("toAsciiFileName", () => { + test("transliterates accents and keeps the extension", () => { + expect(toAsciiFileName("acentuación.png")).toBe("acentuacion.png"); + expect(toAsciiFileName("ñandú.png")).toBe("nandu.png"); + }); + + /** + * The production failure. macOS separates the time from AM/PM with U+202F (narrow no-break + * space), so every screenshot a user uploads carries a non-ASCII byte in a name that looks + * entirely ASCII — `Screenshot 2026-07-09 at 12.23.56 PM.png` is rejected while the same name + * retyped with an ordinary space is accepted. Escaped on purpose: the literal character is + * invisible in a diff and an editor may silently normalize it away. + */ + test("replaces the U+202F in a macOS screenshot name", () => { + expect(toAsciiFileName("Screenshot 2026-07-09 at 12.23.56\u202fPM.png")).toBe("Screenshot_2026-07-09_at_12.23.56_PM.png"); + }); + + test("leaves names Alfredpay already accepts alone", () => { + expect(toAsciiFileName("plain.png")).toBe("plain.png"); + expect(toAsciiFileName("with space.png")).toBe("with_space.png"); + expect(toAsciiFileName("IMG_1234.png")).toBe("IMG_1234.png"); + expect(toAsciiFileName("scan-2.pdf")).toBe("scan-2.pdf"); + }); + + test("replaces everything else outside [A-Za-z0-9._-]", () => { + expect(toAsciiFileName("emoji😀.png")).toBe("emoji__.png"); + expect(toAsciiFileName("参考.png")).toBe("__.png"); + }); + + test("never yields a name without a single alphanumeric", () => { + expect(toAsciiFileName("😀")).toBe("upload__"); + expect(toAsciiFileName("")).toBe("upload"); + }); +}); + +/** + * The regression these guard: Alfredpay's relate-person upload 500s with + * `{"errorCode":111301,"errorMessage":"UNKNOWN_ERROR"}` when the multipart filename carries a + * non-ASCII byte, so the name that goes on the wire must be sanitized — not just sanitizable. + * Drop the third `append` argument and these fail while `toAsciiFileName`'s own tests still pass. + */ +describe("uploads send an ASCII multipart filename", () => { + let requests: FormData[]; + const realFetch = globalThis.fetch; + const accentedPng = () => new File([new Uint8Array([1])], "Identificación oficial.png", { type: "image/png" }); + + beforeEach(() => { + requests = []; + globalThis.fetch = (async (_url: string, init: RequestInit) => { + requests.push(init.body as FormData); + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + function sentFileName(field: string): string { + return (requests[0]?.get(field) as File).name; + } + + test("relate-person upload — the one Alfredpay rejects", async () => { + await AlfredpayApiService.getInstance().submitKybRelatedPersonFiles( + "cust-1", + "person-1", + AlfredpayKybRelatedPersonFileType.DOC_FRONT, + accentedPng() + ); + expect(sentFileName("rawBody")).toBe("Identificacion_oficial.png"); + }); + + test("KYB company-document upload", async () => { + await AlfredpayApiService.getInstance().submitKybFiles("cust-1", "sub-1", AlfredpayKybFileType.PROOF_ADDRESS, accentedPng()); + expect(sentFileName("rawBody")).toBe("Identificacion_oficial.png"); + }); + + test("KYC document upload", async () => { + await AlfredpayApiService.getInstance().submitKycFile("cust-1", "sub-1", AlfredpayKycFileType.DOC_FRONT, accentedPng()); + expect(sentFileName("fileBody")).toBe("Identificacion_oficial.png"); + }); +}); diff --git a/packages/shared/src/services/alfredpay/alfredpayApiService.ts b/packages/shared/src/services/alfredpay/alfredpayApiService.ts index b682cc158..1c86d19fc 100644 --- a/packages/shared/src/services/alfredpay/alfredpayApiService.ts +++ b/packages/shared/src/services/alfredpay/alfredpayApiService.ts @@ -56,6 +56,44 @@ export class AlfredpayApiError extends ProviderHttpError { // these requests inline. const REQUEST_TIMEOUT_MS = 30_000; +/** + * Alfredpay's relate-person upload rejects any non-ASCII byte in the multipart filename with a + * bare 500 `{"errorCode":111301,"errorMessage":"UNKNOWN_ERROR"}`. Verified against the sandbox: + * `ñandú.png` and `acentuación.png` fail while `with space.png`, `parens(1).png` and + * `IMG_1234.png` pass, on the very customer and related person a production upload failed for. + * + * The filename we send is throwaway — Alfredpay stores every upload under a generated + * `{uuid}.{ext}` — but it reaches users, who name documents in their own language + * ("Identificación oficial.png"). So transliterate accents, replace anything else outside + * `[A-Za-z0-9._-]`, and keep the extension. Applied to all three uploads: only relate-person is + * known to reject these, and the company-document endpoint next door accepts them, but a name + * that is discarded on arrival is not worth a per-endpoint carve-out. + */ +export function toAsciiFileName(name: string): string { + const withoutAccents = name.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + const ascii = withoutAccents.replace(/[^A-Za-z0-9._-]/g, "_"); + // A name that was entirely non-ASCII collapses to underscores; give the provider something. + return /[A-Za-z0-9]/.test(ascii) ? ascii : `upload${ascii}`; +} + +/** + * Rebuild the upload under an ASCII name, copying the bytes out first. + * + * The copy is load-bearing, and neither shorter spelling works under Bun: `new File([file], name)` + * and `new Blob([file])` alias a single Blob part rather than copying it, so the result stays the + * original File and keeps its name, and `formData.append(field, file, name)` then ignores its + * filename argument because the value is a File. Every one of those silently sends the original + * name — alfredpayApiService.test.ts asserts the name that actually reaches the wire, so it fails + * if this is "simplified" back into any of them. + * + * The uploads are typed Blob, which carries no name; only the File the controllers build from the + * multipart request does. + */ +async function asAsciiNamedUpload(file: Blob): Promise { + const name = file instanceof File ? toAsciiFileName(file.name) : "upload"; + return new File([await file.arrayBuffer()], name, { type: file.type }); +} + export class AlfredpayApiService { private static instance: AlfredpayApiService; @@ -323,7 +361,7 @@ export class AlfredpayApiService { file: Blob ): Promise { const formData = new FormData(); - formData.append("fileBody", file); + formData.append("fileBody", await asAsciiNamedUpload(file)); formData.append("fileType", fileType); const url = `${ALFREDPAY_BASE_URL}/api/v1/third-party-service/penny/customers/${customerId}/kyc/${submissionId}/files`; @@ -385,7 +423,7 @@ export class AlfredpayApiService { file: Blob ): Promise { const formData = new FormData(); - formData.append("rawBody", file); + formData.append("rawBody", await asAsciiNamedUpload(file)); formData.append("fileType", fileType); const url = `${ALFREDPAY_BASE_URL}/api/v1/third-party-service/penny/customers/${customerId}/kyb/${submissionId}/files`; @@ -416,7 +454,7 @@ export class AlfredpayApiService { file: Blob ): Promise { const formData = new FormData(); - formData.append("rawBody", file); + formData.append("rawBody", await asAsciiNamedUpload(file)); formData.append("fileType", fileType); const url = `${ALFREDPAY_BASE_URL}/api/v1/third-party-service/penny/customers/${customerId}/kyb/${relatedPersonId}/files/relate-person`; diff --git a/packages/shared/src/services/alfredpay/schemas.ts b/packages/shared/src/services/alfredpay/schemas.ts index 25f53fcaa..acf8c4345 100644 --- a/packages/shared/src/services/alfredpay/schemas.ts +++ b/packages/shared/src/services/alfredpay/schemas.ts @@ -6,6 +6,8 @@ import { AlfredpayFiatAccount, AlfredpayFiatAccountType, AlfredpayFiatPaymentInstructions, + AlfredpayKybCustomerAndBusiness, + AlfredpayKybRelatedPersonDetails, AlfredpayKycStatus, AlfredpayOfframpStatus, AlfredpayOfframpTransaction, @@ -47,6 +49,10 @@ type ConsumedFiatAccount = Pick & { metadata?: { failureReason?: string } | null; }; +type ConsumedKybRelatedPerson = Pick; +type ConsumedKybBusiness = Pick & { + relatedPersons: ConsumedKybRelatedPerson[]; +}; const DECIMAL_STRING = /^\d+(\.\d+)?$/; const DIGITS_OR_EMPTY = /^\d*$/; @@ -139,6 +145,19 @@ export const alfredpayFiatAccountsResponseSchema = z.array( }) ) satisfies z.ZodType; +/** + * The body of a GET …/customers/{customerId}/kyb/details response: one entry per business the + * customer has. `submissionId` is what ties a business to the submission being filed, and + * `relatedPersons[].idRelatedPerson` is the path key for the representative's document uploads + * (POST …/kyb/{idRelatedPerson}/files/relate-person) — the pair this endpoint exists to supply. + */ +export const alfredpayKybBusinessDetailsResponseSchema = z.array( + z.looseObject({ + relatedPersons: z.array(z.looseObject({ idRelatedPerson: z.string().min(1) })), + submissionId: z.string().min(1) + }) +) satisfies z.ZodType; + /** The body of a GET …/kyc/{submissionId}/status (and KYB equivalent) response. */ export const alfredpayKycStatusResponseSchema = z.looseObject({ metadata: z.looseObject({ failureReason: z.string().optional() }).nullish(), diff --git a/packages/shared/src/services/alfredpay/types.ts b/packages/shared/src/services/alfredpay/types.ts index bd21476b4..f4a55abe7 100644 --- a/packages/shared/src/services/alfredpay/types.ts +++ b/packages/shared/src/services/alfredpay/types.ts @@ -458,11 +458,17 @@ export enum AlfredpayArgentinaDocumentType { DNI = "DNI" } -// KYB form submission types +// KYB form submission types. +// Alfredpay self-describes the per-country requirement set at GET …/penny/kybRequirements?country= +// (MEX and MX both resolve); that endpoint is the source of truth for what `sendKybSubmission` +// accepts. BUSINESS_LICENSE and AML_POLICY are demanded only when `isRegulatedBusiness` is true. export enum AlfredpayKybFileType { TAX_ID_DOCUMENT = "taxIdDocument", ARTICLES_INCORPORATION = "articlesIncorporation", - PROOF_ADDRESS = "proofAddress" + PROOF_ADDRESS = "proofAddress", + SHAREHOLDER_REGISTRY = "shareholderRegistry", + BUSINESS_LICENSE = "businessLicense", + AML_POLICY = "uploadAmlPolicy" } /** Penny relate-person upload: only docFront + docBack (URI fields are derived server-side). */ @@ -482,7 +488,34 @@ export interface AlfredpayKybRelatedPerson { pep?: boolean; } -export interface SubmitKybInformationRequest { +/** + * The compliance questionnaire Alfredpay requires before `sendKybSubmission` accepts a submission — + * omitting any of the required entries fails finalization with `110002 "Invalid field(s)"` naming + * exactly them. They are sent flat alongside the company fields; Alfredpay stores them nested under + * `questionnaire` in the KYB details response. + * + * Requiredness mirrors GET …/penny/kybRequirements?country= : the nine below are required for every + * corridor, and the two conditionals are demanded only once their trigger is true. + */ +export interface AlfredpayKybQuestionnaire { + /** Wallets interacting with Alfredpay, or "N/A" when the business is not on-chain. */ + walletAddresses: string; + sourceOfFunds: string; + transmitsCustomerFunds: boolean; + /** Required by Alfredpay only when `transmitsCustomerFunds` is true. */ + conductsComplianceScreening?: boolean; + /** Required by Alfredpay only when `conductsComplianceScreening` is true. */ + complianceScreeningDescription?: string; + operatesInSanctionedCountries: boolean; + /** When true, Alfredpay additionally requires the businessLicense and uploadAmlPolicy documents. */ + isRegulatedBusiness: boolean; + businessActivities: string; + accountPurpose: string; + expectedMonthlyVolumeUsd: number; + expectedMonthlyTransactions: number; +} + +export interface SubmitKybInformationRequest extends AlfredpayKybQuestionnaire { businessName: string; taxId: string; country: string;