diff --git a/migration/1768315830503-UpdateXtDeuroLiquidityMinimum.js b/migration/1768315830503-UpdateXtDeuroLiquidityMinimum.js new file mode 100644 index 0000000000..deb2000733 --- /dev/null +++ b/migration/1768315830503-UpdateXtDeuroLiquidityMinimum.js @@ -0,0 +1,36 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class UpdateXtDeuroLiquidityMinimum1768315830503 { + name = 'UpdateXtDeuroLiquidityMinimum1768315830503' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + // Update XT/DEURO liquidity rule minimum from 4300 to 10000 + await queryRunner.query(` + UPDATE "dbo"."liquidity_management_rule" + SET "minimal" = 10000 + WHERE "id" = 295 + `); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + // Revert XT/DEURO liquidity rule minimum back to 4300 + await queryRunner.query(` + UPDATE "dbo"."liquidity_management_rule" + SET "minimal" = 4300 + WHERE "id" = 295 + `); + } +} diff --git a/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts b/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts index 928f40c28b..77e57cf827 100644 --- a/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts +++ b/src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { GetConfig } from 'src/config/config'; +import { Config, Environment, GetConfig } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -84,7 +84,10 @@ export class Eip7702DelegationService { * RealUnit app supports eth_sign (unlike MetaMask), so EIP-7702 works */ isDelegationSupportedForRealUnit(blockchain: Blockchain): boolean { - return blockchain === Blockchain.ETHEREUM && CHAIN_CONFIG[blockchain] !== undefined; + const expectedBlockchain = [Environment.DEV, Environment.LOC].includes(Config.environment) + ? Blockchain.SEPOLIA + : Blockchain.ETHEREUM; + return blockchain === expectedBlockchain && CHAIN_CONFIG[blockchain] !== undefined; } /** diff --git a/src/integration/sift/dto/sift.dto.ts b/src/integration/sift/dto/sift.dto.ts index bc26b98b54..843a139f0b 100644 --- a/src/integration/sift/dto/sift.dto.ts +++ b/src/integration/sift/dto/sift.dto.ts @@ -1037,6 +1037,7 @@ export const SiftAmlDeclineMap: { [method in AmlReason]: DeclineCategory } = { [AmlReason.BANK_RELEASE_PENDING]: DeclineCategory.OTHER, [AmlReason.VIRTUAL_IBAN_USER_MISMATCH]: DeclineCategory.RISKY, [AmlReason.INTERMEDIARY_WITHOUT_SENDER]: DeclineCategory.RISKY, + [AmlReason.NAME_TOO_SHORT]: DeclineCategory.OTHER, }; export interface ScoreRsponse { diff --git a/src/shared/i18n/de/mail.json b/src/shared/i18n/de/mail.json index 180c054df2..aaa27b0fe0 100644 --- a/src/shared/i18n/de/mail.json +++ b/src/shared/i18n/de/mail.json @@ -95,7 +95,8 @@ "manual_check_ip_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen", "manual_check_ip_country_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen", "merge_incomplete": "Die Email Bestätigung wurde nicht akzeptiert", - "intermediary_without_sender": "Die Absenderbank (Wise/Revolut) hat nur den Banknamen übermittelt, nicht aber den Namen des Kontoinhabers. DFX kann daher den tatsächlichen Absender nicht verifizieren und die Transaktion nicht verarbeiten." + "intermediary_without_sender": "Die Absenderbank (Wise/Revolut) hat nur den Banknamen übermittelt, nicht aber den Namen des Kontoinhabers. DFX kann daher den tatsächlichen Absender nicht verifizieren und die Transaktion nicht verarbeiten.", + "name_too_short": "Dein Name ist zu kurz für die Bankverarbeitung. Banken benötigen mindestens 4 Buchstaben im Namen des Kontoinhabers." }, "kyc_start": "Du kannst den KYC Prozess hier starten:
[url:{urlText}]" }, diff --git a/src/shared/i18n/en/mail.json b/src/shared/i18n/en/mail.json index c969d23a13..db40214c22 100644 --- a/src/shared/i18n/en/mail.json +++ b/src/shared/i18n/en/mail.json @@ -95,7 +95,8 @@ "manual_check_ip_phone": "We were unable to reach you at the phone number you provided", "manual_check_ip_country_phone": "We were unable to reach you at the phone number you provided", "merge_incomplete": "The email confirmation was not accepted", - "intermediary_without_sender": "The sender bank (Wise/Revolut) only transmitted the bank name, not the account holder's name. DFX is therefore unable to verify the actual sender and cannot process the transaction." + "intermediary_without_sender": "The sender bank (Wise/Revolut) only transmitted the bank name, not the account holder's name. DFX is therefore unable to verify the actual sender and cannot process the transaction.", + "name_too_short": "Your name is too short for bank processing. Banks require at least 4 letters in the account holder name." }, "kyc_start": "You can start the KYC process here:
[url:{urlText}]" }, diff --git a/src/shared/i18n/es/mail.json b/src/shared/i18n/es/mail.json index d9e74c2f34..d1a3aa8c6e 100644 --- a/src/shared/i18n/es/mail.json +++ b/src/shared/i18n/es/mail.json @@ -95,7 +95,8 @@ "manual_check_ip_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó", "manual_check_ip_country_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó", "merge_incomplete": "El correo electrónico de confirmación no fue aceptado", - "intermediary_without_sender": "El banco emisor (Wise/Revolut) solo transmitió el nombre del banco, no el nombre del titular de la cuenta. Por lo tanto, DFX no puede verificar el remitente real y no puede procesar la transacción." + "intermediary_without_sender": "El banco emisor (Wise/Revolut) solo transmitió el nombre del banco, no el nombre del titular de la cuenta. Por lo tanto, DFX no puede verificar el remitente real y no puede procesar la transacción.", + "name_too_short": "Tu nombre es demasiado corto para el procesamiento bancario. Los bancos requieren al menos 4 letras en el nombre del titular de la cuenta." }, "kyc_start": "Puede iniciar el proceso KYC aquí:
[url:{urlText}]" }, diff --git a/src/shared/i18n/fr/mail.json b/src/shared/i18n/fr/mail.json index 35470ecc1d..a4c7ef800d 100644 --- a/src/shared/i18n/fr/mail.json +++ b/src/shared/i18n/fr/mail.json @@ -95,7 +95,8 @@ "manual_check_ip_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni", "manual_check_ip_country_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni", "merge_incomplete": "L'e-mail de confirmation n'a pas été accepté", - "intermediary_without_sender": "La banque émettrice (Wise/Revolut) n'a transmis que le nom de la banque, et non le nom du titulaire du compte. DFX ne peut donc pas vérifier l'expéditeur réel et ne peut pas traiter la transaction." + "intermediary_without_sender": "La banque émettrice (Wise/Revolut) n'a transmis que le nom de la banque, et non le nom du titulaire du compte. DFX ne peut donc pas vérifier l'expéditeur réel et ne peut pas traiter la transaction.", + "name_too_short": "Votre nom est trop court pour le traitement bancaire. Les banques exigent au moins 4 lettres dans le nom du titulaire du compte." }, "kyc_start": "Vous pouvez commencer le processus KYC ici:
[url:{urlText}]" }, diff --git a/src/shared/i18n/it/mail.json b/src/shared/i18n/it/mail.json index 6cc9c6a18b..1f341d742b 100644 --- a/src/shared/i18n/it/mail.json +++ b/src/shared/i18n/it/mail.json @@ -95,7 +95,8 @@ "manual_check_ip_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito", "manual_check_ip_country_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito", "merge_incomplete": "L'e-mail di conferma non è stata accettata", - "intermediary_without_sender": "La banca mittente (Wise/Revolut) ha trasmesso solo il nome della banca, non il nome del titolare del conto. DFX non può quindi verificare il mittente effettivo e non può elaborare la transazione." + "intermediary_without_sender": "La banca mittente (Wise/Revolut) ha trasmesso solo il nome della banca, non il nome del titolare del conto. DFX non può quindi verificare il mittente effettivo e non può elaborare la transazione.", + "name_too_short": "Il tuo nome è troppo corto per l'elaborazione bancaria. Le banche richiedono almeno 4 lettere nel nome del titolare del conto." }, "kyc_start": "Potete iniziare il processo KYC qui:
[url:{urlText}]" }, diff --git a/src/shared/i18n/pt/mail.json b/src/shared/i18n/pt/mail.json index 08346bf21b..192b00f9a9 100644 --- a/src/shared/i18n/pt/mail.json +++ b/src/shared/i18n/pt/mail.json @@ -95,7 +95,8 @@ "manual_check_ip_phone": "We were unable to reach you at the phone number you provided", "manual_check_ip_country_phone": "We were unable to reach you at the phone number you provided", "merge_incomplete": "The email confirmation was not accepted", - "intermediary_without_sender": "O banco remetente (Wise/Revolut) transmitiu apenas o nome do banco, não o nome do titular da conta. Portanto, a DFX não pode verificar o remetente real e não pode processar a transação." + "intermediary_without_sender": "O banco remetente (Wise/Revolut) transmitiu apenas o nome do banco, não o nome do titular da conta. Portanto, a DFX não pode verificar o remetente real e não pode processar a transação.", + "name_too_short": "O seu nome é muito curto para o processamento bancário. Os bancos exigem pelo menos 4 letras no nome do titular da conta." }, "kyc_start": "You can start the KYC process here:
[url:{urlText}]" }, diff --git a/src/subdomains/core/aml/enums/aml-error.enum.ts b/src/subdomains/core/aml/enums/aml-error.enum.ts index 533cdf83a1..8c75ff38a5 100644 --- a/src/subdomains/core/aml/enums/aml-error.enum.ts +++ b/src/subdomains/core/aml/enums/aml-error.enum.ts @@ -26,6 +26,7 @@ export enum AmlError { INVALID_KYC_TYPE = 'InvalidKycType', NO_VERIFIED_NAME = 'NoVerifiedName', NAME_MISSING = 'NameMissing', + NAME_TOO_SHORT = 'NameTooShort', VERIFIED_COUNTRY_NOT_ALLOWED = 'VerifiedCountryNotAllowed', IBAN_COUNTRY_FATF_NOT_ALLOWED = 'IbanCountryFatfNotAllowed', TX_COUNTRY_NOT_ALLOWED = 'TxCountryNotAllowed', @@ -151,6 +152,11 @@ export const AmlErrorResult: { amlCheck: CheckStatus.PENDING, amlReason: AmlReason.KYC_DATA_NEEDED, }, + [AmlError.NAME_TOO_SHORT]: { + type: AmlErrorType.CRUCIAL, + amlCheck: CheckStatus.FAIL, + amlReason: AmlReason.NAME_TOO_SHORT, + }, [AmlError.VERIFIED_COUNTRY_NOT_ALLOWED]: { type: AmlErrorType.CRUCIAL, amlCheck: CheckStatus.GSHEET, diff --git a/src/subdomains/core/aml/enums/aml-reason.enum.ts b/src/subdomains/core/aml/enums/aml-reason.enum.ts index b7773a74de..5f55419276 100644 --- a/src/subdomains/core/aml/enums/aml-reason.enum.ts +++ b/src/subdomains/core/aml/enums/aml-reason.enum.ts @@ -39,6 +39,7 @@ export enum AmlReason { BANK_RELEASE_PENDING = 'BankReleasePending', VIRTUAL_IBAN_USER_MISMATCH = 'VirtualIbanUserMismatch', INTERMEDIARY_WITHOUT_SENDER = 'IntermediaryWithoutSender', + NAME_TOO_SHORT = 'NameTooShort', } export const KycAmlReasons = [ diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index e8cd420bc0..2a021e9ee2 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -69,6 +69,13 @@ export class AmlHelperService { if (!entity.userData.verifiedName) errors.push(AmlError.NO_VERIFIED_NAME); if (!entity.userData.verifiedName && !bankData?.name && !entity.userData.completeName) errors.push(AmlError.NAME_MISSING); + + // Check name length (min 4 letters for bank processing) + const completeName = entity.userData.verifiedName ?? bankData?.name ?? entity.userData.completeName; + if (completeName && this.countLetters(completeName) < 4) { + errors.push(AmlError.NAME_TOO_SHORT); + } + if (entity.userData.verifiedCountry && !entity.userData.verifiedCountry.fatfEnable) errors.push(AmlError.VERIFIED_COUNTRY_NOT_ALLOWED); if (ibanCountry && !ibanCountry.fatfEnable) errors.push(AmlError.IBAN_COUNTRY_FATF_NOT_ALLOWED); @@ -606,4 +613,8 @@ export class AmlHelperService { // No Result - only comment return { bankData, comment }; } + + private static countLetters(str: string): number { + return str.replace(/[^\p{L}]/gu, '').length; + } } diff --git a/src/subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts index 552fe04110..83c796df25 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/dfx-dex.adapter.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { ExchangeRegistryService } from 'src/integration/exchange/services/exchange-registry.service'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { LiquidityOrderContext } from 'src/subdomains/supporting/dex/entities/liquidity-order.entity'; import { ReserveLiquidityRequest } from 'src/subdomains/supporting/dex/interfaces'; @@ -40,10 +42,8 @@ export class DfxDexAdapter extends LiquidityActionAdapter { async checkCompletion(order: LiquidityManagementOrder): Promise { switch (order.action.command) { case DfxDexAdapterCommands.PURCHASE: - return this.checkSellPurchaseCompletion(order); - case DfxDexAdapterCommands.SELL: - return this.checkSellPurchaseCompletion(order); + return this.checkSwapCompletion(order); case DfxDexAdapterCommands.WITHDRAW: return this.checkWithdrawCompletion(order); @@ -59,10 +59,8 @@ export class DfxDexAdapter extends LiquidityActionAdapter { return this.validateWithdrawParams(params); case DfxDexAdapterCommands.PURCHASE: - return true; - case DfxDexAdapterCommands.SELL: - return true; + return this.validateSwapParams(params); default: throw new Error(`Command ${command} not supported by DfxDexAdapter`); @@ -73,23 +71,43 @@ export class DfxDexAdapter extends LiquidityActionAdapter { /** * @note - * correlationId is the orderId and set by liquidity management + * correlationId is the orderId and set by liquidity management. + * targetAsset (from rule) is what we're buying, swapAsset is what we spend. + * Amount is in target asset (targetAsset). */ private async purchase(order: LiquidityManagementOrder): Promise { const { pipeline: { - rule: { targetAsset: asset }, + rule: { targetAsset }, }, - maxAmount: amount, id: correlationId, + minAmount, + maxAmount, } = order; + const { swapAsset: swapAssetName } = this.parseSwapParams(order.action.paramMap); + const swapAsset = await this.getSwapAsset(targetAsset.blockchain, swapAssetName); + + const price = await this.dexService.calculatePrice(swapAsset, targetAsset); + const minSwapAmount = minAmount * price; + const maxSwapAmount = maxAmount * price; + + const swapLiquidity = await this.resolveSwapLiquidity( + correlationId.toString(), + swapAsset, + minSwapAmount, + maxSwapAmount, + ); + + order.inputAmount = swapLiquidity.amount; + order.inputAsset = swapLiquidity.asset.name; + const request = { context: LiquidityOrderContext.LIQUIDITY_MANAGEMENT, correlationId: correlationId.toString(), - referenceAsset: asset, - referenceAmount: amount, - targetAsset: asset, + referenceAsset: swapLiquidity.asset, + referenceAmount: swapLiquidity.amount, + targetAsset, }; await this.dexService.purchaseLiquidity(request); @@ -99,25 +117,37 @@ export class DfxDexAdapter extends LiquidityActionAdapter { /** * @note - * correlationId is the orderId and set by liquidity management + * correlationId is the orderId and set by liquidity management. + * targetAsset (from rule) is what we're selling, swapAsset is what we receive. + * Amount is in source asset (targetAsset). */ private async sell(order: LiquidityManagementOrder): Promise { const { pipeline: { - rule: { targetAsset: asset }, + rule: { targetAsset }, }, - maxAmount: amount, id: correlationId, + minAmount, + maxAmount, } = order; + const { swapAsset: swapAssetName } = this.parseSwapParams(order.action.paramMap); + const swapAsset = await this.getSwapAsset(targetAsset.blockchain, swapAssetName); + + const sellLiquidity = await this.resolveSwapLiquidity(correlationId.toString(), targetAsset, minAmount, maxAmount); + + order.inputAmount = sellLiquidity.amount; + order.inputAsset = sellLiquidity.asset.name; + const request = { context: LiquidityOrderContext.LIQUIDITY_MANAGEMENT, correlationId: correlationId.toString(), - sellAsset: asset, - sellAmount: amount, + referenceAsset: sellLiquidity.asset, + referenceAmount: sellLiquidity.amount, + targetAsset: swapAsset, }; - await this.dexService.sellLiquidity(request); + await this.dexService.purchaseLiquidity(request); return correlationId.toString(); } @@ -163,7 +193,7 @@ export class DfxDexAdapter extends LiquidityActionAdapter { // --- COMPLETION CHECKS --- // - private async checkSellPurchaseCompletion(order: LiquidityManagementOrder): Promise { + private async checkSwapCompletion(order: LiquidityManagementOrder): Promise { try { const result = await this.dexService.checkOrderReady( LiquidityOrderContext.LIQUIDITY_MANAGEMENT, @@ -172,6 +202,9 @@ export class DfxDexAdapter extends LiquidityActionAdapter { if (result.isReady) { await this.dexService.completeOrders(LiquidityOrderContext.LIQUIDITY_MANAGEMENT, order.correlationId); + + order.outputAmount = result.targetAmount; + order.outputAsset = result.targetAsset; } return result.isReady; @@ -235,4 +268,68 @@ export class DfxDexAdapter extends LiquidityActionAdapter { this.exchangeRegistry.get(system) ); } + + private validateSwapParams(params: Record): boolean { + try { + this.parseSwapParams(params); + return true; + } catch { + return false; + } + } + + private parseSwapParams(params: Record): { swapAsset: string } { + const swapAsset = params?.tradeAsset as string | undefined; + + if (!(typeof swapAsset === 'string' && swapAsset.length > 0)) + throw new Error('Params provided to DfxDexAdapter swap command are invalid.'); + + return { swapAsset }; + } + + // --- SWAP HELPERS --- // + + private async resolveSwapLiquidity( + correlationId: string, + liquidityAsset: Asset, + minAmount: number, + maxAmount: number, + ): Promise<{ asset: Asset; amount: number }> { + // Check available liquidity + const checkRequest = { + context: LiquidityOrderContext.LIQUIDITY_MANAGEMENT, + correlationId, + referenceAsset: liquidityAsset, + referenceAmount: minAmount, + targetAsset: liquidityAsset, + }; + + const { + reference: { availableAmount }, + } = await this.dexService.checkLiquidity(checkRequest); + + if (availableAmount < minAmount) { + throw new OrderNotProcessableException( + `Not enough ${liquidityAsset.name} liquidity (balance: ${availableAmount}, min. requested: ${minAmount}, max. requested: ${maxAmount})`, + ); + } + + const amount = Math.min(maxAmount, availableAmount); + + return { asset: liquidityAsset, amount }; + } + + private async getSwapAsset(blockchain: Blockchain, swapAssetName: string): Promise { + const swapAsset = await this.assetService.getAssetByQuery({ + name: swapAssetName, + blockchain, + type: AssetType.TOKEN, + }); + + if (!swapAsset) { + throw new OrderNotProcessableException(`Swap asset ${swapAssetName} not found on ${blockchain}`); + } + + return swapAsset; + } } diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts index a836ab9768..b7dcf8fc6a 100644 --- a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts @@ -15,6 +15,7 @@ import { createCustomFiatOutput } from 'src/subdomains/supporting/fiat-output/__ import { FiatOutputService } from 'src/subdomains/supporting/fiat-output/fiat-output.service'; import { createCustomCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; +import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; @@ -57,6 +58,7 @@ describe('BuyFiatService', () => { let transactionHelper: TransactionHelper; let custodyOrderService: CustodyOrderService; let supportLogService: SupportLogService; + let payoutService: PayoutService; beforeEach(async () => { buyFiatRepo = createMock(); @@ -78,6 +80,7 @@ describe('BuyFiatService', () => { transactionHelper = createMock(); custodyOrderService = createMock(); supportLogService = createMock(); + payoutService = createMock(); const module: TestingModule = await Test.createTestingModule({ imports: [TestSharedModule], @@ -102,6 +105,7 @@ describe('BuyFiatService', () => { { provide: TransactionHelper, useValue: transactionHelper }, { provide: CustodyOrderService, useValue: custodyOrderService }, { provide: SupportLogService, useValue: supportLogService }, + { provide: PayoutService, useValue: payoutService }, ], }).compile(); diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts index 89f8d71124..0b1ee1cece 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts @@ -3,6 +3,8 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; import { CryptoInput, PayInPurpose, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; +import { PayoutOrderContext } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; +import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; import { IsNull, Not } from 'typeorm'; import { SellRepository } from '../../route/sell.repository'; import { BuyFiatRepository } from '../buy-fiat.repository'; @@ -24,29 +26,53 @@ export class BuyFiatRegistrationService { private readonly sellRepository: SellRepository, private readonly payInService: PayInService, private readonly transactionHelper: TransactionHelper, + private readonly payoutService: PayoutService, ) {} async syncReturnTxId(): Promise { + const baseWhere = { chargebackAllowedDate: Not(IsNull()), chargebackTxId: IsNull() }; + const entities = await this.buyFiatRepo.find({ - where: { - cryptoInput: { returnTxId: Not(IsNull()), status: PayInStatus.RETURN_CONFIRMED }, - chargebackTxId: IsNull(), - }, + where: [ + // PayIn returned + { ...baseWhere, cryptoInput: { status: PayInStatus.RETURN_CONFIRMED, returnTxId: Not(IsNull()) } }, + // Payout forwarded + { ...baseWhere, cryptoInput: { status: PayInStatus.FORWARD_CONFIRMED } }, + ], relations: { cryptoInput: true, sell: true, transaction: { user: { wallet: true }, userData: true } }, }); for (const entity of entities) { try { - await this.buyFiatRepo.update(entity.id, { chargebackTxId: entity.cryptoInput.returnTxId, isComplete: true }); + const txId = await this.getReturnTxId(entity); + if (!txId) continue; - // send webhook + await this.buyFiatRepo.update(entity.id, { chargebackTxId: txId, isComplete: true }); await this.buyFiatService.triggerWebhook(entity); } catch (e) { - this.logger.error(`Error during buyFiat payIn returnTxId sync (${entity.id}):`, e); + this.logger.error(`Error during buyFiat returnTxId sync (${entity.id}):`, e); } } } + private async getReturnTxId(entity: { id: number; cryptoInput: CryptoInput }): Promise { + // PayIn return (funds were on deposit address) + if (entity.cryptoInput.status === PayInStatus.RETURN_CONFIRMED && entity.cryptoInput.returnTxId) { + return entity.cryptoInput.returnTxId; + } + + // Payout return (funds were forwarded to liquidity) + if (entity.cryptoInput.status === PayInStatus.FORWARD_CONFIRMED) { + const { isComplete, payoutTxId } = await this.payoutService.checkOrderCompletion( + PayoutOrderContext.BUY_FIAT_RETURN, + `${entity.id}`, + ); + return isComplete ? payoutTxId : undefined; + } + + return undefined; + } + async registerSellPayIn(): Promise { const newPayIns = await this.payInService.getNewPayIns(); diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts index c83829a118..ccbbeaeca4 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts @@ -14,13 +14,15 @@ import { CreateBankDataDto } from 'src/subdomains/generic/user/models/bank-data/ import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { WebhookService } from 'src/subdomains/generic/user/services/webhook/webhook.service'; import { BankTxService } from 'src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service'; -import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { CryptoInput, PayInStatus } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; import { TransactionTypeInternal } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { TransactionHelper } from 'src/subdomains/supporting/payment/services/transaction-helper'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; +import { PayoutOrderContext } from 'src/subdomains/supporting/payout/entities/payout-order.entity'; +import { PayoutService } from 'src/subdomains/supporting/payout/services/payout.service'; import { SupportLogType } from 'src/subdomains/supporting/support-issue/enums/support-log.enum'; import { SupportLogService } from 'src/subdomains/supporting/support-issue/services/support-log.service'; import { Between, FindOptionsRelations, In, MoreThan } from 'typeorm'; @@ -68,6 +70,7 @@ export class BuyFiatService { @Inject(forwardRef(() => CustodyOrderService)) private readonly custodyOrderService: CustodyOrderService, private readonly supportLogService: SupportLogService, + private readonly payoutService: PayoutService, ) {} async createFromCryptoInput(cryptoInput: CryptoInput, sell: Sell, request?: TransactionRequest): Promise { @@ -301,11 +304,22 @@ export class BuyFiatService { let blockchainFee: number; if (dto.chargebackAllowedDate && chargebackAmount) { blockchainFee = await this.transactionHelper.getBlockchainFee(buyFiat.cryptoInput.asset, true); - await this.payInService.returnPayIn( - buyFiat.cryptoInput, - refundUser.address ?? buyFiat.chargebackAddress, - chargebackAmount, - ); + + const returnAddress = refundUser.address ?? buyFiat.chargebackAddress; + + if (buyFiat.cryptoInput.status === PayInStatus.FORWARD_CONFIRMED) { + // Funds already forwarded to liquidity - use PayoutOrder to return + await this.payoutService.doPayout({ + context: PayoutOrderContext.BUY_FIAT_RETURN, + correlationId: `${buyFiat.id}`, + asset: buyFiat.cryptoInput.asset, + amount: chargebackAmount, + destinationAddress: returnAddress, + }); + } else { + // Funds still on deposit address - use PayIn return + await this.payInService.returnPayIn(buyFiat.cryptoInput, returnAddress, chargebackAmount); + } } await this.buyFiatRepo.update( diff --git a/src/subdomains/core/sell-crypto/sell-crypto.module.ts b/src/subdomains/core/sell-crypto/sell-crypto.module.ts index a30334ba74..dd188325c3 100644 --- a/src/subdomains/core/sell-crypto/sell-crypto.module.ts +++ b/src/subdomains/core/sell-crypto/sell-crypto.module.ts @@ -9,6 +9,7 @@ import { BankModule } from 'src/subdomains/supporting/bank/bank.module'; import { FiatOutputModule } from 'src/subdomains/supporting/fiat-output/fiat-output.module'; import { NotificationModule } from 'src/subdomains/supporting/notification/notification.module'; import { PayInModule } from 'src/subdomains/supporting/payin/payin.module'; +import { PayoutModule } from 'src/subdomains/supporting/payout/payout.module'; import { PaymentModule } from 'src/subdomains/supporting/payment/payment.module'; import { TransactionModule } from 'src/subdomains/supporting/payment/transaction.module'; import { PricingModule } from 'src/subdomains/supporting/pricing/pricing.module'; @@ -42,6 +43,7 @@ import { SellService } from './route/sell.service'; forwardRef(() => BankModule), forwardRef(() => BankTxModule), forwardRef(() => PayInModule), + PayoutModule, forwardRef(() => BuyCryptoModule), forwardRef(() => AddressPoolModule), FiatOutputModule, diff --git a/src/subdomains/generic/user/models/bank-data/dto/__tests__/bank-data-dto.spec.ts b/src/subdomains/generic/user/models/bank-data/dto/__tests__/bank-data-dto.spec.ts new file mode 100644 index 0000000000..a8c058c4fb --- /dev/null +++ b/src/subdomains/generic/user/models/bank-data/dto/__tests__/bank-data-dto.spec.ts @@ -0,0 +1,73 @@ +import { plainToInstance } from 'class-transformer'; +import { CreateBankDataDto } from '../create-bank-data.dto'; +import { UpdateBankDataDto } from '../update-bank-data.dto'; + +describe('BankDataDto', () => { + describe('CreateBankDataDto', () => { + describe('name transform', () => { + it('should keep valid name unchanged', () => { + const dto = plainToInstance(CreateBankDataDto, { iban: 'CH123', name: 'Max Mustermann' }); + expect(dto.name).toBe('Max Mustermann'); + }); + + it('should trim whitespace from name', () => { + const dto = plainToInstance(CreateBankDataDto, { iban: 'CH123', name: ' Max Mustermann ' }); + expect(dto.name).toBe('Max Mustermann'); + }); + + it('should transform empty string to null', () => { + const dto = plainToInstance(CreateBankDataDto, { iban: 'CH123', name: '' }); + expect(dto.name).toBeUndefined(); + }); + + it('should transform whitespace-only string to null', () => { + const dto = plainToInstance(CreateBankDataDto, { iban: 'CH123', name: ' ' }); + expect(dto.name).toBeUndefined(); + }); + + it('should transform undefined to null', () => { + const dto = plainToInstance(CreateBankDataDto, { iban: 'CH123', name: undefined }); + expect(dto.name).toBeUndefined(); + }); + + it('should transform null to null', () => { + const dto = plainToInstance(CreateBankDataDto, { iban: 'CH123', name: null }); + expect(dto.name).toBeUndefined(); + }); + }); + }); + + describe('UpdateBankDataDto', () => { + describe('name transform', () => { + it('should keep valid name unchanged', () => { + const dto = plainToInstance(UpdateBankDataDto, { name: 'Max Mustermann' }); + expect(dto.name).toBe('Max Mustermann'); + }); + + it('should trim whitespace from name', () => { + const dto = plainToInstance(UpdateBankDataDto, { name: ' Max Mustermann ' }); + expect(dto.name).toBe('Max Mustermann'); + }); + + it('should transform empty string to null', () => { + const dto = plainToInstance(UpdateBankDataDto, { name: '' }); + expect(dto.name).toBeUndefined(); + }); + + it('should transform whitespace-only string to null', () => { + const dto = plainToInstance(UpdateBankDataDto, { name: ' ' }); + expect(dto.name).toBeUndefined(); + }); + + it('should transform undefined to null', () => { + const dto = plainToInstance(UpdateBankDataDto, { name: undefined }); + expect(dto.name).toBeUndefined(); + }); + + it('should transform null to null', () => { + const dto = plainToInstance(UpdateBankDataDto, { name: null }); + expect(dto.name).toBeUndefined(); + }); + }); + }); +}); diff --git a/src/subdomains/generic/user/models/bank-data/dto/create-bank-data.dto.ts b/src/subdomains/generic/user/models/bank-data/dto/create-bank-data.dto.ts index ec32ae40e5..3f6d6aa905 100644 --- a/src/subdomains/generic/user/models/bank-data/dto/create-bank-data.dto.ts +++ b/src/subdomains/generic/user/models/bank-data/dto/create-bank-data.dto.ts @@ -17,6 +17,7 @@ export class CreateBankDataDto { @IsOptional() @IsString() + @Transform(({ value }) => value?.trim() || undefined) name?: string; @IsOptional() diff --git a/src/subdomains/generic/user/models/bank-data/dto/update-bank-data.dto.ts b/src/subdomains/generic/user/models/bank-data/dto/update-bank-data.dto.ts index 0dfc6102fb..d4c13117d5 100644 --- a/src/subdomains/generic/user/models/bank-data/dto/update-bank-data.dto.ts +++ b/src/subdomains/generic/user/models/bank-data/dto/update-bank-data.dto.ts @@ -1,3 +1,4 @@ +import { Transform } from 'class-transformer'; import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { UpdateBankAccountDto } from 'src/subdomains/supporting/bank/bank-account/dto/update-bank-account.dto'; @@ -6,6 +7,7 @@ import { BankDataType } from '../bank-data.entity'; export class UpdateBankDataDto extends UpdateBankAccountDto { @IsOptional() @IsString() + @Transform(({ value }) => value?.trim() || undefined) name?: string; @IsOptional() diff --git a/src/subdomains/supporting/dex/services/dex.service.ts b/src/subdomains/supporting/dex/services/dex.service.ts index a657657ba6..5d910cb424 100644 --- a/src/subdomains/supporting/dex/services/dex.service.ts +++ b/src/subdomains/supporting/dex/services/dex.service.ts @@ -170,13 +170,15 @@ export class DexService { async checkOrderReady( context: LiquidityOrderContext, correlationId: string, - ): Promise<{ isReady: boolean; purchaseTxId: string }> { + ): Promise<{ isReady: boolean; purchaseTxId: string; targetAmount: number; targetAsset: string }> { const order = await this.liquidityOrderRepo.findOneBy({ context, correlationId }); - const purchaseTxId = order && order.txId; - const isReady = order && order.isReady; + const purchaseTxId = order?.txId; + const isReady = order?.isReady ?? false; + const targetAmount = order?.targetAmount; + const targetAsset = order?.targetAsset?.name; - return { isReady, purchaseTxId }; + return { isReady, purchaseTxId, targetAmount, targetAsset }; } async checkOrderCompletion( diff --git a/src/subdomains/supporting/payment/dto/transaction.dto.ts b/src/subdomains/supporting/payment/dto/transaction.dto.ts index 6dd3d3a86d..2f7876c026 100644 --- a/src/subdomains/supporting/payment/dto/transaction.dto.ts +++ b/src/subdomains/supporting/payment/dto/transaction.dto.ts @@ -114,6 +114,7 @@ export const TransactionReasonMapper: { [AmlReason.BANK_RELEASE_PENDING]: TransactionReason.BANK_RELEASE_PENDING, [AmlReason.VIRTUAL_IBAN_USER_MISMATCH]: TransactionReason.UNKNOWN, [AmlReason.INTERMEDIARY_WITHOUT_SENDER]: TransactionReason.BANK_NOT_ALLOWED, + [AmlReason.NAME_TOO_SHORT]: TransactionReason.KYC_DATA_NEEDED, }; export class UnassignedTransactionDto { diff --git a/src/subdomains/supporting/realunit/__tests__/realunit-dev.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit-dev.service.spec.ts index 652abe997b..6df4881e3e 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit-dev.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit-dev.service.spec.ts @@ -100,14 +100,6 @@ describe('RealUnitDevService', () => { let transactionService: jest.Mocked; let buyCryptoRepo: jest.Mocked; - const mainnetRealuAsset = createCustomAsset({ - id: 399, - name: 'REALU', - blockchain: Blockchain.ETHEREUM, - type: AssetType.TOKEN, - decimals: 0, - }); - const sepoliaRealuAsset = createCustomAsset({ id: 408, name: 'REALU', @@ -144,7 +136,7 @@ describe('RealUnitDevService', () => { id: 7, amount: 100, sourceId: 1, - targetId: 399, + targetId: 408, // Sepolia REALU asset ID routeId: 1, status: TransactionRequestStatus.WAITING_FOR_PAYMENT, type: TransactionRequestType.BUY, @@ -244,37 +236,25 @@ describe('RealUnitDevService', () => { it('should execute on DEV environment', async () => { (global as any).__mockEnvironment = 'dev'; - assetService.getAssetByQuery.mockResolvedValueOnce(mainnetRealuAsset); assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); transactionRequestRepo.find.mockResolvedValue([]); await service.simulateRealuPayments(); - expect(assetService.getAssetByQuery).toHaveBeenCalledTimes(2); + expect(assetService.getAssetByQuery).toHaveBeenCalledTimes(1); }); it('should execute on LOC environment', async () => { (global as any).__mockEnvironment = 'loc'; - assetService.getAssetByQuery.mockResolvedValueOnce(mainnetRealuAsset); assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); transactionRequestRepo.find.mockResolvedValue([]); await service.simulateRealuPayments(); - expect(assetService.getAssetByQuery).toHaveBeenCalledTimes(2); - }); - - it('should skip if mainnet REALU asset not found', async () => { - assetService.getAssetByQuery.mockResolvedValueOnce(null); - assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); - - await service.simulateRealuPayments(); - - expect(transactionRequestRepo.find).not.toHaveBeenCalled(); + expect(assetService.getAssetByQuery).toHaveBeenCalledTimes(1); }); it('should skip if sepolia REALU asset not found', async () => { - assetService.getAssetByQuery.mockResolvedValueOnce(mainnetRealuAsset); assetService.getAssetByQuery.mockResolvedValueOnce(null); await service.simulateRealuPayments(); @@ -283,7 +263,6 @@ describe('RealUnitDevService', () => { }); it('should skip if no waiting requests', async () => { - assetService.getAssetByQuery.mockResolvedValueOnce(mainnetRealuAsset); assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); transactionRequestRepo.find.mockResolvedValue([]); @@ -292,8 +271,7 @@ describe('RealUnitDevService', () => { expect(buyService.getBuyByKey).not.toHaveBeenCalled(); }); - it('should query for WAITING_FOR_PAYMENT requests with mainnet REALU targetId', async () => { - assetService.getAssetByQuery.mockResolvedValueOnce(mainnetRealuAsset); + it('should query for WAITING_FOR_PAYMENT requests with sepolia REALU targetId', async () => { assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); transactionRequestRepo.find.mockResolvedValue([]); @@ -303,7 +281,7 @@ describe('RealUnitDevService', () => { where: { status: TransactionRequestStatus.WAITING_FOR_PAYMENT, type: TransactionRequestType.BUY, - targetId: 399, + targetId: 408, }, }); }); @@ -311,7 +289,6 @@ describe('RealUnitDevService', () => { describe('simulatePaymentForRequest', () => { beforeEach(() => { - assetService.getAssetByQuery.mockResolvedValueOnce(mainnetRealuAsset); assetService.getAssetByQuery.mockResolvedValueOnce(sepoliaRealuAsset); }); diff --git a/src/subdomains/supporting/realunit/realunit-dev.service.ts b/src/subdomains/supporting/realunit/realunit-dev.service.ts index 174ecc52d2..9025c16cca 100644 --- a/src/subdomains/supporting/realunit/realunit-dev.service.ts +++ b/src/subdomains/supporting/realunit/realunit-dev.service.ts @@ -28,6 +28,12 @@ import { TransactionService } from '../payment/services/transaction.service'; @Injectable() export class RealUnitDevService { private readonly logger = new DfxLogger(RealUnitDevService); + private readonly tokenName = 'REALU'; + private readonly tokenBlockchain = Blockchain.SEPOLIA; + + private get isDevEnvironment(): boolean { + return [Environment.DEV, Environment.LOC].includes(Config.environment); + } constructor( private readonly transactionRequestRepo: TransactionRequestRepository, @@ -44,7 +50,7 @@ export class RealUnitDevService { @Cron(CronExpression.EVERY_MINUTE) @Lock(60) async simulateRealuPayments(): Promise { - if (![Environment.DEV, Environment.LOC].includes(Config.environment)) return; + if (!this.isDevEnvironment) return; try { await this.processWaitingRealuRequests(); @@ -54,22 +60,14 @@ export class RealUnitDevService { } private async processWaitingRealuRequests(): Promise { - // TransactionRequests are created with Mainnet REALU (via realunit.service.ts) - const mainnetRealuAsset = await this.assetService.getAssetByQuery({ - name: 'REALU', - blockchain: Blockchain.ETHEREUM, - type: AssetType.TOKEN, - }); - - // But payouts go to Sepolia in DEV environment - const sepoliaRealuAsset = await this.assetService.getAssetByQuery({ - name: 'REALU', - blockchain: Blockchain.SEPOLIA, + const realuAsset = await this.assetService.getAssetByQuery({ + name: this.tokenName, + blockchain: this.tokenBlockchain, type: AssetType.TOKEN, }); - if (!mainnetRealuAsset || !sepoliaRealuAsset) { - this.logger.warn('REALU asset not found (mainnet or sepolia) - skipping simulation'); + if (!realuAsset) { + this.logger.warn('REALU asset not found - skipping buy simulation'); return; } @@ -77,7 +75,7 @@ export class RealUnitDevService { where: { status: TransactionRequestStatus.WAITING_FOR_PAYMENT, type: TransactionRequestType.BUY, - targetId: mainnetRealuAsset.id, + targetId: realuAsset.id, }, }); @@ -87,7 +85,7 @@ export class RealUnitDevService { for (const request of waitingRequests) { try { - await this.simulatePaymentForRequest(request, sepoliaRealuAsset); + await this.simulatePaymentForRequest(request, realuAsset); } catch (e) { this.logger.error(`Failed to simulate payment for TransactionRequest ${request.id}:`, e); } diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 33b0abaf93..3d539bf539 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -8,7 +8,7 @@ import { } from '@nestjs/common'; import { verifyTypedData } from 'ethers/lib/utils'; import { request } from 'graphql-request'; -import { Config, GetConfig } from 'src/config/config'; +import { Config, Environment, GetConfig } from 'src/config/config'; import { BrokerbotBuyPriceDto, BrokerbotInfoDto, @@ -75,7 +75,9 @@ export class RealUnitService { private readonly ponderUrl: string; private readonly genesisDate = new Date('2022-04-12 07:46:41.000'); private readonly tokenName = 'REALU'; - private readonly tokenBlockchain = Blockchain.ETHEREUM; + private readonly tokenBlockchain = [Environment.DEV, Environment.LOC].includes(Config.environment) + ? Blockchain.SEPOLIA + : Blockchain.ETHEREUM; private readonly historicalPriceCache = new AsyncCache(CacheItemResetPeriod.EVERY_6_HOURS); constructor(