Skip to content

Improve pix onramp payment handling #631

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 14, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 0 additions & 50 deletions api/src/api/controllers/brla.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,56 +348,6 @@ export const fetchSubaccountKycStatus = async (
}
};

/**
* Retrieves a a BR Code that can be used to onramp into BRLA
*
* Fetches a user's subaccount information from the BRLA API service.
* It validates that the user exists and has completed a KYC verification.
* It returns the corresponding BR Code given the amount and reference label, if any.
*
* @returns Sends JSON response with brCode on success.
*
* @throws 400 - If subaccount's KYC is invalid, or the amount exceeds KYC limits.
* @throws 404 - If the subaccount cannot be found
* @throws 500 - For any server-side errors during processing
*/
export const getPayInCode = async (
req: Request<unknown, unknown, unknown, PayInCodeQuery>,
res: Response<BrlaEndpoints.GetPayInCodeResponse | BrlaEndpoints.BrlaErrorResponse>,
): Promise<void> => {
try {
const { taxId, amount, receiverAddress } = req.query as PayInCodeQuery;

const brlaApiService = BrlaApiService.getInstance();
const subaccount = await brlaApiService.getSubaccount(taxId);
if (!subaccount) {
res.status(404).json({ error: 'Subaccount not found' });
return;
}

if (subaccount.kyc.level < 1) {
res.status(400).json({ error: 'KYC invalid' });
return;
}

const { limitMint } = subaccount.kyc.limits;

if (Number(amount) > limitMint) {
res.status(400).json({ error: 'Amount exceeds limit' });
return;
}

const brCode = await brlaApiService.generateBrCode({
subaccountId: subaccount.id,
amount: String(amount),
referenceLabel: generateReferenceLabel(receiverAddress),
});

res.status(200).json(brCode);
} catch (error) {
handleApiError(error, res, 'triggerOnramp');
}
};

/**
* Validates a pix key
Expand Down
22 changes: 0 additions & 22 deletions api/src/api/middlewares/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,28 +403,6 @@ export const validataSubaccountCreation: RequestHandler = (req, res, next) => {
next();
};

export const validateGetPayInCode: RequestHandler = (req, res, next) => {
const { taxId, receiverAddress, amount } = req.query as PayInCodeQuery;

if (!taxId) {
res.status(400).json({ error: 'Missing taxId parameter' });
return;
}

if (!amount || isNaN(Number(amount))) {
res.status(400).json({ error: 'Missing or invalid amount parameter' });
return;
}

if (!receiverAddress || !(receiverAddress as string).startsWith('0x')) {
res.status(400).json({
error: 'Missing or invalid receiverAddress parameter. receiverAddress must be a valid Evm address',
});
return;
}

next();
};

export const validateStartKyc2: RequestHandler = (req, res, next) => {
const { taxId, documentType } = req.body as BrlaEndpoints.StartKYC2Request;
Expand Down
3 changes: 0 additions & 3 deletions api/src/api/routes/v1/brla.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import * as brlaController from '../../controllers/brla.controller';
import {
validateBrlaTriggerOfframpInput,
validataSubaccountCreation,
validateGetPayInCode,
validateStartKyc2,
} from '../../middlewares/validators';

Expand All @@ -19,8 +18,6 @@ router.route('/getKycStatus').get(brlaController.fetchSubaccountKycStatus);

router.route('/validatePixKey').get(brlaController.validatePixKey);

router.route('/payIn').get(validateGetPayInCode, brlaController.getPayInCode);

router.route('/triggerOfframp').post(validateBrlaTriggerOfframpInput, brlaController.triggerBrlaOfframp);

router.route('/createSubaccount').post(validataSubaccountCreation, brlaController.createSubaccount);
Expand Down
7 changes: 4 additions & 3 deletions api/src/api/services/brla/brlaTeleportService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type Teleport = {
dateRequested: string;
status: TeleportStatus;
receiverAddress: EvmAddress;
memo?: string;
memo: string;
id?: string;
};

Expand Down Expand Up @@ -53,13 +53,14 @@ export class BrlaTeleportService {
return BrlaTeleportService.teleportService;
}

public async requestTeleport(subaccountId: string, amount: number, receiverAddress: EvmAddress): Promise<void> {
public async requestTeleport(subaccountId: string, amount: number, receiverAddress: EvmAddress, memo: string): Promise<void> {
const teleport: Teleport = {
amount,
subaccountId,
dateRequested: new Date().toISOString(),
status: 'claimed',
receiverAddress,
memo,
};
logger.info('Requesting teleport:', teleport);
this.teleports.set(subaccountId, teleport);
Expand Down Expand Up @@ -163,7 +164,7 @@ export class BrlaTeleportService {
// Check the referceLabel to match the address requested, and amount.
// Last mintOp should match the amount.
if (
verifyReferenceLabel(lastPayIn.referenceLabel, teleport.receiverAddress) // TODO testing && lastPayIn.mintOps[0].amount === teleport.amount
verifyReferenceLabel(lastPayIn.referenceLabel, teleport.memo)
) {
this.teleports.set(subaccountId, { ...teleport, status: 'arrived' });
this.startTeleport(subaccountId);
Expand Down
16 changes: 10 additions & 6 deletions api/src/api/services/brla/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { sha256 } from 'ethers';
import { EvmAddress } from './brlaTeleportService';
import QuoteTicket from '../../../models/quoteTicket.model';

export function verifyReferenceLabel(referenceLabel: string, receiverAddress: EvmAddress): boolean {
return true; // TODO ONRAMP testing, remove.
const referenceLabelClaimed = sha256(receiverAddress).toString().slice(0, 18);
return referenceLabel === referenceLabelClaimed;
export function verifyReferenceLabel(referenceLabel: string, memo: string): boolean {
return referenceLabel === memo;
}

export function generateReferenceLabel(receiverAddress: EvmAddress): string {
return sha256(receiverAddress).toString().slice(0, 18);
type QuoteId = string;

export function generateReferenceLabel(quote: QuoteTicket | QuoteId): string {
if (typeof quote === 'string') {
return quote.slice(0, 8);
}
return quote.id.slice(0, 8);
}
7 changes: 7 additions & 0 deletions api/src/api/services/brla/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ export interface Event {
acknowledged: boolean;
}

export type Kyc2FailureReason =
| 'face match failure' // Selfie issue. Blurred, too dark, too light, glasses, etc.
| 'name does not match' // Document picture issue. Anything from not legible to no document at all.

interface EventData {
status: string;
kycStatus: string;
failureReason: Kyc2FailureReason;
level: number;
[key: string]: unknown;
}


export class EventPoller {
private cache: Map<string, Event[]> = new Map();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ export class BrlaPayoutOnMoonbeamPhaseHandler extends BasePhaseHandler {
if (balanceCheckError.message === 'Balance did not meet the limit within the specified time') {
throw new Error(`BrlaPayoutOnMoonbeamPhaseHandler: balanceCheckError ${balanceCheckError.message}`);
} else {
logger.error('Error checking Moonbeam balance:', balanceCheckError);
throw new Error(`Error checking Moonbeam balance`);
logger.error('Error checking Polygon balance:', balanceCheckError);
throw new Error(`Error checking Polygon balance`);
}
}
}
Expand Down
42 changes: 32 additions & 10 deletions api/src/api/services/phases/handlers/brla-teleport-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { checkEvmBalancePeriodically } from '../../moonbeam/balance';
import { BrlaTeleportService } from '../../brla/brlaTeleportService';
import logger from '../../../../config/logger';
import { moonbeam } from 'viem/chains';
import { generateReferenceLabel } from '../../brla/helpers';

const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
const EVM_BALANCE_CHECK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

export class BrlaTeleportPhaseHandler extends BasePhaseHandler {
public getPhaseName(): RampPhase {
Expand All @@ -32,12 +36,14 @@ export class BrlaTeleportPhaseHandler extends BasePhaseHandler {
if (!subaccount) {
throw new Error('Subaccount not found');
}
logger.info('Requesting teleport:', subaccount.id, inputAmountBrla, moonbeamEphemeralAddress);
const memo = generateReferenceLabel(state.quoteId);
logger.info('Requesting teleport:', subaccount.id, inputAmountBrla, moonbeamEphemeralAddress, memo);
const teleportService = BrlaTeleportService.getInstance();
await teleportService.requestTeleport(
subaccount.id,
Number(inputAmountBrla),
moonbeamEphemeralAddress as `0x${string}`,
memo
);

// now we wait and verify that funds have arrived at the actual destination ephemeral.
Expand All @@ -48,7 +54,6 @@ export class BrlaTeleportPhaseHandler extends BasePhaseHandler {

try {
const pollingTimeMs = 1000;
const maxWaitingTimeMs = 5 * 60 * 1000; // 5 minutes

const tokenDetails = getAnyFiatTokenDetailsMoonbeam(FiatToken.BRL);

Expand All @@ -57,25 +62,42 @@ export class BrlaTeleportPhaseHandler extends BasePhaseHandler {
moonbeamEphemeralAddress,
inputAmountBeforeSwapRaw, // TODO verify this is okay, regarding decimals.
pollingTimeMs,
maxWaitingTimeMs,
EVM_BALANCE_CHECK_TIMEOUT_MS,
moonbeam,
);

// Add delay to ensure the transaction is settled
await new Promise((resolve) => setTimeout(resolve, 12000)); // 12 seconds, 2 moonbeam blocks.

} catch (balanceCheckError) {
if (balanceCheckError instanceof Error) {
if (balanceCheckError.message === 'Balance did not meet the limit within the specified time') {
throw new Error(`BrlaTeleportPhaseHandler: balanceCheckError ${balanceCheckError.message}`);
} else {
logger.error('Error checking Moonbeam balance:', balanceCheckError);
throw new Error(`BrlaTeleportPhaseHandler: Error checking Moonbeam balance`);
}
if (!(balanceCheckError instanceof Error)) throw balanceCheckError;
const { message } = balanceCheckError;

const isCheckTimeout = message.includes('did not meet the limit within the specified time');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check seems quite brittle to me, any chance we can refactor to make it less reliant on the exact wording of the error message?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we can start implementing some better practices and return custom errors probably, not only here but everywhere...

if (isCheckTimeout && this.finalPaymentTimeout(state)) {
logger.error('Payment timeout:', balanceCheckError);
return this.transitionToNextPhase(state, 'failed');
}

throw isCheckTimeout
? this.createRecoverableError(`BrlaTeleportPhaseHandler: ${message}`)
: new Error(`Error checking Moonbeam balance: ${message}`);
}

return this.transitionToNextPhase(state, 'moonbeamToPendulumXcm');
}

protected finalPaymentTimeout(state: RampState): boolean {
const thisPhaseEntry = state.phaseHistory.find((phaseHistoryEntry) => phaseHistoryEntry.phase === this.getPhaseName());
if (!thisPhaseEntry) {
throw new Error('BrlaTeleportPhaseHandler: Phase not found in history. State corrupted.');
}

if (thisPhaseEntry.timestamp.getTime() + PAYMENT_TIMEOUT_MS < Date.now()) {
return true;
}
return false;
}
}

export default new BrlaTeleportPhaseHandler();
6 changes: 3 additions & 3 deletions api/src/api/services/ramp/ramp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export class RampService extends BaseRampService {

brCode = await this.validateBrlaOnrampRequest(
additionalData.taxId,
moonbeamEphemeralEntry.address as `0x${string}`,
quote,
quote.inputAmount,
);
({ unsignedTxs, stateMeta } = await prepareOnrampTransactions(
Expand Down Expand Up @@ -369,7 +369,7 @@ export class RampService extends BaseRampService {
*/
public async validateBrlaOnrampRequest(
taxId: string,
ephemeralAddress: `0x${string}`,
quote: QuoteTicket,
amount: string,
): Promise<string> {
const brlaApiService = BrlaApiService.getInstance();
Expand All @@ -391,7 +391,7 @@ export class RampService extends BaseRampService {
const brCode = await brlaApiService.generateBrCode({
subaccountId: subaccount.id,
amount: String(amount),
referenceLabel: generateReferenceLabel(ephemeralAddress),
referenceLabel: generateReferenceLabel(quote),
});

return brCode.brCode;
Expand Down
17 changes: 0 additions & 17 deletions frontend/src/services/api/brla.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,6 @@ export class BrlaService {
});
}

/**
* Get a BR Code for payment
* @param taxId The user's tax ID
* @param amount The payment amount
* @param receiverAddress The receiver's address
* @returns The BR Code for payment
*/
static async getPayInCode(
taxId: string,
amount: string,
receiverAddress: EvmAddress,
): Promise<BrlaEndpoints.GetPayInCodeResponse> {
return apiRequest<BrlaEndpoints.GetPayInCodeResponse>('get', `${this.BASE_PATH}/payIn`, undefined, {
params: { taxId, amount, receiverAddress },
});
}

/**
* Get the remaining limit for a user
* @param taxId The user's tax ID
Expand Down
12 changes: 0 additions & 12 deletions shared/src/endpoints/brla.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,6 @@ export namespace BrlaEndpoints {
valid: boolean;
}

// GET /brla/payIn?taxId=:taxId&amount=:amount&receiverAddress=:receiverAddress
export interface GetPayInCodeRequest {
taxId: string;
amount: string;
receiverAddress: string;
}

export interface GetPayInCodeResponse {
brCode: string;
[key: string]: any; // Additional fields from BRLA API
}

export interface GetUserRemainingLimitRequest {
taxId: string;
}
Expand Down