Skip to content

Commit 011e242

Browse files
authored
Merge pull request #631 from pendulum-chain/add-reference-label-check
Improve pix onramp payment handling
2 parents fef75c0 + 6c3e916 commit 011e242

File tree

14 files changed

+228
-212
lines changed

14 files changed

+228
-212
lines changed

api/src/api/controllers/brla.controller.ts

Lines changed: 1 addition & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ export const fetchSubaccountKycStatus = async (
332332
if (!lastInteraction) {
333333
res.status(404).json({ error: `No KYC process started for ${taxId}` });
334334
}
335-
if (lastInteraction && lastEventCached.createdAt <= (lastInteraction - 60000)) {
335+
if (lastInteraction && lastEventCached.createdAt <= lastInteraction - 60000) {
336336
// If the last event is older than 1 minute from the last interaction, we assume it's not a new event.
337337
// So it is ignored.
338338
console.log('Last kyc interaction', lastInteraction);
@@ -351,57 +351,6 @@ export const fetchSubaccountKycStatus = async (
351351
}
352352
};
353353

354-
/**
355-
* Retrieves a a BR Code that can be used to onramp into BRLA
356-
*
357-
* Fetches a user's subaccount information from the BRLA API service.
358-
* It validates that the user exists and has completed a KYC verification.
359-
* It returns the corresponding BR Code given the amount and reference label, if any.
360-
*
361-
* @returns Sends JSON response with brCode on success.
362-
*
363-
* @throws 400 - If subaccount's KYC is invalid, or the amount exceeds KYC limits.
364-
* @throws 404 - If the subaccount cannot be found
365-
* @throws 500 - For any server-side errors during processing
366-
*/
367-
export const getPayInCode = async (
368-
req: Request<unknown, unknown, unknown, PayInCodeQuery>,
369-
res: Response<BrlaEndpoints.GetPayInCodeResponse | BrlaEndpoints.BrlaErrorResponse>,
370-
): Promise<void> => {
371-
try {
372-
const { taxId, amount, receiverAddress } = req.query as PayInCodeQuery;
373-
374-
const brlaApiService = BrlaApiService.getInstance();
375-
const subaccount = await brlaApiService.getSubaccount(taxId);
376-
if (!subaccount) {
377-
res.status(httpStatus.NOT_FOUND).json({ error: 'Subaccount not found' });
378-
return;
379-
}
380-
381-
if (subaccount.kyc.level < 1) {
382-
res.status(httpStatus.BAD_REQUEST).json({ error: 'KYC invalid' });
383-
return;
384-
}
385-
386-
const { limitMint } = subaccount.kyc.limits;
387-
388-
if (Number(amount) > limitMint) {
389-
res.status(httpStatus.BAD_REQUEST).json({ error: 'Amount exceeds limit' });
390-
return;
391-
}
392-
393-
const brCode = await brlaApiService.generateBrCode({
394-
subaccountId: subaccount.id,
395-
amount: String(amount),
396-
referenceLabel: generateReferenceLabel(receiverAddress),
397-
});
398-
399-
res.status(httpStatus.OK).json(brCode);
400-
} catch (error) {
401-
handleApiError(error, res, 'triggerOnramp');
402-
}
403-
};
404-
405354
/**
406355
* Validates a pix key
407356
*

api/src/api/middlewares/validators.ts

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -404,29 +404,6 @@ export const validataSubaccountCreation: RequestHandler = (req, res, next) => {
404404
next();
405405
};
406406

407-
export const validateGetPayInCode: RequestHandler = (req, res, next) => {
408-
const { taxId, receiverAddress, amount } = req.query as PayInCodeQuery;
409-
410-
if (!taxId) {
411-
res.status(httpStatus.BAD_REQUEST).json({ error: 'Missing taxId parameter' });
412-
return;
413-
}
414-
415-
if (!amount || isNaN(Number(amount))) {
416-
res.status(httpStatus.BAD_REQUEST).json({ error: 'Missing or invalid amount parameter' });
417-
return;
418-
}
419-
420-
if (!receiverAddress || !(receiverAddress as string).startsWith('0x')) {
421-
res.status(httpStatus.BAD_REQUEST).json({
422-
error: 'Missing or invalid receiverAddress parameter. receiverAddress must be a valid Evm address',
423-
});
424-
return;
425-
}
426-
427-
next();
428-
};
429-
430407
export const validateStartKyc2: RequestHandler = (req, res, next) => {
431408
const { taxId, documentType } = req.body as BrlaEndpoints.StartKYC2Request;
432409

api/src/api/routes/v1/brla.route.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import * as brlaController from '../../controllers/brla.controller';
33
import {
44
validateBrlaTriggerOfframpInput,
55
validataSubaccountCreation,
6-
validateGetPayInCode,
76
validateStartKyc2,
87
} from '../../middlewares/validators';
98

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

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

22-
router.route('/payIn').get(validateGetPayInCode, brlaController.getPayInCode);
23-
2421
router.route('/triggerOfframp').post(validateBrlaTriggerOfframpInput, brlaController.triggerBrlaOfframp);
2522

2623
router.route('/createSubaccount').post(validataSubaccountCreation, brlaController.createSubaccount);

api/src/api/services/brla/brlaTeleportService.ts

Lines changed: 123 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ type Teleport = {
2121
dateRequested: string;
2222
status: TeleportStatus;
2323
receiverAddress: EvmAddress;
24-
memo?: string;
24+
memo: string;
2525
id?: string;
2626
};
2727

@@ -34,6 +34,7 @@ export class BrlaTeleportService {
3434

3535
private intervalMs = 10000;
3636

37+
// Key is a composite of subaccountId and memo: `${subaccountId}:${memo}`
3738
private teleports: Map<string, Teleport> = new Map();
3839

3940
private completedTeleports: Map<string, Teleport> = new Map();
@@ -53,32 +54,73 @@ export class BrlaTeleportService {
5354
return BrlaTeleportService.teleportService;
5455
}
5556

56-
public async requestTeleport(subaccountId: string, amount: number, receiverAddress: EvmAddress): Promise<void> {
57+
private getCompositeKey(subaccountId: string, memo: string): string {
58+
const safeMemo = typeof memo === 'string' ? memo : '';
59+
return `${subaccountId}:${safeMemo}`;
60+
}
61+
62+
public async requestTeleport(
63+
subaccountId: string,
64+
amount: number,
65+
receiverAddress: EvmAddress,
66+
memo: string,
67+
): Promise<void> {
68+
const compositeKey = this.getCompositeKey(subaccountId, memo);
69+
const existing = this.teleports.get(compositeKey);
70+
71+
if (
72+
existing &&
73+
existing.amount === amount &&
74+
existing.receiverAddress === receiverAddress &&
75+
(existing.status === 'claimed' || existing.status === 'started')
76+
) {
77+
logger.info(
78+
`Skipping duplicate teleport request for subaccount ${subaccountId}, memo "${memo}" ` +
79+
`(amount=${amount}, receiver=${receiverAddress}). Current status: ${existing.status}.`,
80+
);
81+
return;
82+
}
83+
5784
const teleport: Teleport = {
5885
amount,
5986
subaccountId,
6087
dateRequested: new Date().toISOString(),
6188
status: 'claimed',
6289
receiverAddress,
90+
memo,
6391
};
64-
logger.info('Requesting teleport:', teleport);
65-
this.teleports.set(subaccountId, teleport);
92+
logger.info(`Requesting teleport "${compositeKey}":`, teleport);
93+
this.teleports.set(compositeKey, teleport);
6694
this.maybeStartPeriodicChecks();
6795
}
6896

69-
private async startTeleport(subaccountId: string): Promise<void> {
70-
const teleport = this.teleports.get(subaccountId);
97+
public cancelPendingTeleport(subaccountId: string, memo: string): void {
98+
const compositeKey = this.getCompositeKey(subaccountId, memo);
99+
const pending = this.teleports.get(compositeKey);
100+
if (pending) {
101+
this.teleports.delete(compositeKey);
102+
logger.info(`Cancelled pending teleport for key "${compositeKey}" (subaccount ${subaccountId}, memo "${memo}").`);
103+
} else {
104+
logger.info(
105+
`No pending teleport found to cancel for key "${compositeKey}" (subaccount ${subaccountId}, memo "${memo}").`,
106+
);
107+
}
108+
}
109+
110+
private async startTeleport(compositeKey: string): Promise<void> {
111+
const teleport = this.teleports.get(compositeKey);
71112

72-
// Ignore operation
73113
if (teleport === undefined) {
74-
throw new Error('Teleport not found. This cannot not happen.');
114+
logger.error(`Teleport not found for key "${compositeKey}" at startTeleport. This should not happen.`);
115+
return;
75116
}
76117

77118
if (teleport.status !== 'arrived') {
78-
throw new Error('Teleport not in arrived state.');
119+
logger.warn(`Teleport "${compositeKey}" not in 'arrived' state.`);
120+
return;
79121
}
80122

81-
logger.info('Starting teleport:', teleport);
123+
logger.info(`Starting teleport "${compositeKey}":`, teleport);
82124
const fastQuoteParams: FastQuoteQueryParams = {
83125
subaccountId: teleport.subaccountId,
84126
operation: 'swap',
@@ -91,31 +133,35 @@ export class BrlaTeleportService {
91133

92134
try {
93135
const { token: quoteToken } = await this.brlaApiService.createFastQuote(fastQuoteParams);
94-
this.teleports.set(subaccountId, { ...teleport, status: 'quoted' });
136+
const quotedTeleportState: Teleport = { ...teleport, status: 'quoted' };
137+
this.teleports.set(compositeKey, quotedTeleportState);
138+
logger.info(`Teleport ${compositeKey} status: quoted`);
95139

96-
// Execute the actual swap operation
97140
const { id } = await this.brlaApiService.swapRequest({
98141
token: quoteToken,
99-
receiverAddress: teleport.receiverAddress,
142+
receiverAddress: quotedTeleportState.receiverAddress,
100143
});
101-
this.teleports.set(subaccountId, { ...teleport, status: 'started', id });
144+
145+
const startedTeleportState: Teleport = { ...quotedTeleportState, status: 'started', id };
146+
this.teleports.set(compositeKey, startedTeleportState);
147+
logger.info(`Teleport ${compositeKey} status: started, API operationId: ${id}`);
102148

103149
this.maybeStartPeriodicChecks();
104150
} catch (e) {
105-
logger.error('Error starting teleport:', e);
106-
this.teleports.set(subaccountId, { ...teleport, status: 'failed' });
151+
logger.error(`Error starting teleport "${compositeKey}":`, e);
152+
this.teleports.set(compositeKey, { ...teleport, status: 'failed' });
107153
}
108154
}
109155

110156
private maybeStartPeriodicChecks(): void {
111-
const pendingTeleports = [...this.teleports.entries()].filter(
112-
(entry) => entry[1].status === 'claimed' || entry[1].status === 'started',
157+
const pendingTeleports = [...this.teleports.values()].filter(
158+
(teleport) => teleport.status === 'claimed' || teleport.status === 'started',
113159
).length;
114160

115161
if (this.checkInterval === null && pendingTeleports > 0) {
116162
this.checkInterval = setInterval(() => {
117163
this.checkPendingTeleports().catch((err) => {
118-
console.error('Error in periodic teleport check:', err);
164+
logger.error('Error in periodic teleport check:', err);
119165
});
120166
}, this.intervalMs);
121167
}
@@ -125,55 +171,72 @@ export class BrlaTeleportService {
125171
if (this.teleports.size === 0 && this.checkInterval !== null) {
126172
clearInterval(this.checkInterval);
127173
this.checkInterval = null;
174+
return;
128175
}
129176

130-
this.teleports.forEach(async (teleport, subaccountId) => {
177+
// Process 'started' teleports first
178+
for (const [compositeKey, teleport] of this.teleports) {
131179
if (teleport.status === 'started') {
132-
const onChainOuts = await this.brlaApiService.getOnChainHistoryOut(subaccountId);
133-
const relevantOut = onChainOuts.find((out: OnchainLog) => out.id === teleport.id);
134-
135-
if (!relevantOut) {
136-
return;
137-
}
138-
139-
// We are interested in the last contract op for this operation being
140-
// a mint one. smartContractOps are ordered descending by timestamp.
141-
const lastContractOp = relevantOut.smartContractOps[0];
142-
143-
if (lastContractOp.operationName === SmartContractOperationType.MINT && lastContractOp.executed === true) {
144-
this.completedTeleports.set(subaccountId, { ...teleport, status: 'completed' });
145-
logger.info('Teleport completed:', teleport);
146-
this.teleports.delete(subaccountId);
180+
try {
181+
const onChainOuts = await this.brlaApiService.getOnChainHistoryOut(teleport.subaccountId);
182+
const relevantOut = onChainOuts.find((out: OnchainLog) => out.id === teleport.id);
183+
184+
if (!relevantOut) {
185+
return;
186+
}
187+
188+
// We are interested in the last contract op for this operation being
189+
// a mint one. smartContractOps are ordered descending by timestamp.
190+
const lastContractOp = relevantOut.smartContractOps[0];
191+
192+
if (
193+
lastContractOp &&
194+
lastContractOp.operationName === SmartContractOperationType.MINT &&
195+
lastContractOp.executed === true
196+
) {
197+
const completedTeleport = { ...teleport, status: 'completed' as TeleportStatus };
198+
this.completedTeleports.set(compositeKey, completedTeleport);
199+
logger.info(`Teleport completed "${compositeKey}":`, completedTeleport);
200+
this.teleports.delete(compositeKey);
201+
}
202+
} catch (error) {
203+
logger.error(`Error checking 'started' teleport ${compositeKey}:`, error);
147204
}
148205
}
149-
});
206+
}
150207

151-
this.teleports.forEach(async (teleport, subaccountId) => {
152-
// For claimed teleports, we need to wait for funds to arrive, only then it makes sense to
153-
// start the actual transfer process.
208+
// Process 'claimed' teleports
209+
for (const [compositeKey, teleport] of this.teleports) {
154210
if (teleport.status === 'claimed') {
155-
const payIns = await this.brlaApiService.getPayInHistory(subaccountId);
156-
// TODO we also need to check actual balance, which has to be gte what is requested.
157-
if (payIns.length === 0) {
158-
return;
159-
}
160-
161-
// Must be the last one, if any.
162-
const lastPayIn = payIns[0];
163-
// Check the referceLabel to match the address requested, and amount.
164-
// Last mintOp should match the amount.
165-
if (
166-
verifyReferenceLabel(lastPayIn.referenceLabel, teleport.receiverAddress) // TODO testing && lastPayIn.mintOps[0].amount === teleport.amount
167-
) {
168-
this.teleports.set(subaccountId, { ...teleport, status: 'arrived' });
169-
this.startTeleport(subaccountId);
170-
}
171-
172-
// delete teleports that have been waiting for more than 15 minutes
173-
if (teleport.status === 'claimed' && Date.now() - new Date(teleport.dateRequested).getTime() > 15 * 60 * 1000) {
174-
this.teleports.delete(subaccountId);
211+
try {
212+
const payIns = await this.brlaApiService.getPayInHistory(teleport.subaccountId);
213+
if (payIns.length === 0) {
214+
return;
215+
}
216+
217+
// Must be the last one, if any.
218+
const matchingPayIn = payIns.find(
219+
(payIn) =>
220+
verifyReferenceLabel(payIn.referenceLabel, teleport.memo) &&
221+
Number(payIn.amount) >= Number(teleport.amount),
222+
);
223+
224+
if (matchingPayIn) {
225+
logger.info(`Matching PayIn found for teleport "${compositeKey}". Status changing to 'arrived'.`);
226+
this.teleports.set(compositeKey, { ...teleport, status: 'arrived' });
227+
// Intentionally not awaiting startTeleport to allow checkPendingTeleports to complete its iteration.
228+
// startTeleport is async and will handle its own errors.
229+
this.startTeleport(compositeKey).catch((err) => {
230+
logger.error(
231+
`Error occurred during startTeleport called for ${compositeKey} from checkPendingTeleports:`,
232+
err,
233+
);
234+
});
235+
} // Deletion of teleports is handled by the phase processor.
236+
} catch (error) {
237+
logger.error(`Error checking 'claimed' teleport ${compositeKey}:`, error);
175238
}
176239
}
177-
});
240+
}
178241
}
179242
}

api/src/api/services/brla/helpers.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { sha256 } from 'ethers';
22
import { EvmAddress } from './brlaTeleportService';
3+
import QuoteTicket from '../../../models/quoteTicket.model';
34

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

10-
export function generateReferenceLabel(receiverAddress: EvmAddress): string {
11-
return sha256(receiverAddress).toString().slice(0, 18);
9+
type QuoteId = string;
10+
11+
export function generateReferenceLabel(quote: QuoteTicket | QuoteId): string {
12+
if (typeof quote === 'string') {
13+
return quote.slice(0, 8);
14+
}
15+
return quote.id.slice(0, 8);
1216
}

0 commit comments

Comments
 (0)