diff --git a/.env.example b/.env.example index 4f4202e873..d0cc361a06 100644 --- a/.env.example +++ b/.env.example @@ -36,9 +36,13 @@ ETH_WALLET_ADDRESS= ETH_WALLET_PRIVATE_KEY= ETH_GATEWAY_URL= ETH_API_KEY= +ETH_SWAP_CONTRACT_ADDRESS= +ETH_SWAP_TOKEN_ADDRESS= BSC_WALLET_ADDRESS= BSC_WALLET_PRIVATE_KEY= BSC_GATEWAY_URL= +BSC_SWAP_CONTRACT_ADDRESS= +BSC_SWAP_TOKEN_ADDRESS= LETTER_USER= LETTER_AUTH= LETTER_URL= diff --git a/infrastructure/bicep/dfx-api.bicep b/infrastructure/bicep/dfx-api.bicep index 8d2d85ea0d..22882dda66 100644 --- a/infrastructure/bicep/dfx-api.bicep +++ b/infrastructure/bicep/dfx-api.bicep @@ -45,11 +45,15 @@ param ethWalletPrivateKey string param ethGatewayUrl string @secure() param ethApiKey string +param ethSwapContractAddress string +param ethSwapTokenAddress string param bscWalletAddress string @secure() param bscWalletPrivateKey string param bscGatewayUrl string +param bscSwapContractAddress string +param bscSwapTokenAddress string param nodeServicePlanSkuName string param nodeServicePlanSkuTier string @@ -113,7 +117,6 @@ param paymentUrl string @secure() param lockApiKey string - // --- VARIABLES --- // var compName = 'dfx' var apiName = 'api' @@ -134,7 +137,6 @@ var apiServicePlanName = 'plan-${compName}-${apiName}-${env}' var apiAppName = 'app-${compName}-${apiName}-${env}' var appInsightsName = 'appi-${compName}-${apiName}-${env}' - var nodeProps = [ { name: 'nodes-input-${env}' @@ -246,7 +248,6 @@ resource virtualNet 'Microsoft.Network/virtualNetworks@2020-11-01' = { } } - // Storage Account resource storageAccount 'Microsoft.Storage/storageAccounts@2021-04-01' = { name: storageAccountName @@ -267,7 +268,6 @@ resource dbBackupContainer 'Microsoft.Storage/storageAccounts/blobServices/conta name: '${storageAccount.name}/default/${dbBackupContainerName}' } - // SQL Database resource sqlServer 'Microsoft.Sql/servers@2021-02-01-preview' = { name: sqlServerName @@ -279,11 +279,11 @@ resource sqlServer 'Microsoft.Sql/servers@2021-02-01-preview' = { } resource sqlVNetRule 'Microsoft.Sql/servers/virtualNetworkRules@2021-02-01-preview' = { - parent: sqlServer - name: 'apiVNetRule' - properties: { - virtualNetworkSubnetId: virtualNet.properties.subnets[0].id - } + parent: sqlServer + name: 'apiVNetRule' + properties: { + virtualNetworkSubnetId: virtualNet.properties.subnets[0].id + } } resource sqlAllRule 'Microsoft.Sql/servers/firewallRules@2021-02-01-preview' = if (dbAllowAllIps) { @@ -326,14 +326,13 @@ resource sqlDbLtrPolicy 'Microsoft.Sql/servers/databases/backupLongTermRetention } } - // API App Service resource appServicePlan 'Microsoft.Web/serverfarms@2018-02-01' = { name: apiServicePlanName location: location kind: 'linux' properties: { - reserved: true + reserved: true } sku: { name: 'P1v2' @@ -350,7 +349,7 @@ resource apiAppService 'Microsoft.Web/sites@2018-11-01' = { serverFarmId: appServicePlan.id httpsOnly: true virtualNetworkSubnetId: virtualNet.properties.subnets[0].id - + siteConfig: { alwaysOn: true linuxFxVersion: 'NODE|14-lts' @@ -359,7 +358,7 @@ resource apiAppService 'Microsoft.Web/sites@2018-11-01' = { logsDirectorySizeLimit: 100 vnetRouteAllEnabled: true scmIpSecurityRestrictionsUseMain: true - + appSettings: [ { name: 'APPINSIGHTS_INSTRUMENTATIONKEY' @@ -534,6 +533,14 @@ resource apiAppService 'Microsoft.Web/sites@2018-11-01' = { name: 'ETH_API_KEY' value: ethApiKey } + { + name: 'ETH_SWAP_CONTRACT_ADDRESS' + value: ethSwapContractAddress + } + { + name: 'ETH_SWAP_TOKEN_ADDRESS' + value: ethSwapTokenAddress + } { name: 'BSC_WALLET_ADDRESS' value: bscWalletAddress @@ -546,6 +553,14 @@ resource apiAppService 'Microsoft.Web/sites@2018-11-01' = { name: 'BSC_GATEWAY_URL' value: bscGatewayUrl } + { + name: 'BSC_SWAP_CONTRACT_ADDRESS' + value: bscSwapContractAddress + } + { + name: 'BSC_SWAP_TOKEN_ADDRESS' + value: bscSwapTokenAddress + } { name: 'BTC_COLLECTOR_ADDRESS' value: btcCollectorAddress @@ -679,7 +694,6 @@ resource appInsights 'microsoft.insights/components@2020-02-02-preview' = { } } - // DeFi Nodes module nodes 'defi-node.bicep' = [for node in nodeProps: { name: node.name @@ -699,7 +713,6 @@ module nodes 'defi-node.bicep' = [for node in nodeProps: { } }] - // BTC Node resource vmNsg 'Microsoft.Network/networkSecurityGroups@2020-11-01' = { name: vmNsgName diff --git a/infrastructure/bicep/parameters/dev.json b/infrastructure/bicep/parameters/dev.json index a4c441ab86..a60e92d50f 100644 --- a/infrastructure/bicep/parameters/dev.json +++ b/infrastructure/bicep/parameters/dev.json @@ -86,6 +86,12 @@ "ethApiKey": { "value": "xxx" }, + "ethSwapContractAddress": { + "value": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D" + }, + "ethSwapTokenAddress": { + "value": "0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6" + }, "bscWalletAddress": { "value": "xxx" }, @@ -95,6 +101,12 @@ "bscGatewayUrl": { "value": "https://data-seed-prebsc-1-s1.binance.org:8545" }, + "bscSwapContractAddress": { + "value": "0xD99D1c33F9fC3444f8101754aBC46c52416550D1" + }, + "bscSwapTokenAddress": { + "value": "0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd" + }, "btcCollectorAddress": { "value": "xxx" }, diff --git a/infrastructure/bicep/parameters/loc.json b/infrastructure/bicep/parameters/loc.json index 07e6d1edc6..f2f99b3a3a 100644 --- a/infrastructure/bicep/parameters/loc.json +++ b/infrastructure/bicep/parameters/loc.json @@ -86,6 +86,12 @@ "ethApiKey": { "value": "xxx" }, + "ethSwapContractAddress": { + "value": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D" + }, + "ethSwapTokenAddress": { + "value": "0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6" + }, "bscWalletAddress": { "value": "xxx" }, @@ -95,6 +101,12 @@ "bscGatewayUrl": { "value": "https://data-seed-prebsc-1-s1.binance.org:8545" }, + "bscSwapContractAddress": { + "value": "0xD99D1c33F9fC3444f8101754aBC46c52416550D1" + }, + "bscSwapTokenAddress": { + "value": "0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd" + }, "btcCollectorAddress": { "value": "xxx" }, diff --git a/infrastructure/bicep/parameters/prd.json b/infrastructure/bicep/parameters/prd.json index be80740fbf..d7772428b4 100644 --- a/infrastructure/bicep/parameters/prd.json +++ b/infrastructure/bicep/parameters/prd.json @@ -86,6 +86,12 @@ "ethApiKey": { "value": "xxx" }, + "ethSwapContractAddress": { + "value": "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D" + }, + "ethSwapTokenAddress": { + "value": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + }, "bscWalletAddress": { "value": "xxx" }, @@ -95,6 +101,12 @@ "bscGatewayUrl": { "value": "https://bsc-dataseed.binance.org" }, + "bscSwapContractAddress": { + "value": "0x10ED43C718714eb63d5aA57B78B54704E256024E" + }, + "bscSwapTokenAddress": { + "value": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c" + }, "btcCollectorAddress": { "value": "xxx" }, diff --git a/migration/1665408086368-assetChainId.js b/migration/1665408086368-assetChainId.js new file mode 100644 index 0000000000..ef50c84715 --- /dev/null +++ b/migration/1665408086368-assetChainId.js @@ -0,0 +1,13 @@ +const { MigrationInterface, QueryRunner } = require("typeorm"); + +module.exports = class assetChainId1665408086368 { + name = 'assetChainId1665408086368' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "asset" ALTER COLUMN "chainId" nvarchar(255)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "asset" ALTER COLUMN "chainId" int`); + } +} diff --git a/migration/1665486331833-notificationModule.js b/migration/1665486331833-notificationModule.js new file mode 100644 index 0000000000..0c97b819d1 --- /dev/null +++ b/migration/1665486331833-notificationModule.js @@ -0,0 +1,13 @@ +const { MigrationInterface, QueryRunner } = require("typeorm"); + +module.exports = class notificationModule1665486331833 { + name = 'notificationModule1665486331833' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "notification" ("id" int NOT NULL IDENTITY(1,1), "updated" datetime2 NOT NULL CONSTRAINT "DF_e3d9266f1a6d4cf00832ae607c3" DEFAULT getdate(), "created" datetime2 NOT NULL CONSTRAINT "DF_af7e45ec51e6aff202fbb030ecd" DEFAULT getdate(), "type" nvarchar(256) NOT NULL, "context" nvarchar(256) NOT NULL, "correlationId" nvarchar(MAX) NOT NULL, "sendDate" datetime2 NOT NULL, "suppressRecurring" bit NOT NULL CONSTRAINT "DF_830adad2aae5ed9e956909141fb" DEFAULT 0, "debounce" float, CONSTRAINT "PK_705b6c7cdf9b2c2ff7ac7872cb7" PRIMARY KEY ("id"))`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP TABLE "notification"`); + } +} diff --git a/migration/1665740859063-amountInColsBankTxReturn.js b/migration/1665740859063-amountInColsBankTxReturn.js new file mode 100644 index 0000000000..50e4ee7af1 --- /dev/null +++ b/migration/1665740859063-amountInColsBankTxReturn.js @@ -0,0 +1,17 @@ +const { MigrationInterface, QueryRunner } = require("typeorm"); + +module.exports = class amountInColsBankTxReturn1665740859063 { + name = 'amountInColsBankTxReturn1665740859063' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" ADD "amountInChf" float`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" ADD "amountInEur" float`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" ADD "amountInUsd" float`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP COLUMN "amountInUsd"`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP COLUMN "amountInEur"`); + await queryRunner.query(`ALTER TABLE "dbo"."bank_tx_return" DROP COLUMN "amountInChf"`); + } +} diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index c1cd787cdf..1e675f6108 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -11,6 +11,8 @@ import { } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth, ApiExcludeEndpoint } from '@nestjs/swagger'; +import { MailType } from 'src/notification/enums'; +import { NotificationService } from 'src/notification/services/notification.service'; import { BankTxType } from 'src/payment/models/bank-tx/bank-tx.entity'; import { BuyCrypto } from 'src/payment/models/buy-crypto/entities/buy-crypto.entity'; import { BuyCryptoService } from 'src/payment/models/buy-crypto/services/buy-crypto.service'; @@ -29,12 +31,12 @@ import { StakingRewardService } from 'src/payment/models/staking-reward/staking- import { RoleGuard } from 'src/shared/auth/role.guard'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { LetterService } from 'src/shared/services/letter.service'; -import { MailService } from 'src/shared/services/mail.service'; import { UserDataService } from 'src/user/models/user-data/user-data.service'; import { Customer } from 'src/user/services/spider/dto/spider.dto'; import { SpiderApiService } from 'src/user/services/spider/spider-api.service'; import { SpiderService } from 'src/user/services/spider/spider.service'; import { getConnection } from 'typeorm'; +import { dbQueryDto } from './dto/db-query.dto'; import { RenameRefDto } from './dto/rename-ref.dto'; import { SendLetterDto } from './dto/send-letter.dto'; import { SendMailDto } from './dto/send-mail.dto'; @@ -43,7 +45,7 @@ import { UploadFileDto } from './dto/upload-file.dto'; @Controller('admin') export class AdminController { constructor( - private readonly mailService: MailService, + private readonly notificationService: NotificationService, private readonly spiderService: SpiderService, private readonly spiderApiService: SpiderApiService, private readonly letterService: LetterService, @@ -63,7 +65,7 @@ export class AdminController { @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) async sendMail(@Body() dtoList: SendMailDto[]): Promise { for (const dto of dtoList) { - await this.mailService.sendMail(dto); + await this.notificationService.sendMail({ type: MailType.GENERIC, input: dto }); } } @@ -120,37 +122,32 @@ export class AdminController { @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) async getRawData( @Query() - { - table, - min, - updatedSince, - extended, - maxLine, - sorting = 'ASC', - }: { - table: string; - min?: string; - updatedSince?: string; - extended?: string; - maxLine?: string; - sorting?: 'ASC' | 'DESC'; - }, + query: dbQueryDto, ): Promise { - const id = min ? +min : 1; - const maxResult = maxLine ? +maxLine : undefined; - const updated = updatedSince ? new Date(updatedSince) : new Date(0); + const id = query.min ? +query.min : 1; + const maxResult = query.maxLine ? +query.maxLine : undefined; + const updated = query.updatedSince ? new Date(query.updatedSince) : new Date(0); let data: any[]; - if (extended && table === 'bank_tx') { - data = await this.getExtendedBankTxData(table, id, updated, maxResult, sorting); + if (query.extended && query.table === 'bank_tx') { + data = await this.getExtendedBankTxData({ + table: query.table, + min: id, + updatedSince: updated, + maxLine: maxResult, + sorting: query.sorting, + filterCols: query.filterCols, + extended: true, + }); } else { data = await getConnection() .createQueryBuilder() - .from(table, table) + .select(query.filterCols) + .from(query.table, query.table) .where('id >= :id', { id }) .andWhere('updated >= :updated', { updated }) - .orderBy('id', sorting) + .orderBy('id', query.sorting) .take(maxResult) .getRawMany() .catch((e: Error) => { @@ -169,7 +166,7 @@ export class AdminController { // workarounds for GS's if (arrayData) { - switch (table) { + switch (query.table) { case 'buy': const userTable = await getConnection().createQueryBuilder().from('user', 'user').getRawMany(); @@ -187,27 +184,28 @@ export class AdminController { return arrayData; } - private async getExtendedBankTxData( - table: string, - id: number, - updated: Date, - maxResult: number, - sorting: 'ASC' | 'DESC', - ): Promise { + private async getExtendedBankTxData(dbQuery: dbQueryDto): Promise { + const select = dbQuery.filterCols + ? dbQuery.filterCols + .split(',') + .map((e) => dbQuery.table + '.' + e) + .join(',') + : dbQuery.table; + const buyCryptoData = await getConnection() .createQueryBuilder() - .from(table, table) - .select('bank_tx', 'bankTx') + .from(dbQuery.table, dbQuery.table) + .select(select) .addSelect('userData.id', 'userDataId') .leftJoin('bank_tx.buyCrypto', 'buyCrypto') .leftJoin('buyCrypto.buy', 'buy') .leftJoin('buy.user', 'user') .leftJoin('user.userData', 'userData') - .where('bank_tx.id >= :id', { id }) - .andWhere('bank_tx.updated >= :updated', { updated }) + .where('bank_tx.id >= :id', { id: dbQuery.min }) + .andWhere('bank_tx.updated >= :updated', { updated: dbQuery.updatedSince }) .andWhere('bank_tx.type = :type', { type: BankTxType.BUY_CRYPTO }) - .orderBy('bank_tx.id', sorting) - .take(maxResult) + .orderBy('bank_tx.id', dbQuery.sorting) + .take(dbQuery.maxLine) .getRawMany() .catch((e: Error) => { throw new BadRequestException(e.message); @@ -215,18 +213,18 @@ export class AdminController { const buyFiatData = await getConnection() .createQueryBuilder() - .from(table, table) - .select('bank_tx', 'bankTx') + .from(dbQuery.table, dbQuery.table) + .select(select) .addSelect('userData.id', 'userDataId') .leftJoin('bank_tx.buyFiat', 'buyFiat') .leftJoin('buyFiat.sell', 'sell') .leftJoin('sell.user', 'user') .leftJoin('user.userData', 'userData') - .where('bank_tx.id >= :id', { id }) - .andWhere('bank_tx.updated >= :updated', { updated }) + .where('bank_tx.id >= :id', { id: dbQuery.min }) + .andWhere('bank_tx.updated >= :updated', { updated: dbQuery.updatedSince }) .andWhere('bank_tx.type = :type', { type: BankTxType.BUY_FIAT }) - .orderBy('bank_tx.id', sorting) - .take(maxResult) + .orderBy('bank_tx.id', dbQuery.sorting) + .take(dbQuery.maxLine) .getRawMany() .catch((e: Error) => { throw new BadRequestException(e.message); @@ -234,16 +232,16 @@ export class AdminController { const bankTxRestData = await getConnection() .createQueryBuilder() - .from(table, table) - .select('bank_tx', 'bankTx') - .where('bank_tx.id >= :id', { id }) - .andWhere('bank_tx.updated >= :updated', { updated }) + .from(dbQuery.table, dbQuery.table) + .select(select) + .where('bank_tx.id >= :id', { id: dbQuery.min }) + .andWhere('bank_tx.updated >= :updated', { updated: dbQuery.updatedSince }) .andWhere('(type IS NULL OR type NOT IN (:crypto, :fiat))', { crypto: BankTxType.BUY_CRYPTO, fiat: BankTxType.BUY_FIAT, }) - .orderBy('bank_tx.id', sorting) - .take(maxResult) + .orderBy('bank_tx.id', dbQuery.sorting) + .take(dbQuery.maxLine) .getRawMany() .catch((e: Error) => { throw new BadRequestException(e.message); @@ -253,7 +251,7 @@ export class AdminController { return buyCryptoData .concat(buyFiatData, bankTxRestData) - .sort((a, b) => (sorting == 'ASC' ? a.bank_tx_id - b.bank_tx_id : b.bank_tx_id - a.bank_tx_id)); + .sort((a, b) => (dbQuery.sorting == 'ASC' ? a.bank_tx_id - b.bank_tx_id : b.bank_tx_id - a.bank_tx_id)); } @Get('support') diff --git a/src/admin/dto/db-query.dto.ts b/src/admin/dto/db-query.dto.ts new file mode 100644 index 0000000000..cf72dd8b0d --- /dev/null +++ b/src/admin/dto/db-query.dto.ts @@ -0,0 +1,28 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class dbQueryDto { + @IsNotEmpty() + @IsString() + table: string; + + @IsOptional() + min: number; + + @IsOptional() + maxLine: number; + + @IsOptional() + updatedSince: Date; + + @IsOptional() + extended: boolean; + + @IsOptional() + @IsString() + sorting: 'ASC' | 'DESC' = 'ASC'; + + // Comma separated column names + @IsOptional() + @IsString() + filterCols?: string; +} diff --git a/src/app.module.ts b/src/app.module.ts index 069913ac05..f42cac9d0a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { GetConfig } from './config/config'; import { MonitoringModule } from './monitoring/monitoring.module'; import { EthereumModule } from './blockchain/ethereum/ethereum.module'; import { BscModule } from './blockchain/bsc/bsc.module'; +import { NotificationModule } from './notification/notification.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { BscModule } from './blockchain/bsc/bsc.module'; PaymentModule, UserModule, MonitoringModule, + NotificationModule, ], controllers: [AppController, StatisticController, AdminController], providers: [StatisticService, CfpService], diff --git a/src/blockchain/bsc/bsc-client.ts b/src/blockchain/bsc/bsc-client.ts index 2185a8b910..98d4559b88 100644 --- a/src/blockchain/bsc/bsc-client.ts +++ b/src/blockchain/bsc/bsc-client.ts @@ -1,7 +1,13 @@ import { EvmClient } from '../shared/evm/evm-client'; export class BscClient extends EvmClient { - constructor(gatewayUrl: string, privateKey: string, address: string) { - super(gatewayUrl, privateKey, address); + constructor( + gatewayUrl: string, + privateKey: string, + dfxAddress: string, + swapContractAddress: string, + swapTokenAddress: string, + ) { + super(gatewayUrl, privateKey, dfxAddress, swapContractAddress, swapTokenAddress); } } diff --git a/src/blockchain/bsc/bsc.service.ts b/src/blockchain/bsc/bsc.service.ts index 9504d9e917..3a282c9f37 100644 --- a/src/blockchain/bsc/bsc.service.ts +++ b/src/blockchain/bsc/bsc.service.ts @@ -6,8 +6,9 @@ import { EvmService } from '../shared/evm/evm.service'; @Injectable() export class BscService extends EvmService { constructor() { - const { bscGatewayUrl, bscWalletAddress, bscWalletPrivateKey } = GetConfig().blockchain.bsc; + const { bscGatewayUrl, bscWalletAddress, bscWalletPrivateKey, pancakeRouterAddress, swapTokenAddress } = + GetConfig().blockchain.bsc; - super(bscGatewayUrl, '', bscWalletAddress, bscWalletPrivateKey, BscClient); + super(bscGatewayUrl, '', bscWalletAddress, bscWalletPrivateKey, pancakeRouterAddress, swapTokenAddress, BscClient); } } diff --git a/src/blockchain/ethereum/ethereum-client.ts b/src/blockchain/ethereum/ethereum-client.ts index d84968ab29..4bcf9228e0 100644 --- a/src/blockchain/ethereum/ethereum-client.ts +++ b/src/blockchain/ethereum/ethereum-client.ts @@ -1,7 +1,13 @@ import { EvmClient } from '../shared/evm/evm-client'; export class EthereumClient extends EvmClient { - constructor(gatewayUrl: string, privateKey: string, address: string) { - super(gatewayUrl, privateKey, address); + constructor( + gatewayUrl: string, + privateKey: string, + dfxAddress: string, + swapContractAddress: string, + swapTokenAddress: string, + ) { + super(gatewayUrl, privateKey, dfxAddress, swapContractAddress, swapTokenAddress); } } diff --git a/src/blockchain/ethereum/ethereum.service.ts b/src/blockchain/ethereum/ethereum.service.ts index a2a48cc366..45b0669840 100644 --- a/src/blockchain/ethereum/ethereum.service.ts +++ b/src/blockchain/ethereum/ethereum.service.ts @@ -6,8 +6,23 @@ import { EvmService } from '../shared/evm/evm.service'; @Injectable() export class EthereumService extends EvmService { constructor() { - const { ethGatewayUrl, ethApiKey, ethWalletAddress, ethWalletPrivateKey } = GetConfig().blockchain.ethereum; + const { + ethGatewayUrl, + ethApiKey, + ethWalletAddress, + ethWalletPrivateKey, + uniswapV2Router02Address, + swapTokenAddress, + } = GetConfig().blockchain.ethereum; - super(ethGatewayUrl, ethApiKey, ethWalletAddress, ethWalletPrivateKey, EthereumClient); + super( + ethGatewayUrl, + ethApiKey, + ethWalletAddress, + ethWalletPrivateKey, + uniswapV2Router02Address, + swapTokenAddress, + EthereumClient, + ); } } diff --git a/src/blockchain/shared/evm/abi/erc20.abi.json b/src/blockchain/shared/evm/abi/erc20.abi.json new file mode 100644 index 0000000000..06b572ddc2 --- /dev/null +++ b/src/blockchain/shared/evm/abi/erc20.abi.json @@ -0,0 +1,222 @@ +[ + { + "constant": true, + "inputs": [], + "name": "name", + "outputs": [ + { + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_spender", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_from", + "type": "address" + }, + { + "name": "_to", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "decimals", + "outputs": [ + { + "name": "", + "type": "uint8" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "name": "balance", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "symbol", + "outputs": [ + { + "name": "", + "type": "string" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_to", + "type": "address" + }, + { + "name": "_value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_owner", + "type": "address" + }, + { + "name": "_spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "payable": true, + "stateMutability": "payable", + "type": "fallback" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "name": "from", + "type": "address" + }, + { + "indexed": true, + "name": "to", + "type": "address" + }, + { + "indexed": false, + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + } +] diff --git a/src/blockchain/shared/evm/abi/uniswap-router02.abi.json b/src/blockchain/shared/evm/abi/uniswap-router02.abi.json new file mode 100644 index 0000000000..55ca7a7317 --- /dev/null +++ b/src/blockchain/shared/evm/abi/uniswap-router02.abi.json @@ -0,0 +1,953 @@ +[ + { + "inputs": [], + "name": "WETH", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "tokenA", + "type": "address" + }, + { + "internalType": "address", + "name": "tokenB", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amountADesired", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountBDesired", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountAMin", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountBMin", + "type": "uint256" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "addLiquidity", + "outputs": [ + { + "internalType": "uint256", + "name": "amountA", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountB", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "liquidity", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amountTokenDesired", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountTokenMin", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountETHMin", + "type": "uint256" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "addLiquidityETH", + "outputs": [ + { + "internalType": "uint256", + "name": "amountToken", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountETH", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "liquidity", + "type": "uint256" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "factory", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountOut", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "reserveIn", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "reserveOut", + "type": "uint256" + } + ], + "name": "getAmountIn", + "outputs": [ + { + "internalType": "uint256", + "name": "amountIn", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountIn", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "reserveIn", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "reserveOut", + "type": "uint256" + } + ], + "name": "getAmountOut", + "outputs": [ + { + "internalType": "uint256", + "name": "amountOut", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountOut", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "path", + "type": "address[]" + } + ], + "name": "getAmountsIn", + "outputs": [ + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountIn", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "path", + "type": "address[]" + } + ], + "name": "getAmountsOut", + "outputs": [ + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountA", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "reserveA", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "reserveB", + "type": "uint256" + } + ], + "name": "quote", + "outputs": [ + { + "internalType": "uint256", + "name": "amountB", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "tokenA", + "type": "address" + }, + { + "internalType": "address", + "name": "tokenB", + "type": "address" + }, + { + "internalType": "uint256", + "name": "liquidity", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountAMin", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountBMin", + "type": "uint256" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "removeLiquidity", + "outputs": [ + { + "internalType": "uint256", + "name": "amountA", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountB", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "liquidity", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountTokenMin", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountETHMin", + "type": "uint256" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "removeLiquidityETH", + "outputs": [ + { + "internalType": "uint256", + "name": "amountToken", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountETH", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "liquidity", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountTokenMin", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountETHMin", + "type": "uint256" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "removeLiquidityETHSupportingFeeOnTransferTokens", + "outputs": [ + { + "internalType": "uint256", + "name": "amountETH", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "liquidity", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountTokenMin", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountETHMin", + "type": "uint256" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "approveMax", + "type": "bool" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "removeLiquidityETHWithPermit", + "outputs": [ + { + "internalType": "uint256", + "name": "amountToken", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountETH", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + }, + { + "internalType": "uint256", + "name": "liquidity", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountTokenMin", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountETHMin", + "type": "uint256" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "approveMax", + "type": "bool" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "removeLiquidityETHWithPermitSupportingFeeOnTransferTokens", + "outputs": [ + { + "internalType": "uint256", + "name": "amountETH", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "tokenA", + "type": "address" + }, + { + "internalType": "address", + "name": "tokenB", + "type": "address" + }, + { + "internalType": "uint256", + "name": "liquidity", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountAMin", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountBMin", + "type": "uint256" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "approveMax", + "type": "bool" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "removeLiquidityWithPermit", + "outputs": [ + { + "internalType": "uint256", + "name": "amountA", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountB", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountOut", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "path", + "type": "address[]" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "swapETHForExactTokens", + "outputs": [ + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "path", + "type": "address[]" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "swapExactETHForTokens", + "outputs": [ + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "path", + "type": "address[]" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "swapExactETHForTokensSupportingFeeOnTransferTokens", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountIn", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "path", + "type": "address[]" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "swapExactTokensForETH", + "outputs": [ + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountIn", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "path", + "type": "address[]" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "swapExactTokensForETHSupportingFeeOnTransferTokens", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountIn", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "path", + "type": "address[]" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "swapExactTokensForTokens", + "outputs": [ + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountIn", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountOutMin", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "path", + "type": "address[]" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "swapExactTokensForTokensSupportingFeeOnTransferTokens", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountOut", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountInMax", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "path", + "type": "address[]" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "swapTokensForExactETH", + "outputs": [ + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amountOut", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amountInMax", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "path", + "type": "address[]" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "swapTokensForExactTokens", + "outputs": [ + { + "internalType": "uint256[]", + "name": "amounts", + "type": "uint256[]" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/src/blockchain/shared/evm/evm-client.ts b/src/blockchain/shared/evm/evm-client.ts index 69849a8f5b..740418828c 100644 --- a/src/blockchain/shared/evm/evm-client.ts +++ b/src/blockchain/shared/evm/evm-client.ts @@ -1,29 +1,51 @@ -import { ethers } from 'ethers'; +import { BigNumber, Contract, ethers } from 'ethers'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import * as ERC20_ABI from './abi/erc20.abi.json'; +import * as UNISWAP_ROUTER_02_ABI from './abi/uniswap-router02.abi.json'; export class EvmClient { - #address: string; + #dfxAddress: string; #provider: ethers.providers.JsonRpcProvider; #wallet: ethers.Wallet; + #router: Contract; + #erc20Tokens: Map = new Map(); + #swapTokenAddress: string; - constructor(gatewayUrl: string, privateKey: string, address: string) { + constructor( + gatewayUrl: string, + privateKey: string, + dfxAddress: string, + swapContractAddress: string, + swapTokenAddress: string, + ) { this.#provider = new ethers.providers.JsonRpcProvider(gatewayUrl); this.#wallet = new ethers.Wallet(privateKey, this.#provider); - this.#address = address; + this.#dfxAddress = dfxAddress; + this.#swapTokenAddress = swapTokenAddress; + this.#router = new ethers.Contract(swapContractAddress, UNISWAP_ROUTER_02_ABI, this.#wallet); } - async getBalance(): Promise { - const balance = await this.#provider.getBalance(this.#address); + async getNativeCoinBalance(): Promise { + const balance = await this.#provider.getBalance(this.#dfxAddress); - return parseFloat(ethers.utils.formatEther(balance)); + return this.convertToEthLikeDenomination(balance); } - async send(address: string, amount: number): Promise { + async getTokenBalance(token: Asset): Promise { + const contract = this.getERC20Contract(token.chainId); + const balance = await contract.balanceOf(this.#dfxAddress); + const decimals = await contract.decimals(); + + return this.convertToEthLikeDenomination(balance, decimals); + } + + async sendNativeCoin(address: string, amount: number): Promise { const gasPrice = await this.#provider.getGasPrice(); const tx = await this.#wallet.sendTransaction({ - from: this.#address, + from: this.#dfxAddress, to: address, - value: ethers.utils.parseUnits(`${amount}`, 'ether'), + value: this.convertToWeiLikeDenomination(amount, 'ether'), gasPrice, // has to be provided as a number for BSC gasLimit: 21000, @@ -32,6 +54,16 @@ export class EvmClient { return tx.hash; } + async sendToken(address: string, token: Asset, amount: number): Promise { + const contract = this.getERC20Contract(token.chainId); + const decimals = await contract.decimals(); + const targetAmount = this.convertToWeiLikeDenomination(amount, decimals); + + const tx = await contract.transfer(address, targetAmount); + + return tx.hash; + } + async isTxComplete(txHash: string): Promise { const transaction = await this.getTx(txHash); @@ -41,4 +73,38 @@ export class EvmClient { async getTx(txHash: string): Promise { return this.#provider.getTransaction(txHash); } + + async nativeCryptoTestSwap(nativeCryptoAmount: number, targetToken: Asset): Promise { + const contract = new ethers.Contract(targetToken.chainId, ERC20_ABI, this.#wallet); + const inputAmount = this.convertToWeiLikeDenomination(nativeCryptoAmount, 'ether'); + const outputAmounts = await this.#router.getAmountsOut(inputAmount, [this.#swapTokenAddress, targetToken.chainId]); + const decimals = await contract.decimals(); + + return this.convertToEthLikeDenomination(outputAmounts[1], decimals); + } + + //*** HELPER METHODS ***// + + private getERC20Contract(tokenAddress: string): Contract { + let tokenContract = this.#erc20Tokens.get(tokenAddress); + + if (!tokenContract) { + tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, this.#wallet); + this.#erc20Tokens.set(tokenAddress, tokenContract); + } + + return tokenContract; + } + + private convertToWeiLikeDenomination(amountEthLike: number, decimals: number | 'ether'): BigNumber { + const amount = decimals === 'ether' ? amountEthLike : amountEthLike.toFixed(decimals); + + return ethers.utils.parseUnits(`${amount}`, decimals); + } + + private convertToEthLikeDenomination(amountWeiLike: BigNumber, decimals?: number): number { + return decimals + ? parseFloat(ethers.utils.formatUnits(amountWeiLike, decimals)) + : parseFloat(ethers.utils.formatEther(amountWeiLike)); + } } diff --git a/src/blockchain/shared/evm/evm.service.ts b/src/blockchain/shared/evm/evm.service.ts index 40b9db6c93..ce2fb20d70 100644 --- a/src/blockchain/shared/evm/evm.service.ts +++ b/src/blockchain/shared/evm/evm.service.ts @@ -8,9 +8,25 @@ export abstract class EvmService { apiKey: string, walletAddress: string, walletPrivateKey: string, - client: { new (gatewayUrl: string, privateKey: string, address: string): EvmClient }, + swapContractAddress: string, + swapTokenAddress: string, + client: { + new ( + gatewayUrl: string, + privateKey: string, + dfxAddress: string, + swapContractAddress: string, + swapTokenAddress: string, + ): EvmClient; + }, ) { - this.client = new client(`${gatewayUrl}/${apiKey}`, walletPrivateKey, walletAddress); + this.client = new client( + `${gatewayUrl}/${apiKey}`, + walletPrivateKey, + walletAddress, + swapContractAddress, + swapTokenAddress, + ); } getDefaultClient(): T { diff --git a/src/config/config.ts b/src/config/config.ts index 0d92ecaabf..018e4d20d9 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -4,7 +4,7 @@ import { Exchange } from 'ccxt'; import { I18nJsonParser, I18nOptions } from 'nestjs-i18n'; import * as path from 'path'; import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; -import { MailOptions } from 'src/shared/services/mail.service'; +import { MailOptions } from 'src/notification/services/mail.service'; export function GetConfig(): Configuration { return new Configuration(); @@ -226,11 +226,15 @@ export class Configuration { ethWalletPrivateKey: process.env.ETH_WALLET_PRIVATE_KEY, ethGatewayUrl: process.env.ETH_GATEWAY_URL, ethApiKey: process.env.ETH_API_KEY, + uniswapV2Router02Address: process.env.ETH_SWAP_CONTRACT_ADDRESS, + swapTokenAddress: process.env.ETH_SWAP_TOKEN_ADDRESS, }, bsc: { bscWalletAddress: process.env.BSC_WALLET_ADDRESS, bscWalletPrivateKey: process.env.BSC_WALLET_PRIVATE_KEY, bscGatewayUrl: process.env.BSC_GATEWAY_URL, + pancakeRouterAddress: process.env.BSC_SWAP_CONTRACT_ADDRESS, + swapTokenAddress: process.env.BSC_SWAP_TOKEN_ADDRESS, }, }; diff --git a/src/monitoring/monitoring.module.ts b/src/monitoring/monitoring.module.ts index b429ddf097..4428f81b06 100644 --- a/src/monitoring/monitoring.module.ts +++ b/src/monitoring/monitoring.module.ts @@ -15,6 +15,7 @@ import { PaymentObserver } from './observers/payment.observer'; import { StakingBalanceObserver } from './observers/staking-balance.observer'; import { UserObserver } from './observers/user.observer'; import { SystemStateSnapshotRepository } from './system-state-snapshot.repository'; +import { NotificationModule } from 'src/notification/notification.module'; @Module({ imports: [ @@ -23,6 +24,7 @@ import { SystemStateSnapshotRepository } from './system-state-snapshot.repositor AinModule, PaymentModule, UserModule, + NotificationModule, ], providers: [ MonitoringService, diff --git a/src/monitoring/monitoring.service.ts b/src/monitoring/monitoring.service.ts index 55f63d2b95..438d14dd36 100644 --- a/src/monitoring/monitoring.service.ts +++ b/src/monitoring/monitoring.service.ts @@ -4,7 +4,8 @@ import { BehaviorSubject, debounceTime, pairwise } from 'rxjs'; import { MetricObserver } from './metric.observer'; import { Metric, MetricName, SubsystemName, SubsystemState, SystemState } from './system-state-snapshot.entity'; import { SystemStateSnapshotRepository } from './system-state-snapshot.repository'; -import { MailService } from 'src/shared/services/mail.service'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { MailType } from 'src/notification/enums'; type SubsystemObservers = Map>; @@ -13,7 +14,10 @@ export class MonitoringService { #$state: BehaviorSubject = new BehaviorSubject({}); #observers: Map = new Map(); - constructor(private systemStateSnapshotRepo: SystemStateSnapshotRepository, readonly mailService: MailService) { + constructor( + private systemStateSnapshotRepo: SystemStateSnapshotRepository, + readonly notificationService: NotificationService, + ) { this.initState(); } @@ -45,7 +49,11 @@ export class MonitoringService { return JSON.parse(latestPersistedState.data); } catch (e) { console.error('Failed to parse loaded system state. Defaulting to empty state', e); - this.mailService.sendErrorMail('Monitoring Error. Failed to parse loaded system state.', [e]); + + await this.notificationService.sendMail({ + type: MailType.ERROR_MONITORING, + input: { subject: 'Monitoring Error. Failed to parse loaded system state.', errors: [e] }, + }); return null; } @@ -93,7 +101,11 @@ export class MonitoringService { } } catch (e) { console.error('Error persisting the state', e); - this.mailService.sendErrorMail('Monitoring Error. Error persisting the state.', [e]); + + await this.notificationService.sendMail({ + type: MailType.ERROR_MONITORING, + input: { subject: 'Monitoring Error. Error persisting the state.', errors: [e] }, + }); } } @@ -157,7 +169,7 @@ export class MonitoringService { ); } - private updateSystemState(subsystem: string, metric: string, data: unknown) { + private async updateSystemState(subsystem: string, metric: string, data: unknown) { try { const currentState = cloneDeep(this.#$state.value); @@ -171,7 +183,11 @@ export class MonitoringService { this.#$state.next(newSystemState); } catch (e) { console.error('Error updating monitoring state', e); - this.mailService.sendErrorMail('Monitoring Error. Updating monitoring state.', [e]); + + await this.notificationService.sendMail({ + type: MailType.ERROR_MONITORING, + input: { subject: 'Monitoring Error. Updating monitoring state.', errors: [e] }, + }); } } } diff --git a/src/monitoring/observers/node-health.observer.ts b/src/monitoring/observers/node-health.observer.ts index ed71429e73..52b012479b 100644 --- a/src/monitoring/observers/node-health.observer.ts +++ b/src/monitoring/observers/node-health.observer.ts @@ -4,7 +4,8 @@ import { NodeMode } from 'src/blockchain/ain/node/node-client'; import { NodeService, NodeType } from 'src/blockchain/ain/node/node.service'; import { MetricObserver } from 'src/monitoring/metric.observer'; import { MonitoringService } from 'src/monitoring/monitoring.service'; -import { MailService } from 'src/shared/services/mail.service'; +import { MailType } from 'src/notification/enums'; +import { NotificationService } from 'src/notification/services/notification.service'; type MailMessage = string; @@ -26,7 +27,7 @@ export class NodeHealthObserver extends MetricObserver { constructor( monitoringService: MonitoringService, readonly nodeService: NodeService, - readonly mailService: MailService, + readonly notificationService: NotificationService, ) { super(monitoringService, 'node', 'health'); } @@ -98,7 +99,10 @@ export class NodeHealthObserver extends MetricObserver { if (messages.length > 0) { console.log(messages); - await this.mailService.sendErrorMail('Node Error', messages); + await this.notificationService.sendMail({ + type: MailType.ERROR_MONITORING, + input: { subject: 'Node Error', errors: messages }, + }); } } } diff --git a/src/notification/entities/mail/base/mail.ts b/src/notification/entities/mail/base/mail.ts new file mode 100644 index 0000000000..5a1e380ed0 --- /dev/null +++ b/src/notification/entities/mail/base/mail.ts @@ -0,0 +1,116 @@ +import { GetConfig } from 'src/config/config'; +import { NotificationType } from 'src/notification/enums'; +import { Notification, NotificationOptions, NotificationMetadata } from '../../notification.entity'; + +export interface MailParams { + to: string; + subject: string; + salutation: string; + body: string; + from?: string; + displayName?: string; + cc?: string; + bcc?: string; + template?: string; + date?: number; + telegramUrl?: string; + twitterUrl?: string; + linkedinUrl?: string; + instagramUrl?: string; + options?: NotificationOptions; + metadata?: NotificationMetadata; +} + +export class Mail extends Notification { + readonly #from: { name: string; address: string } = { + name: 'DFX.swiss', + address: GetConfig().mail.contact.noReplyMail, + }; + readonly #to: string; + readonly #cc: string; + readonly #bcc: string; + readonly #template: string = GetConfig().mail.defaultMailTemplate; + readonly #subject: string; + readonly #salutation: string; + readonly #body: string; + readonly #date: number = new Date().getFullYear(); + readonly #telegramUrl: string = GetConfig().defaultTelegramUrl; + readonly #twitterUrl: string = GetConfig().defaultTwitterUrl; + readonly #linkedinUrl: string = GetConfig().defaultLinkedinUrl; + readonly #instagramUrl: string = GetConfig().defaultInstagramUrl; + + constructor(params: MailParams) { + super(); + this.create(NotificationType.MAIL, params.metadata, params.options); + + this.#to = params.to; + this.#subject = params.subject; + this.#salutation = params.salutation; + this.#body = params.body; + this.#from = { + name: params.displayName ?? 'DFX.swiss', + address: params.from ?? GetConfig().mail.contact.noReplyMail, + }; + this.#cc = params.cc ?? this.#cc; + this.#bcc = params.bcc ?? this.#bcc; + this.#template = params.template ?? this.#template; + this.#date = params.date ?? this.#date; + this.#telegramUrl = params.telegramUrl ?? this.#telegramUrl; + this.#twitterUrl = params.twitterUrl ?? this.#twitterUrl; + this.#linkedinUrl = params.linkedinUrl ?? this.#linkedinUrl; + this.#instagramUrl = params.instagramUrl ?? this.#instagramUrl; + } + + get from(): { name: string; address: string } { + const { name, address } = this.#from; + return { name, address }; + } + + get to(): string { + return this.#to; + } + + get cc(): string { + return this.#cc; + } + + get bcc(): string { + return this.#bcc; + } + + get template(): string { + return this.#template; + } + + get subject(): string { + return this.#subject; + } + + get salutation(): string { + return this.#salutation; + } + + get body(): string { + return this.#body; + } + + get date(): number { + return this.#date; + } + + get telegramUrl(): string { + return this.#telegramUrl; + } + + get twitterUrl(): string { + return this.#twitterUrl; + } + + get linkedinUrl(): string { + return this.#linkedinUrl; + } + + get instagramUrl(): string { + return this.#instagramUrl; + } +} diff --git a/src/notification/entities/mail/error-monitoring-mail.ts b/src/notification/entities/mail/error-monitoring-mail.ts new file mode 100644 index 0000000000..cf848d6691 --- /dev/null +++ b/src/notification/entities/mail/error-monitoring-mail.ts @@ -0,0 +1,38 @@ +import { GetConfig } from 'src/config/config'; +import { NotificationMetadata, NotificationOptions } from '../notification.entity'; +import { Mail } from './base/mail'; + +export type ErrorMonitoringMailInput = ErrorMonitoringMailParams; + +export interface ErrorMonitoringMailParams { + subject: string; + errors: string[]; + metadata?: NotificationMetadata; + options?: NotificationOptions; +} + +export class ErrorMonitoringMail extends Mail { + constructor(params: ErrorMonitoringMailParams) { + const _params = { + to: GetConfig().mail.contact.monitoringMail, + subject: `${params.subject} (${GetConfig().environment.toUpperCase()})`, + salutation: 'Hi DFX Tech Support', + body: ErrorMonitoringMail.createBody(params.errors), + metadata: params.metadata, + options: params.options, + }; + + super(_params); + } + + static createBody(errors: string[]): string { + const env = GetConfig().environment.toUpperCase(); + + return ` +

there seem to be some problems on ${env} API:

+
    + ${errors.reduce((prev, curr) => prev + '
  • ' + curr + '
  • ', '')} +
+ `; + } +} diff --git a/src/notification/entities/mail/kyc-support-mail.ts b/src/notification/entities/mail/kyc-support-mail.ts new file mode 100644 index 0000000000..1e0e209a8c --- /dev/null +++ b/src/notification/entities/mail/kyc-support-mail.ts @@ -0,0 +1,49 @@ +import { GetConfig } from 'src/config/config'; +import { UserData } from 'src/user/models/user-data/user-data.entity'; +import { NotificationMetadata, NotificationOptions } from '../notification.entity'; +import { Mail } from './base/mail'; + +export interface KycSupportMailInput { + userData: UserData; +} + +export interface KycSupportMailParams { + userDataId: number; + kycCustomerId: number; + kycStatus: string; + metadata?: NotificationMetadata; + options?: NotificationOptions; +} + +export class KycSupportMail extends Mail { + constructor(params: KycSupportMailParams) { + const _params = { + to: GetConfig().mail.contact.supportMail, + subject: 'KYC failed or expired', + salutation: 'Hi DFX Support', + body: KycSupportMail.createBody(params), + metadata: params.metadata, + options: params.options, + }; + + super(_params); + } + + static createBody(params: KycSupportMailParams): string { + const { userDataId, kycCustomerId, kycStatus } = params; + + return ` +

a customer has failed or expired during progress ${kycStatus}.

+ + + + + + + + + +
Reference:${userDataId}
Customer ID:${kycCustomerId}
+ `; + } +} diff --git a/src/notification/entities/mail/user-mail.ts b/src/notification/entities/mail/user-mail.ts new file mode 100644 index 0000000000..45c4665f7d --- /dev/null +++ b/src/notification/entities/mail/user-mail.ts @@ -0,0 +1,24 @@ +import { UserData } from 'src/user/models/user-data/user-data.entity'; +import { NotificationMetadata, NotificationOptions } from '../notification.entity'; +import { Mail } from './base/mail'; + +export interface UserMailInput { + userData: UserData; + translationKey: string; + translationParams: object; +} + +export interface UserMailParams { + to: string; + subject: string; + salutation: string; + body: string; + metadata?: NotificationMetadata; + options?: NotificationOptions; +} + +export class UserMail extends Mail { + constructor(params: UserMailParams) { + super({ ...params, template: 'default' }); + } +} diff --git a/src/notification/entities/notification.entity.ts b/src/notification/entities/notification.entity.ts new file mode 100644 index 0000000000..0516b65c25 --- /dev/null +++ b/src/notification/entities/notification.entity.ts @@ -0,0 +1,78 @@ +import { IEntity } from 'src/shared/models/entity'; +import { Entity, Column } from 'typeorm'; +import { MailContext, NotificationType } from '../enums'; +import { NotificationSuppressedException } from '../exceptions/notification-suppressed.exception'; + +export interface NotificationMetadata { + context: MailContext; + correlationId: string; +} + +export interface NotificationOptions { + suppressRecurring?: boolean; + debounce?: number; // debounce time in milliseconds +} + +@Entity() +export class Notification extends IEntity { + @Column({ length: 256, nullable: false }) + type: NotificationType; + + @Column({ length: 256, nullable: false }) + context: MailContext; + + @Column({ length: 'MAX', nullable: false }) + correlationId: string; + + @Column({ type: 'datetime2', nullable: false }) + sendDate: Date; + + @Column({ nullable: false, default: false }) + suppressRecurring: boolean; + + @Column({ type: 'float', nullable: true }) + debounce: number; + + protected create(type: NotificationType, metadata?: NotificationMetadata, options?: NotificationOptions) { + this.sendDate = new Date(); + this.type = type; + + this.context = metadata?.context; + this.correlationId = metadata?.correlationId; + + this.suppressRecurring = options?.suppressRecurring; + this.debounce = options?.debounce; + } + + shouldAbortGiven(existingNotification: Notification): void { + if (this.isSameNotification(existingNotification)) { + if (this.suppressRecurring) { + throw new NotificationSuppressedException(); + } + + if (this.isDebounced(existingNotification)) { + throw new NotificationSuppressedException(); + } + } + } + + shouldBePersisted(): boolean { + if (!this.hasMandatoryParams()) return false; + + return !!(this.suppressRecurring || this.debounce); + } + + //*** HELPER METHODS ***// + + private isSameNotification(existingNotification: Notification): boolean { + return existingNotification.correlationId === this.correlationId && existingNotification.context === this.context; + } + + private isDebounced(existingNotification: Notification): boolean { + return this.debounce && Date.now() < existingNotification.sendDate.getTime() + existingNotification.debounce; + } + + private hasMandatoryParams(): boolean { + return !!(this.type && this.context && this.correlationId); + } +} diff --git a/src/notification/enums/index.ts b/src/notification/enums/index.ts new file mode 100644 index 0000000000..db8b2c6cda --- /dev/null +++ b/src/notification/enums/index.ts @@ -0,0 +1,17 @@ +export enum NotificationType { + MAIL = 'Mail', +} + +export enum MailType { + GENERIC = 'Generic', + KYC_SUPPORT = 'KycSupport', + ERROR_MONITORING = 'ErrorMonitoring', + USER = 'User', +} + +export enum MailContext { + BUY_CRYPTO = 'BuyCrypto', + DEX = 'Dex', + PAYOUT = 'Payout', + PRICING = 'Pricing', +} diff --git a/src/notification/exceptions/notification-suppressed.exception.ts b/src/notification/exceptions/notification-suppressed.exception.ts new file mode 100644 index 0000000000..bbd8413fae --- /dev/null +++ b/src/notification/exceptions/notification-suppressed.exception.ts @@ -0,0 +1,5 @@ +export class NotificationSuppressedException extends Error { + constructor() { + super(); + } +} diff --git a/src/notification/factories/mail.factory.ts b/src/notification/factories/mail.factory.ts new file mode 100644 index 0000000000..091d0d009d --- /dev/null +++ b/src/notification/factories/mail.factory.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@nestjs/common'; +import { I18nService } from 'nestjs-i18n'; +import { ErrorMonitoringMail, ErrorMonitoringMailInput } from '../entities/mail/error-monitoring-mail'; +import { KycSupportMailInput, KycSupportMail } from '../entities/mail/kyc-support-mail'; +import { Mail } from '../entities/mail/base/mail'; +import { UserMail, UserMailInput } from '../entities/mail/user-mail'; +import { MailType } from '../enums'; +import { MailRequest, MailRequestGenericInput } from '../interfaces'; + +@Injectable() +export class MailFactory { + constructor(private readonly i18n: I18nService) {} + + async createMail(request: MailRequest): Promise { + switch (request.type) { + case MailType.GENERIC: { + return this.createGenericMail(request); + } + + case MailType.ERROR_MONITORING: { + return this.createErrorMonitoringMail(request); + } + + case MailType.KYC_SUPPORT: { + return this.createKycSupportMail(request); + } + + case MailType.USER: { + return this.createUserMail(request); + } + + default: { + throw new Error(`Unsupported mail type: ${request.type}`); + } + } + } + + //*** HELPER METHODS ***// + + private createGenericMail(request: MailRequest): ErrorMonitoringMail { + const input = request.input as MailRequestGenericInput; + const { metadata, options } = request; + + return new Mail({ ...input, metadata, options }); + } + + private createErrorMonitoringMail(request: MailRequest): ErrorMonitoringMail { + const { subject, errors } = request.input as ErrorMonitoringMailInput; + const { metadata, options } = request; + + return new ErrorMonitoringMail({ subject, errors, metadata, options }); + } + + private createKycSupportMail(request: MailRequest): KycSupportMail { + const { userData } = request.input as KycSupportMailInput; + const { metadata, options } = request; + + return new KycSupportMail({ + userDataId: userData.id, + kycStatus: userData.kycStatus, + kycCustomerId: userData.kycCustomerId, + metadata, + options, + }); + } + + private async createUserMail(request: MailRequest): Promise { + const { userData, translationKey, translationParams } = request.input as UserMailInput; + const { metadata, options } = request; + + const { subject, salutation, body } = await this.t( + translationKey, + userData.language?.symbol.toLowerCase(), + translationParams, + ); + + return new UserMail({ + to: userData.mail, + subject, + salutation, + body, + metadata, + options, + }); + } + + //*** TRANSLATION METHODS ***// + + private async t( + key: string, + lang: string, + args?: any, + ): Promise<{ salutation: string; body: string; subject: string }> { + const salutation = await this.i18n.translate(`${key}.salutation`, { lang, args }); + const body = await this.i18n.translate(`${key}.body`, { lang, args }); + const subject = await this.i18n.translate(`${key}.title`, { lang, args }); + + return { salutation, body, subject }; + } +} diff --git a/src/notification/interfaces/index.ts b/src/notification/interfaces/index.ts new file mode 100644 index 0000000000..73eb3e0e17 --- /dev/null +++ b/src/notification/interfaces/index.ts @@ -0,0 +1,29 @@ +import { ErrorMonitoringMailInput } from '../entities/mail/error-monitoring-mail'; +import { KycSupportMailInput } from '../entities/mail/kyc-support-mail'; +import { UserMailInput } from '../entities/mail/user-mail'; +import { NotificationMetadata, NotificationOptions } from '../entities/notification.entity'; +import { MailType } from '../enums'; + +export interface MailRequest { + type: MailType; + input: MailRequestGenericInput | UserMailInput | KycSupportMailInput | ErrorMonitoringMailInput; + metadata?: NotificationMetadata; + options?: NotificationOptions; +} + +export interface MailRequestGenericInput { + to: string; + subject: string; + salutation: string; + body: string; + from?: string; + displayName?: string; + cc?: string; + bcc?: string; + template?: string; + date?: number; + telegramUrl?: string; + twitterUrl?: string; + linkedinUrl?: string; + instagramUrl?: string; +} diff --git a/src/notification/notification.controller.ts b/src/notification/notification.controller.ts new file mode 100644 index 0000000000..09ba0962aa --- /dev/null +++ b/src/notification/notification.controller.ts @@ -0,0 +1,23 @@ +import { Controller, UseGuards, Body, Post } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; +import { RoleGuard } from 'src/shared/auth/role.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { MailRequest } from './interfaces'; +import { NotificationService } from './services/notification.service'; + +@ApiTags('notification') +@Controller('notification') +export class NotificationController { + constructor(private readonly notificationService: NotificationService) {} + + @Post('send-mail') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + async sendMail(@Body() dto: MailRequest): Promise { + if (process.env.ENVIRONMENT === 'test') { + return this.notificationService.sendMail(dto); + } + } +} diff --git a/src/notification/notification.module.ts b/src/notification/notification.module.ts new file mode 100644 index 0000000000..1ffba440ad --- /dev/null +++ b/src/notification/notification.module.ts @@ -0,0 +1,22 @@ +import { MailerModule } from '@nestjs-modules/mailer'; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { GetConfig } from 'src/config/config'; +import { SharedModule } from 'src/shared/shared.module'; +import { MailService } from '../notification/services/mail.service'; +import { MailFactory } from './factories/mail.factory'; +import { NotificationController } from './notification.controller'; +import { NotificationRepository } from './repositories/notification.repository'; +import { NotificationService } from './services/notification.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([NotificationRepository]), + MailerModule.forRoot(GetConfig().mail.options), + SharedModule, + ], + providers: [MailService, NotificationService, MailFactory], + controllers: [NotificationController], + exports: [NotificationService], +}) +export class NotificationModule {} diff --git a/src/notification/repositories/notification.repository.ts b/src/notification/repositories/notification.repository.ts new file mode 100644 index 0000000000..9ce81f60a0 --- /dev/null +++ b/src/notification/repositories/notification.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Notification } from '../entities/notification.entity'; + +@EntityRepository(Notification) +export class NotificationRepository extends Repository {} diff --git a/src/notification/services/mail.service.ts b/src/notification/services/mail.service.ts new file mode 100644 index 0000000000..3a00e704e7 --- /dev/null +++ b/src/notification/services/mail.service.ts @@ -0,0 +1,49 @@ +import { MailerOptions, MailerService } from '@nestjs-modules/mailer'; +import { Injectable } from '@nestjs/common'; +import { Util } from '../../shared/util'; +import { Mail } from '../entities/mail/base/mail'; + +export interface MailOptions { + options: MailerOptions; + defaultMailTemplate: string; + contact: { + supportMail: string; + monitoringMail: string; + noReplyMail: string; + }; +} + +@Injectable() +export class MailService { + constructor(private readonly mailerService: MailerService) {} + + async send(mail: Mail): Promise { + try { + await Util.retry( + () => + this.mailerService.sendMail({ + from: mail.from, + to: mail.to, + cc: mail.cc, + bcc: mail.bcc, + template: mail.template, + context: { + salutation: mail.salutation, + body: mail.body, + date: mail.date, + telegramUrl: mail.telegramUrl, + twitterUrl: mail.twitterUrl, + linkedinUrl: mail.linkedinUrl, + instagramUrl: mail.instagramUrl, + }, + subject: mail.subject, + }), + 3, + 1000, + ); + } catch (e) { + console.error(`Exception during send mail: from:${mail.from}, to:${mail.to}, subject:${mail.subject}:`, e); + throw e; + } + } +} diff --git a/src/notification/services/notification.service.ts b/src/notification/services/notification.service.ts new file mode 100644 index 0000000000..f0be978251 --- /dev/null +++ b/src/notification/services/notification.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@nestjs/common'; +import { Notification, NotificationMetadata } from '../entities/notification.entity'; +import { NotificationSuppressedException } from '../exceptions/notification-suppressed.exception'; +import { MailFactory } from '../factories/mail.factory'; +import { MailRequest } from '../interfaces'; +import { NotificationRepository } from '../repositories/notification.repository'; +import { MailService } from './mail.service'; + +@Injectable() +export class NotificationService { + constructor( + private readonly mailFactory: MailFactory, + private readonly mailService: MailService, + private readonly notificationRepo: NotificationRepository, + ) {} + + async sendMail(request: MailRequest): Promise { + try { + const mail = await this.mailFactory.createMail(request); + + await this.verify(mail); + await this.persist(mail); + + await this.mailService.send(mail); + } catch (e) { + this.handleNotificationError(e, request.metadata); + } + } + + //*** HELPER METHODS ***// + + private async verify(newNotification: Notification): Promise { + const { correlationId, context } = newNotification; + + const existingNotification = await this.notificationRepo.findOne({ + order: { id: 'DESC' }, + where: { correlationId, context }, + }); + + if (existingNotification) newNotification.shouldAbortGiven(existingNotification); + } + + private async persist(notification: Notification): Promise { + if (notification.shouldBePersisted()) { + await this.notificationRepo.save(notification); + } + } + + //*** ERROR HANDLING ***// + + private handleNotificationError(e: Error, metadata: NotificationMetadata): void { + if (e instanceof NotificationSuppressedException) { + console.info(`Suppressed mail request. Context: ${metadata?.context}. CorrelationId: ${metadata?.correlationId}`); + return; + } + + throw e; + } +} diff --git a/src/payment/models/bank-tx-return/bank-tx-return.entity.ts b/src/payment/models/bank-tx-return/bank-tx-return.entity.ts index dfd393f91d..38e1f33406 100644 --- a/src/payment/models/bank-tx-return/bank-tx-return.entity.ts +++ b/src/payment/models/bank-tx-return/bank-tx-return.entity.ts @@ -14,4 +14,13 @@ export class BankTxReturn extends IEntity { @Column({ length: 256, nullable: true }) info: string; + + @Column({ type: 'float', nullable: true }) + amountInChf: number; + + @Column({ type: 'float', nullable: true }) + amountInEur: number; + + @Column({ type: 'float', nullable: true }) + amountInUsd: number; } diff --git a/src/payment/models/bank-tx-return/dto/update-bank-tx-return.dto.ts b/src/payment/models/bank-tx-return/dto/update-bank-tx-return.dto.ts index 106d32962a..8f77121a86 100644 --- a/src/payment/models/bank-tx-return/dto/update-bank-tx-return.dto.ts +++ b/src/payment/models/bank-tx-return/dto/update-bank-tx-return.dto.ts @@ -1,4 +1,4 @@ -import { IsInt, IsOptional, IsString } from 'class-validator'; +import { IsInt, IsNumber, IsOptional, IsString } from 'class-validator'; export class UpdateBankTxReturnDto { @IsOptional() @@ -8,4 +8,16 @@ export class UpdateBankTxReturnDto { @IsOptional() @IsInt() chargebackBankTxId: number; + + @IsOptional() + @IsNumber() + amountInChf: number; + + @IsOptional() + @IsNumber() + amountInEur: number; + + @IsOptional() + @IsNumber() + amountInUsd: number; } diff --git a/src/payment/models/bank-tx/bank-tx.entity.ts b/src/payment/models/bank-tx/bank-tx.entity.ts index 26fb1710d8..8718380fb6 100644 --- a/src/payment/models/bank-tx/bank-tx.entity.ts +++ b/src/payment/models/bank-tx/bank-tx.entity.ts @@ -15,6 +15,8 @@ export enum BankTxType { BUY_FIAT = 'BuyFiat', FIAT_FIAT = 'FiatFiat', TEST_FIAT_FIAT = 'TestFiatFiat', + GSHEET = 'GSheet', + PENDING = 'Pending', UNKNOWN = 'Unknown', } diff --git a/src/payment/models/bank-tx/bank-tx.service.ts b/src/payment/models/bank-tx/bank-tx.service.ts index 72f92328f2..34555571bf 100644 --- a/src/payment/models/bank-tx/bank-tx.service.ts +++ b/src/payment/models/bank-tx/bank-tx.service.ts @@ -4,7 +4,6 @@ import { BankTxBatchRepository } from './bank-tx-batch.repository'; import { BankTxBatch } from './bank-tx-batch.entity'; import { SepaParser } from './sepa-parser.service'; import { In } from 'typeorm'; -import { MailService } from 'src/shared/services/mail.service'; import { UpdateBankTxDto } from './dto/update-bank-tx.dto'; import { BankTx, BankTxType } from './bank-tx.entity'; import { BuyCryptoService } from '../buy-crypto/services/buy-crypto.service'; @@ -12,6 +11,8 @@ import { Interval } from '@nestjs/schedule'; import { SettingService } from 'src/shared/models/setting/setting.service'; import { FrickService } from './frick.service'; import { OlkypayService } from './olkypay.service'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { MailType } from 'src/notification/enums'; import { BankTxReturnService } from '../bank-tx-return/bank-tx-return.service'; import { BankTxRepeatService } from '../bank-tx-repeat/bank-tx-repeat.service'; @@ -21,7 +22,7 @@ export class BankTxService { private readonly bankTxRepo: BankTxRepository, private readonly bankTxBatchRepo: BankTxBatchRepository, private readonly buyCryptoService: BuyCryptoService, - private readonly mailService: MailService, + private readonly notificationService: NotificationService, private readonly settingService: SettingService, private readonly frickService: FrickService, private readonly olkyService: OlkypayService, @@ -111,7 +112,11 @@ export class BankTxService { if (duplicates.length > 0) { const message = `Duplicate SEPA entries found in batch ${batch.identification}:`; console.log(message, duplicates); - this.mailService.sendErrorMail('SEPA Error', [message + ` ${duplicates.join(', ')}`]); + + await this.notificationService.sendMail({ + type: MailType.ERROR_MONITORING, + input: { subject: 'SEPA Error', errors: [message + ` ${duplicates.join(', ')}`] }, + }); } // store the entries diff --git a/src/payment/models/buy-crypto/entities/__tests__/buy-crypto.entity.spec.ts b/src/payment/models/buy-crypto/entities/__tests__/buy-crypto.entity.spec.ts index f1a0feb938..ab1f22d1c0 100644 --- a/src/payment/models/buy-crypto/entities/__tests__/buy-crypto.entity.spec.ts +++ b/src/payment/models/buy-crypto/entities/__tests__/buy-crypto.entity.spec.ts @@ -179,10 +179,10 @@ describe('BuyCrypto', () => { expect(entity.outputReferenceAsset).toBe('USDT'); }); - it('assigns outputReferenceAsset to ETH, on Ethereum blockchain', () => { + it('assigns outputReferenceAsset to ETH, on Ethereum blockchain when outputAsset is not DFI', () => { const entity = createCustomBuyCrypto({ outputReferenceAsset: undefined, - buy: createCustomBuy({ asset: createCustomAsset({ blockchain: Blockchain.ETHEREUM }) }), + buy: createCustomBuy({ asset: createCustomAsset({ blockchain: Blockchain.ETHEREUM, dexName: 'GOOGL' }) }), }); expect(entity.outputReferenceAsset).toBeUndefined(); @@ -192,10 +192,25 @@ describe('BuyCrypto', () => { expect(entity.outputReferenceAsset).toBe('ETH'); }); - it('assigns outputReferenceAsset to BNB, on BSC blockchain', () => { + it('assigns outputReferenceAsset to outputAsset, on Ethereum blockchain when outputAsset is DFI', () => { const entity = createCustomBuyCrypto({ outputReferenceAsset: undefined, - buy: createCustomBuy({ asset: createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN }) }), + buy: createCustomBuy({ asset: createCustomAsset({ blockchain: Blockchain.ETHEREUM, dexName: 'DFI' }) }), + }); + + expect(entity.outputReferenceAsset).toBeUndefined(); + + entity.defineAssetExchangePair(); + + expect(entity.outputReferenceAsset).toBe('DFI'); + }); + + it('assigns outputReferenceAsset to BNB, on BSC blockchain when outputAsset is not DFI | BUSD', () => { + const entity = createCustomBuyCrypto({ + outputReferenceAsset: undefined, + buy: createCustomBuy({ + asset: createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN, dexName: 'GOOGL' }), + }), }); expect(entity.outputReferenceAsset).toBeUndefined(); @@ -205,6 +220,36 @@ describe('BuyCrypto', () => { expect(entity.outputReferenceAsset).toBe('BNB'); }); + it('assigns outputReferenceAsset to outputAsset, on BSC blockchain when outputAsset is DFI', () => { + const entity = createCustomBuyCrypto({ + outputReferenceAsset: undefined, + buy: createCustomBuy({ + asset: createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN, dexName: 'DFI' }), + }), + }); + + expect(entity.outputReferenceAsset).toBeUndefined(); + + entity.defineAssetExchangePair(); + + expect(entity.outputReferenceAsset).toBe('DFI'); + }); + + it('assigns outputReferenceAsset to outputAsset, on BSC blockchain when outputAsset is BUSD', () => { + const entity = createCustomBuyCrypto({ + outputReferenceAsset: undefined, + buy: createCustomBuy({ + asset: createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN, dexName: 'BUSD' }), + }), + }); + + expect(entity.outputReferenceAsset).toBeUndefined(); + + entity.defineAssetExchangePair(); + + expect(entity.outputReferenceAsset).toBe('BUSD'); + }); + it('defaults outputReferenceAsset to BTC on Bitcoin blockchain', () => { const entity = createCustomBuyCrypto({ outputReferenceAsset: undefined, diff --git a/src/payment/models/buy-crypto/entities/buy-crypto.entity.ts b/src/payment/models/buy-crypto/entities/buy-crypto.entity.ts index a29feb3489..b09806def5 100644 --- a/src/payment/models/buy-crypto/entities/buy-crypto.entity.ts +++ b/src/payment/models/buy-crypto/entities/buy-crypto.entity.ts @@ -134,10 +134,20 @@ export class BuyCrypto extends IEntity { switch (this.target.asset.blockchain) { case Blockchain.ETHEREUM: + if (this.outputAsset === 'DFI') { + this.outputReferenceAsset = this.outputAsset; + break; + } + this.outputReferenceAsset = 'ETH'; break; case Blockchain.BINANCE_SMART_CHAIN: + if (['DFI', 'BUSD'].includes(this.outputAsset)) { + this.outputReferenceAsset = this.outputAsset; + break; + } + this.outputReferenceAsset = 'BNB'; break; diff --git a/src/payment/models/buy-crypto/services/buy-crypto-batch.service.ts b/src/payment/models/buy-crypto/services/buy-crypto-batch.service.ts index d6b2b3bb01..65a33fb4f0 100644 --- a/src/payment/models/buy-crypto/services/buy-crypto-batch.service.ts +++ b/src/payment/models/buy-crypto/services/buy-crypto-batch.service.ts @@ -6,7 +6,8 @@ import { BuyCryptoBatchRepository } from '../repositories/buy-crypto-batch.repos import { BuyCryptoRepository } from '../repositories/buy-crypto.repository'; import { BuyCryptoBatch, BuyCryptoBatchStatus } from '../entities/buy-crypto-batch.entity'; import { BuyCrypto } from '../entities/buy-crypto.entity'; -import { PriceResult } from '../../pricing/interfaces'; +import { PriceRequest, PriceResult } from '../../pricing/interfaces'; +import { PriceRequestContext } from '../../pricing/enums'; @Injectable() export class BuyCryptoBatchService { @@ -82,7 +83,7 @@ export class BuyCryptoBatchService { const prices = await Promise.all( referenceAssetPairs.map(async (pair) => { - const priceRequest = { from: pair[0], to: pair[1] }; + const priceRequest = this.createPriceRequest(pair, txWithAssets); return this.pricingService.getPrice(priceRequest).catch((e) => { console.error('Failed to get price:', e); @@ -143,4 +144,9 @@ export class BuyCryptoBatchService { return [...batches.values()]; } + + private createPriceRequest(currencyPair: string[], transactions: BuyCrypto[] = []): PriceRequest { + const correlationId = 'BuyCryptoTransactions' + transactions.reduce((acc, t) => acc + `|${t.id}|`, ''); + return { context: PriceRequestContext.BUY_CRYPTO, correlationId, from: currencyPair[0], to: currencyPair[1] }; + } } diff --git a/src/payment/models/buy-crypto/services/buy-crypto-dex.service.ts b/src/payment/models/buy-crypto/services/buy-crypto-dex.service.ts index 10b6a1ec3e..92d30e4ee8 100644 --- a/src/payment/models/buy-crypto/services/buy-crypto-dex.service.ts +++ b/src/payment/models/buy-crypto/services/buy-crypto-dex.service.ts @@ -96,6 +96,7 @@ export class BuyCryptoDexService { if (e instanceof PriceSlippageException) { await this.handleSlippageException( + batch, `Slippage error while checking liquidity for asset '${batch.outputAsset}. Batch ID: ${batch.id}`, e, ); @@ -116,6 +117,7 @@ export class BuyCryptoDexService { } catch (e) { if (e instanceof PriceSlippageException) { await this.handleSlippageException( + batch, `Composite swap slippage error while purchasing asset '${batch.outputAsset}. Batch ID: ${batch.id}`, e, ); @@ -150,7 +152,7 @@ export class BuyCryptoDexService { }; } - private async handleSlippageException(message: string, e: Error): Promise { - await this.buyCryptoNotificationService.sendNonRecoverableErrorMail(message, e); + private async handleSlippageException(batch: BuyCryptoBatch, message: string, e: Error): Promise { + await this.buyCryptoNotificationService.sendNonRecoverableErrorMail(batch, message, e); } } diff --git a/src/payment/models/buy-crypto/services/buy-crypto-notification.service.ts b/src/payment/models/buy-crypto/services/buy-crypto-notification.service.ts index df58beda13..706c33c955 100644 --- a/src/payment/models/buy-crypto/services/buy-crypto-notification.service.ts +++ b/src/payment/models/buy-crypto/services/buy-crypto-notification.service.ts @@ -1,10 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { MailService } from 'src/shared/services/mail.service'; import { In, IsNull, Not } from 'typeorm'; import { BuyCryptoRepository } from '../repositories/buy-crypto.repository'; -import { BuyCryptoBatchStatus } from '../entities/buy-crypto-batch.entity'; +import { BuyCryptoBatch, BuyCryptoBatchStatus } from '../entities/buy-crypto-batch.entity'; import { Util } from 'src/shared/util'; -import { Blockchain, BlockchainExplorerUrls } from 'src/blockchain/shared/enums/blockchain.enum'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { MailContext, MailType } from 'src/notification/enums'; +import { BlockchainExplorerUrls } from 'src/blockchain/shared/enums/blockchain.enum'; import { AmlCheck } from '../enums/aml-check.enum'; import { I18nService } from 'nestjs-i18n'; import { AmlReason } from '../enums/aml-reason.enum'; @@ -13,7 +14,7 @@ import { AmlReason } from '../enums/aml-reason.enum'; export class BuyCryptoNotificationService { constructor( private readonly buyCryptoRepo: BuyCryptoRepository, - private readonly mailService: MailService, + private readonly notificationService: NotificationService, private readonly i18nService: I18nService, ) {} @@ -37,10 +38,12 @@ export class BuyCryptoNotificationService { 'buy', 'buy.user', 'buy.user.userData', + 'buy.asset', 'batch', 'cryptoRoute', 'cryptoRoute.user', 'cryptoRoute.user.userData', + 'cryptoRoute.asset', ], }); @@ -54,18 +57,22 @@ export class BuyCryptoNotificationService { for (const tx of txOutput) { try { tx.user.userData.mail && - (await this.mailService.sendTranslatedMail({ - userData: tx.user.userData, - translationKey: tx.translationKey, - params: { - buyInputAmount: tx.inputAmount, - buyInputAsset: tx.inputAsset, - buyOutputAmount: tx.outputAmount, - buyOutputAsset: tx.outputAsset, - buyFeePercentage: Util.round(tx.percentFee * 100, 2), - exchangeRate: Util.round(tx.inputAmount / tx.outputAmount, 2), - buyWalletAddress: Util.trimBlockchainAddress(tx.target.address), - buyTransactionLink: `${BlockchainExplorerUrls[Blockchain.DEFICHAIN]}/${tx.txId}`, + (await this.notificationService.sendMail({ + type: MailType.USER, + input: { + userData: tx.user.userData, + translationKey: tx.translationKey, + translationParams: { + buyInputAmount: tx.inputAmount, + buyInputAsset: tx.inputAsset, + buyOutputAmount: tx.outputAmount, + buyOutputAsset: tx.outputAsset, + buyFeePercentage: Util.round(tx.percentFee * 100, 2), + exchangeRate: Util.round(tx.inputAmount / tx.outputAmount, 2), + buyWalletAddress: Util.trimBlockchainAddress(tx.target.address), + buyTxId: tx.txId, + buyTransactionLink: `${BlockchainExplorerUrls[tx.target.asset.blockchain]}/${tx.txId}`, + }, }, })); @@ -79,10 +86,16 @@ export class BuyCryptoNotificationService { } } - async sendNonRecoverableErrorMail(message: string, e?: Error): Promise { - const body = e ? [message, e.message] : [message]; + async sendNonRecoverableErrorMail(batch: BuyCryptoBatch, message: string, e?: Error): Promise { + const correlationId = `BuyCryptoBatch&${batch.id}`; + const errors = e ? [message, e.message] : [message]; - await this.mailService.sendErrorMail('Buy Crypto Error', body); + await this.notificationService.sendMail({ + type: MailType.ERROR_MONITORING, + input: { subject: 'Buy Crypto Error', errors }, + options: { suppressRecurring: true }, + metadata: { context: MailContext.BUY_CRYPTO, correlationId }, + }); } async paybackToAddressInitiated(): Promise { @@ -112,17 +125,20 @@ export class BuyCryptoNotificationService { for (const entity of entities) { try { if (entity.user.userData.mail) { - await this.mailService.sendTranslatedMail({ - userData: entity.user.userData, - translationKey: entity.translationKey, - params: { - inputAmount: entity.inputAmount, - inputAsset: entity.inputAsset, - returnTransactionLink: entity.chargebackRemittanceInfo, - returnReason: await this.i18nService.translate(`mail.amlReasonMailText.${entity.amlReason}`, { - lang: entity.user.userData.language?.symbol.toLowerCase(), - }), - userAddressTrimmed: Util.trimBlockchainAddress(entity.user.address), + await this.notificationService.sendMail({ + type: MailType.USER, + input: { + userData: entity.user.userData, + translationKey: entity.translationKey, + translationParams: { + inputAmount: entity.inputAmount, + inputAsset: entity.inputAsset, + returnTransactionLink: entity.chargebackRemittanceInfo, + returnReason: await this.i18nService.translate(`mail.amlReasonMailText.${entity.amlReason}`, { + lang: entity.user.userData.language?.symbol.toLowerCase(), + }), + userAddressTrimmed: Util.trimBlockchainAddress(entity.user.address), + }, }, }); } @@ -160,11 +176,14 @@ export class BuyCryptoNotificationService { for (const entity of entities) { try { if (entity.user.userData.mail) { - await this.mailService.sendTranslatedMail({ - userData: entity.user.userData, - translationKey: entity.translationKey, - params: { - hashLink: `https://payment.dfx.swiss/kyc?code=${entity.user.userData.kycHash}`, + await this.notificationService.sendMail({ + type: MailType.USER, + input: { + userData: entity.user.userData, + translationKey: entity.translationKey, + translationParams: { + hashLink: `https://payment.dfx.swiss/kyc?code=${entity.user.userData.kycHash}`, + }, }, }); } diff --git a/src/payment/models/buy-fiat/buy-fiat-notification.service.ts b/src/payment/models/buy-fiat/buy-fiat-notification.service.ts index c9d189e0c4..0ce657a4ad 100644 --- a/src/payment/models/buy-fiat/buy-fiat-notification.service.ts +++ b/src/payment/models/buy-fiat/buy-fiat-notification.service.ts @@ -2,8 +2,9 @@ import { Injectable } from '@nestjs/common'; import { Interval } from '@nestjs/schedule'; import { I18nService } from 'nestjs-i18n'; import { BlockchainExplorerUrls } from 'src/blockchain/shared/enums/blockchain.enum'; +import { MailType } from 'src/notification/enums'; +import { NotificationService } from 'src/notification/services/notification.service'; import { Lock } from 'src/shared/lock'; -import { MailService } from 'src/shared/services/mail.service'; import { Util } from 'src/shared/util'; import { IsNull, Not } from 'typeorm'; import { AmlCheck } from '../buy-crypto/enums/aml-check.enum'; @@ -15,7 +16,7 @@ export class BuyFiatNotificationService { constructor( private readonly buyFiatRepo: BuyFiatRepository, - private readonly mailService: MailService, + private readonly notificationService: NotificationService, private readonly i18nService: I18nService, ) {} @@ -44,15 +45,18 @@ export class BuyFiatNotificationService { const recipientMail = entity.sell.user.userData.mail; if (recipientMail) { - await this.mailService.sendTranslatedMail({ - userData: entity.sell.user.userData, - translationKey: 'mail.payment.withdrawal.offRampInitiated', - params: { - inputAmount: entity.cryptoInput.amount, - inputAsset: entity.cryptoInput.asset.dexName, - inputTransactionLink: `${BlockchainExplorerUrls[entity.cryptoInput.asset.blockchain]}/${ - entity.cryptoInput.inTxId - }`, + await this.notificationService.sendMail({ + type: MailType.USER, + input: { + userData: entity.sell.user.userData, + translationKey: 'mail.payment.withdrawal.offRampInitiated', + translationParams: { + inputAmount: entity.cryptoInput.amount, + inputAsset: entity.cryptoInput.asset.dexName, + inputTransactionLink: `${BlockchainExplorerUrls[entity.cryptoInput.asset.blockchain]}/${ + entity.cryptoInput.inTxId + }`, + }, }, }); } else { @@ -82,16 +86,19 @@ export class BuyFiatNotificationService { for (const entity of entities) { try { if (entity.sell.user.userData.mail) { - await this.mailService.sendTranslatedMail({ - userData: entity.sell.user.userData, - translationKey: 'mail.payment.withdrawal.cryptoExchangedToFiat', - params: { - inputAmount: entity.inputAmount, - inputAsset: entity.inputAsset, - percentFee: entity.percentFeeString, - exchangeRate: entity.exchangeRateString, - outputAmount: entity.outputAmount, - outputAsset: entity.outputAsset, + await this.notificationService.sendMail({ + type: MailType.USER, + input: { + userData: entity.sell.user.userData, + translationKey: 'mail.payment.withdrawal.cryptoExchangedToFiat', + translationParams: { + inputAmount: entity.inputAmount, + inputAsset: entity.inputAsset, + percentFee: entity.percentFeeString, + exchangeRate: entity.exchangeRateString, + outputAmount: entity.outputAmount, + outputAsset: entity.outputAsset, + }, }, }); } @@ -119,14 +126,17 @@ export class BuyFiatNotificationService { for (const entity of entities) { try { if (entity.sell.user.userData.mail) { - await this.mailService.sendTranslatedMail({ - userData: entity.sell.user.userData, - translationKey: 'mail.payment.withdrawal.fiatToBankTransferInitiated', - params: { - outputAmount: entity.outputAmount, - outputAsset: entity.outputAsset, - bankAccountTrimmed: Util.trimIBAN(entity.sell.iban), - remittanceInfo: entity.remittanceInfo, + await this.notificationService.sendMail({ + type: MailType.USER, + input: { + userData: entity.sell.user.userData, + translationKey: 'mail.payment.withdrawal.fiatToBankTransferInitiated', + translationParams: { + outputAmount: entity.outputAmount, + outputAsset: entity.outputAsset, + bankAccountTrimmed: Util.trimIBAN(entity.sell.iban), + remittanceInfo: entity.remittanceInfo, + }, }, }); } @@ -158,19 +168,22 @@ export class BuyFiatNotificationService { entity.paybackToAddressInitiated(); if (entity.sell.user.userData.mail) { - await this.mailService.sendTranslatedMail({ - userData: entity.sell.user.userData, - translationKey: 'mail.payment.withdrawal.paybackToAddressInitiated', - params: { - inputAmount: entity.inputAmount, - inputAsset: entity.inputAsset, - returnTransactionLink: `${BlockchainExplorerUrls[entity.cryptoInput.asset.blockchain]}/${ - entity.cryptoReturnTxId - }`, - returnReason: await this.i18nService.translate(`mail.amlReasonMailText.${entity.amlReason}`, { - lang: entity.sell.user.userData.language?.symbol.toLowerCase(), - }), - userAddressTrimmed: Util.trimBlockchainAddress(entity.sell.user.address), + await this.notificationService.sendMail({ + type: MailType.USER, + input: { + userData: entity.sell.user.userData, + translationKey: 'mail.payment.withdrawal.paybackToAddressInitiated', + translationParams: { + inputAmount: entity.inputAmount, + inputAsset: entity.inputAsset, + returnTransactionLink: `${BlockchainExplorerUrls[entity.cryptoInput.asset.blockchain]}/${ + entity.cryptoReturnTxId + }`, + returnReason: await this.i18nService.translate(`mail.amlReasonMailText.${entity.amlReason}`, { + lang: entity.sell.user.userData.language?.symbol.toLowerCase(), + }), + userAddressTrimmed: Util.trimBlockchainAddress(entity.sell.user.address), + }, }, }); } diff --git a/src/payment/models/dex/dex.controller.ts b/src/payment/models/dex/dex.controller.ts new file mode 100644 index 0000000000..a4327d05db --- /dev/null +++ b/src/payment/models/dex/dex.controller.ts @@ -0,0 +1,100 @@ +import { Controller, UseGuards, Body, Post, Get, Put, Query } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; +import { RoleGuard } from 'src/shared/auth/role.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { LiquidityOrderContext } from './entities/liquidity-order.entity'; +import { LiquidityRequest, TransferRequest } from './interfaces'; +import { DexService } from './services/dex.service'; + +@ApiTags('dex') +@Controller('dex') +export class DexController { + constructor(private readonly dexService: DexService) {} + + @Get('check-liquidity') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + async checkLiquidity(@Query() dto: LiquidityRequest): Promise { + if (process.env.ENVIRONMENT === 'test') { + return this.dexService.checkLiquidity(dto); + } + } + + @Post('reserve-liquidity') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + async reserveLiquidity(@Body() dto: LiquidityRequest): Promise { + if (process.env.ENVIRONMENT === 'test') { + return this.dexService.reserveLiquidity(dto); + } + } + + @Post('purchase-liquidity') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + async purchaseLiquidity(@Body() dto: LiquidityRequest): Promise { + if (process.env.ENVIRONMENT === 'test') { + return this.dexService.purchaseLiquidity(dto); + } + } + + @Post('transfer-liquidity') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + async transferLiquidity(@Body() dto: TransferRequest): Promise { + if (process.env.ENVIRONMENT === 'test') { + return this.dexService.transferLiquidity(dto); + } + } + + @Post('transfer-minimal-utxo') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + async transferMinimalUtxo(@Query('address') address: string): Promise { + if (process.env.ENVIRONMENT === 'test') { + return this.dexService.transferMinimalUtxo(address); + } + } + + @Get('liquidity-after-purchase') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + async fetchTargetLiquidityAfterPurchase( + @Query('context') context: LiquidityOrderContext, + @Query('correlationId') correlationId: string, + ): Promise { + if (process.env.ENVIRONMENT === 'test') { + return this.dexService.fetchTargetLiquidityAfterPurchase(context, correlationId); + } + } + + @Get('transfer-completion') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + async checkTransferCompletion(@Query('transferTxId') transferTxId: string): Promise { + if (process.env.ENVIRONMENT === 'test') { + return this.dexService.checkTransferCompletion(transferTxId); + } + } + + @Put('complete-orders') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + async completeOrders( + @Query('context') context: LiquidityOrderContext, + @Query('correlationId') correlationId: string, + ): Promise { + if (process.env.ENVIRONMENT === 'test') { + return this.dexService.completeOrders(context, correlationId); + } + } +} diff --git a/src/payment/models/dex/dex.module.ts b/src/payment/models/dex/dex.module.ts index 20ff6ab841..f4a7c8d819 100644 --- a/src/payment/models/dex/dex.module.ts +++ b/src/payment/models/dex/dex.module.ts @@ -2,44 +2,69 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AinModule } from 'src/blockchain/ain/ain.module'; import { EthereumModule } from 'src/blockchain/ethereum/ethereum.module'; +import { BscModule } from 'src/blockchain/bsc/bsc.module'; import { SharedModule } from 'src/shared/shared.module'; import { LiquidityOrderFactory } from './factories/liquidity-order.factory'; import { LiquidityOrderRepository } from './repositories/liquidity-order.repository'; import { DexEthereumService } from './services/dex-ethereum.service'; import { DexService } from './services/dex.service'; import { DexDeFiChainService } from './services/dex-defichain.service'; -import { CheckLiquidityDeFiChainDefaultStrategy } from './strategies/check-liquidity/check-liquidity-defichain-default.strategy'; -import { CheckLiquidityDeFiChainPoolPairStrategy } from './strategies/check-liquidity/check-liquidity-defichain-poolpair.strategy'; -import { PurchaseLiquidityDeFiChainCryptoStrategy } from './strategies/purchase-liquidity/purchase-liquidity-defichain-crypto.strategy'; -import { PurchaseLiquidityDeFiChainPoolPairStrategy } from './strategies/purchase-liquidity/purchase-liquidity-defichain-poolpair.strategy'; -import { PurchaseLiquidityDeFiChainStockStrategy } from './strategies/purchase-liquidity/purchase-liquidity-defichain-stock.strategy'; -import { CheckLiquidityEthereumStrategy } from './strategies/check-liquidity/check-liquidity-ethereum.strategy'; -import { DexStrategiesFacade } from './strategies/strategies.facade'; -import { PurchaseLiquidityEthereumStrategy } from './strategies/purchase-liquidity/purchase-liquidity-ethereum.strategy'; -import { BscModule } from 'src/blockchain/bsc/bsc.module'; import { DexBscService } from './services/dex-bsc.service'; -import { PurchaseLiquidityBscStrategy } from './strategies/purchase-liquidity/purchase-liquidity-bsc.strategy'; -import { CheckLiquidityBscStrategy } from './strategies/check-liquidity/check-liquidity-bsc.strategy'; +import { DexBitcoinService } from './services/dex-bitcoin.service'; +import { CheckLiquidityStrategies } from './strategies/check-liquidity/check-liquidity.facade'; +import { PurchaseLiquidityStrategies } from './strategies/purchase-liquidity/purchase-liquidity.facade'; +import { DeFiChainDefaultStrategy as DeFiChainDefaultStrategyCL } from './strategies/check-liquidity/impl/defichain-default.strategy'; +import { DeFiChainPoolPairStrategy as DeFiChainPoolPairStrategyCL } from './strategies/check-liquidity/impl/defichain-poolpair.strategy'; +import { EthereumCoinStrategy as EthereumCryptoStrategyCL } from './strategies/check-liquidity/impl/ethereum-coin.strategy'; +import { BscCoinStrategy as BscCryptoStrategyCL } from './strategies/check-liquidity/impl/bsc-coin.strategy'; +import { BitcoinStrategy as BitcoinStrategyCL } from './strategies/check-liquidity/impl/bitcoin.strategy'; +import { BscTokenStrategy as BscTokenStrategyCL } from './strategies/check-liquidity/impl/bsc-token.strategy'; +import { EthereumTokenStrategy as EthereumTokenStrategyCL } from './strategies/check-liquidity/impl/ethereum-token.strategy'; +import { DeFiChainCryptoStrategy as DeFiChainCryptoStrategyPL } from './strategies/purchase-liquidity/impl/defichain-crypto.strategy'; +import { DeFiChainPoolPairStrategy as DeFiChainPoolPairStrategyPL } from './strategies/purchase-liquidity/impl/defichain-poolpair.strategy'; +import { DeFiChainStockStrategy as DeFiChainStockStrategyPL } from './strategies/purchase-liquidity/impl/defichain-stock.strategy'; +import { EthereumCoinStrategy as EthereumCryptoStrategyPL } from './strategies/purchase-liquidity/impl/ethereum-coin.strategy'; +import { BscCoinStrategy as BscCryptoStrategyPL } from './strategies/purchase-liquidity/impl/bsc-coin.strategy'; +import { BitcoinStrategy as BitcoinStrategyPL } from './strategies/purchase-liquidity/impl/bitcoin.strategy'; +import { BscTokenStrategy as BscTokenStrategyPL } from './strategies/purchase-liquidity/impl/bsc-token.strategy'; +import { EthereumTokenStrategy as EthereumTokenStrategyPL } from './strategies/purchase-liquidity/impl/ethereum-token.strategy'; +import { NotificationModule } from 'src/notification/notification.module'; +import { DexController } from './dex.controller'; @Module({ - imports: [TypeOrmModule.forFeature([LiquidityOrderRepository]), AinModule, EthereumModule, BscModule, SharedModule], - controllers: [], + imports: [ + TypeOrmModule.forFeature([LiquidityOrderRepository]), + AinModule, + EthereumModule, + BscModule, + NotificationModule, + SharedModule, + ], + controllers: [DexController], providers: [ + DexService, LiquidityOrderFactory, DexDeFiChainService, DexEthereumService, DexBscService, - DexStrategiesFacade, - DexService, - CheckLiquidityDeFiChainPoolPairStrategy, - CheckLiquidityDeFiChainDefaultStrategy, - CheckLiquidityEthereumStrategy, - CheckLiquidityBscStrategy, - PurchaseLiquidityDeFiChainCryptoStrategy, - PurchaseLiquidityDeFiChainPoolPairStrategy, - PurchaseLiquidityDeFiChainStockStrategy, - PurchaseLiquidityEthereumStrategy, - PurchaseLiquidityBscStrategy, + DexBitcoinService, + CheckLiquidityStrategies, + PurchaseLiquidityStrategies, + DeFiChainDefaultStrategyCL, + DeFiChainPoolPairStrategyCL, + EthereumCryptoStrategyCL, + BscCryptoStrategyCL, + BitcoinStrategyCL, + BscTokenStrategyCL, + EthereumTokenStrategyCL, + DeFiChainCryptoStrategyPL, + DeFiChainPoolPairStrategyPL, + DeFiChainStockStrategyPL, + EthereumCryptoStrategyPL, + BscCryptoStrategyPL, + BitcoinStrategyPL, + BscTokenStrategyPL, + EthereumTokenStrategyPL, ], exports: [DexService], }) diff --git a/src/payment/models/dex/entities/liquidity-order.entity.ts b/src/payment/models/dex/entities/liquidity-order.entity.ts index aa6fd6d324..2c28160215 100644 --- a/src/payment/models/dex/entities/liquidity-order.entity.ts +++ b/src/payment/models/dex/entities/liquidity-order.entity.ts @@ -7,6 +7,7 @@ export enum LiquidityOrderContext { BUY_CRYPTO = 'BuyCrypto', STAKING_REWARD = 'StakingReward', CREATE_POOL_PAIR = 'CreatePoolPair', + PRICING = 'Pricing', } export enum LiquidityOrderType { diff --git a/src/payment/models/dex/interfaces/index.ts b/src/payment/models/dex/interfaces/index.ts index f0567694f5..de3344dec2 100644 --- a/src/payment/models/dex/interfaces/index.ts +++ b/src/payment/models/dex/interfaces/index.ts @@ -7,6 +7,12 @@ export interface LiquidityRequest { referenceAsset: string; referenceAmount: number; targetAsset: Asset; + options?: LiquidityRequestOptions; +} + +export interface LiquidityRequestOptions { + bypassAvailabilityCheck?: boolean; + bypassSlippageProtection?: boolean; } export interface TransferRequest { diff --git a/src/payment/models/dex/services/dex-bitcoin.service.ts b/src/payment/models/dex/services/dex-bitcoin.service.ts new file mode 100644 index 0000000000..9baca9bb02 --- /dev/null +++ b/src/payment/models/dex/services/dex-bitcoin.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { BtcClient } from 'src/blockchain/ain/node/btc-client'; +import { NodeService, NodeType } from 'src/blockchain/ain/node/node.service'; +import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; +import { Util } from 'src/shared/util'; +import { LiquidityOrder } from '../entities/liquidity-order.entity'; +import { NotEnoughLiquidityException } from '../exceptions/not-enough-liquidity.exception'; +import { LiquidityOrderRepository } from '../repositories/liquidity-order.repository'; + +@Injectable() +export class DexBitcoinService { + #client: BtcClient; + + constructor(private readonly liquidityOrderRepo: LiquidityOrderRepository, readonly nodeService: NodeService) { + nodeService.getConnectedNode(NodeType.BTC_OUTPUT).subscribe((client) => (this.#client = client)); + } + + async checkAvailableTargetLiquidity(amount: number): Promise { + const pendingAmount = await this.getPendingAmount(); + const availableAmount = await this.#client.getBalance(); + + this.checkLiquidity(amount, pendingAmount, +availableAmount); + + return amount; + } + + //*** HELPER METHODS ***// + + private async getPendingAmount(): Promise { + const pendingOrders = (await this.liquidityOrderRepo.find({ isReady: true, isComplete: false })).filter( + (o) => o.targetAsset.dexName === 'BTC' && o.targetAsset.blockchain === Blockchain.BITCOIN, + ); + + return Util.sumObj(pendingOrders, 'targetAmount'); + } + + private checkLiquidity(requiredAmount: number, pendingAmount: number, availableAmount: number): void { + if (requiredAmount > availableAmount - pendingAmount) { + throw new NotEnoughLiquidityException( + `Not enough liquidity of asset BTC. Trying to use ${requiredAmount} BTC worth liquidity. Available amount: ${availableAmount}. Pending amount: ${pendingAmount}`, + ); + } + } +} diff --git a/src/payment/models/dex/services/dex-defichain.service.ts b/src/payment/models/dex/services/dex-defichain.service.ts index 4e00f0f094..c27792e313 100644 --- a/src/payment/models/dex/services/dex-defichain.service.ts +++ b/src/payment/models/dex/services/dex-defichain.service.ts @@ -31,15 +31,17 @@ export class DexDeFiChainService { sourceAmount: number, targetAsset: string, maxSlippage: number, + bypassAvailabilityCheck?: boolean, + bypassSlippageProtection?: boolean, ): Promise { const targetAmount = targetAsset === sourceAsset ? sourceAmount : await this.#dexClient.testCompositeSwap(sourceAsset, targetAsset, sourceAmount); - await this.checkAssetAvailability(targetAsset, targetAmount); + !bypassAvailabilityCheck && (await this.checkAssetAvailability(targetAsset, targetAmount)); - if ((await this.settingService.get('slippage-protection')) === 'on') { + if ((await this.settingService.get('slippage-protection')) === 'on' && !bypassSlippageProtection) { await this.checkTestSwapPriceSlippage(sourceAsset, sourceAmount, targetAsset, targetAmount, maxSlippage); } diff --git a/src/payment/models/dex/services/dex-evm.service.ts b/src/payment/models/dex/services/dex-evm.service.ts index 547f6d8ebe..ba3fd7fea2 100644 --- a/src/payment/models/dex/services/dex-evm.service.ts +++ b/src/payment/models/dex/services/dex-evm.service.ts @@ -1,6 +1,7 @@ import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; import { EvmClient } from 'src/blockchain/shared/evm/evm-client'; import { EvmService } from 'src/blockchain/shared/evm/evm.service'; +import { Asset } from 'src/shared/models/asset/asset.entity'; import { Util } from 'src/shared/util'; import { LiquidityOrder } from '../entities/liquidity-order.entity'; import { NotEnoughLiquidityException } from '../exceptions/not-enough-liquidity.exception'; @@ -18,29 +19,67 @@ export abstract class DexEvmService { this.#client = service.getDefaultClient(); } - async getBalance(): Promise { - return this.#client.getBalance(); + async checkNativeCoinAvailability(amount: number): Promise { + const pendingAmount = await this.getPendingAmount(this.nativeCoin); + const availableAmount = await this.#client.getNativeCoinBalance(); + + this.checkLiquidity(amount, pendingAmount, availableAmount, this.nativeCoin); + + return amount; + } + + async getAndCheckTokenAvailability(sourceAsset: string, sourceAmount: number, targetAsset: Asset): Promise { + const amount = await this.getTargetAmount(sourceAsset, sourceAmount, targetAsset); + + await this.checkTokenAvailability(targetAsset, amount); + + return amount; + } + + get _nativeCoin(): string { + return this.nativeCoin; + } + + //*** HELPER METHODS ***// + + private async getTargetAmount(sourceAsset: string, sourceAmount: number, targetAsset: Asset): Promise { + if (sourceAsset === targetAsset.dexName) return sourceAmount; + if (sourceAsset !== this._nativeCoin) { + // only native coin is enabled as a sourceAsset + throw new Error( + `Only native coin reference is supported by EVM test swap. Provided source asset: ${sourceAsset}. Target asset: ${targetAsset.dexName}. Blockchain: ${targetAsset.blockchain}`, + ); + } + + return this.#client.nativeCryptoTestSwap(sourceAmount, targetAsset); + } + + private async checkTokenAvailability(asset: Asset, amount: number): Promise { + const pendingAmount = await this.getPendingAmount(asset.dexName); + const availableAmount = await this.#client.getTokenBalance(asset); + + this.checkLiquidity(amount, pendingAmount, availableAmount, asset.dexName); } - async checkCoinAvailability(amount: number): Promise { + private async getPendingAmount(assetName: string): Promise { const pendingOrders = (await this.liquidityOrderRepo.find({ isReady: true, isComplete: false })).filter( - (o) => o.targetAsset.dexName === this.nativeCoin && o.targetAsset.blockchain === this.blockchain, + (o) => o.targetAsset.dexName === assetName && o.targetAsset.blockchain === this.blockchain, ); - const pendingAmount = Util.sumObj(pendingOrders, 'targetAmount'); - const availableAmount = await this.getBalance(); + return Util.sumObj(pendingOrders, 'targetAmount'); + } + private checkLiquidity( + requiredAmount: number, + pendingAmount: number, + availableAmount: number, + assetName: string, + ): void { // 5% cap for unexpected meantime swaps - if (amount * 1.05 > availableAmount - pendingAmount) { + if (requiredAmount * 1.05 > availableAmount - pendingAmount) { throw new NotEnoughLiquidityException( - `Not enough liquidity of asset ${this.nativeCoin}. Trying to use ${amount} ${this.nativeCoin} worth liquidity. Available amount: ${availableAmount}. Pending amount: ${pendingAmount}`, + `Not enough liquidity of asset ${assetName}. Trying to use ${requiredAmount} ${assetName} worth liquidity. Available amount: ${availableAmount}. Pending amount: ${pendingAmount}`, ); } - - return amount; - } - - get _nativeCoin(): string { - return this.nativeCoin; } } diff --git a/src/payment/models/dex/services/dex.service.ts b/src/payment/models/dex/services/dex.service.ts index 111bad8d68..88e6d761c5 100644 --- a/src/payment/models/dex/services/dex.service.ts +++ b/src/payment/models/dex/services/dex.service.ts @@ -9,15 +9,17 @@ import { Interval } from '@nestjs/schedule'; import { Lock } from 'src/shared/lock'; import { Not, IsNull } from 'typeorm'; import { LiquidityOrderFactory } from '../factories/liquidity-order.factory'; -import { DexStrategiesFacade } from '../strategies/strategies.facade'; +import { CheckLiquidityStrategies } from '../strategies/check-liquidity/check-liquidity.facade'; import { LiquidityRequest, TransferRequest } from '../interfaces'; +import { PurchaseLiquidityStrategies } from '../strategies/purchase-liquidity/purchase-liquidity.facade'; @Injectable() export class DexService { private readonly verifyPurchaseOrdersLock = new Lock(1800); constructor( - private readonly strategies: DexStrategiesFacade, + private readonly checkStrategies: CheckLiquidityStrategies, + private readonly purchaseStrategies: PurchaseLiquidityStrategies, private readonly dexDeFiChainService: DexDeFiChainService, private readonly liquidityOrderRepo: LiquidityOrderRepository, private readonly liquidityOrderFactory: LiquidityOrderFactory, @@ -29,7 +31,7 @@ export class DexService { const { context, correlationId, targetAsset } = request; try { - const strategy = this.strategies.getCheckLiquidityStrategy(targetAsset); + const strategy = this.checkStrategies.getCheckLiquidityStrategy(targetAsset); return strategy.checkLiquidity(request); } catch (e) { @@ -50,7 +52,7 @@ export class DexService { try { console.info(`Reserving ${targetAsset.dexName} liquidity. Context: ${context}. Correlation ID: ${correlationId}`); - const strategy = this.strategies.getCheckLiquidityStrategy(targetAsset); + const strategy = this.checkStrategies.getCheckLiquidityStrategy(targetAsset); const liquidity = await strategy.checkLiquidity(request); @@ -80,7 +82,7 @@ export class DexService { async purchaseLiquidity(request: LiquidityRequest): Promise { const { context, correlationId, targetAsset } = request; - const strategy = this.strategies.getPurchaseLiquidityStrategy(targetAsset); + const strategy = this.purchaseStrategies.getPurchaseLiquidityStrategy(targetAsset); if (!strategy) { throw new Error(`No purchase liquidity strategy for asset category ${targetAsset?.category}`); diff --git a/src/payment/models/dex/strategies/__tests__/strategies.facade.spec.ts b/src/payment/models/dex/strategies/__tests__/strategies.facade.spec.ts deleted file mode 100644 index 0dfa14c01a..0000000000 --- a/src/payment/models/dex/strategies/__tests__/strategies.facade.spec.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { mock } from 'jest-mock-extended'; -import { BehaviorSubject } from 'rxjs'; -import { NodeService } from 'src/blockchain/ain/node/node.service'; -import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; -import { AssetCategory } from 'src/shared/models/asset/asset.entity'; -import { AssetService } from 'src/shared/models/asset/asset.service'; -import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; -import { SettingService } from 'src/shared/models/setting/setting.service'; -import { MailService } from 'src/shared/services/mail.service'; -import { LiquidityOrderFactory } from '../../factories/liquidity-order.factory'; -import { LiquidityOrderRepository } from '../../repositories/liquidity-order.repository'; -import { DexBscService } from '../../services/dex-bsc.service'; -import { DexDeFiChainService } from '../../services/dex-defichain.service'; -import { DexEthereumService } from '../../services/dex-ethereum.service'; -import { DexService } from '../../services/dex.service'; -import { CheckLiquidityBscStrategy } from '../check-liquidity/check-liquidity-bsc.strategy'; -import { CheckLiquidityDeFiChainDefaultStrategy } from '../check-liquidity/check-liquidity-defichain-default.strategy'; -import { CheckLiquidityDeFiChainPoolPairStrategy } from '../check-liquidity/check-liquidity-defichain-poolpair.strategy'; -import { CheckLiquidityEthereumStrategy } from '../check-liquidity/check-liquidity-ethereum.strategy'; -import { PurchaseLiquidityBscStrategy } from '../purchase-liquidity/purchase-liquidity-bsc.strategy'; -import { PurchaseLiquidityDeFiChainCryptoStrategy } from '../purchase-liquidity/purchase-liquidity-defichain-crypto.strategy'; -import { PurchaseLiquidityDeFiChainPoolPairStrategy } from '../purchase-liquidity/purchase-liquidity-defichain-poolpair.strategy'; -import { PurchaseLiquidityDeFiChainStockStrategy } from '../purchase-liquidity/purchase-liquidity-defichain-stock.strategy'; -import { PurchaseLiquidityEthereumStrategy } from '../purchase-liquidity/purchase-liquidity-ethereum.strategy'; -import { CheckLiquidityStrategyAlias, DexStrategiesFacade, PurchaseLiquidityStrategyAlias } from '../strategies.facade'; - -describe('DexStrategiesFacade', () => { - let nodeService: NodeService; - - let checkLiquidityDeFiChainPoolPairStrategy: CheckLiquidityDeFiChainPoolPairStrategy; - let checkLiquidityDeFiChainDefaultStrategy: CheckLiquidityDeFiChainDefaultStrategy; - let checkLiquidityEthereumStrategy: CheckLiquidityEthereumStrategy; - let checkLiquidityBSCStrategy: CheckLiquidityBscStrategy; - let purchaseLiquidityDeFiChainPoolPairStrategy: PurchaseLiquidityDeFiChainPoolPairStrategy; - let purchaseLiquidityDeFiChainStockStrategy: PurchaseLiquidityDeFiChainStockStrategy; - let purchaseLiquidityDeFiChainCryptoStrategy: PurchaseLiquidityDeFiChainCryptoStrategy; - let purchaseLiquidityEthereumStrategy: PurchaseLiquidityEthereumStrategy; - let purchaseLiquidityBscStrategy: PurchaseLiquidityBscStrategy; - - let facade: DexStrategiesFacadeWrapper; - - beforeEach(() => { - nodeService = mock(); - jest.spyOn(nodeService, 'getConnectedNode').mockImplementation(() => new BehaviorSubject(null)); - - checkLiquidityDeFiChainPoolPairStrategy = new CheckLiquidityDeFiChainPoolPairStrategy(); - checkLiquidityDeFiChainDefaultStrategy = new CheckLiquidityDeFiChainDefaultStrategy(mock()); - checkLiquidityEthereumStrategy = new CheckLiquidityEthereumStrategy(mock()); - checkLiquidityBSCStrategy = new CheckLiquidityBscStrategy(mock()); - purchaseLiquidityDeFiChainPoolPairStrategy = new PurchaseLiquidityDeFiChainPoolPairStrategy( - nodeService, - mock(), - mock(), - mock(), - mock(), - mock(), - mock(), - ); - purchaseLiquidityDeFiChainStockStrategy = new PurchaseLiquidityDeFiChainStockStrategy( - mock(), - mock(), - mock(), - mock(), - ); - purchaseLiquidityDeFiChainCryptoStrategy = new PurchaseLiquidityDeFiChainCryptoStrategy( - mock(), - mock(), - mock(), - mock(), - ); - purchaseLiquidityEthereumStrategy = new PurchaseLiquidityEthereumStrategy( - mock(), - mock(), - ); - purchaseLiquidityBscStrategy = new PurchaseLiquidityBscStrategy(mock(), mock()); - - facade = new DexStrategiesFacadeWrapper( - checkLiquidityDeFiChainPoolPairStrategy, - checkLiquidityDeFiChainDefaultStrategy, - checkLiquidityEthereumStrategy, - checkLiquidityBSCStrategy, - purchaseLiquidityDeFiChainPoolPairStrategy, - purchaseLiquidityDeFiChainStockStrategy, - purchaseLiquidityDeFiChainCryptoStrategy, - purchaseLiquidityEthereumStrategy, - purchaseLiquidityBscStrategy, - ); - }); - - describe('#constructor(...)', () => { - it('adds all checkLiquidityStrategies to a map', () => { - expect([...facade.getCheckLiquidityStrategies().entries()].length).toBe(4); - }); - - it('sets all required checkLiquidityStrategies aliases', () => { - const aliases = [...facade.getCheckLiquidityStrategies().keys()]; - - expect(aliases.includes(CheckLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR)).toBe(true); - expect(aliases.includes(CheckLiquidityStrategyAlias.DEFICHAIN_DEFAULT)).toBe(true); - expect(aliases.includes(CheckLiquidityStrategyAlias.ETHEREUM_DEFAULT)).toBe(true); - expect(aliases.includes(CheckLiquidityStrategyAlias.BSC_DEFAULT)).toBe(true); - }); - - it('assigns proper checkLiquidityStrategies to aliases', () => { - expect(facade.getCheckLiquidityStrategies().get(CheckLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR)).toBeInstanceOf( - CheckLiquidityDeFiChainPoolPairStrategy, - ); - - expect(facade.getCheckLiquidityStrategies().get(CheckLiquidityStrategyAlias.DEFICHAIN_DEFAULT)).toBeInstanceOf( - CheckLiquidityDeFiChainDefaultStrategy, - ); - - expect(facade.getCheckLiquidityStrategies().get(CheckLiquidityStrategyAlias.ETHEREUM_DEFAULT)).toBeInstanceOf( - CheckLiquidityEthereumStrategy, - ); - - expect(facade.getCheckLiquidityStrategies().get(CheckLiquidityStrategyAlias.BSC_DEFAULT)).toBeInstanceOf( - CheckLiquidityBscStrategy, - ); - }); - - it('adds all purchaseLiquidityStrategies to a map', () => { - expect([...facade.getPurchaseLiquidityStrategies().entries()].length).toBe(5); - }); - - it('sets all required purchaseLiquidityStrategies aliases', () => { - const aliases = [...facade.getPurchaseLiquidityStrategies().keys()]; - - expect(aliases.includes(PurchaseLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR)).toBe(true); - expect(aliases.includes(PurchaseLiquidityStrategyAlias.DEFICHAIN_STOCK)).toBe(true); - expect(aliases.includes(PurchaseLiquidityStrategyAlias.DEFICHAIN_CRYPTO)).toBe(true); - expect(aliases.includes(PurchaseLiquidityStrategyAlias.ETHEREUM_DEFAULT)).toBe(true); - expect(aliases.includes(PurchaseLiquidityStrategyAlias.BSC_DEFAULT)).toBe(true); - }); - - it('assigns proper purchaseLiquidityStrategies to aliases', () => { - expect( - facade.getPurchaseLiquidityStrategies().get(PurchaseLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR), - ).toBeInstanceOf(PurchaseLiquidityDeFiChainPoolPairStrategy); - - expect( - facade.getPurchaseLiquidityStrategies().get(PurchaseLiquidityStrategyAlias.DEFICHAIN_STOCK), - ).toBeInstanceOf(PurchaseLiquidityDeFiChainStockStrategy); - - expect( - facade.getPurchaseLiquidityStrategies().get(PurchaseLiquidityStrategyAlias.DEFICHAIN_CRYPTO), - ).toBeInstanceOf(PurchaseLiquidityDeFiChainCryptoStrategy); - - expect( - facade.getPurchaseLiquidityStrategies().get(PurchaseLiquidityStrategyAlias.ETHEREUM_DEFAULT), - ).toBeInstanceOf(PurchaseLiquidityEthereumStrategy); - - expect(facade.getPurchaseLiquidityStrategies().get(PurchaseLiquidityStrategyAlias.BSC_DEFAULT)).toBeInstanceOf( - PurchaseLiquidityBscStrategy, - ); - }); - }); - - describe('#getCheckLiquidityStrategy(...)', () => { - describe('getting strategy by Asset', () => { - it('gets DEFICHAIN_POOL_PAIR strategy for DEFICHAIN', () => { - const strategy = facade.getCheckLiquidityStrategy( - createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: AssetCategory.POOL_PAIR }), - ); - - expect(strategy).toBeInstanceOf(CheckLiquidityDeFiChainPoolPairStrategy); - }); - - it('gets DEFICHAIN_DEFAULT strategy for DEFICHAIN', () => { - const strategyCrypto = facade.getCheckLiquidityStrategy( - createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: AssetCategory.CRYPTO }), - ); - - expect(strategyCrypto).toBeInstanceOf(CheckLiquidityDeFiChainDefaultStrategy); - - const strategyStock = facade.getCheckLiquidityStrategy( - createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: AssetCategory.STOCK }), - ); - - expect(strategyStock).toBeInstanceOf(CheckLiquidityDeFiChainDefaultStrategy); - }); - - it('gets DEFICHAIN_DEFAULT strategy for BITCOIN', () => { - const strategyCrypto = facade.getCheckLiquidityStrategy( - createCustomAsset({ blockchain: Blockchain.BITCOIN, category: AssetCategory.CRYPTO }), - ); - - expect(strategyCrypto).toBeInstanceOf(CheckLiquidityDeFiChainDefaultStrategy); - - const strategyStock = facade.getCheckLiquidityStrategy( - createCustomAsset({ blockchain: Blockchain.BITCOIN, category: AssetCategory.STOCK }), - ); - - expect(strategyStock).toBeInstanceOf(CheckLiquidityDeFiChainDefaultStrategy); - }); - - it('gets ETHEREUM_DEFAULT strategy', () => { - const strategy = facade.getCheckLiquidityStrategy(createCustomAsset({ blockchain: Blockchain.ETHEREUM })); - - expect(strategy).toBeInstanceOf(CheckLiquidityEthereumStrategy); - }); - - it('gets BSC_DEFAULT strategy', () => { - const strategy = facade.getCheckLiquidityStrategy( - createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN }), - ); - - expect(strategy).toBeInstanceOf(CheckLiquidityBscStrategy); - }); - - it('fails to get strategy for non-supported Blockchain', () => { - const testCall = () => - facade.getCheckLiquidityStrategy(createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain })); - - expect(testCall).toThrow(); - expect(testCall).toThrowError('No CheckLiquidityStrategy found. Alias: undefined'); - }); - }); - - describe('getting strategy by Alias', () => { - it('gets DEFICHAIN_POOL_PAIR strategy', () => { - const strategy = facade.getCheckLiquidityStrategy(CheckLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR); - - expect(strategy).toBeInstanceOf(CheckLiquidityDeFiChainPoolPairStrategy); - }); - - it('gets DEFICHAIN_DEFAULT strategy', () => { - const strategyCrypto = facade.getCheckLiquidityStrategy(CheckLiquidityStrategyAlias.DEFICHAIN_DEFAULT); - - expect(strategyCrypto).toBeInstanceOf(CheckLiquidityDeFiChainDefaultStrategy); - }); - - it('gets ETHEREUM_DEFAULT strategy', () => { - const strategy = facade.getCheckLiquidityStrategy(CheckLiquidityStrategyAlias.ETHEREUM_DEFAULT); - - expect(strategy).toBeInstanceOf(CheckLiquidityEthereumStrategy); - }); - - it('gets BSC_DEFAULT strategy', () => { - const strategy = facade.getCheckLiquidityStrategy(CheckLiquidityStrategyAlias.BSC_DEFAULT); - - expect(strategy).toBeInstanceOf(CheckLiquidityBscStrategy); - }); - - it('fails to get strategy for non-supported Alias', () => { - const testCall = () => facade.getCheckLiquidityStrategy('NonExistingAlias' as CheckLiquidityStrategyAlias); - - expect(testCall).toThrow(); - expect(testCall).toThrowError('No CheckLiquidityStrategy found. Alias: NonExistingAlias'); - }); - }); - }); - - describe('#getPurchaseLiquidityStrategy(...)', () => { - describe('getting strategy by Asset', () => { - it('gets DEFICHAIN_POOL_PAIR strategy for DEFICHAIN Pool Pair', () => { - const strategy = facade.getPurchaseLiquidityStrategy( - createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: AssetCategory.POOL_PAIR }), - ); - - expect(strategy).toBeInstanceOf(PurchaseLiquidityDeFiChainPoolPairStrategy); - }); - - it('gets DEFICHAIN_STOCK strategy for DEFICHAIN Stock', () => { - const strategy = facade.getPurchaseLiquidityStrategy( - createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: AssetCategory.STOCK }), - ); - - expect(strategy).toBeInstanceOf(PurchaseLiquidityDeFiChainStockStrategy); - }); - - it('gets DEFICHAIN_CRYPTO strategy for DEFICHAIN Crypto', () => { - const strategy = facade.getPurchaseLiquidityStrategy( - createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: AssetCategory.CRYPTO }), - ); - - expect(strategy).toBeInstanceOf(PurchaseLiquidityDeFiChainCryptoStrategy); - }); - - it('gets DEFICHAIN_CRYPTO strategy for BITCOIN Crypto', () => { - const strategy = facade.getPurchaseLiquidityStrategy( - createCustomAsset({ blockchain: Blockchain.BITCOIN, category: AssetCategory.CRYPTO }), - ); - - expect(strategy).toBeInstanceOf(PurchaseLiquidityDeFiChainCryptoStrategy); - }); - - it('gets ETHEREUM_DEFAULT strategy', () => { - const strategy = facade.getPurchaseLiquidityStrategy(createCustomAsset({ blockchain: Blockchain.ETHEREUM })); - - expect(strategy).toBeInstanceOf(PurchaseLiquidityEthereumStrategy); - }); - - it('gets BSC_DEFAULT strategy', () => { - const strategy = facade.getPurchaseLiquidityStrategy( - createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN }), - ); - - expect(strategy).toBeInstanceOf(PurchaseLiquidityBscStrategy); - }); - - it('fails to get strategy for non-supported Blockchain', () => { - const testCall = () => - facade.getPurchaseLiquidityStrategy(createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain })); - - expect(testCall).toThrow(); - expect(testCall).toThrowError('No PurchaseLiquidityStrategy found. Alias: undefined'); - }); - - it('fails to get strategy for non-supported AssetCategory', () => { - const testCall = () => - facade.getPurchaseLiquidityStrategy( - createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: 'NewCategory' as AssetCategory }), - ); - - expect(testCall).toThrow(); - expect(testCall).toThrowError('No PurchaseLiquidityStrategy found. Alias: undefined'); - }); - }); - - describe('getting strategy by Alias', () => { - it('gets DEFICHAIN_POOL_PAIR strategy', () => { - const strategy = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR); - - expect(strategy).toBeInstanceOf(PurchaseLiquidityDeFiChainPoolPairStrategy); - }); - - it('gets DEFICHAIN_STOCK strategy', () => { - const strategyCrypto = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.DEFICHAIN_STOCK); - - expect(strategyCrypto).toBeInstanceOf(PurchaseLiquidityDeFiChainStockStrategy); - }); - - it('gets DEFICHAIN_CRYPTO strategy', () => { - const strategyCrypto = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.DEFICHAIN_CRYPTO); - - expect(strategyCrypto).toBeInstanceOf(PurchaseLiquidityDeFiChainCryptoStrategy); - }); - - it('gets ETHEREUM_DEFAULT strategy', () => { - const strategy = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.ETHEREUM_DEFAULT); - - expect(strategy).toBeInstanceOf(PurchaseLiquidityEthereumStrategy); - }); - - it('gets BSC_DEFAULT strategy', () => { - const strategy = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.BSC_DEFAULT); - - expect(strategy).toBeInstanceOf(PurchaseLiquidityBscStrategy); - }); - - it('fails to get strategy for non-supported Alias', () => { - const testCall = () => - facade.getPurchaseLiquidityStrategy('NonExistingAlias' as PurchaseLiquidityStrategyAlias); - - expect(testCall).toThrow(); - expect(testCall).toThrowError('No PurchaseLiquidityStrategy found. Alias: NonExistingAlias'); - }); - }); - }); -}); - -class DexStrategiesFacadeWrapper extends DexStrategiesFacade { - constructor( - checkLiquidityDeFiChainPoolPairStrategy: CheckLiquidityDeFiChainPoolPairStrategy, - checkLiquidityDeFiChainDefaultStrategy: CheckLiquidityDeFiChainDefaultStrategy, - checkLiquidityEthereumStrategy: CheckLiquidityEthereumStrategy, - checkLiquidityBSCStrategy: CheckLiquidityBscStrategy, - purchaseLiquidityDeFiChainPoolPairStrategy: PurchaseLiquidityDeFiChainPoolPairStrategy, - purchaseLiquidityDeFiChainStockStrategy: PurchaseLiquidityDeFiChainStockStrategy, - purchaseLiquidityDeFiChainCryptoStrategy: PurchaseLiquidityDeFiChainCryptoStrategy, - purchaseLiquidityEthereumStrategy: PurchaseLiquidityEthereumStrategy, - purchaseLiquidityBSCStrategy: PurchaseLiquidityBscStrategy, - ) { - super( - checkLiquidityDeFiChainPoolPairStrategy, - checkLiquidityDeFiChainDefaultStrategy, - checkLiquidityEthereumStrategy, - checkLiquidityBSCStrategy, - purchaseLiquidityDeFiChainPoolPairStrategy, - purchaseLiquidityDeFiChainStockStrategy, - purchaseLiquidityDeFiChainCryptoStrategy, - purchaseLiquidityEthereumStrategy, - purchaseLiquidityBSCStrategy, - ); - } - - getCheckLiquidityStrategies() { - return this.checkLiquidityStrategies; - } - - getPurchaseLiquidityStrategies() { - return this.purchaseLiquidityStrategies; - } -} diff --git a/src/payment/models/dex/strategies/check-liquidity/__tests__/check-liquidity.facade.spec.ts b/src/payment/models/dex/strategies/check-liquidity/__tests__/check-liquidity.facade.spec.ts new file mode 100644 index 0000000000..3b343af9d5 --- /dev/null +++ b/src/payment/models/dex/strategies/check-liquidity/__tests__/check-liquidity.facade.spec.ts @@ -0,0 +1,233 @@ +import { mock } from 'jest-mock-extended'; +import { BehaviorSubject } from 'rxjs'; +import { NodeService } from 'src/blockchain/ain/node/node.service'; +import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; +import { AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { DexBscService } from '../../../services/dex-bsc.service'; +import { DexDeFiChainService } from '../../../services/dex-defichain.service'; +import { DexEthereumService } from '../../../services/dex-ethereum.service'; +import { CheckLiquidityStrategies, CheckLiquidityAlias } from '../check-liquidity.facade'; +import { BscCoinStrategy } from '../impl/bsc-coin.strategy'; +import { DeFiChainDefaultStrategy } from '../impl/defichain-default.strategy'; +import { DeFiChainPoolPairStrategy } from '../impl/defichain-poolpair.strategy'; +import { EthereumCoinStrategy } from '../impl/ethereum-coin.strategy'; +import { BitcoinStrategy } from '../impl/bitcoin.strategy'; +import { BscTokenStrategy } from '../impl/bsc-token.strategy'; +import { EthereumTokenStrategy } from '../impl/ethereum-token.strategy'; +import { DexBitcoinService } from '../../../services/dex-bitcoin.service'; + +describe('CheckLiquidityStrategies', () => { + let nodeService: NodeService; + + let bitcoin: BitcoinStrategy; + let bscCoin: BscCoinStrategy; + let bscToken: BscTokenStrategy; + let deFiChainPoolPair: DeFiChainPoolPairStrategy; + let deFiChainDefault: DeFiChainDefaultStrategy; + let ethereumCoin: EthereumCoinStrategy; + let ethereumToken: EthereumTokenStrategy; + + let facade: CheckLiquidityStrategiesWrapper; + + beforeEach(() => { + nodeService = mock(); + jest.spyOn(nodeService, 'getConnectedNode').mockImplementation(() => new BehaviorSubject(null)); + + bitcoin = new BitcoinStrategy(mock()); + bscCoin = new BscCoinStrategy(mock()); + bscToken = new BscTokenStrategy(mock()); + deFiChainPoolPair = new DeFiChainPoolPairStrategy(); + deFiChainDefault = new DeFiChainDefaultStrategy(mock()); + ethereumCoin = new EthereumCoinStrategy(mock()); + ethereumToken = new EthereumTokenStrategy(mock()); + + facade = new CheckLiquidityStrategiesWrapper( + bitcoin, + bscCoin, + bscToken, + deFiChainDefault, + deFiChainPoolPair, + ethereumCoin, + ethereumToken, + ); + }); + + describe('#constructor(...)', () => { + it('adds all checkLiquidityStrategies to a map', () => { + expect([...facade.getStrategies().entries()].length).toBe(7); + }); + + it('assigns strategies to all aliases', () => { + expect([...facade.getStrategies().entries()].length).toBe(Object.values(CheckLiquidityAlias).length); + }); + + it('sets all required checkLiquidityStrategies aliases', () => { + const aliases = [...facade.getStrategies().keys()]; + + expect(aliases.includes(CheckLiquidityAlias.BITCOIN)).toBe(true); + expect(aliases.includes(CheckLiquidityAlias.BSC_COIN)).toBe(true); + expect(aliases.includes(CheckLiquidityAlias.BSC_TOKEN)).toBe(true); + expect(aliases.includes(CheckLiquidityAlias.DEFICHAIN_POOL_PAIR)).toBe(true); + expect(aliases.includes(CheckLiquidityAlias.DEFICHAIN_DEFAULT)).toBe(true); + expect(aliases.includes(CheckLiquidityAlias.ETHEREUM_COIN)).toBe(true); + expect(aliases.includes(CheckLiquidityAlias.ETHEREUM_TOKEN)).toBe(true); + }); + + it('assigns proper checkLiquidityStrategies to aliases', () => { + expect(facade.getStrategies().get(CheckLiquidityAlias.BITCOIN)).toBeInstanceOf(BitcoinStrategy); + expect(facade.getStrategies().get(CheckLiquidityAlias.BSC_COIN)).toBeInstanceOf(BscCoinStrategy); + expect(facade.getStrategies().get(CheckLiquidityAlias.BSC_TOKEN)).toBeInstanceOf(BscTokenStrategy); + expect(facade.getStrategies().get(CheckLiquidityAlias.DEFICHAIN_POOL_PAIR)).toBeInstanceOf( + DeFiChainPoolPairStrategy, + ); + expect(facade.getStrategies().get(CheckLiquidityAlias.DEFICHAIN_DEFAULT)).toBeInstanceOf( + DeFiChainDefaultStrategy, + ); + expect(facade.getStrategies().get(CheckLiquidityAlias.ETHEREUM_COIN)).toBeInstanceOf(EthereumCoinStrategy); + expect(facade.getStrategies().get(CheckLiquidityAlias.ETHEREUM_TOKEN)).toBeInstanceOf(EthereumTokenStrategy); + }); + }); + + describe('#getCheckLiquidityStrategy(...)', () => { + describe('getting strategy by Asset', () => { + it('gets BITCOIN strategy for BITCOIN', () => { + const strategy = facade.getCheckLiquidityStrategy(createCustomAsset({ blockchain: Blockchain.BITCOIN })); + + expect(strategy).toBeInstanceOf(BitcoinStrategy); + }); + + it('gets BSC_COIN strategy', () => { + const strategy = facade.getCheckLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(BscCoinStrategy); + }); + + it('gets BSC_TOKEN strategy', () => { + const strategy = facade.getCheckLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(BscTokenStrategy); + }); + + it('gets DEFICHAIN_POOL_PAIR strategy for DEFICHAIN', () => { + const strategy = facade.getCheckLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: AssetCategory.POOL_PAIR }), + ); + + expect(strategy).toBeInstanceOf(DeFiChainPoolPairStrategy); + }); + + it('gets DEFICHAIN_DEFAULT strategy for DEFICHAIN', () => { + const strategyCrypto = facade.getCheckLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: AssetCategory.CRYPTO }), + ); + + expect(strategyCrypto).toBeInstanceOf(DeFiChainDefaultStrategy); + + const strategyStock = facade.getCheckLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: AssetCategory.STOCK }), + ); + + expect(strategyStock).toBeInstanceOf(DeFiChainDefaultStrategy); + }); + + it('gets ETHEREUM_COIN strategy', () => { + const strategy = facade.getCheckLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.ETHEREUM, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(EthereumCoinStrategy); + }); + + it('gets ETHEREUM_TOKEN strategy', () => { + const strategy = facade.getCheckLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.ETHEREUM, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(EthereumTokenStrategy); + }); + + it('fails to get strategy for non-supported Blockchain', () => { + const testCall = () => + facade.getCheckLiquidityStrategy(createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain })); + + expect(testCall).toThrow(); + expect(testCall).toThrowError('No CheckLiquidityStrategy found. Alias: undefined'); + }); + }); + + describe('getting strategy by CheckLiquidityAlias', () => { + it('gets BITCOIN strategy', () => { + const strategy = facade.getCheckLiquidityStrategy(CheckLiquidityAlias.BITCOIN); + + expect(strategy).toBeInstanceOf(BitcoinStrategy); + }); + + it('gets BSC_COIN strategy', () => { + const strategy = facade.getCheckLiquidityStrategy(CheckLiquidityAlias.BSC_COIN); + + expect(strategy).toBeInstanceOf(BscCoinStrategy); + }); + + it('gets BSC_TOKEN strategy', () => { + const strategy = facade.getCheckLiquidityStrategy(CheckLiquidityAlias.BSC_TOKEN); + + expect(strategy).toBeInstanceOf(BscTokenStrategy); + }); + + it('gets DEFICHAIN_POOL_PAIR strategy', () => { + const strategy = facade.getCheckLiquidityStrategy(CheckLiquidityAlias.DEFICHAIN_POOL_PAIR); + + expect(strategy).toBeInstanceOf(DeFiChainPoolPairStrategy); + }); + + it('gets DEFICHAIN_DEFAULT strategy', () => { + const strategyCrypto = facade.getCheckLiquidityStrategy(CheckLiquidityAlias.DEFICHAIN_DEFAULT); + + expect(strategyCrypto).toBeInstanceOf(DeFiChainDefaultStrategy); + }); + + it('gets ETHEREUM_COIN strategy', () => { + const strategy = facade.getCheckLiquidityStrategy(CheckLiquidityAlias.ETHEREUM_COIN); + + expect(strategy).toBeInstanceOf(EthereumCoinStrategy); + }); + + it('gets ETHEREUM_TOKEN strategy', () => { + const strategy = facade.getCheckLiquidityStrategy(CheckLiquidityAlias.ETHEREUM_TOKEN); + + expect(strategy).toBeInstanceOf(EthereumTokenStrategy); + }); + + it('fails to get strategy for non-supported CheckLiquidityAlias', () => { + const testCall = () => + facade.getCheckLiquidityStrategy('NonExistingCheckLiquidityAlias' as CheckLiquidityAlias); + + expect(testCall).toThrow(); + expect(testCall).toThrowError('No CheckLiquidityStrategy found. Alias: NonExistingCheckLiquidityAlias'); + }); + }); + }); +}); + +class CheckLiquidityStrategiesWrapper extends CheckLiquidityStrategies { + constructor( + bitcoin: BitcoinStrategy, + bscCoin: BscCoinStrategy, + bscToken: BscTokenStrategy, + deFiChainDefault: DeFiChainDefaultStrategy, + deFiChainPoolPair: DeFiChainPoolPairStrategy, + ethereumCoin: EthereumCoinStrategy, + ethereumToken: EthereumTokenStrategy, + ) { + super(bitcoin, bscCoin, bscToken, deFiChainDefault, deFiChainPoolPair, ethereumCoin, ethereumToken); + } + + getStrategies() { + return this.strategies; + } +} diff --git a/src/payment/models/dex/strategies/check-liquidity/base/check-liquidity-evm.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/base/check-liquidity-evm.strategy.ts deleted file mode 100644 index 2d7ad8de6e..0000000000 --- a/src/payment/models/dex/strategies/check-liquidity/base/check-liquidity-evm.strategy.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { LiquidityRequest } from '../../../interfaces'; -import { DexEvmService } from '../../../services/dex-evm.service'; -import { CheckLiquidityStrategy } from './check-liquidity.strategy'; - -export class CheckLiquidityEvmStrategy implements CheckLiquidityStrategy { - constructor(protected readonly dexEvmService: DexEvmService) {} - - async checkLiquidity(request: LiquidityRequest): Promise { - const targetAmount = request.referenceAmount; - - await this.dexEvmService.checkCoinAvailability(targetAmount); - - return targetAmount; - } -} diff --git a/src/payment/models/dex/strategies/check-liquidity/check-liquidity-bsc.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/check-liquidity-bsc.strategy.ts deleted file mode 100644 index 76b793e8b7..0000000000 --- a/src/payment/models/dex/strategies/check-liquidity/check-liquidity-bsc.strategy.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DexBscService } from '../../services/dex-bsc.service'; -import { CheckLiquidityEvmStrategy } from './base/check-liquidity-evm.strategy'; - -@Injectable() -export class CheckLiquidityBscStrategy extends CheckLiquidityEvmStrategy { - constructor(dexBscService: DexBscService) { - super(dexBscService); - } -} diff --git a/src/payment/models/dex/strategies/check-liquidity/check-liquidity-ethereum.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/check-liquidity-ethereum.strategy.ts deleted file mode 100644 index 3d1439d359..0000000000 --- a/src/payment/models/dex/strategies/check-liquidity/check-liquidity-ethereum.strategy.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DexEthereumService } from '../../services/dex-ethereum.service'; -import { CheckLiquidityEvmStrategy } from './base/check-liquidity-evm.strategy'; - -@Injectable() -export class CheckLiquidityEthereumStrategy extends CheckLiquidityEvmStrategy { - constructor(dexEthereumService: DexEthereumService) { - super(dexEthereumService); - } -} diff --git a/src/payment/models/dex/strategies/check-liquidity/check-liquidity.facade.ts b/src/payment/models/dex/strategies/check-liquidity/check-liquidity.facade.ts new file mode 100644 index 0000000000..5d78660a68 --- /dev/null +++ b/src/payment/models/dex/strategies/check-liquidity/check-liquidity.facade.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { CheckLiquidityStrategy } from './impl/base/check-liquidity.strategy'; +import { BitcoinStrategy } from './impl/bitcoin.strategy'; +import { BscCoinStrategy } from './impl/bsc-coin.strategy'; +import { BscTokenStrategy } from './impl/bsc-token.strategy'; +import { DeFiChainDefaultStrategy } from './impl/defichain-default.strategy'; +import { DeFiChainPoolPairStrategy } from './impl/defichain-poolpair.strategy'; +import { EthereumCoinStrategy } from './impl/ethereum-coin.strategy'; +import { EthereumTokenStrategy } from './impl/ethereum-token.strategy'; + +enum Alias { + BITCOIN = 'Bitcoin', + BSC_COIN = 'BscCoin', + BSC_TOKEN = 'BscToken', + DEFICHAIN_POOL_PAIR = 'DeFiChainPoolPair', + DEFICHAIN_DEFAULT = 'DeFiChainDefault', + ETHEREUM_COIN = 'EthereumCoin', + ETHEREUM_TOKEN = 'EthereumToken', +} + +export { Alias as CheckLiquidityAlias }; + +@Injectable() +export class CheckLiquidityStrategies { + protected readonly strategies = new Map(); + + constructor( + bitcoin: BitcoinStrategy, + bscCoin: BscCoinStrategy, + bscToken: BscTokenStrategy, + deFiChainDefault: DeFiChainDefaultStrategy, + deFiChainPoolPair: DeFiChainPoolPairStrategy, + ethereumCoin: EthereumCoinStrategy, + ethereumToken: EthereumTokenStrategy, + ) { + this.strategies.set(Alias.BITCOIN, bitcoin); + this.strategies.set(Alias.BSC_COIN, bscCoin); + this.strategies.set(Alias.BSC_TOKEN, bscToken); + this.strategies.set(Alias.DEFICHAIN_POOL_PAIR, deFiChainPoolPair); + this.strategies.set(Alias.DEFICHAIN_DEFAULT, deFiChainDefault); + this.strategies.set(Alias.ETHEREUM_COIN, ethereumCoin); + this.strategies.set(Alias.ETHEREUM_TOKEN, ethereumToken); + } + + getCheckLiquidityStrategy(criteria: Asset | Alias): CheckLiquidityStrategy { + return criteria instanceof Asset ? this.getByAsset(criteria) : this.getByAlias(criteria); + } + + //*** HELPER METHODS ***// + + private getByAlias(alias: Alias): CheckLiquidityStrategy { + const strategy = this.strategies.get(alias); + + if (!strategy) throw new Error(`No CheckLiquidityStrategy found. Alias: ${alias}`); + + return strategy; + } + + private getByAsset(asset: Asset): CheckLiquidityStrategy { + const alias = this.getAlias(asset); + + return this.getByAlias(alias); + } + + private getAlias(asset: Asset): Alias { + const { blockchain, category: assetCategory, type: assetType } = asset; + + if (blockchain === Blockchain.BITCOIN) return Alias.BITCOIN; + + if (blockchain === Blockchain.BINANCE_SMART_CHAIN) { + return assetType === AssetType.COIN ? Alias.BSC_COIN : Alias.BSC_TOKEN; + } + + if (blockchain === Blockchain.DEFICHAIN) { + if (assetCategory === AssetCategory.POOL_PAIR) return Alias.DEFICHAIN_POOL_PAIR; + return Alias.DEFICHAIN_DEFAULT; + } + + if (blockchain === Blockchain.ETHEREUM) { + return assetType === AssetType.COIN ? Alias.ETHEREUM_COIN : Alias.ETHEREUM_TOKEN; + } + } +} diff --git a/src/payment/models/dex/strategies/check-liquidity/base/check-liquidity.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/impl/base/check-liquidity.strategy.ts similarity index 64% rename from src/payment/models/dex/strategies/check-liquidity/base/check-liquidity.strategy.ts rename to src/payment/models/dex/strategies/check-liquidity/impl/base/check-liquidity.strategy.ts index b618e63130..600a01de70 100644 --- a/src/payment/models/dex/strategies/check-liquidity/base/check-liquidity.strategy.ts +++ b/src/payment/models/dex/strategies/check-liquidity/impl/base/check-liquidity.strategy.ts @@ -1,4 +1,4 @@ -import { LiquidityRequest } from '../../../interfaces'; +import { LiquidityRequest } from '../../../../interfaces'; export interface CheckLiquidityStrategy { checkLiquidity(request: LiquidityRequest): Promise; diff --git a/src/payment/models/dex/strategies/check-liquidity/impl/base/evm-coin.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/impl/base/evm-coin.strategy.ts new file mode 100644 index 0000000000..66533c2ec5 --- /dev/null +++ b/src/payment/models/dex/strategies/check-liquidity/impl/base/evm-coin.strategy.ts @@ -0,0 +1,20 @@ +import { LiquidityRequest } from '../../../../interfaces'; +import { DexEvmService } from '../../../../services/dex-evm.service'; +import { CheckLiquidityStrategy } from './check-liquidity.strategy'; + +export class EvmCoinStrategy implements CheckLiquidityStrategy { + constructor(protected readonly dexEvmService: DexEvmService) {} + + async checkLiquidity(request: LiquidityRequest): Promise { + const { referenceAsset, referenceAmount, context, correlationId } = request; + + if (referenceAsset === this.dexEvmService._nativeCoin) { + return this.dexEvmService.checkNativeCoinAvailability(referenceAmount); + } + + // only native coin is enabled as a referenceAsset + throw new Error( + `Only native coin reference is supported by EVM CheckLiquidity strategy. Provided reference asset: ${referenceAsset} Context: ${context}. CorrelationID: ${correlationId}`, + ); + } +} diff --git a/src/payment/models/dex/strategies/check-liquidity/impl/base/evm-token.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/impl/base/evm-token.strategy.ts new file mode 100644 index 0000000000..2327f110b1 --- /dev/null +++ b/src/payment/models/dex/strategies/check-liquidity/impl/base/evm-token.strategy.ts @@ -0,0 +1,13 @@ +import { LiquidityRequest } from '../../../../interfaces'; +import { DexEvmService } from '../../../../services/dex-evm.service'; +import { CheckLiquidityStrategy } from './check-liquidity.strategy'; + +export class EvmTokenStrategy implements CheckLiquidityStrategy { + constructor(protected readonly dexEvmService: DexEvmService) {} + + async checkLiquidity(request: LiquidityRequest): Promise { + const { referenceAmount, referenceAsset, targetAsset } = request; + + return this.dexEvmService.getAndCheckTokenAvailability(referenceAsset, referenceAmount, targetAsset); + } +} diff --git a/src/payment/models/dex/strategies/check-liquidity/impl/bitcoin.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/impl/bitcoin.strategy.ts new file mode 100644 index 0000000000..6c3b4067af --- /dev/null +++ b/src/payment/models/dex/strategies/check-liquidity/impl/bitcoin.strategy.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { LiquidityRequest } from '../../../interfaces'; +import { DexBitcoinService } from '../../../services/dex-bitcoin.service'; +import { CheckLiquidityStrategy } from './base/check-liquidity.strategy'; + +@Injectable() +export class BitcoinStrategy implements CheckLiquidityStrategy { + constructor(private readonly dexBtcService: DexBitcoinService) {} + + async checkLiquidity(request: LiquidityRequest): Promise { + const { referenceAmount: bitcoinAmount } = request; + + return this.dexBtcService.checkAvailableTargetLiquidity(bitcoinAmount); + } +} diff --git a/src/payment/models/dex/strategies/check-liquidity/impl/bsc-coin.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/impl/bsc-coin.strategy.ts new file mode 100644 index 0000000000..4f30518cbd --- /dev/null +++ b/src/payment/models/dex/strategies/check-liquidity/impl/bsc-coin.strategy.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { DexBscService } from '../../../services/dex-bsc.service'; +import { EvmCoinStrategy } from './base/evm-coin.strategy'; + +@Injectable() +export class BscCoinStrategy extends EvmCoinStrategy { + constructor(dexBscService: DexBscService) { + super(dexBscService); + } +} diff --git a/src/payment/models/dex/strategies/check-liquidity/impl/bsc-token.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/impl/bsc-token.strategy.ts new file mode 100644 index 0000000000..31c82a3c9c --- /dev/null +++ b/src/payment/models/dex/strategies/check-liquidity/impl/bsc-token.strategy.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { DexBscService } from '../../../services/dex-bsc.service'; +import { EvmTokenStrategy } from './base/evm-token.strategy'; + +@Injectable() +export class BscTokenStrategy extends EvmTokenStrategy { + constructor(dexBscService: DexBscService) { + super(dexBscService); + } +} diff --git a/src/payment/models/dex/strategies/check-liquidity/check-liquidity-defichain-default.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/impl/defichain-default.strategy.ts similarity index 56% rename from src/payment/models/dex/strategies/check-liquidity/check-liquidity-defichain-default.strategy.ts rename to src/payment/models/dex/strategies/check-liquidity/impl/defichain-default.strategy.ts index d7cd3177ba..56018a1b97 100644 --- a/src/payment/models/dex/strategies/check-liquidity/check-liquidity-defichain-default.strategy.ts +++ b/src/payment/models/dex/strategies/check-liquidity/impl/defichain-default.strategy.ts @@ -1,15 +1,15 @@ import { Injectable } from '@nestjs/common'; -import { LiquidityOrder } from '../../entities/liquidity-order.entity'; -import { LiquidityRequest } from '../../interfaces'; -import { DexDeFiChainService } from '../../services/dex-defichain.service'; +import { LiquidityOrder } from '../../../entities/liquidity-order.entity'; +import { LiquidityRequest } from '../../../interfaces'; +import { DexDeFiChainService } from '../../../services/dex-defichain.service'; import { CheckLiquidityStrategy } from './base/check-liquidity.strategy'; @Injectable() -export class CheckLiquidityDeFiChainDefaultStrategy implements CheckLiquidityStrategy { +export class DeFiChainDefaultStrategy implements CheckLiquidityStrategy { constructor(private readonly dexDeFiChainService: DexDeFiChainService) {} async checkLiquidity(request: LiquidityRequest): Promise { - const { referenceAsset, referenceAmount, targetAsset } = request; + const { referenceAsset, referenceAmount, targetAsset, options } = request; // calculating how much targetAmount is needed and if it's available on the node return this.dexDeFiChainService.getAndCheckAvailableTargetLiquidity( @@ -17,6 +17,8 @@ export class CheckLiquidityDeFiChainDefaultStrategy implements CheckLiquidityStr referenceAmount, targetAsset.dexName, LiquidityOrder.getMaxPriceSlippage(targetAsset.dexName), + options?.bypassAvailabilityCheck, + options?.bypassSlippageProtection, ); } } diff --git a/src/payment/models/dex/strategies/check-liquidity/check-liquidity-defichain-poolpair.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/impl/defichain-poolpair.strategy.ts similarity index 74% rename from src/payment/models/dex/strategies/check-liquidity/check-liquidity-defichain-poolpair.strategy.ts rename to src/payment/models/dex/strategies/check-liquidity/impl/defichain-poolpair.strategy.ts index 8049d4cdbb..cf1b6407a8 100644 --- a/src/payment/models/dex/strategies/check-liquidity/check-liquidity-defichain-poolpair.strategy.ts +++ b/src/payment/models/dex/strategies/check-liquidity/impl/defichain-poolpair.strategy.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { CheckLiquidityStrategy } from './base/check-liquidity.strategy'; @Injectable() -export class CheckLiquidityDeFiChainPoolPairStrategy implements CheckLiquidityStrategy { +export class DeFiChainPoolPairStrategy implements CheckLiquidityStrategy { // assume there is no poolpair liquidity available on DEX node async checkLiquidity(): Promise { return 0; diff --git a/src/payment/models/dex/strategies/check-liquidity/impl/ethereum-coin.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/impl/ethereum-coin.strategy.ts new file mode 100644 index 0000000000..b49b957298 --- /dev/null +++ b/src/payment/models/dex/strategies/check-liquidity/impl/ethereum-coin.strategy.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { DexEthereumService } from '../../../services/dex-ethereum.service'; +import { EvmCoinStrategy } from './base/evm-coin.strategy'; + +@Injectable() +export class EthereumCoinStrategy extends EvmCoinStrategy { + constructor(dexEthereumService: DexEthereumService) { + super(dexEthereumService); + } +} diff --git a/src/payment/models/dex/strategies/check-liquidity/impl/ethereum-token.strategy.ts b/src/payment/models/dex/strategies/check-liquidity/impl/ethereum-token.strategy.ts new file mode 100644 index 0000000000..55ee9d5635 --- /dev/null +++ b/src/payment/models/dex/strategies/check-liquidity/impl/ethereum-token.strategy.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { DexEthereumService } from '../../../services/dex-ethereum.service'; +import { EvmTokenStrategy } from './base/evm-token.strategy'; + +@Injectable() +export class EthereumTokenStrategy extends EvmTokenStrategy { + constructor(dexEthereumService: DexEthereumService) { + super(dexEthereumService); + } +} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/__tests__/purchase-liquidity.facade.spec.ts b/src/payment/models/dex/strategies/purchase-liquidity/__tests__/purchase-liquidity.facade.spec.ts new file mode 100644 index 0000000000..2a106076cd --- /dev/null +++ b/src/payment/models/dex/strategies/purchase-liquidity/__tests__/purchase-liquidity.facade.spec.ts @@ -0,0 +1,288 @@ +import { mock } from 'jest-mock-extended'; +import { BehaviorSubject } from 'rxjs'; +import { NodeService } from 'src/blockchain/ain/node/node.service'; +import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { LiquidityOrderFactory } from '../../../factories/liquidity-order.factory'; +import { LiquidityOrderRepository } from '../../../repositories/liquidity-order.repository'; +import { DexBitcoinService } from '../../../services/dex-bitcoin.service'; +import { DexBscService } from '../../../services/dex-bsc.service'; +import { DexDeFiChainService } from '../../../services/dex-defichain.service'; +import { DexService } from '../../../services/dex.service'; +import { BitcoinStrategy } from '../impl/bitcoin.strategy'; +import { BscCoinStrategy } from '../impl/bsc-coin.strategy'; +import { BscTokenStrategy } from '../impl/bsc-token.strategy'; +import { DeFiChainCryptoStrategy } from '../impl/defichain-crypto.strategy'; +import { DeFiChainPoolPairStrategy } from '../impl/defichain-poolpair.strategy'; +import { DeFiChainStockStrategy } from '../impl/defichain-stock.strategy'; +import { EthereumCoinStrategy } from '../impl/ethereum-coin.strategy'; +import { EthereumTokenStrategy } from '../impl/ethereum-token.strategy'; +import { PurchaseLiquidityStrategyAlias, PurchaseLiquidityStrategies } from '../purchase-liquidity.facade'; + +describe('PurchaseLiquidityStrategies', () => { + let nodeService: NodeService; + + let bitcoin: BitcoinStrategy; + let bscCoin: BscCoinStrategy; + let bscToken: BscTokenStrategy; + let deFiChainPoolPair: DeFiChainPoolPairStrategy; + let deFiChainStock: DeFiChainStockStrategy; + let deFiChainCrypto: DeFiChainCryptoStrategy; + let ethereumCoin: EthereumCoinStrategy; + let ethereumToken: EthereumTokenStrategy; + + let facade: PurchaseLiquidityStrategiesWrapper; + + beforeEach(() => { + nodeService = mock(); + jest.spyOn(nodeService, 'getConnectedNode').mockImplementation(() => new BehaviorSubject(null)); + + bitcoin = new BitcoinStrategy(mock(), mock()); + bscCoin = new BscCoinStrategy(mock(), mock()); + bscToken = new BscTokenStrategy(mock(), mock()); + + deFiChainPoolPair = new DeFiChainPoolPairStrategy( + nodeService, + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + ); + deFiChainStock = new DeFiChainStockStrategy( + mock(), + mock(), + mock(), + mock(), + ); + deFiChainCrypto = new DeFiChainCryptoStrategy( + mock(), + mock(), + mock(), + mock(), + ); + ethereumCoin = new EthereumCoinStrategy(mock(), mock()); + ethereumToken = new EthereumTokenStrategy(mock(), mock()); + + facade = new PurchaseLiquidityStrategiesWrapper( + bitcoin, + bscCoin, + bscToken, + deFiChainCrypto, + deFiChainPoolPair, + deFiChainStock, + ethereumCoin, + ethereumToken, + ); + }); + + describe('#constructor(...)', () => { + it('adds all purchaseLiquidityStrategies to a map', () => { + expect([...facade.getStrategies().entries()].length).toBe(8); + }); + + it('assigns strategies to all aliases', () => { + expect([...facade.getStrategies().entries()].length).toBe(Object.values(PurchaseLiquidityStrategyAlias).length); + }); + + it('sets all required purchaseLiquidityStrategies aliases', () => { + const aliases = [...facade.getStrategies().keys()]; + + expect(aliases.includes(PurchaseLiquidityStrategyAlias.BITCOIN)).toBe(true); + expect(aliases.includes(PurchaseLiquidityStrategyAlias.BSC_COIN)).toBe(true); + expect(aliases.includes(PurchaseLiquidityStrategyAlias.BSC_TOKEN)).toBe(true); + expect(aliases.includes(PurchaseLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR)).toBe(true); + expect(aliases.includes(PurchaseLiquidityStrategyAlias.DEFICHAIN_STOCK)).toBe(true); + expect(aliases.includes(PurchaseLiquidityStrategyAlias.DEFICHAIN_CRYPTO)).toBe(true); + expect(aliases.includes(PurchaseLiquidityStrategyAlias.ETHEREUM_COIN)).toBe(true); + expect(aliases.includes(PurchaseLiquidityStrategyAlias.ETHEREUM_TOKEN)).toBe(true); + }); + + it('assigns proper purchaseLiquidityStrategies to aliases', () => { + expect(facade.getStrategies().get(PurchaseLiquidityStrategyAlias.BITCOIN)).toBeInstanceOf(BitcoinStrategy); + expect(facade.getStrategies().get(PurchaseLiquidityStrategyAlias.BSC_COIN)).toBeInstanceOf(BscCoinStrategy); + expect(facade.getStrategies().get(PurchaseLiquidityStrategyAlias.BSC_TOKEN)).toBeInstanceOf(BscTokenStrategy); + expect(facade.getStrategies().get(PurchaseLiquidityStrategyAlias.DEFICHAIN_CRYPTO)).toBeInstanceOf( + DeFiChainCryptoStrategy, + ); + expect(facade.getStrategies().get(PurchaseLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR)).toBeInstanceOf( + DeFiChainPoolPairStrategy, + ); + expect(facade.getStrategies().get(PurchaseLiquidityStrategyAlias.DEFICHAIN_STOCK)).toBeInstanceOf( + DeFiChainStockStrategy, + ); + expect(facade.getStrategies().get(PurchaseLiquidityStrategyAlias.ETHEREUM_COIN)).toBeInstanceOf( + EthereumCoinStrategy, + ); + expect(facade.getStrategies().get(PurchaseLiquidityStrategyAlias.ETHEREUM_TOKEN)).toBeInstanceOf( + EthereumTokenStrategy, + ); + }); + }); + + describe('#getPurchaseLiquidityStrategy(...)', () => { + describe('getting strategy by Asset', () => { + it('gets BITCOIN strategy for BITCOIN Crypto', () => { + const strategy = facade.getPurchaseLiquidityStrategy(createCustomAsset({ blockchain: Blockchain.BITCOIN })); + + expect(strategy).toBeInstanceOf(BitcoinStrategy); + }); + + it('gets BSC_COIN strategy', () => { + const strategy = facade.getPurchaseLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(BscCoinStrategy); + }); + + it('gets BSC_TOKEN strategy', () => { + const strategy = facade.getPurchaseLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(BscTokenStrategy); + }); + + it('gets DEFICHAIN_CRYPTO strategy for DEFICHAIN Crypto', () => { + const strategy = facade.getPurchaseLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: AssetCategory.CRYPTO }), + ); + + expect(strategy).toBeInstanceOf(DeFiChainCryptoStrategy); + }); + + it('gets DEFICHAIN_POOL_PAIR strategy for DEFICHAIN Pool Pair', () => { + const strategy = facade.getPurchaseLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: AssetCategory.POOL_PAIR }), + ); + + expect(strategy).toBeInstanceOf(DeFiChainPoolPairStrategy); + }); + + it('gets DEFICHAIN_STOCK strategy for DEFICHAIN Stock', () => { + const strategy = facade.getPurchaseLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: AssetCategory.STOCK }), + ); + + expect(strategy).toBeInstanceOf(DeFiChainStockStrategy); + }); + + it('gets ETHEREUM_COIN strategy', () => { + const strategy = facade.getPurchaseLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.ETHEREUM, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(EthereumCoinStrategy); + }); + + it('gets ETHEREUM_TOKEN strategy', () => { + const strategy = facade.getPurchaseLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.ETHEREUM, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(EthereumTokenStrategy); + }); + + it('fails to get strategy for non-supported Blockchain', () => { + const testCall = () => + facade.getPurchaseLiquidityStrategy(createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain })); + + expect(testCall).toThrow(); + expect(testCall).toThrowError('No PurchaseLiquidityStrategy found. Alias: undefined'); + }); + + it('fails to get strategy for non-supported AssetCategory', () => { + const testCall = () => + facade.getPurchaseLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.DEFICHAIN, category: 'NewCategory' as AssetCategory }), + ); + + expect(testCall).toThrow(); + expect(testCall).toThrowError('No PurchaseLiquidityStrategy found. Alias: undefined'); + }); + }); + + describe('getting strategy by Alias', () => { + it('gets BITCOIN strategy', () => { + const strategy = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.BITCOIN); + + expect(strategy).toBeInstanceOf(BitcoinStrategy); + }); + + it('gets BSC_COIN strategy', () => { + const strategy = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.BSC_COIN); + + expect(strategy).toBeInstanceOf(BscCoinStrategy); + }); + + it('gets BSC_TOKEN strategy', () => { + const strategy = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.BSC_TOKEN); + + expect(strategy).toBeInstanceOf(BscTokenStrategy); + }); + + it('gets DEFICHAIN_CRYPTO strategy', () => { + const strategyCrypto = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.DEFICHAIN_CRYPTO); + + expect(strategyCrypto).toBeInstanceOf(DeFiChainCryptoStrategy); + }); + + it('gets DEFICHAIN_POOL_PAIR strategy', () => { + const strategy = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR); + + expect(strategy).toBeInstanceOf(DeFiChainPoolPairStrategy); + }); + + it('gets DEFICHAIN_STOCK strategy', () => { + const strategyCrypto = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.DEFICHAIN_STOCK); + + expect(strategyCrypto).toBeInstanceOf(DeFiChainStockStrategy); + }); + + it('gets ETHEREUM_COIN strategy', () => { + const strategy = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.ETHEREUM_COIN); + + expect(strategy).toBeInstanceOf(EthereumCoinStrategy); + }); + + it('gets ETHEREUM_TOKEN strategy', () => { + const strategy = facade.getPurchaseLiquidityStrategy(PurchaseLiquidityStrategyAlias.ETHEREUM_TOKEN); + + expect(strategy).toBeInstanceOf(EthereumTokenStrategy); + }); + + it('fails to get strategy for non-supported Alias', () => { + const testCall = () => + facade.getPurchaseLiquidityStrategy('NonExistingAlias' as PurchaseLiquidityStrategyAlias); + + expect(testCall).toThrow(); + expect(testCall).toThrowError('No PurchaseLiquidityStrategy found. Alias: NonExistingAlias'); + }); + }); + }); +}); + +class PurchaseLiquidityStrategiesWrapper extends PurchaseLiquidityStrategies { + constructor( + bitcoin: BitcoinStrategy, + bscCoin: BscCoinStrategy, + bscToken: BscTokenStrategy, + deFiChainCrypto: DeFiChainCryptoStrategy, + deFiChainPoolPair: DeFiChainPoolPairStrategy, + deFiChainStock: DeFiChainStockStrategy, + ethereumCoin: EthereumCoinStrategy, + ethereumToken: EthereumTokenStrategy, + ) { + super(bitcoin, bscCoin, bscToken, deFiChainCrypto, deFiChainPoolPair, deFiChainStock, ethereumCoin, ethereumToken); + } + + getStrategies() { + return this.strategies; + } +} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/base/purchase-liquidity.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/base/purchase-liquidity.strategy.ts deleted file mode 100644 index eb7c901319..0000000000 --- a/src/payment/models/dex/strategies/purchase-liquidity/base/purchase-liquidity.strategy.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { MailService } from 'src/shared/services/mail.service'; -import { NotEnoughLiquidityException } from '../../../exceptions/not-enough-liquidity.exception'; -import { PriceSlippageException } from '../../../exceptions/price-slippage.exception'; -import { LiquidityRequest } from '../../../interfaces'; - -export abstract class PurchaseLiquidityStrategy { - constructor(protected readonly mailService: MailService) {} - - abstract purchaseLiquidity(request: LiquidityRequest): Promise; - - protected async handlePurchaseLiquidityError(e: Error, request: LiquidityRequest): Promise { - const errorMessage = `Correlation ID: ${request.correlationId}. Context: ${request.context}. ${e.message}`; - - if (e instanceof NotEnoughLiquidityException) { - await this.mailService.sendErrorMail('Purchase Liquidity Error', [errorMessage]); - } - - if (e instanceof PriceSlippageException) { - throw new PriceSlippageException(errorMessage); - } - - throw new Error(errorMessage); - } -} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/base/purchase-liquidity-defichain-non-poolpair.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/impl/base/defichain-non-poolpair.strategy.ts similarity index 78% rename from src/payment/models/dex/strategies/purchase-liquidity/base/purchase-liquidity-defichain-non-poolpair.strategy.ts rename to src/payment/models/dex/strategies/purchase-liquidity/impl/base/defichain-non-poolpair.strategy.ts index 4571560986..68cab4888f 100644 --- a/src/payment/models/dex/strategies/purchase-liquidity/base/purchase-liquidity-defichain-non-poolpair.strategy.ts +++ b/src/payment/models/dex/strategies/purchase-liquidity/impl/base/defichain-non-poolpair.strategy.ts @@ -1,25 +1,25 @@ import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; +import { NotificationService } from 'src/notification/services/notification.service'; import { AssetCategory } from 'src/shared/models/asset/asset.entity'; -import { MailService } from 'src/shared/services/mail.service'; -import { LiquidityOrder } from '../../../entities/liquidity-order.entity'; -import { NotEnoughLiquidityException } from '../../../exceptions/not-enough-liquidity.exception'; -import { LiquidityOrderFactory } from '../../../factories/liquidity-order.factory'; -import { LiquidityRequest } from '../../../interfaces'; -import { LiquidityOrderRepository } from '../../../repositories/liquidity-order.repository'; -import { DexDeFiChainService } from '../../../services/dex-defichain.service'; +import { LiquidityOrder } from '../../../../entities/liquidity-order.entity'; +import { NotEnoughLiquidityException } from '../../../../exceptions/not-enough-liquidity.exception'; +import { LiquidityOrderFactory } from '../../../../factories/liquidity-order.factory'; +import { LiquidityRequest } from '../../../../interfaces'; +import { LiquidityOrderRepository } from '../../../../repositories/liquidity-order.repository'; +import { DexDeFiChainService } from '../../../../services/dex-defichain.service'; import { PurchaseLiquidityStrategy } from './purchase-liquidity.strategy'; -export abstract class PurchaseLiquidityDeFiChainNonPoolPairStrategy extends PurchaseLiquidityStrategy { +export abstract class DeFiChainNonPoolPairStrategy extends PurchaseLiquidityStrategy { private prioritySwapAssets: string[] = []; constructor( - mailService: MailService, + notificationService: NotificationService, protected readonly dexDeFiChainService: DexDeFiChainService, protected readonly liquidityOrderRepo: LiquidityOrderRepository, protected readonly liquidityOrderFactory: LiquidityOrderFactory, prioritySwapAssets: string[], ) { - super(mailService); + super(notificationService); this.prioritySwapAssets = prioritySwapAssets; } diff --git a/src/payment/models/dex/strategies/purchase-liquidity/base/purchase-liquidity-evm.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/impl/base/evm-coin.strategy.ts similarity index 51% rename from src/payment/models/dex/strategies/purchase-liquidity/base/purchase-liquidity-evm.strategy.ts rename to src/payment/models/dex/strategies/purchase-liquidity/impl/base/evm-coin.strategy.ts index eb69befad3..81bd1b8b0d 100644 --- a/src/payment/models/dex/strategies/purchase-liquidity/base/purchase-liquidity-evm.strategy.ts +++ b/src/payment/models/dex/strategies/purchase-liquidity/impl/base/evm-coin.strategy.ts @@ -1,19 +1,20 @@ -import { MailService } from 'src/shared/services/mail.service'; -import { LiquidityRequest } from '../../../interfaces'; -import { DexEvmService } from '../../../services/dex-evm.service'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { LiquidityRequest } from '../../../../interfaces'; +import { DexEvmService } from '../../../../services/dex-evm.service'; import { PurchaseLiquidityStrategy } from './purchase-liquidity.strategy'; -export class PurchaseLiquidityEvmStrategy extends PurchaseLiquidityStrategy { - constructor(mailService: MailService, protected readonly dexEvmService: DexEvmService) { - super(mailService); +export class EvmCoinStrategy extends PurchaseLiquidityStrategy { + constructor(notificationService: NotificationService, protected readonly dexEvmService: DexEvmService) { + super(notificationService); } async purchaseLiquidity(request: LiquidityRequest): Promise { const { referenceAsset, referenceAmount, context, correlationId } = request; + try { // should always throw, even if there is amount, additional check is done for API consistency and sending mail if (referenceAsset === this.dexEvmService._nativeCoin) { - const amount = await this.dexEvmService.checkCoinAvailability(referenceAmount); + const amount = await this.dexEvmService.checkNativeCoinAvailability(referenceAmount); if (amount) { throw new Error( @@ -22,9 +23,9 @@ export class PurchaseLiquidityEvmStrategy extends PurchaseLiquidityStrategy { } } - // throw by default, only native coin trading enabled + // throw by default, only native coin is enabled as a referenceAsset throw new Error( - `Only native coins are supported by EVM PurchaseLiquidity strategy. Provided reference asset: ${referenceAsset} Context: ${context}. CorrelationID: ${correlationId}`, + `Only native coin reference is supported by EVM PurchaseLiquidity strategy. Provided reference asset: ${referenceAsset} Context: ${context}. CorrelationID: ${correlationId}`, ); } catch (e) { await this.handlePurchaseLiquidityError(e, request); diff --git a/src/payment/models/dex/strategies/purchase-liquidity/impl/base/evm-token.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/impl/base/evm-token.strategy.ts new file mode 100644 index 0000000000..f1bc164c55 --- /dev/null +++ b/src/payment/models/dex/strategies/purchase-liquidity/impl/base/evm-token.strategy.ts @@ -0,0 +1,31 @@ +import { NotificationService } from 'src/notification/services/notification.service'; +import { LiquidityRequest } from '../../../../interfaces'; +import { DexEvmService } from '../../../../services/dex-evm.service'; +import { PurchaseLiquidityStrategy } from './purchase-liquidity.strategy'; + +export class EvmTokenStrategy extends PurchaseLiquidityStrategy { + constructor(notificationService: NotificationService, protected readonly dexEvmService: DexEvmService) { + super(notificationService); + } + + async purchaseLiquidity(request: LiquidityRequest): Promise { + const { referenceAsset, referenceAmount, targetAsset, context, correlationId } = request; + + try { + // should always throw, even if there is amount, additional check is done for API consistency and sending mail + const amount = await this.dexEvmService.getAndCheckTokenAvailability( + referenceAsset, + referenceAmount, + targetAsset, + ); + + if (amount) { + throw new Error( + `Requested ${referenceAsset} liquidity is already available on the wallet. No purchase required, retry checkLiquidity. Context: ${context}. CorrelationID: ${correlationId}`, + ); + } + } catch (e) { + await this.handlePurchaseLiquidityError(e, request); + } + } +} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/impl/base/purchase-liquidity.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/impl/base/purchase-liquidity.strategy.ts new file mode 100644 index 0000000000..1a3b4814c4 --- /dev/null +++ b/src/payment/models/dex/strategies/purchase-liquidity/impl/base/purchase-liquidity.strategy.ts @@ -0,0 +1,44 @@ +import { MailContext, MailType } from 'src/notification/enums'; +import { MailRequest } from 'src/notification/interfaces'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { NotEnoughLiquidityException } from '../../../../exceptions/not-enough-liquidity.exception'; +import { PriceSlippageException } from '../../../../exceptions/price-slippage.exception'; +import { LiquidityRequest } from '../../../../interfaces'; + +export abstract class PurchaseLiquidityStrategy { + constructor(protected readonly notificationService: NotificationService) {} + + abstract purchaseLiquidity(request: LiquidityRequest): Promise; + + protected async handlePurchaseLiquidityError(e: Error, request: LiquidityRequest): Promise { + const errorMessage = `Correlation ID: ${request.correlationId}. Context: ${request.context}. ${e.message}`; + + if (e instanceof NotEnoughLiquidityException) { + const mailRequest = this.createMailRequest(request, errorMessage); + + await this.notificationService.sendMail(mailRequest); + } + + if (e instanceof PriceSlippageException) { + throw new PriceSlippageException(errorMessage); + } + + throw new Error(errorMessage); + } + + //*** HELPER METHODS ***// + + private createMailRequest(liquidityRequest: LiquidityRequest, errorMessage: string): MailRequest { + const correlationId = `PurchaseLiquidity&${liquidityRequest.context}&${liquidityRequest.correlationId}`; + + return { + type: MailType.ERROR_MONITORING, + input: { subject: 'Purchase Liquidity Error', errors: [errorMessage] }, + metadata: { + context: MailContext.DEX, + correlationId, + }, + options: { suppressRecurring: true }, + }; + } +} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/impl/bitcoin.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/impl/bitcoin.strategy.ts new file mode 100644 index 0000000000..c8e93bf9ec --- /dev/null +++ b/src/payment/models/dex/strategies/purchase-liquidity/impl/bitcoin.strategy.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { PurchaseLiquidityStrategy } from './base/purchase-liquidity.strategy'; +import { LiquidityRequest } from '../../../interfaces'; +import { DexBitcoinService } from '../../../services/dex-bitcoin.service'; +import { NotificationService } from 'src/notification/services/notification.service'; + +@Injectable() +export class BitcoinStrategy extends PurchaseLiquidityStrategy { + constructor(notificationService: NotificationService, private readonly dexBtcService: DexBitcoinService) { + super(notificationService); + } + + async purchaseLiquidity(request: LiquidityRequest): Promise { + const { referenceAsset, referenceAmount, context, correlationId } = request; + try { + // should always throw, even if there is amount, additional check is done for API consistency and sending mail + const amount = await this.dexBtcService.checkAvailableTargetLiquidity(referenceAmount); + + if (amount) { + throw new Error( + `Requested ${referenceAsset} liquidity is already available on the wallet. No purchase required, retry checkLiquidity. Context: ${context}. CorrelationID: ${correlationId}`, + ); + } + } catch (e) { + await this.handlePurchaseLiquidityError(e, request); + } + } +} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/impl/bsc-coin.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/impl/bsc-coin.strategy.ts new file mode 100644 index 0000000000..2847372714 --- /dev/null +++ b/src/payment/models/dex/strategies/purchase-liquidity/impl/bsc-coin.strategy.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { DexBscService } from '../../../services/dex-bsc.service'; +import { EvmCoinStrategy } from './base/evm-coin.strategy'; + +@Injectable() +export class BscCoinStrategy extends EvmCoinStrategy { + constructor(notificationService: NotificationService, dexBscService: DexBscService) { + super(notificationService, dexBscService); + } +} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/impl/bsc-token.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/impl/bsc-token.strategy.ts new file mode 100644 index 0000000000..b354a4446a --- /dev/null +++ b/src/payment/models/dex/strategies/purchase-liquidity/impl/bsc-token.strategy.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { DexBscService } from '../../../services/dex-bsc.service'; +import { EvmTokenStrategy } from './base/evm-token.strategy'; + +@Injectable() +export class BscTokenStrategy extends EvmTokenStrategy { + constructor(notificationService: NotificationService, dexBscService: DexBscService) { + super(notificationService, dexBscService); + } +} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/impl/defichain-crypto.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/impl/defichain-crypto.strategy.ts new file mode 100644 index 0000000000..637c9f3671 --- /dev/null +++ b/src/payment/models/dex/strategies/purchase-liquidity/impl/defichain-crypto.strategy.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { LiquidityOrderFactory } from '../../../factories/liquidity-order.factory'; +import { LiquidityOrderRepository } from '../../../repositories/liquidity-order.repository'; +import { DexDeFiChainService } from '../../../services/dex-defichain.service'; +import { DeFiChainNonPoolPairStrategy } from './base/defichain-non-poolpair.strategy'; + +@Injectable() +export class DeFiChainCryptoStrategy extends DeFiChainNonPoolPairStrategy { + constructor( + readonly notificationService: NotificationService, + readonly dexDeFiChainService: DexDeFiChainService, + readonly liquidityOrderRepo: LiquidityOrderRepository, + readonly liquidityOrderFactory: LiquidityOrderFactory, + ) { + super(notificationService, dexDeFiChainService, liquidityOrderRepo, liquidityOrderFactory, ['DFI']); + } +} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-defichain-poolpair.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/impl/defichain-poolpair.strategy.ts similarity index 89% rename from src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-defichain-poolpair.strategy.ts rename to src/payment/models/dex/strategies/purchase-liquidity/impl/defichain-poolpair.strategy.ts index 7d3799cc36..7146873b88 100644 --- a/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-defichain-poolpair.strategy.ts +++ b/src/payment/models/dex/strategies/purchase-liquidity/impl/defichain-poolpair.strategy.ts @@ -5,30 +5,30 @@ import { Not } from 'typeorm'; import { Config } from 'src/config/config'; import { Asset, AssetCategory } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; -import { MailService } from 'src/shared/services/mail.service'; -import { LiquidityOrder, LiquidityOrderContext } from '../../entities/liquidity-order.entity'; -import { LiquidityOrderFactory } from '../../factories/liquidity-order.factory'; -import { LiquidityOrderRepository } from '../../repositories/liquidity-order.repository'; -import { DexService } from '../../services/dex.service'; -import { PurchaseLiquidityStrategy } from './base/purchase-liquidity.strategy'; import { Util } from 'src/shared/util'; import { Lock } from 'src/shared/lock'; -import { NotEnoughLiquidityException } from '../../exceptions/not-enough-liquidity.exception'; -import { PriceSlippageException } from '../../exceptions/price-slippage.exception'; import { SettingService } from 'src/shared/models/setting/setting.service'; import { NodeService, NodeType } from 'src/blockchain/ain/node/node.service'; import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; -import { LiquidityRequest } from '../../interfaces'; +import { LiquidityOrderContext, LiquidityOrder } from '../../../entities/liquidity-order.entity'; +import { NotEnoughLiquidityException } from '../../../exceptions/not-enough-liquidity.exception'; +import { PriceSlippageException } from '../../../exceptions/price-slippage.exception'; +import { LiquidityOrderFactory } from '../../../factories/liquidity-order.factory'; +import { LiquidityRequest } from '../../../interfaces'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { LiquidityOrderRepository } from '../../../repositories/liquidity-order.repository'; +import { DexService } from '../../../services/dex.service'; +import { PurchaseLiquidityStrategy } from './base/purchase-liquidity.strategy'; @Injectable() -export class PurchaseLiquidityDeFiChainPoolPairStrategy extends PurchaseLiquidityStrategy { +export class DeFiChainPoolPairStrategy extends PurchaseLiquidityStrategy { private readonly verifyDerivedOrdersLock = new Lock(1800); private chainClient: DeFiClient; constructor( readonly nodeService: NodeService, - readonly mailService: MailService, + readonly notificationService: NotificationService, private readonly settingService: SettingService, private readonly assetService: AssetService, private readonly liquidityOrderRepo: LiquidityOrderRepository, @@ -36,7 +36,7 @@ export class PurchaseLiquidityDeFiChainPoolPairStrategy extends PurchaseLiquidit @Inject(forwardRef(() => DexService)) private readonly dexService: DexService, ) { - super(mailService); + super(notificationService); nodeService.getConnectedNode(NodeType.DEX).subscribe((client) => (this.chainClient = client)); } diff --git a/src/payment/models/dex/strategies/purchase-liquidity/impl/defichain-stock.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/impl/defichain-stock.strategy.ts new file mode 100644 index 0000000000..cca78726f4 --- /dev/null +++ b/src/payment/models/dex/strategies/purchase-liquidity/impl/defichain-stock.strategy.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { LiquidityOrderFactory } from '../../../factories/liquidity-order.factory'; +import { LiquidityOrderRepository } from '../../../repositories/liquidity-order.repository'; +import { DexDeFiChainService } from '../../../services/dex-defichain.service'; +import { DeFiChainNonPoolPairStrategy } from './base/defichain-non-poolpair.strategy'; + +@Injectable() +export class DeFiChainStockStrategy extends DeFiChainNonPoolPairStrategy { + constructor( + readonly notificationService: NotificationService, + readonly dexDeFiChainService: DexDeFiChainService, + readonly liquidityOrderRepo: LiquidityOrderRepository, + readonly liquidityOrderFactory: LiquidityOrderFactory, + ) { + super(notificationService, dexDeFiChainService, liquidityOrderRepo, liquidityOrderFactory, ['DUSD', 'DFI']); + } +} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/impl/ethereum-coin.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/impl/ethereum-coin.strategy.ts new file mode 100644 index 0000000000..8293726099 --- /dev/null +++ b/src/payment/models/dex/strategies/purchase-liquidity/impl/ethereum-coin.strategy.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { DexEthereumService } from '../../../services/dex-ethereum.service'; +import { EvmCoinStrategy } from './base/evm-coin.strategy'; + +@Injectable() +export class EthereumCoinStrategy extends EvmCoinStrategy { + constructor(notificationService: NotificationService, dexEthereumService: DexEthereumService) { + super(notificationService, dexEthereumService); + } +} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/impl/ethereum-token.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/impl/ethereum-token.strategy.ts new file mode 100644 index 0000000000..ea7a12dacd --- /dev/null +++ b/src/payment/models/dex/strategies/purchase-liquidity/impl/ethereum-token.strategy.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { DexEthereumService } from '../../../services/dex-ethereum.service'; +import { EvmTokenStrategy } from './base/evm-token.strategy'; + +@Injectable() +export class EthereumTokenStrategy extends EvmTokenStrategy { + constructor(notificationService: NotificationService, dexEthereumService: DexEthereumService) { + super(notificationService, dexEthereumService); + } +} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-bsc.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-bsc.strategy.ts deleted file mode 100644 index 0a3b02e48b..0000000000 --- a/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-bsc.strategy.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { MailService } from 'src/shared/services/mail.service'; -import { DexBscService } from '../../services/dex-bsc.service'; -import { PurchaseLiquidityEvmStrategy } from './base/purchase-liquidity-evm.strategy'; - -@Injectable() -export class PurchaseLiquidityBscStrategy extends PurchaseLiquidityEvmStrategy { - constructor(mailService: MailService, dexBscService: DexBscService) { - super(mailService, dexBscService); - } -} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-defichain-crypto.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-defichain-crypto.strategy.ts deleted file mode 100644 index 252ae58d48..0000000000 --- a/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-defichain-crypto.strategy.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { LiquidityOrderRepository } from '../../repositories/liquidity-order.repository'; -import { DexDeFiChainService } from '../../services/dex-defichain.service'; -import { MailService } from 'src/shared/services/mail.service'; -import { LiquidityOrderFactory } from '../../factories/liquidity-order.factory'; -import { PurchaseLiquidityDeFiChainNonPoolPairStrategy } from './base/purchase-liquidity-defichain-non-poolpair.strategy'; - -@Injectable() -export class PurchaseLiquidityDeFiChainCryptoStrategy extends PurchaseLiquidityDeFiChainNonPoolPairStrategy { - constructor( - readonly mailService: MailService, - readonly dexDeFiChainService: DexDeFiChainService, - readonly liquidityOrderRepo: LiquidityOrderRepository, - readonly liquidityOrderFactory: LiquidityOrderFactory, - ) { - super(mailService, dexDeFiChainService, liquidityOrderRepo, liquidityOrderFactory, ['DFI']); - } -} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-defichain-stock.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-defichain-stock.strategy.ts deleted file mode 100644 index 3fa83d3d3f..0000000000 --- a/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-defichain-stock.strategy.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { LiquidityOrderRepository } from '../../repositories/liquidity-order.repository'; -import { DexDeFiChainService } from '../../services/dex-defichain.service'; -import { MailService } from 'src/shared/services/mail.service'; -import { LiquidityOrderFactory } from '../../factories/liquidity-order.factory'; -import { PurchaseLiquidityDeFiChainNonPoolPairStrategy } from './base/purchase-liquidity-defichain-non-poolpair.strategy'; - -@Injectable() -export class PurchaseLiquidityDeFiChainStockStrategy extends PurchaseLiquidityDeFiChainNonPoolPairStrategy { - constructor( - readonly mailService: MailService, - readonly dexDeFiChainService: DexDeFiChainService, - readonly liquidityOrderRepo: LiquidityOrderRepository, - readonly liquidityOrderFactory: LiquidityOrderFactory, - ) { - super(mailService, dexDeFiChainService, liquidityOrderRepo, liquidityOrderFactory, ['DUSD', 'DFI']); - } -} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-ethereum.strategy.ts b/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-ethereum.strategy.ts deleted file mode 100644 index 149caa473e..0000000000 --- a/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity-ethereum.strategy.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { MailService } from 'src/shared/services/mail.service'; -import { DexEthereumService } from '../../services/dex-ethereum.service'; -import { PurchaseLiquidityEvmStrategy } from './base/purchase-liquidity-evm.strategy'; - -@Injectable() -export class PurchaseLiquidityEthereumStrategy extends PurchaseLiquidityEvmStrategy { - constructor(mailService: MailService, dexEthereumService: DexEthereumService) { - super(mailService, dexEthereumService); - } -} diff --git a/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity.facade.ts b/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity.facade.ts new file mode 100644 index 0000000000..b5ef8b4476 --- /dev/null +++ b/src/payment/models/dex/strategies/purchase-liquidity/purchase-liquidity.facade.ts @@ -0,0 +1,91 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { BscCoinStrategy } from './impl/bsc-coin.strategy'; +import { DeFiChainCryptoStrategy } from './impl/defichain-crypto.strategy'; +import { EthereumCoinStrategy } from './impl/ethereum-coin.strategy'; +import { PurchaseLiquidityStrategy } from './impl/base/purchase-liquidity.strategy'; +import { DeFiChainPoolPairStrategy } from './impl/defichain-poolpair.strategy'; +import { DeFiChainStockStrategy } from './impl/defichain-stock.strategy'; +import { BscTokenStrategy } from './impl/bsc-token.strategy'; +import { BitcoinStrategy } from './impl/bitcoin.strategy'; +import { EthereumTokenStrategy } from './impl/ethereum-token.strategy'; + +enum Alias { + BITCOIN = 'Bitcoin', + BSC_COIN = 'BscCoin', + BSC_TOKEN = 'BscToken', + DEFICHAIN_POOL_PAIR = 'DeFiChainPoolPair', + DEFICHAIN_STOCK = 'DeFiChainStock', + DEFICHAIN_CRYPTO = 'DeFiChainCrypto', + ETHEREUM_COIN = 'EthereumCoin', + ETHEREUM_TOKEN = 'EthereumToken', +} + +export { Alias as PurchaseLiquidityStrategyAlias }; + +@Injectable() +export class PurchaseLiquidityStrategies { + protected readonly strategies = new Map(); + + constructor( + bitcoin: BitcoinStrategy, + bscCoin: BscCoinStrategy, + bscToken: BscTokenStrategy, + deFiChainCrypto: DeFiChainCryptoStrategy, + @Inject(forwardRef(() => DeFiChainPoolPairStrategy)) + deFiChainPoolPair: DeFiChainPoolPairStrategy, + deFiChainStock: DeFiChainStockStrategy, + ethereumCoin: EthereumCoinStrategy, + ethereumToken: EthereumTokenStrategy, + ) { + this.strategies.set(Alias.BITCOIN, bitcoin); + this.strategies.set(Alias.BSC_COIN, bscCoin); + this.strategies.set(Alias.BSC_TOKEN, bscToken); + this.strategies.set(Alias.DEFICHAIN_POOL_PAIR, deFiChainPoolPair); + this.strategies.set(Alias.DEFICHAIN_STOCK, deFiChainStock); + this.strategies.set(Alias.DEFICHAIN_CRYPTO, deFiChainCrypto); + this.strategies.set(Alias.ETHEREUM_COIN, ethereumCoin); + this.strategies.set(Alias.ETHEREUM_TOKEN, ethereumToken); + } + + getPurchaseLiquidityStrategy(criteria: Asset | Alias): PurchaseLiquidityStrategy { + return criteria instanceof Asset ? this.getByAsset(criteria) : this.getByAlias(criteria); + } + + //*** HELPER METHODS ***// + + private getByAlias(alias: Alias): PurchaseLiquidityStrategy { + const strategy = this.strategies.get(alias); + + if (!strategy) throw new Error(`No PurchaseLiquidityStrategy found. Alias: ${alias}`); + + return strategy; + } + + private getByAsset(asset: Asset): PurchaseLiquidityStrategy { + const alias = this.getAlias(asset); + + return this.getByAlias(alias); + } + + private getAlias(asset: Asset): Alias { + const { blockchain, category: assetCategory, type: assetType } = asset; + + if (blockchain === Blockchain.BITCOIN) return Alias.BITCOIN; + + if (blockchain === Blockchain.BINANCE_SMART_CHAIN) { + return assetType === AssetType.COIN ? Alias.BSC_COIN : Alias.BSC_TOKEN; + } + + if (blockchain === Blockchain.DEFICHAIN) { + if (assetCategory === AssetCategory.POOL_PAIR) return Alias.DEFICHAIN_POOL_PAIR; + if (assetCategory === AssetCategory.STOCK) return Alias.DEFICHAIN_STOCK; + if (assetCategory === AssetCategory.CRYPTO) return Alias.DEFICHAIN_CRYPTO; + } + + if (blockchain === Blockchain.ETHEREUM) { + return assetType === AssetType.COIN ? Alias.ETHEREUM_COIN : Alias.ETHEREUM_TOKEN; + } + } +} diff --git a/src/payment/models/dex/strategies/strategies.facade.ts b/src/payment/models/dex/strategies/strategies.facade.ts deleted file mode 100644 index 8741a375c1..0000000000 --- a/src/payment/models/dex/strategies/strategies.facade.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; -import { Asset, AssetCategory } from 'src/shared/models/asset/asset.entity'; -import { CheckLiquidityBscStrategy } from './check-liquidity/check-liquidity-bsc.strategy'; -import { CheckLiquidityDeFiChainDefaultStrategy } from './check-liquidity/check-liquidity-defichain-default.strategy'; -import { CheckLiquidityEthereumStrategy } from './check-liquidity/check-liquidity-ethereum.strategy'; -import { CheckLiquidityStrategy } from './check-liquidity/base/check-liquidity.strategy'; -import { CheckLiquidityDeFiChainPoolPairStrategy } from './check-liquidity/check-liquidity-defichain-poolpair.strategy'; -import { PurchaseLiquidityBscStrategy } from './purchase-liquidity/purchase-liquidity-bsc.strategy'; -import { PurchaseLiquidityDeFiChainCryptoStrategy } from './purchase-liquidity/purchase-liquidity-defichain-crypto.strategy'; -import { PurchaseLiquidityEthereumStrategy } from './purchase-liquidity/purchase-liquidity-ethereum.strategy'; -import { PurchaseLiquidityStrategy } from './purchase-liquidity/base/purchase-liquidity.strategy'; -import { PurchaseLiquidityDeFiChainPoolPairStrategy } from './purchase-liquidity/purchase-liquidity-defichain-poolpair.strategy'; -import { PurchaseLiquidityDeFiChainStockStrategy } from './purchase-liquidity/purchase-liquidity-defichain-stock.strategy'; - -export enum CheckLiquidityStrategyAlias { - DEFICHAIN_POOL_PAIR = 'DeFiChainPoolPair', - DEFICHAIN_DEFAULT = 'DeFiChainDefault', - ETHEREUM_DEFAULT = 'EthereumDefault', - BSC_DEFAULT = 'BscDefault', -} - -export enum PurchaseLiquidityStrategyAlias { - DEFICHAIN_POOL_PAIR = 'DeFiChainPoolPair', - DEFICHAIN_STOCK = 'DeFiChainStock', - DEFICHAIN_CRYPTO = 'DeFiChainCrypto', - ETHEREUM_DEFAULT = 'EthereumDefault', - BSC_DEFAULT = 'BscDefault', -} - -@Injectable() -export class DexStrategiesFacade { - protected readonly checkLiquidityStrategies = new Map(); - protected readonly purchaseLiquidityStrategies = new Map(); - - constructor( - checkLiquidityDeFiChainPoolPairStrategy: CheckLiquidityDeFiChainPoolPairStrategy, - checkLiquidityDeFiChainDefaultStrategy: CheckLiquidityDeFiChainDefaultStrategy, - checkLiquidityEthereumStrategy: CheckLiquidityEthereumStrategy, - checkLiquidityBscStrategy: CheckLiquidityBscStrategy, - @Inject(forwardRef(() => PurchaseLiquidityDeFiChainPoolPairStrategy)) - purchaseLiquidityDeFiChainPoolPairStrategy: PurchaseLiquidityDeFiChainPoolPairStrategy, - purchaseLiquidityDeFiChainStockStrategy: PurchaseLiquidityDeFiChainStockStrategy, - purchaseLiquidityDeFiChainCryptoStrategy: PurchaseLiquidityDeFiChainCryptoStrategy, - purchaseLiquidityEthereumStrategy: PurchaseLiquidityEthereumStrategy, - purchaseLiquidityBscStrategy: PurchaseLiquidityBscStrategy, - ) { - this.checkLiquidityStrategies.set( - CheckLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR, - checkLiquidityDeFiChainPoolPairStrategy, - ); - - this.checkLiquidityStrategies.set( - CheckLiquidityStrategyAlias.DEFICHAIN_DEFAULT, - checkLiquidityDeFiChainDefaultStrategy, - ); - - this.checkLiquidityStrategies.set(CheckLiquidityStrategyAlias.ETHEREUM_DEFAULT, checkLiquidityEthereumStrategy); - - this.checkLiquidityStrategies.set(CheckLiquidityStrategyAlias.BSC_DEFAULT, checkLiquidityBscStrategy); - - this.purchaseLiquidityStrategies.set( - PurchaseLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR, - purchaseLiquidityDeFiChainPoolPairStrategy, - ); - - this.purchaseLiquidityStrategies.set( - PurchaseLiquidityStrategyAlias.DEFICHAIN_STOCK, - purchaseLiquidityDeFiChainStockStrategy, - ); - - this.purchaseLiquidityStrategies.set( - PurchaseLiquidityStrategyAlias.DEFICHAIN_CRYPTO, - purchaseLiquidityDeFiChainCryptoStrategy, - ); - - this.purchaseLiquidityStrategies.set( - PurchaseLiquidityStrategyAlias.ETHEREUM_DEFAULT, - purchaseLiquidityEthereumStrategy, - ); - - this.purchaseLiquidityStrategies.set(PurchaseLiquidityStrategyAlias.BSC_DEFAULT, purchaseLiquidityBscStrategy); - } - - getCheckLiquidityStrategy(criteria: Asset | CheckLiquidityStrategyAlias): CheckLiquidityStrategy { - return criteria instanceof Asset - ? this.getCheckLiquidityStrategyByAsset(criteria) - : this.getCheckLiquidityStrategyByAlias(criteria); - } - - getPurchaseLiquidityStrategy(criteria: Asset | PurchaseLiquidityStrategyAlias): PurchaseLiquidityStrategy { - return criteria instanceof Asset - ? this.getPurchaseLiquidityStrategyByAsset(criteria) - : this.getPurchaseLiquidityStrategyByAlias(criteria); - } - - //*** HELPER METHODS ***// - - private getCheckLiquidityStrategyByAlias(alias: CheckLiquidityStrategyAlias): CheckLiquidityStrategy { - const strategy = this.checkLiquidityStrategies.get(alias); - - if (!strategy) throw new Error(`No CheckLiquidityStrategy found. Alias: ${alias}`); - - return strategy; - } - - private getCheckLiquidityStrategyByAsset(asset: Asset): CheckLiquidityStrategy { - const alias = this.getCheckLiquidityStrategyAlias(asset); - - return this.getCheckLiquidityStrategyByAlias(alias); - } - - private getPurchaseLiquidityStrategyByAlias(alias: PurchaseLiquidityStrategyAlias): PurchaseLiquidityStrategy { - const strategy = this.purchaseLiquidityStrategies.get(alias); - - if (!strategy) throw new Error(`No PurchaseLiquidityStrategy found. Alias: ${alias}`); - - return strategy; - } - - private getPurchaseLiquidityStrategyByAsset(asset: Asset): PurchaseLiquidityStrategy { - const alias = this.getPurchaseLiquidityStrategyAlias(asset); - - return this.getPurchaseLiquidityStrategyByAlias(alias); - } - - private getCheckLiquidityStrategyAlias(asset: Asset): CheckLiquidityStrategyAlias { - const { blockchain, category: assetCategory } = asset; - - if (blockchain === Blockchain.DEFICHAIN || blockchain === Blockchain.BITCOIN) { - if (assetCategory === AssetCategory.POOL_PAIR) return CheckLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR; - return CheckLiquidityStrategyAlias.DEFICHAIN_DEFAULT; - } - - if (blockchain === Blockchain.ETHEREUM) return CheckLiquidityStrategyAlias.ETHEREUM_DEFAULT; - if (blockchain === Blockchain.BINANCE_SMART_CHAIN) return CheckLiquidityStrategyAlias.BSC_DEFAULT; - } - - private getPurchaseLiquidityStrategyAlias(asset: Asset): PurchaseLiquidityStrategyAlias { - const { blockchain, category: assetCategory } = asset; - - if (blockchain === Blockchain.DEFICHAIN || blockchain === Blockchain.BITCOIN) { - if (assetCategory === AssetCategory.POOL_PAIR) return PurchaseLiquidityStrategyAlias.DEFICHAIN_POOL_PAIR; - if (assetCategory === AssetCategory.STOCK) return PurchaseLiquidityStrategyAlias.DEFICHAIN_STOCK; - if (assetCategory === AssetCategory.CRYPTO) return PurchaseLiquidityStrategyAlias.DEFICHAIN_CRYPTO; - } - - if (blockchain === Blockchain.ETHEREUM) return PurchaseLiquidityStrategyAlias.ETHEREUM_DEFAULT; - if (blockchain === Blockchain.BINANCE_SMART_CHAIN) return PurchaseLiquidityStrategyAlias.BSC_DEFAULT; - } -} diff --git a/src/payment/models/payout/entities/__mocks__/payout-order.entity.mock.ts b/src/payment/models/payout/entities/__mocks__/payout-order.entity.mock.ts index 43f77fba3c..71ef5f6f93 100644 --- a/src/payment/models/payout/entities/__mocks__/payout-order.entity.mock.ts +++ b/src/payment/models/payout/entities/__mocks__/payout-order.entity.mock.ts @@ -7,12 +7,13 @@ export function createDefaultPayoutOrder(): PayoutOrder { } export function createCustomPayoutOrder(customValues: Partial): PayoutOrder { - const { context, correlationId, chain, asset, amount, destinationAddress, status, transferTxId, payoutTxId } = + const { id, context, correlationId, chain, asset, amount, destinationAddress, status, transferTxId, payoutTxId } = customValues; const keys = Object.keys(customValues); const entity = new PayoutOrder(); + entity.id = keys.includes('id') ? id : 1; entity.context = keys.includes('context') ? context : PayoutOrderContext.BUY_CRYPTO; entity.correlationId = keys.includes('correlationId') ? correlationId : 'CID_01'; entity.chain = keys.includes('chain') ? chain : Blockchain.DEFICHAIN; diff --git a/src/payment/models/payout/payout.controller.ts b/src/payment/models/payout/payout.controller.ts new file mode 100644 index 0000000000..dcc3426a7f --- /dev/null +++ b/src/payment/models/payout/payout.controller.ts @@ -0,0 +1,37 @@ +import { Controller, UseGuards, Body, Post, Get, Query } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; +import { RoleGuard } from 'src/shared/auth/role.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { PayoutService } from './services/payout.service'; +import { PayoutRequest } from './interfaces'; +import { PayoutOrderContext } from './entities/payout-order.entity'; + +@ApiTags('payout') +@Controller('payout') +export class PayoutController { + constructor(private readonly payoutService: PayoutService) {} + + @Post() + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + async doPayout(@Body() dto: PayoutRequest): Promise { + if (process.env.ENVIRONMENT === 'test') { + return this.payoutService.doPayout(dto); + } + } + + @Get('completion') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + async checkOrderCompletion( + @Query('context') context: PayoutOrderContext, + @Query('correlationId') correlationId: string, + ): Promise<{ isComplete: boolean; payoutTxId: string }> { + if (process.env.ENVIRONMENT === 'test') { + return this.payoutService.checkOrderCompletion(context, correlationId); + } + } +} diff --git a/src/payment/models/payout/payout.module.ts b/src/payment/models/payout/payout.module.ts index 675d34571a..e80d8ac4c0 100644 --- a/src/payment/models/payout/payout.module.ts +++ b/src/payment/models/payout/payout.module.ts @@ -12,14 +12,22 @@ import { PayoutDeFiChainService } from './services/payout-defichain.service'; import { PayoutEthereumService } from './services/payout-ethereum.service'; import { PayoutLogService } from './services/payout-log.service'; import { PayoutService } from './services/payout.service'; -import { PayoutBscStrategy } from './strategies/payout/payout-bsc.strategy'; -import { PayoutDeFiChainDFIStrategy } from './strategies/payout/payout-defichain-dfi.strategy'; -import { PayoutEthereumStrategy } from './strategies/payout/payout-ethereum.strategy'; -import { PayoutDeFiChainTokenStrategy } from './strategies/payout/payout-defichain-token.strategy'; -import { PrepareBscStrategy } from './strategies/prepare/prepare-bsc.strategy'; -import { PrepareDeFiChainStrategy } from './strategies/prepare/prepare-defichain.strategy'; -import { PrepareEthereumStrategy } from './strategies/prepare/prepare-ethereum.strategy'; -import { PayoutStrategiesFacade } from './strategies/strategies.facade'; +import { PayoutStrategiesFacade } from './strategies/payout/payout.facade'; +import { PayoutBitcoinService } from './services/payout-bitcoin.service'; +import { PrepareStrategiesFacade } from './strategies/prepare/prepare.facade'; +import { BitcoinStrategy as BitcoinStrategyPO } from './strategies/payout/impl/bitcoin.strategy'; +import { BscCoinStrategy as BscCryptoStrategyPO } from './strategies/payout/impl/bsc-coin.strategy'; +import { BscTokenStrategy as BscTokenStrategyPO } from './strategies/payout/impl/bsc-token.strategy'; +import { DeFiChainCoinStrategy as DeFiChainDfiStrategyPO } from './strategies/payout/impl/defichain-coin.strategy'; +import { DeFiChainTokenStrategy as DeFiChainTokenStrategyPO } from './strategies/payout/impl/defichain-token.strategy'; +import { EthereumCoinStrategy as EthereumCryptoStrategyPO } from './strategies/payout/impl/ethereum-coin.strategy'; +import { EthereumTokenStrategy as EthereumTokenStrategyPO } from './strategies/payout/impl/ethereum-token.strategy'; +import { BitcoinStrategy as BitcoinStrategyPR } from './strategies/prepare/impl/bitcoin.strategy'; +import { BscStrategy as BscStrategyPR } from './strategies/prepare/impl/bsc.strategy'; +import { DeFiChainStrategy as DeFiChainStrategyPR } from './strategies/prepare/impl/defichain.strategy'; +import { EthereumStrategy as EthereumStrategyPR } from './strategies/prepare/impl/ethereum.strategy'; +import { NotificationModule } from 'src/notification/notification.module'; +import { PayoutController } from './payout.controller'; @Module({ imports: [ @@ -29,23 +37,30 @@ import { PayoutStrategiesFacade } from './strategies/strategies.facade'; BscModule, SharedModule, DexModule, + NotificationModule, ], - controllers: [], + controllers: [PayoutController], providers: [ PayoutOrderFactory, PayoutLogService, PayoutService, + PayoutBitcoinService, PayoutDeFiChainService, PayoutEthereumService, PayoutBscService, - PayoutDeFiChainDFIStrategy, - PayoutDeFiChainTokenStrategy, - PayoutEthereumStrategy, - PayoutBscStrategy, - PrepareDeFiChainStrategy, - PrepareEthereumStrategy, - PrepareBscStrategy, PayoutStrategiesFacade, + PrepareStrategiesFacade, + BitcoinStrategyPO, + BscCryptoStrategyPO, + BscTokenStrategyPO, + DeFiChainDfiStrategyPO, + DeFiChainTokenStrategyPO, + EthereumCryptoStrategyPO, + EthereumTokenStrategyPO, + BitcoinStrategyPR, + BscStrategyPR, + DeFiChainStrategyPR, + EthereumStrategyPR, ], exports: [PayoutService], }) diff --git a/src/payment/models/payout/services/base/payout-jellyfish.service.ts b/src/payment/models/payout/services/base/payout-jellyfish.service.ts new file mode 100644 index 0000000000..fdeccbba7f --- /dev/null +++ b/src/payment/models/payout/services/base/payout-jellyfish.service.ts @@ -0,0 +1,8 @@ +import { PayoutOrderContext } from '../../entities/payout-order.entity'; + +export type PayoutGroup = { addressTo: string; amount: number }[]; + +export abstract class PayoutJellyfishService { + abstract isHealthy(context: PayoutOrderContext): Promise; + abstract checkPayoutCompletion(context: PayoutOrderContext, payoutTxId: string): Promise; +} diff --git a/src/payment/models/payout/services/payout-bitcoin.service.ts b/src/payment/models/payout/services/payout-bitcoin.service.ts new file mode 100644 index 0000000000..c855b2d145 --- /dev/null +++ b/src/payment/models/payout/services/payout-bitcoin.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { BtcClient } from 'src/blockchain/ain/node/btc-client'; +import { NodeService, NodeType } from 'src/blockchain/ain/node/node.service'; +import { PayoutOrderContext } from '../entities/payout-order.entity'; +import { PayoutGroup, PayoutJellyfishService } from './base/payout-jellyfish.service'; + +@Injectable() +export class PayoutBitcoinService extends PayoutJellyfishService { + #client: BtcClient; + + constructor(readonly nodeService: NodeService) { + super(); + nodeService.getConnectedNode(NodeType.BTC_OUTPUT).subscribe((client) => (this.#client = client)); + } + + async isHealthy(): Promise { + try { + return !!(await this.#client.getInfo()); + } catch { + return false; + } + } + + async sendUtxoToMany(_context: PayoutOrderContext, payout: PayoutGroup): Promise { + return this.#client.sendUtxoToMany(payout); + } + + async checkPayoutCompletion(payoutTxId: string): Promise { + const transaction = await this.#client.getTx(payoutTxId); + + return transaction && transaction.blockhash && transaction.confirmations > 0; + } +} diff --git a/src/payment/models/payout/services/payout-defichain.service.ts b/src/payment/models/payout/services/payout-defichain.service.ts index da63652b2e..5ca921d94d 100644 --- a/src/payment/models/payout/services/payout-defichain.service.ts +++ b/src/payment/models/payout/services/payout-defichain.service.ts @@ -4,15 +4,15 @@ import { NodeService, NodeType } from 'src/blockchain/ain/node/node.service'; import { WhaleService } from 'src/blockchain/ain/whale/whale.service'; import { Config } from 'src/config/config'; import { PayoutOrderContext } from '../entities/payout-order.entity'; - -export type PayoutGroup = { addressTo: string; amount: number }[]; +import { PayoutGroup, PayoutJellyfishService } from './base/payout-jellyfish.service'; @Injectable() -export class PayoutDeFiChainService { +export class PayoutDeFiChainService extends PayoutJellyfishService { #outClient: DeFiClient; #intClient: DeFiClient; constructor(readonly nodeService: NodeService, private readonly whaleService: WhaleService) { + super(); nodeService.getConnectedNode(NodeType.OUTPUT).subscribe((client) => (this.#outClient = client)); nodeService.getConnectedNode(NodeType.INT).subscribe((client) => (this.#intClient = client)); } diff --git a/src/payment/models/payout/services/payout-evm.service.ts b/src/payment/models/payout/services/payout-evm.service.ts index 616cc3c7f7..a89ec253e4 100644 --- a/src/payment/models/payout/services/payout-evm.service.ts +++ b/src/payment/models/payout/services/payout-evm.service.ts @@ -1,5 +1,6 @@ import { EvmClient } from 'src/blockchain/shared/evm/evm-client'; import { EvmService } from 'src/blockchain/shared/evm/evm.service'; +import { Asset } from 'src/shared/models/asset/asset.entity'; export abstract class PayoutEvmService { #client: EvmClient; @@ -8,8 +9,12 @@ export abstract class PayoutEvmService { this.#client = service.getDefaultClient(); } - async send(address: string, amount: number): Promise { - return this.#client.send(address, amount); + async sendNativeCoin(address: string, amount: number): Promise { + return this.#client.sendNativeCoin(address, amount); + } + + async sendToken(address: string, tokenName: Asset, amount: number): Promise { + return this.#client.sendToken(address, tokenName, amount); } async checkPayoutCompletion(txHash: string): Promise { diff --git a/src/payment/models/payout/services/payout.service.ts b/src/payment/models/payout/services/payout.service.ts index d8e3ea7270..05ecb32fef 100644 --- a/src/payment/models/payout/services/payout.service.ts +++ b/src/payment/models/payout/services/payout.service.ts @@ -1,24 +1,27 @@ import { Injectable } from '@nestjs/common'; import { Interval } from '@nestjs/schedule'; import { Lock } from 'src/shared/lock'; -import { PayoutOrderContext, PayoutOrderStatus } from '../entities/payout-order.entity'; +import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../entities/payout-order.entity'; import { PayoutOrderFactory } from '../factories/payout-order.factory'; import { PayoutOrderRepository } from '../repositories/payout-order.repository'; import { DuplicatedEntryException } from '../exceptions/duplicated-entry.exception'; -import { MailService } from 'src/shared/services/mail.service'; -import { PayoutStrategiesFacade, PayoutStrategyAlias } from '../strategies/strategies.facade'; -import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; import { PayoutLogService } from './payout-log.service'; import { PayoutRequest } from '../interfaces'; +import { MailContext, MailType } from 'src/notification/enums'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { MailRequest } from 'src/notification/interfaces'; +import { PayoutStrategiesFacade, PayoutStrategyAlias } from '../strategies/payout/payout.facade'; +import { PrepareStrategiesFacade } from '../strategies/prepare/prepare.facade'; @Injectable() export class PayoutService { private readonly processOrdersLock = new Lock(1800); constructor( - private readonly strategies: PayoutStrategiesFacade, + private readonly payoutStrategies: PayoutStrategiesFacade, + private readonly prepareStrategies: PrepareStrategiesFacade, private readonly logs: PayoutLogService, - private readonly mailService: MailService, + private readonly notificationService: NotificationService, private readonly payoutOrderRepo: PayoutOrderRepository, private readonly payoutOrderFactory: PayoutOrderFactory, ) {} @@ -86,7 +89,7 @@ export class PayoutService { const confirmedOrders = []; for (const order of orders) { - const strategy = this.strategies.getPrepareStrategy(order.asset); + const strategy = this.prepareStrategies.getPrepareStrategy(order.asset); try { await strategy.checkPreparationCompletion(order); @@ -105,7 +108,7 @@ export class PayoutService { const confirmedOrders = []; for (const order of orders) { - const strategy = this.strategies.getPayoutStrategy(order.asset); + const strategy = this.payoutStrategies.getPayoutStrategy(order.asset); try { await strategy.checkPayoutCompletion(order); @@ -123,7 +126,7 @@ export class PayoutService { const confirmedOrders = []; for (const order of orders) { - const strategy = this.strategies.getPrepareStrategy(order.asset); + const strategy = this.prepareStrategies.getPrepareStrategy(order.asset); try { await strategy.preparePayout(order); @@ -138,23 +141,16 @@ export class PayoutService { private async payoutOrders(): Promise { const orders = await this.payoutOrderRepo.find({ status: PayoutOrderStatus.PREPARATION_CONFIRMED }); + const groups = this.groupOrdersByPayoutStrategies(orders); - const dfiOrders = orders.filter((o) => o.asset.blockchain === Blockchain.DEFICHAIN && o.asset.dexName === 'DFI'); - const tokenOrders = orders.filter((o) => o.asset.blockchain === Blockchain.DEFICHAIN && o.asset.dexName !== 'DFI'); - const ethOrders = orders.filter((o) => o.asset.blockchain === Blockchain.ETHEREUM && o.asset.dexName === 'ETH'); - const bnbOrders = orders.filter( - (o) => o.asset.blockchain === Blockchain.BINANCE_SMART_CHAIN && o.asset.dexName === 'BNB', - ); - - const dfiStrategy = this.strategies.getPayoutStrategy(PayoutStrategyAlias.DEFICHAIN_DFI); - const tokenStrategy = this.strategies.getPayoutStrategy(PayoutStrategyAlias.DEFICHAIN_TOKEN); - const ethStrategy = this.strategies.getPayoutStrategy(PayoutStrategyAlias.ETHEREUM_DEFAULT); - const bnbStrategy = this.strategies.getPayoutStrategy(PayoutStrategyAlias.BSC_DEFAULT); - - await dfiStrategy.doPayout(dfiOrders); - await tokenStrategy.doPayout(tokenOrders); - await ethStrategy.doPayout(ethOrders); - await bnbStrategy.doPayout(bnbOrders); + for (const group of groups.entries()) { + try { + const strategy = this.payoutStrategies.getPayoutStrategy(group[0]); + await strategy.doPayout(group[1]); + } catch { + continue; + } + } } private async processFailedOrders(): Promise { @@ -163,11 +159,47 @@ export class PayoutService { if (orders.length === 0) return; const logMessage = this.logs.logFailedOrders(orders); - await this.mailService.sendErrorMail('Payout Error', [logMessage]); + const mailRequest = this.createMailRequest(logMessage, orders); + + await this.notificationService.sendMail(mailRequest); for (const order of orders) { order.pendingInvestigation(); await this.payoutOrderRepo.save(order); } } + + private groupOrdersByPayoutStrategies(orders: PayoutOrder[]): Map { + const groups = new Map(); + + for (const order of orders) { + const alias = this.payoutStrategies.getPayoutStrategyAlias(order.asset); + + if (!alias) { + console.warn(`No payout alias found for payout order ID ${order.id}. Ignoring the order`); + continue; + } + + const group = groups.get(alias) ?? []; + group.push(order); + + groups.set(alias, group); + } + + return groups; + } + + private createMailRequest(errorMessage: string, orders: PayoutOrder[] = []): MailRequest { + const correlationId = orders.reduce((acc, o) => acc + `|${o.id}&${o.context}|`, ''); + + return { + type: MailType.ERROR_MONITORING, + input: { subject: 'Payout Error', errors: [errorMessage] }, + metadata: { + context: MailContext.PAYOUT, + correlationId, + }, + options: { suppressRecurring: true }, + }; + } } diff --git a/src/payment/models/payout/strategies/__tests__/strategies.facade.spec.ts b/src/payment/models/payout/strategies/__tests__/strategies.facade.spec.ts deleted file mode 100644 index d86ef185ac..0000000000 --- a/src/payment/models/payout/strategies/__tests__/strategies.facade.spec.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { mock } from 'jest-mock-extended'; -import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; -import { DexService } from 'src/payment/models/dex/services/dex.service'; -import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; -import { MailService } from 'src/shared/services/mail.service'; -import { PayoutOrderRepository } from '../../repositories/payout-order.repository'; -import { PayoutBscService } from '../../services/payout-bsc.service'; -import { PayoutDeFiChainService } from '../../services/payout-defichain.service'; -import { PayoutEthereumService } from '../../services/payout-ethereum.service'; -import { PayoutBscStrategy } from '../payout/payout-bsc.strategy'; -import { PayoutDeFiChainDFIStrategy } from '../payout/payout-defichain-dfi.strategy'; -import { PayoutDeFiChainTokenStrategy } from '../payout/payout-defichain-token.strategy'; -import { PayoutEthereumStrategy } from '../payout/payout-ethereum.strategy'; -import { PrepareBscStrategy } from '../prepare/prepare-bsc.strategy'; -import { PrepareDeFiChainStrategy } from '../prepare/prepare-defichain.strategy'; -import { PrepareEthereumStrategy } from '../prepare/prepare-ethereum.strategy'; -import { PayoutStrategiesFacade, PayoutStrategyAlias, PrepareStrategyAlias } from '../strategies.facade'; - -describe('PayoutStrategiesFacade', () => { - let payoutDFIStrategy: PayoutDeFiChainDFIStrategy; - let payoutTokenStrategy: PayoutDeFiChainTokenStrategy; - let payoutETHStrategy: PayoutEthereumStrategy; - let payoutBSCStrategy: PayoutBscStrategy; - let prepareOnDefichainStrategy: PrepareDeFiChainStrategy; - let prepareOnEthereumStrategy: PrepareEthereumStrategy; - let prepareOnBscStrategy: PrepareBscStrategy; - - let facade: PayoutStrategiesFacadeWrapper; - - beforeEach(() => { - payoutDFIStrategy = new PayoutDeFiChainDFIStrategy( - mock(), - mock(), - mock(), - ); - payoutTokenStrategy = new PayoutDeFiChainTokenStrategy( - mock(), - mock(), - mock(), - mock(), - ); - payoutETHStrategy = new PayoutEthereumStrategy(mock(), mock()); - payoutBSCStrategy = new PayoutBscStrategy(mock(), mock()); - prepareOnDefichainStrategy = new PrepareDeFiChainStrategy( - mock(), - mock(), - mock(), - ); - prepareOnEthereumStrategy = new PrepareEthereumStrategy(mock()); - prepareOnBscStrategy = new PrepareBscStrategy(mock()); - - facade = new PayoutStrategiesFacadeWrapper( - payoutDFIStrategy, - payoutTokenStrategy, - payoutETHStrategy, - payoutBSCStrategy, - prepareOnDefichainStrategy, - prepareOnEthereumStrategy, - prepareOnBscStrategy, - ); - }); - - describe('#constructor(...)', () => { - it('adds all payoutStrategies to a map', () => { - expect([...facade.getPayoutStrategies().entries()].length).toBe(4); - }); - - it('sets all required payoutStrategies aliases', () => { - const aliases = [...facade.getPayoutStrategies().keys()]; - - expect(aliases.includes(PayoutStrategyAlias.DEFICHAIN_DFI)).toBe(true); - expect(aliases.includes(PayoutStrategyAlias.DEFICHAIN_TOKEN)).toBe(true); - expect(aliases.includes(PayoutStrategyAlias.ETHEREUM_DEFAULT)).toBe(true); - expect(aliases.includes(PayoutStrategyAlias.BSC_DEFAULT)).toBe(true); - }); - - it('assigns proper payoutStrategies to aliases', () => { - expect(facade.getPayoutStrategies().get(PayoutStrategyAlias.DEFICHAIN_DFI)).toBeInstanceOf( - PayoutDeFiChainDFIStrategy, - ); - - expect(facade.getPayoutStrategies().get(PayoutStrategyAlias.DEFICHAIN_TOKEN)).toBeInstanceOf( - PayoutDeFiChainTokenStrategy, - ); - - expect(facade.getPayoutStrategies().get(PayoutStrategyAlias.ETHEREUM_DEFAULT)).toBeInstanceOf( - PayoutEthereumStrategy, - ); - - expect(facade.getPayoutStrategies().get(PayoutStrategyAlias.BSC_DEFAULT)).toBeInstanceOf(PayoutBscStrategy); - }); - - it('adds all prepareStrategies to a map', () => { - expect([...facade.getPrepareStrategies().entries()].length).toBe(3); - }); - - it('sets all required prepareStrategies aliases', () => { - const aliases = [...facade.getPrepareStrategies().keys()]; - - expect(aliases.includes(PrepareStrategyAlias.DEFICHAIN)).toBe(true); - expect(aliases.includes(PrepareStrategyAlias.ETHEREUM)).toBe(true); - expect(aliases.includes(PrepareStrategyAlias.BSC)).toBe(true); - }); - - it('assigns proper prepareStrategies to aliases', () => { - expect(facade.getPrepareStrategies().get(PrepareStrategyAlias.DEFICHAIN)).toBeInstanceOf( - PrepareDeFiChainStrategy, - ); - - expect(facade.getPrepareStrategies().get(PrepareStrategyAlias.ETHEREUM)).toBeInstanceOf(PrepareEthereumStrategy); - - expect(facade.getPrepareStrategies().get(PrepareStrategyAlias.BSC)).toBeInstanceOf(PrepareBscStrategy); - }); - }); - - describe('#getPayoutStrategy(...)', () => { - describe('getting strategy by Asset', () => { - it('gets ETHEREUM_DEFAULT strategy', () => { - const strategy = facade.getPayoutStrategy(createCustomAsset({ blockchain: Blockchain.ETHEREUM })); - - expect(strategy).toBeInstanceOf(PayoutEthereumStrategy); - }); - - it('gets BSC_DEFAULT strategy', () => { - const strategy = facade.getPayoutStrategy(createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN })); - - expect(strategy).toBeInstanceOf(PayoutBscStrategy); - }); - - it('gets DEFICHAIN_DFI strategy', () => { - const strategy = facade.getPayoutStrategy( - createCustomAsset({ blockchain: Blockchain.DEFICHAIN, dexName: 'DFI' }), - ); - - expect(strategy).toBeInstanceOf(PayoutDeFiChainDFIStrategy); - }); - - it('gets DEFICHAIN_TOKEN strategy for DEFICHAIN', () => { - const strategy = facade.getPayoutStrategy( - createCustomAsset({ blockchain: Blockchain.DEFICHAIN, dexName: 'non-DFI' }), - ); - - expect(strategy).toBeInstanceOf(PayoutDeFiChainTokenStrategy); - }); - - it('gets DEFICHAIN_TOKEN strategy for BITCOIN', () => { - const strategy = facade.getPayoutStrategy( - createCustomAsset({ blockchain: Blockchain.BITCOIN, dexName: 'non-DFI' }), - ); - - expect(strategy).toBeInstanceOf(PayoutDeFiChainTokenStrategy); - }); - - it('fails to get strategy for non-supported Blockchain', () => { - const testCall = () => - facade.getPayoutStrategy(createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain })); - - expect(testCall).toThrow(); - expect(testCall).toThrowError('No PayoutStrategy found. Alias: undefined'); - }); - - it('fails to get strategy for DFI on Bitcoin blockchain', () => { - const testCall = () => - facade.getPayoutStrategy(createCustomAsset({ blockchain: Blockchain.BITCOIN, dexName: 'DFI' })); - - expect(testCall).toThrow(); - expect(testCall).toThrowError('No PayoutStrategy found. Alias: undefined'); - }); - }); - - describe('getting strategy by Alias', () => { - it('gets ETHEREUM_DEFAULT strategy', () => { - const strategy = facade.getPayoutStrategy(PayoutStrategyAlias.ETHEREUM_DEFAULT); - - expect(strategy).toBeInstanceOf(PayoutEthereumStrategy); - }); - - it('gets BSC_DEFAULT strategy', () => { - const strategyCrypto = facade.getPayoutStrategy(PayoutStrategyAlias.BSC_DEFAULT); - - expect(strategyCrypto).toBeInstanceOf(PayoutBscStrategy); - }); - - it('gets DEFICHAIN_DFI strategy', () => { - const strategy = facade.getPayoutStrategy(PayoutStrategyAlias.DEFICHAIN_DFI); - - expect(strategy).toBeInstanceOf(PayoutDeFiChainDFIStrategy); - }); - - it('gets DEFICHAIN_TOKEN strategy', () => { - const strategy = facade.getPayoutStrategy(PayoutStrategyAlias.DEFICHAIN_TOKEN); - - expect(strategy).toBeInstanceOf(PayoutDeFiChainTokenStrategy); - }); - - it('fails to get strategy for non-supported Alias', () => { - const testCall = () => facade.getPayoutStrategy('NonExistingAlias' as PayoutStrategyAlias); - - expect(testCall).toThrow(); - expect(testCall).toThrowError('No PayoutStrategy found. Alias: NonExistingAlias'); - }); - }); - }); - - describe('#getPrepareStrategy(...)', () => { - describe('getting strategy by Asset', () => { - it('gets ETHEREUM strategy', () => { - const strategy = facade.getPrepareStrategy(createCustomAsset({ blockchain: Blockchain.ETHEREUM })); - - expect(strategy).toBeInstanceOf(PrepareEthereumStrategy); - }); - - it('gets BSC strategy', () => { - const strategy = facade.getPrepareStrategy(createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN })); - - expect(strategy).toBeInstanceOf(PrepareBscStrategy); - }); - - it('gets DEFICHAIN strategy for DEFICHAIN', () => { - const strategy = facade.getPrepareStrategy(createCustomAsset({ blockchain: Blockchain.DEFICHAIN })); - - expect(strategy).toBeInstanceOf(PrepareDeFiChainStrategy); - }); - - it('gets DEFICHAIN strategy for BITCOIN', () => { - const strategy = facade.getPrepareStrategy(createCustomAsset({ blockchain: Blockchain.BITCOIN })); - - expect(strategy).toBeInstanceOf(PrepareDeFiChainStrategy); - }); - - it('fails to get strategy for non-supported Blockchain', () => { - const testCall = () => - facade.getPrepareStrategy(createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain })); - - expect(testCall).toThrow(); - expect(testCall).toThrowError('No PrepareStrategy found. Alias: undefined'); - }); - }); - - describe('getting strategy by Alias', () => { - it('gets DEFICHAIN strategy', () => { - const strategy = facade.getPrepareStrategy(PrepareStrategyAlias.DEFICHAIN); - - expect(strategy).toBeInstanceOf(PrepareDeFiChainStrategy); - }); - - it('gets ETHEREUM strategy', () => { - const strategyCrypto = facade.getPrepareStrategy(PrepareStrategyAlias.ETHEREUM); - - expect(strategyCrypto).toBeInstanceOf(PrepareEthereumStrategy); - }); - - it('gets BSC strategy', () => { - const strategyCrypto = facade.getPrepareStrategy(PrepareStrategyAlias.BSC); - - expect(strategyCrypto).toBeInstanceOf(PrepareBscStrategy); - }); - - it('fails to get strategy for non-supported Alias', () => { - const testCall = () => facade.getPrepareStrategy('NonExistingAlias' as PrepareStrategyAlias); - - expect(testCall).toThrow(); - expect(testCall).toThrowError('No PrepareStrategy found. Alias: NonExistingAlias'); - }); - }); - }); -}); - -class PayoutStrategiesFacadeWrapper extends PayoutStrategiesFacade { - constructor( - payoutDFIStrategy: PayoutDeFiChainDFIStrategy, - payoutTokenStrategy: PayoutDeFiChainTokenStrategy, - payoutETHStrategy: PayoutEthereumStrategy, - payoutBSCStrategy: PayoutBscStrategy, - prepareOnDefichainStrategy: PrepareDeFiChainStrategy, - prepareOnEthereumStrategy: PrepareEthereumStrategy, - prepareOnBscStrategy: PrepareBscStrategy, - ) { - super( - payoutDFIStrategy, - payoutTokenStrategy, - payoutETHStrategy, - payoutBSCStrategy, - prepareOnDefichainStrategy, - prepareOnEthereumStrategy, - prepareOnBscStrategy, - ); - } - - getPayoutStrategies() { - return this.payoutStrategies; - } - - getPrepareStrategies() { - return this.prepareStrategies; - } -} diff --git a/src/payment/models/payout/strategies/payout/__tests__/payout-defichain-token.strategy.spec.ts b/src/payment/models/payout/strategies/payout/__tests__/payout-defichain-token.strategy.spec.ts index c4e8531060..21c76ec49c 100644 --- a/src/payment/models/payout/strategies/payout/__tests__/payout-defichain-token.strategy.spec.ts +++ b/src/payment/models/payout/strategies/payout/__tests__/payout-defichain-token.strategy.spec.ts @@ -1,7 +1,7 @@ import { mock } from 'jest-mock-extended'; +import { NotificationService } from 'src/notification/services/notification.service'; import { DexService } from 'src/payment/models/dex/services/dex.service'; import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; -import { MailService } from 'src/shared/services/mail.service'; import { PayoutOrder } from '../../../entities/payout-order.entity'; import { createCustomPayoutOrder, @@ -9,23 +9,28 @@ import { } from '../../../entities/__mocks__/payout-order.entity.mock'; import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; import { PayoutDeFiChainService } from '../../../services/payout-defichain.service'; -import { PayoutDeFiChainTokenStrategy } from '../payout-defichain-token.strategy'; +import { DeFiChainTokenStrategy } from '../impl/defichain-token.strategy'; describe('PayoutDeFiChainTokenStrategy', () => { let strategy: PayoutDeFiChainTokenStrategyWrapper; - let mailService: MailService; + let notificationService: NotificationService; let dexService: DexService; let defichainService: PayoutDeFiChainService; let payoutOrderRepo: PayoutOrderRepository; beforeEach(() => { - mailService = mock(); + notificationService = mock(); dexService = mock(); defichainService = mock(); payoutOrderRepo = mock(); - strategy = new PayoutDeFiChainTokenStrategyWrapper(mailService, dexService, defichainService, payoutOrderRepo); + strategy = new PayoutDeFiChainTokenStrategyWrapper( + notificationService, + dexService, + defichainService, + payoutOrderRepo, + ); }); describe('#groupOrdersByTokens(...)', () => { @@ -66,14 +71,14 @@ describe('PayoutDeFiChainTokenStrategy', () => { }); }); -class PayoutDeFiChainTokenStrategyWrapper extends PayoutDeFiChainTokenStrategy { +class PayoutDeFiChainTokenStrategyWrapper extends DeFiChainTokenStrategy { constructor( - mailService: MailService, + notificationService: NotificationService, dexService: DexService, defichainService: PayoutDeFiChainService, payoutOrderRepo: PayoutOrderRepository, ) { - super(mailService, dexService, defichainService, payoutOrderRepo); + super(notificationService, dexService, defichainService, payoutOrderRepo); } groupOrdersByTokenWrapper(orders: PayoutOrder[]) { diff --git a/src/payment/models/payout/strategies/payout/__tests__/payout-defichain.strategy.spec.ts b/src/payment/models/payout/strategies/payout/__tests__/payout-jellyfish.strategy.spec.ts similarity index 83% rename from src/payment/models/payout/strategies/payout/__tests__/payout-defichain.strategy.spec.ts rename to src/payment/models/payout/strategies/payout/__tests__/payout-jellyfish.strategy.spec.ts index 742de4c8b9..e156a3853f 100644 --- a/src/payment/models/payout/strategies/payout/__tests__/payout-defichain.strategy.spec.ts +++ b/src/payment/models/payout/strategies/payout/__tests__/payout-jellyfish.strategy.spec.ts @@ -1,6 +1,6 @@ import { mock } from 'jest-mock-extended'; +import { NotificationService } from 'src/notification/services/notification.service'; import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; -import { MailService } from 'src/shared/services/mail.service'; import { PayoutOrder, PayoutOrderContext, PayoutOrderStatus } from '../../../entities/payout-order.entity'; import { createCustomPayoutOrder, @@ -8,12 +8,12 @@ import { } from '../../../entities/__mocks__/payout-order.entity.mock'; import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; import { PayoutDeFiChainService } from '../../../services/payout-defichain.service'; -import { PayoutDeFiChainStrategy } from '../base/payout-defichain.strategy'; +import { JellyfishStrategy } from '../impl/base/jellyfish.strategy'; -describe('PayoutDeFiChainStrategy', () => { - let strategy: PayoutDeFiChainStrategyWrapper; +describe('PayoutJellyfishStrategy', () => { + let strategy: PayoutJellyfishStrategyWrapper; - let mailService: MailService; + let notificationService: NotificationService; let payoutOrderRepo: PayoutOrderRepository; let defichainService: PayoutDeFiChainService; @@ -21,14 +21,14 @@ describe('PayoutDeFiChainStrategy', () => { let sendErrorMailSpy: jest.SpyInstance; beforeEach(() => { - mailService = mock(); + notificationService = mock(); payoutOrderRepo = mock(); defichainService = mock(); repoSaveSpy = jest.spyOn(payoutOrderRepo, 'save'); - sendErrorMailSpy = jest.spyOn(mailService, 'sendErrorMail'); + sendErrorMailSpy = jest.spyOn(notificationService, 'sendMail'); - strategy = new PayoutDeFiChainStrategyWrapper(mailService, payoutOrderRepo, defichainService); + strategy = new PayoutJellyfishStrategyWrapper(notificationService, payoutOrderRepo, defichainService); }); afterEach(() => { @@ -235,34 +235,62 @@ describe('PayoutDeFiChainStrategy', () => { describe('#sendNonRecoverableErrorMailWrapper(...)', () => { it('combines custom message with error message', async () => { - await strategy.sendNonRecoverableErrorMailWrapper('Test message', new Error('Another message')); + await strategy.sendNonRecoverableErrorMailWrapper( + createDefaultPayoutOrder(), + 'Test message', + new Error('Another message'), + ); expect(sendErrorMailSpy).toBeCalledTimes(1); - expect(sendErrorMailSpy).toBeCalledWith('Payout Error', ['Test message', 'Another message']); + expect(sendErrorMailSpy).toBeCalledWith({ + input: { errors: ['Test message', 'Another message'], subject: 'Payout Error' }, + type: 'ErrorMonitoring', + metadata: { + context: 'Payout', + correlationId: 'PayoutOrder&BuyCrypto&1', + }, + options: { + suppressRecurring: true, + }, + }); }); - it('calls mailService with Payout Error subject', async () => { - await strategy.sendNonRecoverableErrorMailWrapper(''); + it('calls notificationService with Payout Error subject', async () => { + await strategy.sendNonRecoverableErrorMailWrapper(createDefaultPayoutOrder(), ''); expect(sendErrorMailSpy).toBeCalledTimes(1); - expect(sendErrorMailSpy).toBeCalledWith('Payout Error', ['']); + expect(sendErrorMailSpy).toBeCalledWith({ + input: { errors: [''], subject: 'Payout Error' }, + type: 'ErrorMonitoring', + metadata: { + context: 'Payout', + correlationId: 'PayoutOrder&BuyCrypto&1', + }, + options: { + suppressRecurring: true, + }, + }); }); }); }); -class PayoutDeFiChainStrategyWrapper extends PayoutDeFiChainStrategy { +class PayoutJellyfishStrategyWrapper extends JellyfishStrategy { constructor( - mailService: MailService, + notificationService: NotificationService, payoutOrderRepo: PayoutOrderRepository, defichainService: PayoutDeFiChainService, ) { - super(mailService, payoutOrderRepo, defichainService); + super(notificationService, payoutOrderRepo, defichainService); } protected doPayoutForContext(): Promise { throw new Error('Method not implemented.'); } + protected async dispatchPayout(): Promise { + return 'TX_ID_01'; + } + groupOrdersByContextWrapper(orders: PayoutOrder[]) { return this.groupOrdersByContext(orders); } @@ -283,7 +311,7 @@ class PayoutDeFiChainStrategyWrapper extends PayoutDeFiChainStrategy { return this.rollbackPayoutDesignation(orders); } - sendNonRecoverableErrorMailWrapper(message: string, e?: Error) { - return this.sendNonRecoverableErrorMail(message, e); + sendNonRecoverableErrorMailWrapper(order: PayoutOrder, message: string, e?: Error) { + return this.sendNonRecoverableErrorMail(order, message, e); } } diff --git a/src/payment/models/payout/strategies/payout/__tests__/payout.facade.spec.ts b/src/payment/models/payout/strategies/payout/__tests__/payout.facade.spec.ts new file mode 100644 index 0000000000..740861c9f6 --- /dev/null +++ b/src/payment/models/payout/strategies/payout/__tests__/payout.facade.spec.ts @@ -0,0 +1,231 @@ +import { mock } from 'jest-mock-extended'; +import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { DexService } from 'src/payment/models/dex/services/dex.service'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutBitcoinService } from '../../../services/payout-bitcoin.service'; +import { PayoutBscService } from '../../../services/payout-bsc.service'; +import { PayoutDeFiChainService } from '../../../services/payout-defichain.service'; +import { PayoutEthereumService } from '../../../services/payout-ethereum.service'; +import { BitcoinStrategy } from '../impl/bitcoin.strategy'; +import { BscCoinStrategy } from '../impl/bsc-coin.strategy'; +import { BscTokenStrategy } from '../impl/bsc-token.strategy'; +import { DeFiChainCoinStrategy } from '../impl/defichain-coin.strategy'; +import { DeFiChainTokenStrategy } from '../impl/defichain-token.strategy'; +import { EthereumCoinStrategy } from '../impl/ethereum-coin.strategy'; +import { EthereumTokenStrategy } from '../impl/ethereum-token.strategy'; +import { PayoutStrategiesFacade, PayoutStrategyAlias } from '../payout.facade'; + +describe('PayoutStrategiesFacade', () => { + let bitcoin: BitcoinStrategy; + let deFiChainCoin: DeFiChainCoinStrategy; + let deFiChainToken: DeFiChainTokenStrategy; + let ethereumCoin: EthereumCoinStrategy; + let ethereumToken: EthereumTokenStrategy; + let bscCoin: BscCoinStrategy; + let bscToken: BscTokenStrategy; + + let facade: PayoutStrategiesFacadeWrapper; + + beforeEach(() => { + bitcoin = new BitcoinStrategy( + mock(), + mock(), + mock(), + ); + deFiChainCoin = new DeFiChainCoinStrategy( + mock(), + mock(), + mock(), + ); + deFiChainToken = new DeFiChainTokenStrategy( + mock(), + mock(), + mock(), + mock(), + ); + ethereumCoin = new EthereumCoinStrategy(mock(), mock()); + ethereumToken = new EthereumTokenStrategy(mock(), mock()); + bscCoin = new BscCoinStrategy(mock(), mock()); + bscToken = new BscTokenStrategy(mock(), mock()); + + facade = new PayoutStrategiesFacadeWrapper( + bitcoin, + bscCoin, + bscToken, + deFiChainCoin, + deFiChainToken, + ethereumCoin, + ethereumToken, + ); + }); + + describe('#constructor(...)', () => { + it('adds all payoutStrategies to a map', () => { + expect([...facade.getStrategies().entries()].length).toBe(7); + }); + + it('assigns strategies to all aliases', () => { + expect([...facade.getStrategies().entries()].length).toBe(Object.values(PayoutStrategyAlias).length); + }); + + it('sets all required payoutStrategies aliases', () => { + const aliases = [...facade.getStrategies().keys()]; + + expect(aliases.includes(PayoutStrategyAlias.BITCOIN)).toBe(true); + expect(aliases.includes(PayoutStrategyAlias.BSC_TOKEN)).toBe(true); + expect(aliases.includes(PayoutStrategyAlias.BSC_COIN)).toBe(true); + expect(aliases.includes(PayoutStrategyAlias.DEFICHAIN_COIN)).toBe(true); + expect(aliases.includes(PayoutStrategyAlias.DEFICHAIN_TOKEN)).toBe(true); + expect(aliases.includes(PayoutStrategyAlias.ETHEREUM_COIN)).toBe(true); + expect(aliases.includes(PayoutStrategyAlias.ETHEREUM_TOKEN)).toBe(true); + }); + + it('assigns proper payoutStrategies to aliases', () => { + expect(facade.getStrategies().get(PayoutStrategyAlias.BITCOIN)).toBeInstanceOf(BitcoinStrategy); + expect(facade.getStrategies().get(PayoutStrategyAlias.BSC_COIN)).toBeInstanceOf(BscCoinStrategy); + expect(facade.getStrategies().get(PayoutStrategyAlias.BSC_TOKEN)).toBeInstanceOf(BscTokenStrategy); + expect(facade.getStrategies().get(PayoutStrategyAlias.DEFICHAIN_COIN)).toBeInstanceOf(DeFiChainCoinStrategy); + expect(facade.getStrategies().get(PayoutStrategyAlias.DEFICHAIN_TOKEN)).toBeInstanceOf(DeFiChainTokenStrategy); + expect(facade.getStrategies().get(PayoutStrategyAlias.ETHEREUM_COIN)).toBeInstanceOf(EthereumCoinStrategy); + expect(facade.getStrategies().get(PayoutStrategyAlias.ETHEREUM_TOKEN)).toBeInstanceOf(EthereumTokenStrategy); + }); + }); + + describe('#getPayoutStrategy(...)', () => { + describe('getting strategy by Asset', () => { + it('gets BITCOIN strategy for BITCOIN', () => { + const strategy = facade.getPayoutStrategy(createCustomAsset({ blockchain: Blockchain.BITCOIN })); + + expect(strategy).toBeInstanceOf(BitcoinStrategy); + }); + + it('gets BSC_COIN strategy', () => { + const strategy = facade.getPayoutStrategy( + createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(BscCoinStrategy); + }); + + it('gets BSC_TOKEN strategy', () => { + const strategy = facade.getPayoutStrategy( + createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(BscTokenStrategy); + }); + + it('gets DEFICHAIN_COIN strategy', () => { + const strategy = facade.getPayoutStrategy( + createCustomAsset({ blockchain: Blockchain.DEFICHAIN, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(DeFiChainCoinStrategy); + }); + + it('gets DEFICHAIN_TOKEN strategy for DEFICHAIN', () => { + const strategy = facade.getPayoutStrategy( + createCustomAsset({ blockchain: Blockchain.DEFICHAIN, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(DeFiChainTokenStrategy); + }); + + it('gets ETHEREUM_COIN strategy', () => { + const strategy = facade.getPayoutStrategy( + createCustomAsset({ blockchain: Blockchain.ETHEREUM, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(EthereumCoinStrategy); + }); + + it('gets ETHEREUM_TOKEN strategy', () => { + const strategy = facade.getPayoutStrategy( + createCustomAsset({ blockchain: Blockchain.ETHEREUM, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(EthereumTokenStrategy); + }); + + it('fails to get strategy for non-supported Blockchain', () => { + const testCall = () => + facade.getPayoutStrategy(createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain })); + + expect(testCall).toThrow(); + expect(testCall).toThrowError('No PayoutStrategy found. Alias: undefined'); + }); + }); + + describe('getting strategy by Alias', () => { + it('gets BITCOIN strategy', () => { + const strategyCrypto = facade.getPayoutStrategy(PayoutStrategyAlias.BITCOIN); + + expect(strategyCrypto).toBeInstanceOf(BitcoinStrategy); + }); + + it('gets BSC_COIN strategy', () => { + const strategyCrypto = facade.getPayoutStrategy(PayoutStrategyAlias.BSC_COIN); + + expect(strategyCrypto).toBeInstanceOf(BscCoinStrategy); + }); + + it('gets BSC_TOKEN strategy', () => { + const strategyCrypto = facade.getPayoutStrategy(PayoutStrategyAlias.BSC_TOKEN); + + expect(strategyCrypto).toBeInstanceOf(BscTokenStrategy); + }); + + it('gets DEFICHAIN_COIN strategy', () => { + const strategy = facade.getPayoutStrategy(PayoutStrategyAlias.DEFICHAIN_COIN); + + expect(strategy).toBeInstanceOf(DeFiChainCoinStrategy); + }); + + it('gets DEFICHAIN_TOKEN strategy', () => { + const strategy = facade.getPayoutStrategy(PayoutStrategyAlias.DEFICHAIN_TOKEN); + + expect(strategy).toBeInstanceOf(DeFiChainTokenStrategy); + }); + + it('gets ETHEREUM_COIN strategy', () => { + const strategy = facade.getPayoutStrategy(PayoutStrategyAlias.ETHEREUM_COIN); + + expect(strategy).toBeInstanceOf(EthereumCoinStrategy); + }); + + it('gets ETHEREUM_TOKEN strategy', () => { + const strategy = facade.getPayoutStrategy(PayoutStrategyAlias.ETHEREUM_TOKEN); + + expect(strategy).toBeInstanceOf(EthereumTokenStrategy); + }); + + it('fails to get strategy for non-supported Alias', () => { + const testCall = () => facade.getPayoutStrategy('NonExistingAlias' as PayoutStrategyAlias); + + expect(testCall).toThrow(); + expect(testCall).toThrowError('No PayoutStrategy found. Alias: NonExistingAlias'); + }); + }); + }); +}); + +class PayoutStrategiesFacadeWrapper extends PayoutStrategiesFacade { + constructor( + bitcoin: BitcoinStrategy, + bscCoin: BscCoinStrategy, + bscToken: BscTokenStrategy, + deFiChainCoin: DeFiChainCoinStrategy, + deFiChainToken: DeFiChainTokenStrategy, + ethereumCoin: EthereumCoinStrategy, + ethereumToken: EthereumTokenStrategy, + ) { + super(bitcoin, bscCoin, bscToken, deFiChainCoin, deFiChainToken, ethereumCoin, ethereumToken); + } + + getStrategies() { + return this.strategies; + } +} diff --git a/src/payment/models/payout/strategies/payout/base/payout-evm.strategy.ts b/src/payment/models/payout/strategies/payout/impl/base/evm.strategy.ts similarity index 61% rename from src/payment/models/payout/strategies/payout/base/payout-evm.strategy.ts rename to src/payment/models/payout/strategies/payout/impl/base/evm.strategy.ts index 4f09cf7354..db52b51f90 100644 --- a/src/payment/models/payout/strategies/payout/base/payout-evm.strategy.ts +++ b/src/payment/models/payout/strategies/payout/impl/base/evm.strategy.ts @@ -1,18 +1,20 @@ -import { PayoutOrder } from '../../../entities/payout-order.entity'; -import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; -import { PayoutEvmService } from '../../../services/payout-evm.service'; +import { PayoutOrder } from '../../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../../repositories/payout-order.repository'; +import { PayoutEvmService } from '../../../../services/payout-evm.service'; import { PayoutStrategy } from './payout.strategy'; -export abstract class PayoutEvmStrategy implements PayoutStrategy { +export abstract class EvmStrategy implements PayoutStrategy { constructor( protected readonly payoutEvmService: PayoutEvmService, protected readonly payoutOrderRepo: PayoutOrderRepository, ) {} + protected abstract dispatchPayout(order: PayoutOrder): Promise; + async doPayout(orders: PayoutOrder[]): Promise { for (const order of orders) { try { - const txId = await this.payoutEvmService.send(order.destinationAddress, order.amount); + const txId = await this.dispatchPayout(order); order.pendingPayout(txId); await this.payoutOrderRepo.save(order); @@ -24,7 +26,7 @@ export abstract class PayoutEvmStrategy implements PayoutStrategy { async checkPayoutCompletion(order: PayoutOrder): Promise { try { - const isComplete = this.payoutEvmService.checkPayoutCompletion(order.payoutTxId); + const isComplete = await this.payoutEvmService.checkPayoutCompletion(order.payoutTxId); if (isComplete) { order.complete(); diff --git a/src/payment/models/payout/strategies/payout/base/payout-defichain.strategy.ts b/src/payment/models/payout/strategies/payout/impl/base/jellyfish.strategy.ts similarity index 71% rename from src/payment/models/payout/strategies/payout/base/payout-defichain.strategy.ts rename to src/payment/models/payout/strategies/payout/impl/base/jellyfish.strategy.ts index 9f3efdc3a8..480558b2b6 100644 --- a/src/payment/models/payout/strategies/payout/base/payout-defichain.strategy.ts +++ b/src/payment/models/payout/strategies/payout/impl/base/jellyfish.strategy.ts @@ -1,15 +1,16 @@ -import { MailService } from 'src/shared/services/mail.service'; +import { PayoutGroup, PayoutJellyfishService } from 'src/payment/models/payout/services/base/payout-jellyfish.service'; import { Util } from 'src/shared/util'; -import { PayoutOrder, PayoutOrderContext } from '../../../entities/payout-order.entity'; -import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; -import { PayoutDeFiChainService, PayoutGroup } from '../../../services/payout-defichain.service'; +import { PayoutOrder, PayoutOrderContext } from '../../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../../repositories/payout-order.repository'; import { PayoutStrategy } from './payout.strategy'; +import { MailContext, MailType } from 'src/notification/enums'; +import { NotificationService } from 'src/notification/services/notification.service'; -export abstract class PayoutDeFiChainStrategy implements PayoutStrategy { +export abstract class JellyfishStrategy implements PayoutStrategy { constructor( - protected readonly mailService: MailService, + protected readonly notificationService: NotificationService, protected readonly payoutOrderRepo: PayoutOrderRepository, - protected readonly defichainService: PayoutDeFiChainService, + protected readonly jellyfishService: PayoutJellyfishService, ) {} async doPayout(orders: PayoutOrder[]): Promise { @@ -17,7 +18,7 @@ export abstract class PayoutDeFiChainStrategy implements PayoutStrategy { const groups = this.groupOrdersByContext(orders); for (const [context, group] of [...groups.entries()]) { - if (!(await this.defichainService.isHealthy(context))) return; + if (!(await this.jellyfishService.isHealthy(context))) return; await this.doPayoutForContext(context, group); } @@ -28,7 +29,7 @@ export abstract class PayoutDeFiChainStrategy implements PayoutStrategy { async checkPayoutCompletion(order: PayoutOrder): Promise { try { - const isComplete = await this.defichainService.checkPayoutCompletion(order.context, order.payoutTxId); + const isComplete = await this.jellyfishService.checkPayoutCompletion(order.context, order.payoutTxId); if (isComplete) { order.complete(); @@ -80,19 +81,20 @@ export abstract class PayoutDeFiChainStrategy implements PayoutStrategy { return [...result.values()]; } - protected async send( + protected abstract dispatchPayout( context: PayoutOrderContext, - orders: PayoutOrder[], + payout: PayoutGroup, outputAsset: string, - dispatcher: (context: PayoutOrderContext, payout: PayoutGroup, outputAsset: string) => Promise, - ): Promise { + ): Promise; + + protected async send(context: PayoutOrderContext, orders: PayoutOrder[], outputAsset: string): Promise { let payoutTxId: string; try { const payout = this.aggregatePayout(orders); await this.designatePayout(orders); - payoutTxId = await dispatcher(context, payout, outputAsset); + payoutTxId = await this.dispatchPayout(context, payout, outputAsset); } catch (e) { console.error(`Error on sending ${outputAsset} for payout. Order ID(s): ${orders.map((o) => o.id)}`, e); @@ -109,7 +111,7 @@ export abstract class PayoutDeFiChainStrategy implements PayoutStrategy { const errorMessage = `Error on saving payout payoutTxId to the database. Order ID: ${order.id}. Payout ID: ${payoutTxId}`; console.error(errorMessage, e); - await this.sendNonRecoverableErrorMail(errorMessage, e); + await this.sendNonRecoverableErrorMail(order, errorMessage, e); } } } @@ -135,10 +137,16 @@ export abstract class PayoutDeFiChainStrategy implements PayoutStrategy { } } - protected async sendNonRecoverableErrorMail(message: string, e?: Error): Promise { - const body = e ? [message, e.message] : [message]; + protected async sendNonRecoverableErrorMail(order: PayoutOrder, message: string, e?: Error): Promise { + const correlationId = `PayoutOrder&${order.context}&${order.id}`; + const errors = e ? [message, e.message] : [message]; - await this.mailService.sendErrorMail('Payout Error', body); + await this.notificationService.sendMail({ + type: MailType.ERROR_MONITORING, + input: { subject: 'Payout Error', errors }, + options: { suppressRecurring: true }, + metadata: { context: MailContext.PAYOUT, correlationId }, + }); } //*** HELPER METHODS ***// diff --git a/src/payment/models/payout/strategies/payout/base/payout.strategy.ts b/src/payment/models/payout/strategies/payout/impl/base/payout.strategy.ts similarity index 67% rename from src/payment/models/payout/strategies/payout/base/payout.strategy.ts rename to src/payment/models/payout/strategies/payout/impl/base/payout.strategy.ts index 2111933bee..7fbada9cfd 100644 --- a/src/payment/models/payout/strategies/payout/base/payout.strategy.ts +++ b/src/payment/models/payout/strategies/payout/impl/base/payout.strategy.ts @@ -1,4 +1,4 @@ -import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrder } from '../../../../entities/payout-order.entity'; export interface PayoutStrategy { doPayout(orders: PayoutOrder[]): Promise; diff --git a/src/payment/models/payout/strategies/payout/impl/bitcoin.strategy.ts b/src/payment/models/payout/strategies/payout/impl/bitcoin.strategy.ts new file mode 100644 index 0000000000..10998f8d1d --- /dev/null +++ b/src/payment/models/payout/strategies/payout/impl/bitcoin.strategy.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { PayoutOrder, PayoutOrderContext } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutGroup } from '../../../services/base/payout-jellyfish.service'; +import { PayoutBitcoinService } from '../../../services/payout-bitcoin.service'; +import { JellyfishStrategy } from './base/jellyfish.strategy'; + +@Injectable() +export class BitcoinStrategy extends JellyfishStrategy { + constructor( + notificationService: NotificationService, + protected readonly bitcoinService: PayoutBitcoinService, + protected readonly payoutOrderRepo: PayoutOrderRepository, + ) { + super(notificationService, payoutOrderRepo, bitcoinService); + } + + protected async doPayoutForContext(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + const payoutGroups = this.createPayoutGroups(orders, 100); + + for (const group of payoutGroups) { + try { + if (group.length === 0) { + continue; + } + + console.info(`Paying out ${group.length} BTC orders(s). Order ID(s): ${group.map((o) => o.id)}`); + + await this.sendBTC(context, group); + } catch (e) { + console.error( + `Error in paying out a group of ${group.length} BTC orders(s). Order ID(s): ${group.map((o) => o.id)}`, + ); + // continue with next group in case payout failed + continue; + } + } + } + + protected dispatchPayout(context: PayoutOrderContext, payout: PayoutGroup): Promise { + return this.bitcoinService.sendUtxoToMany(context, payout); + } + + private async sendBTC(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + await this.send(context, orders, 'BTC'); + } +} diff --git a/src/payment/models/payout/strategies/payout/impl/bsc-coin.strategy.ts b/src/payment/models/payout/strategies/payout/impl/bsc-coin.strategy.ts new file mode 100644 index 0000000000..794e8709d7 --- /dev/null +++ b/src/payment/models/payout/strategies/payout/impl/bsc-coin.strategy.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutBscService } from '../../../services/payout-bsc.service'; +import { EvmStrategy } from './base/evm.strategy'; + +@Injectable() +export class BscCoinStrategy extends EvmStrategy { + constructor(protected readonly bscService: PayoutBscService, payoutOrderRepo: PayoutOrderRepository) { + super(bscService, payoutOrderRepo); + } + + protected dispatchPayout(order: PayoutOrder): Promise { + return this.bscService.sendNativeCoin(order.destinationAddress, order.amount); + } +} diff --git a/src/payment/models/payout/strategies/payout/impl/bsc-token.strategy.ts b/src/payment/models/payout/strategies/payout/impl/bsc-token.strategy.ts new file mode 100644 index 0000000000..a6e178f2af --- /dev/null +++ b/src/payment/models/payout/strategies/payout/impl/bsc-token.strategy.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutBscService } from '../../../services/payout-bsc.service'; +import { EvmStrategy } from './base/evm.strategy'; + +@Injectable() +export class BscTokenStrategy extends EvmStrategy { + constructor(protected readonly bscService: PayoutBscService, payoutOrderRepo: PayoutOrderRepository) { + super(bscService, payoutOrderRepo); + } + + protected dispatchPayout(order: PayoutOrder): Promise { + return this.bscService.sendToken(order.destinationAddress, order.asset, order.amount); + } +} diff --git a/src/payment/models/payout/strategies/payout/payout-defichain-dfi.strategy.ts b/src/payment/models/payout/strategies/payout/impl/defichain-coin.strategy.ts similarity index 50% rename from src/payment/models/payout/strategies/payout/payout-defichain-dfi.strategy.ts rename to src/payment/models/payout/strategies/payout/impl/defichain-coin.strategy.ts index c4f57b12b3..767f560c3d 100644 --- a/src/payment/models/payout/strategies/payout/payout-defichain-dfi.strategy.ts +++ b/src/payment/models/payout/strategies/payout/impl/defichain-coin.strategy.ts @@ -1,19 +1,19 @@ import { Injectable } from '@nestjs/common'; -import { MailService } from 'src/shared/services/mail.service'; -import { PayoutOrder, PayoutOrderContext } from '../../entities/payout-order.entity'; -import { PayoutOrderRepository } from '../../repositories/payout-order.repository'; -import { PayoutDeFiChainService } from '../../services/payout-defichain.service'; -import { PayoutDeFiChainStrategy } from './base/payout-defichain.strategy'; +import { PayoutOrderContext, PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutGroup } from '../../../services/base/payout-jellyfish.service'; +import { PayoutDeFiChainService } from '../../../services/payout-defichain.service'; +import { JellyfishStrategy } from './base/jellyfish.strategy'; +import { NotificationService } from 'src/notification/services/notification.service'; @Injectable() -export class PayoutDeFiChainDFIStrategy extends PayoutDeFiChainStrategy { +export class DeFiChainCoinStrategy extends JellyfishStrategy { constructor( - mailService: MailService, - protected readonly defichainService: PayoutDeFiChainService, + notificationService: NotificationService, + protected readonly deFiChainService: PayoutDeFiChainService, protected readonly payoutOrderRepo: PayoutOrderRepository, ) { - super(mailService, payoutOrderRepo, defichainService); - this.defichainService.sendUtxoToMany = this.defichainService.sendUtxoToMany.bind(this.defichainService); + super(notificationService, payoutOrderRepo, deFiChainService); } protected async doPayoutForContext(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { @@ -38,7 +38,11 @@ export class PayoutDeFiChainDFIStrategy extends PayoutDeFiChainStrategy { } } + protected dispatchPayout(context: PayoutOrderContext, payout: PayoutGroup): Promise { + return this.deFiChainService.sendUtxoToMany(context, payout); + } + private async sendDFI(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { - await this.send(context, orders, 'DFI', this.defichainService.sendUtxoToMany); + await this.send(context, orders, 'DFI'); } } diff --git a/src/payment/models/payout/strategies/payout/payout-defichain-token.strategy.ts b/src/payment/models/payout/strategies/payout/impl/defichain-token.strategy.ts similarity index 66% rename from src/payment/models/payout/strategies/payout/payout-defichain-token.strategy.ts rename to src/payment/models/payout/strategies/payout/impl/defichain-token.strategy.ts index 926dd7c58c..8203d875ce 100644 --- a/src/payment/models/payout/strategies/payout/payout-defichain-token.strategy.ts +++ b/src/payment/models/payout/strategies/payout/impl/defichain-token.strategy.ts @@ -1,23 +1,23 @@ import { Injectable } from '@nestjs/common'; +import { NotificationService } from 'src/notification/services/notification.service'; import { DexService } from 'src/payment/models/dex/services/dex.service'; -import { MailService } from 'src/shared/services/mail.service'; -import { PayoutOrderContext, PayoutOrder } from '../../entities/payout-order.entity'; -import { PayoutOrderRepository } from '../../repositories/payout-order.repository'; -import { PayoutDeFiChainService } from '../../services/payout-defichain.service'; -import { PayoutDeFiChainStrategy } from './base/payout-defichain.strategy'; +import { PayoutOrderContext, PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutGroup } from '../../../services/base/payout-jellyfish.service'; +import { PayoutDeFiChainService } from '../../../services/payout-defichain.service'; +import { JellyfishStrategy } from './base/jellyfish.strategy'; type TokenName = string; @Injectable() -export class PayoutDeFiChainTokenStrategy extends PayoutDeFiChainStrategy { +export class DeFiChainTokenStrategy extends JellyfishStrategy { constructor( - mailService: MailService, + notificationService: NotificationService, private readonly dexService: DexService, - protected readonly defichainService: PayoutDeFiChainService, + protected readonly jellyfishService: PayoutDeFiChainService, protected readonly payoutOrderRepo: PayoutOrderRepository, ) { - super(mailService, payoutOrderRepo, defichainService); - this.defichainService.sendTokenToMany = this.defichainService.sendTokenToMany.bind(this.defichainService); + super(notificationService, payoutOrderRepo, jellyfishService); } protected async doPayoutForContext(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { @@ -72,18 +72,22 @@ export class PayoutDeFiChainTokenStrategy extends PayoutDeFiChainStrategy { } private isEligibleForMinimalUtxo(address: string): boolean { - return this.defichainService.isLightWalletAddress(address); + return this.jellyfishService.isLightWalletAddress(address); } private async checkUtxo(address: string): Promise { - const utxo = await this.defichainService.getUtxoForAddress(address); + const utxo = await this.jellyfishService.getUtxoForAddress(address); if (!utxo) { await this.dexService.transferMinimalUtxo(address); } } + protected dispatchPayout(context: PayoutOrderContext, payout: PayoutGroup, outputAsset: string): Promise { + return this.jellyfishService.sendTokenToMany(context, payout, outputAsset); + } + private async sendToken(context: PayoutOrderContext, orders: PayoutOrder[], outputAsset: string): Promise { - await this.send(context, orders, outputAsset, this.defichainService.sendTokenToMany); + await this.send(context, orders, outputAsset); } } diff --git a/src/payment/models/payout/strategies/payout/impl/ethereum-coin.strategy.ts b/src/payment/models/payout/strategies/payout/impl/ethereum-coin.strategy.ts new file mode 100644 index 0000000000..8675ca85d0 --- /dev/null +++ b/src/payment/models/payout/strategies/payout/impl/ethereum-coin.strategy.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutEthereumService } from '../../../services/payout-ethereum.service'; +import { EvmStrategy } from './base/evm.strategy'; + +@Injectable() +export class EthereumCoinStrategy extends EvmStrategy { + constructor(protected readonly ethereumService: PayoutEthereumService, payoutOrderRepo: PayoutOrderRepository) { + super(ethereumService, payoutOrderRepo); + } + + protected dispatchPayout(order: PayoutOrder): Promise { + return this.ethereumService.sendNativeCoin(order.destinationAddress, order.amount); + } +} diff --git a/src/payment/models/payout/strategies/payout/impl/ethereum-token.strategy.ts b/src/payment/models/payout/strategies/payout/impl/ethereum-token.strategy.ts new file mode 100644 index 0000000000..e26fbe01c6 --- /dev/null +++ b/src/payment/models/payout/strategies/payout/impl/ethereum-token.strategy.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutEthereumService } from '../../../services/payout-ethereum.service'; +import { EvmStrategy } from './base/evm.strategy'; + +@Injectable() +export class EthereumTokenStrategy extends EvmStrategy { + constructor(protected readonly ethereumService: PayoutEthereumService, payoutOrderRepo: PayoutOrderRepository) { + super(ethereumService, payoutOrderRepo); + } + + protected dispatchPayout(order: PayoutOrder): Promise { + return this.ethereumService.sendToken(order.destinationAddress, order.asset, order.amount); + } +} diff --git a/src/payment/models/payout/strategies/payout/payout-bsc.strategy.ts b/src/payment/models/payout/strategies/payout/payout-bsc.strategy.ts deleted file mode 100644 index ae60d6506e..0000000000 --- a/src/payment/models/payout/strategies/payout/payout-bsc.strategy.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PayoutOrderRepository } from '../../repositories/payout-order.repository'; -import { PayoutBscService } from '../../services/payout-bsc.service'; -import { PayoutEvmStrategy } from './base/payout-evm.strategy'; - -@Injectable() -export class PayoutBscStrategy extends PayoutEvmStrategy { - constructor(bscService: PayoutBscService, payoutOrderRepo: PayoutOrderRepository) { - super(bscService, payoutOrderRepo); - } -} diff --git a/src/payment/models/payout/strategies/payout/payout-ethereum.strategy.ts b/src/payment/models/payout/strategies/payout/payout-ethereum.strategy.ts deleted file mode 100644 index d24f949163..0000000000 --- a/src/payment/models/payout/strategies/payout/payout-ethereum.strategy.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PayoutOrderRepository } from '../../repositories/payout-order.repository'; -import { PayoutEthereumService } from '../../services/payout-ethereum.service'; -import { PayoutEvmStrategy } from './base/payout-evm.strategy'; - -@Injectable() -export class PayoutEthereumStrategy extends PayoutEvmStrategy { - constructor(ethereumService: PayoutEthereumService, payoutOrderRepo: PayoutOrderRepository) { - super(ethereumService, payoutOrderRepo); - } -} diff --git a/src/payment/models/payout/strategies/payout/payout.facade.ts b/src/payment/models/payout/strategies/payout/payout.facade.ts new file mode 100644 index 0000000000..44114f7af7 --- /dev/null +++ b/src/payment/models/payout/strategies/payout/payout.facade.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { PayoutStrategy } from './impl/base/payout.strategy'; +import { BitcoinStrategy } from './impl/bitcoin.strategy'; +import { BscCoinStrategy } from './impl/bsc-coin.strategy'; +import { BscTokenStrategy } from './impl/bsc-token.strategy'; +import { DeFiChainCoinStrategy } from './impl/defichain-coin.strategy'; +import { DeFiChainTokenStrategy } from './impl/defichain-token.strategy'; +import { EthereumCoinStrategy } from './impl/ethereum-coin.strategy'; +import { EthereumTokenStrategy } from './impl/ethereum-token.strategy'; + +enum Alias { + BITCOIN = 'Bitcoin', + BSC_COIN = 'BscCoin', + BSC_TOKEN = 'BscToken', + DEFICHAIN_COIN = 'DeFiChainCoin', + DEFICHAIN_TOKEN = 'DeFiChainToken', + ETHEREUM_COIN = 'EthereumCoin', + ETHEREUM_TOKEN = 'EthereumToken', +} + +export { Alias as PayoutStrategyAlias }; + +@Injectable() +export class PayoutStrategiesFacade { + protected readonly strategies: Map = new Map(); + + constructor( + bitcoin: BitcoinStrategy, + bscCoin: BscCoinStrategy, + bscToken: BscTokenStrategy, + deFiChainCoin: DeFiChainCoinStrategy, + deFiChainToken: DeFiChainTokenStrategy, + ethereumCoin: EthereumCoinStrategy, + ethereumToken: EthereumTokenStrategy, + ) { + this.strategies.set(Alias.BITCOIN, bitcoin); + this.strategies.set(Alias.BSC_COIN, bscCoin); + this.strategies.set(Alias.BSC_TOKEN, bscToken); + this.strategies.set(Alias.DEFICHAIN_COIN, deFiChainCoin); + this.strategies.set(Alias.DEFICHAIN_TOKEN, deFiChainToken); + this.strategies.set(Alias.ETHEREUM_COIN, ethereumCoin); + this.strategies.set(Alias.ETHEREUM_TOKEN, ethereumToken); + } + + getPayoutStrategy(criteria: Asset | Alias): PayoutStrategy { + return criteria instanceof Asset ? this.getByAsset(criteria) : this.getByAlias(criteria); + } + + getPayoutStrategyAlias(asset: Asset): Alias { + const { blockchain, type: assetType } = asset; + + if (blockchain === Blockchain.BITCOIN) return Alias.BITCOIN; + + if (blockchain === Blockchain.BINANCE_SMART_CHAIN) { + return assetType === AssetType.COIN ? Alias.BSC_COIN : Alias.BSC_TOKEN; + } + + if (blockchain === Blockchain.DEFICHAIN) { + return assetType === AssetType.COIN ? Alias.DEFICHAIN_COIN : Alias.DEFICHAIN_TOKEN; + } + + if (blockchain === Blockchain.ETHEREUM) { + return assetType === AssetType.COIN ? Alias.ETHEREUM_COIN : Alias.ETHEREUM_TOKEN; + } + } + + //*** HELPER METHODS ***// + + private getByAlias(alias: Alias): PayoutStrategy { + const strategy = this.strategies.get(alias); + + if (!strategy) throw new Error(`No PayoutStrategy found. Alias: ${alias}`); + + return strategy; + } + + private getByAsset(asset: Asset): PayoutStrategy { + const alias = this.getPayoutStrategyAlias(asset); + + return this.getByAlias(alias); + } +} diff --git a/src/payment/models/payout/strategies/prepare/__tests__/prepare.facade.spec.ts b/src/payment/models/payout/strategies/prepare/__tests__/prepare.facade.spec.ts new file mode 100644 index 0000000000..9229e0bbc2 --- /dev/null +++ b/src/payment/models/payout/strategies/prepare/__tests__/prepare.facade.spec.ts @@ -0,0 +1,141 @@ +import { mock } from 'jest-mock-extended'; +import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; +import { DexService } from 'src/payment/models/dex/services/dex.service'; +import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutDeFiChainService } from '../../../services/payout-defichain.service'; +import { BitcoinStrategy } from '../impl/bitcoin.strategy'; +import { BscStrategy } from '../impl/bsc.strategy'; +import { DeFiChainStrategy } from '../impl/defichain.strategy'; +import { EthereumStrategy } from '../impl/ethereum.strategy'; +import { PrepareStrategiesFacade, PrepareStrategyAlias } from '../prepare.facade'; + +describe('PrepareStrategiesFacade', () => { + let bitcoin: BitcoinStrategy; + let defichain: DeFiChainStrategy; + let ethereum: EthereumStrategy; + let bsc: BscStrategy; + + let facade: PrepareStrategiesFacadeWrapper; + + beforeEach(() => { + bitcoin = new BitcoinStrategy(mock()); + defichain = new DeFiChainStrategy( + mock(), + mock(), + mock(), + ); + ethereum = new EthereumStrategy(mock()); + bsc = new BscStrategy(mock()); + + facade = new PrepareStrategiesFacadeWrapper(bitcoin, defichain, ethereum, bsc); + }); + + describe('#constructor(...)', () => { + it('adds all prepareStrategies to a map', () => { + expect([...facade.getStrategies().entries()].length).toBe(4); + }); + + it('assigns strategies to all aliases', () => { + expect([...facade.getStrategies().entries()].length).toBe(Object.values(PrepareStrategyAlias).length); + }); + + it('sets all required prepareStrategies aliases', () => { + const aliases = [...facade.getStrategies().keys()]; + + expect(aliases.includes(PrepareStrategyAlias.BITCOIN)).toBe(true); + expect(aliases.includes(PrepareStrategyAlias.DEFICHAIN)).toBe(true); + expect(aliases.includes(PrepareStrategyAlias.ETHEREUM)).toBe(true); + expect(aliases.includes(PrepareStrategyAlias.BSC)).toBe(true); + }); + + it('assigns proper prepareStrategies to aliases', () => { + expect(facade.getStrategies().get(PrepareStrategyAlias.BITCOIN)).toBeInstanceOf(BitcoinStrategy); + + expect(facade.getStrategies().get(PrepareStrategyAlias.DEFICHAIN)).toBeInstanceOf(DeFiChainStrategy); + + expect(facade.getStrategies().get(PrepareStrategyAlias.ETHEREUM)).toBeInstanceOf(EthereumStrategy); + + expect(facade.getStrategies().get(PrepareStrategyAlias.BSC)).toBeInstanceOf(BscStrategy); + }); + }); + + describe('#getPrepareStrategy(...)', () => { + describe('getting strategy by Asset', () => { + it('gets BITCOIN strategy for BITCOIN', () => { + const strategy = facade.getPrepareStrategy(createCustomAsset({ blockchain: Blockchain.BITCOIN })); + + expect(strategy).toBeInstanceOf(BitcoinStrategy); + }); + + it('gets ETHEREUM strategy', () => { + const strategy = facade.getPrepareStrategy(createCustomAsset({ blockchain: Blockchain.ETHEREUM })); + + expect(strategy).toBeInstanceOf(EthereumStrategy); + }); + + it('gets BSC strategy', () => { + const strategy = facade.getPrepareStrategy(createCustomAsset({ blockchain: Blockchain.BINANCE_SMART_CHAIN })); + + expect(strategy).toBeInstanceOf(BscStrategy); + }); + + it('gets DEFICHAIN strategy for DEFICHAIN', () => { + const strategy = facade.getPrepareStrategy(createCustomAsset({ blockchain: Blockchain.DEFICHAIN })); + + expect(strategy).toBeInstanceOf(DeFiChainStrategy); + }); + + it('fails to get strategy for non-supported Blockchain', () => { + const testCall = () => + facade.getPrepareStrategy(createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain })); + + expect(testCall).toThrow(); + expect(testCall).toThrowError('No PrepareStrategy found. Alias: undefined'); + }); + }); + + describe('getting strategy by Alias', () => { + it('gets BITCOIN strategy', () => { + const strategy = facade.getPrepareStrategy(PrepareStrategyAlias.BITCOIN); + + expect(strategy).toBeInstanceOf(BitcoinStrategy); + }); + + it('gets DEFICHAIN strategy', () => { + const strategy = facade.getPrepareStrategy(PrepareStrategyAlias.DEFICHAIN); + + expect(strategy).toBeInstanceOf(DeFiChainStrategy); + }); + + it('gets ETHEREUM strategy', () => { + const strategyCrypto = facade.getPrepareStrategy(PrepareStrategyAlias.ETHEREUM); + + expect(strategyCrypto).toBeInstanceOf(EthereumStrategy); + }); + + it('gets BSC strategy', () => { + const strategyCrypto = facade.getPrepareStrategy(PrepareStrategyAlias.BSC); + + expect(strategyCrypto).toBeInstanceOf(BscStrategy); + }); + + it('fails to get strategy for non-supported Alias', () => { + const testCall = () => facade.getPrepareStrategy('NonExistingAlias' as PrepareStrategyAlias); + + expect(testCall).toThrow(); + expect(testCall).toThrowError('No PrepareStrategy found. Alias: NonExistingAlias'); + }); + }); + }); +}); + +class PrepareStrategiesFacadeWrapper extends PrepareStrategiesFacade { + constructor(bitcoin: BitcoinStrategy, defichain: DeFiChainStrategy, ethereum: EthereumStrategy, bsc: BscStrategy) { + super(bitcoin, defichain, ethereum, bsc); + } + + getStrategies() { + return this.strategies; + } +} diff --git a/src/payment/models/payout/strategies/prepare/base/prepare-evm.strategy.ts b/src/payment/models/payout/strategies/prepare/impl/base/auto-confirm.strategy.ts similarity index 63% rename from src/payment/models/payout/strategies/prepare/base/prepare-evm.strategy.ts rename to src/payment/models/payout/strategies/prepare/impl/base/auto-confirm.strategy.ts index 143e71fb9d..baeb2e55c3 100644 --- a/src/payment/models/payout/strategies/prepare/base/prepare-evm.strategy.ts +++ b/src/payment/models/payout/strategies/prepare/impl/base/auto-confirm.strategy.ts @@ -1,8 +1,8 @@ -import { PayoutOrder } from '../../../entities/payout-order.entity'; -import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutOrder } from '../../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../../repositories/payout-order.repository'; import { PrepareStrategy } from './prepare.strategy'; -export abstract class PrepareEvmStrategy implements PrepareStrategy { +export abstract class AutoConfirmStrategy implements PrepareStrategy { constructor(protected readonly payoutOrderRepo: PayoutOrderRepository) {} async preparePayout(order: PayoutOrder): Promise { diff --git a/src/payment/models/payout/strategies/prepare/impl/base/evm.strategy.ts b/src/payment/models/payout/strategies/prepare/impl/base/evm.strategy.ts new file mode 100644 index 0000000000..9d4290d339 --- /dev/null +++ b/src/payment/models/payout/strategies/prepare/impl/base/evm.strategy.ts @@ -0,0 +1,8 @@ +import { PayoutOrderRepository } from '../../../../repositories/payout-order.repository'; +import { AutoConfirmStrategy } from './auto-confirm.strategy'; + +export abstract class EvmStrategy extends AutoConfirmStrategy { + constructor(payoutOrderRepo: PayoutOrderRepository) { + super(payoutOrderRepo); + } +} diff --git a/src/payment/models/payout/strategies/prepare/base/prepare.strategy.ts b/src/payment/models/payout/strategies/prepare/impl/base/prepare.strategy.ts similarity index 68% rename from src/payment/models/payout/strategies/prepare/base/prepare.strategy.ts rename to src/payment/models/payout/strategies/prepare/impl/base/prepare.strategy.ts index 688ce60ae5..f3aba2e484 100644 --- a/src/payment/models/payout/strategies/prepare/base/prepare.strategy.ts +++ b/src/payment/models/payout/strategies/prepare/impl/base/prepare.strategy.ts @@ -1,4 +1,4 @@ -import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrder } from '../../../../entities/payout-order.entity'; export interface PrepareStrategy { preparePayout(order: PayoutOrder): Promise; diff --git a/src/payment/models/payout/strategies/prepare/impl/bitcoin.strategy.ts b/src/payment/models/payout/strategies/prepare/impl/bitcoin.strategy.ts new file mode 100644 index 0000000000..5dcbc671c2 --- /dev/null +++ b/src/payment/models/payout/strategies/prepare/impl/bitcoin.strategy.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { AutoConfirmStrategy } from './base/auto-confirm.strategy'; + +@Injectable() +export class BitcoinStrategy extends AutoConfirmStrategy { + constructor(payoutOrderRepo: PayoutOrderRepository) { + super(payoutOrderRepo); + } +} diff --git a/src/payment/models/payout/strategies/prepare/impl/bsc.strategy.ts b/src/payment/models/payout/strategies/prepare/impl/bsc.strategy.ts new file mode 100644 index 0000000000..7725b5996d --- /dev/null +++ b/src/payment/models/payout/strategies/prepare/impl/bsc.strategy.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { EvmStrategy } from './base/evm.strategy'; + +@Injectable() +export class BscStrategy extends EvmStrategy { + constructor(payoutOrderRepo: PayoutOrderRepository) { + super(payoutOrderRepo); + } +} diff --git a/src/payment/models/payout/strategies/prepare/prepare-defichain.strategy.ts b/src/payment/models/payout/strategies/prepare/impl/defichain.strategy.ts similarity index 85% rename from src/payment/models/payout/strategies/prepare/prepare-defichain.strategy.ts rename to src/payment/models/payout/strategies/prepare/impl/defichain.strategy.ts index 38741b19fc..2ca2bac7b4 100644 --- a/src/payment/models/payout/strategies/prepare/prepare-defichain.strategy.ts +++ b/src/payment/models/payout/strategies/prepare/impl/defichain.strategy.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; import { DexService } from 'src/payment/models/dex/services/dex.service'; -import { PayoutOrder } from '../../entities/payout-order.entity'; -import { PayoutOrderRepository } from '../../repositories/payout-order.repository'; -import { PayoutDeFiChainService } from '../../services/payout-defichain.service'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutDeFiChainService } from '../../../services/payout-defichain.service'; import { PrepareStrategy } from './base/prepare.strategy'; @Injectable() -export class PrepareDeFiChainStrategy implements PrepareStrategy { +export class DeFiChainStrategy implements PrepareStrategy { constructor( private readonly dexService: DexService, private readonly defichainService: PayoutDeFiChainService, diff --git a/src/payment/models/payout/strategies/prepare/impl/ethereum.strategy.ts b/src/payment/models/payout/strategies/prepare/impl/ethereum.strategy.ts new file mode 100644 index 0000000000..e53acd59e1 --- /dev/null +++ b/src/payment/models/payout/strategies/prepare/impl/ethereum.strategy.ts @@ -0,0 +1,10 @@ +import { Injectable } from '@nestjs/common'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { EvmStrategy } from './base/evm.strategy'; + +@Injectable() +export class EthereumStrategy extends EvmStrategy { + constructor(payoutOrderRepo: PayoutOrderRepository) { + super(payoutOrderRepo); + } +} diff --git a/src/payment/models/payout/strategies/prepare/prepare-bsc.strategy.ts b/src/payment/models/payout/strategies/prepare/prepare-bsc.strategy.ts deleted file mode 100644 index df94ff5c22..0000000000 --- a/src/payment/models/payout/strategies/prepare/prepare-bsc.strategy.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PayoutOrderRepository } from '../../repositories/payout-order.repository'; -import { PrepareEvmStrategy } from './base/prepare-evm.strategy'; - -@Injectable() -export class PrepareBscStrategy extends PrepareEvmStrategy { - constructor(payoutOrderRepo: PayoutOrderRepository) { - super(payoutOrderRepo); - } -} diff --git a/src/payment/models/payout/strategies/prepare/prepare-ethereum.strategy.ts b/src/payment/models/payout/strategies/prepare/prepare-ethereum.strategy.ts deleted file mode 100644 index 7ebca7123e..0000000000 --- a/src/payment/models/payout/strategies/prepare/prepare-ethereum.strategy.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { PayoutOrderRepository } from '../../repositories/payout-order.repository'; -import { PrepareEvmStrategy } from './base/prepare-evm.strategy'; - -@Injectable() -export class PrepareEthereumStrategy extends PrepareEvmStrategy { - constructor(payoutOrderRepo: PayoutOrderRepository) { - super(payoutOrderRepo); - } -} diff --git a/src/payment/models/payout/strategies/prepare/prepare.facade.ts b/src/payment/models/payout/strategies/prepare/prepare.facade.ts new file mode 100644 index 0000000000..d0245d293a --- /dev/null +++ b/src/payment/models/payout/strategies/prepare/prepare.facade.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PrepareStrategy } from './impl/base/prepare.strategy'; +import { BitcoinStrategy } from './impl/bitcoin.strategy'; +import { BscStrategy } from './impl/bsc.strategy'; +import { DeFiChainStrategy } from './impl/defichain.strategy'; +import { EthereumStrategy } from './impl/ethereum.strategy'; + +enum Alias { + BITCOIN = 'Bitcoin', + DEFICHAIN = 'DeFiChain', + ETHEREUM = 'Ethereum', + BSC = 'Bsc', +} + +export { Alias as PrepareStrategyAlias }; + +@Injectable() +export class PrepareStrategiesFacade { + protected readonly strategies: Map = new Map(); + + constructor( + bitcoin: BitcoinStrategy, + deFiChainStrategy: DeFiChainStrategy, + ethereumStrategy: EthereumStrategy, + bscStrategy: BscStrategy, + ) { + this.strategies.set(Alias.BITCOIN, bitcoin); + this.strategies.set(Alias.DEFICHAIN, deFiChainStrategy); + this.strategies.set(Alias.ETHEREUM, ethereumStrategy); + this.strategies.set(Alias.BSC, bscStrategy); + } + + getPrepareStrategy(criteria: Asset | Alias): PrepareStrategy { + return criteria instanceof Asset ? this.getByAsset(criteria) : this.getByAlias(criteria); + } + + //*** HELPER METHODS ***// + + private getByAlias(alias: Alias): PrepareStrategy { + const strategy = this.strategies.get(alias); + + if (!strategy) throw new Error(`No PrepareStrategy found. Alias: ${alias}`); + + return strategy; + } + + private getByAsset(asset: Asset): PrepareStrategy { + const alias = this.getAlias(asset); + + return this.getByAlias(alias); + } + + private getAlias(asset: Asset): Alias { + const { blockchain } = asset; + + if (blockchain === Blockchain.BITCOIN) return Alias.BITCOIN; + if (blockchain === Blockchain.ETHEREUM) return Alias.ETHEREUM; + if (blockchain === Blockchain.BINANCE_SMART_CHAIN) return Alias.BSC; + if (blockchain === Blockchain.DEFICHAIN) return Alias.DEFICHAIN; + } +} diff --git a/src/payment/models/payout/strategies/strategies.facade.ts b/src/payment/models/payout/strategies/strategies.facade.ts deleted file mode 100644 index ba12becb9a..0000000000 --- a/src/payment/models/payout/strategies/strategies.facade.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; -import { Asset } from 'src/shared/models/asset/asset.entity'; -import { PayoutBscStrategy } from './payout/payout-bsc.strategy'; -import { PayoutDeFiChainDFIStrategy } from './payout/payout-defichain-dfi.strategy'; -import { PayoutEthereumStrategy } from './payout/payout-ethereum.strategy'; -import { PayoutDeFiChainTokenStrategy } from './payout/payout-defichain-token.strategy'; -import { PayoutStrategy } from './payout/base/payout.strategy'; -import { PrepareBscStrategy } from './prepare/prepare-bsc.strategy'; -import { PrepareDeFiChainStrategy } from './prepare/prepare-defichain.strategy'; -import { PrepareEthereumStrategy } from './prepare/prepare-ethereum.strategy'; -import { PrepareStrategy } from './prepare/base/prepare.strategy'; - -export enum PayoutStrategyAlias { - DEFICHAIN_DFI = 'DeFiChainDFI', - DEFICHAIN_TOKEN = 'DeFiChainToken', - ETHEREUM_DEFAULT = 'Ethereum', - BSC_DEFAULT = 'BscDefault', -} - -export enum PrepareStrategyAlias { - DEFICHAIN = 'DeFiChain', - ETHEREUM = 'Ethereum', - BSC = 'Bsc', -} - -@Injectable() -export class PayoutStrategiesFacade { - protected readonly payoutStrategies: Map = new Map(); - protected readonly prepareStrategies: Map = new Map(); - - constructor( - payoutDFIStrategy: PayoutDeFiChainDFIStrategy, - payoutTokenStrategy: PayoutDeFiChainTokenStrategy, - payoutEthStrategy: PayoutEthereumStrategy, - payoutBscStrategy: PayoutBscStrategy, - prepareOnDefichainStrategy: PrepareDeFiChainStrategy, - prepareOnEthereumStrategy: PrepareEthereumStrategy, - prepareOnBscStrategy: PrepareBscStrategy, - ) { - this.payoutStrategies.set(PayoutStrategyAlias.DEFICHAIN_DFI, payoutDFIStrategy); - this.payoutStrategies.set(PayoutStrategyAlias.DEFICHAIN_TOKEN, payoutTokenStrategy); - this.payoutStrategies.set(PayoutStrategyAlias.ETHEREUM_DEFAULT, payoutEthStrategy); - this.payoutStrategies.set(PayoutStrategyAlias.BSC_DEFAULT, payoutBscStrategy); - - this.prepareStrategies.set(PrepareStrategyAlias.DEFICHAIN, prepareOnDefichainStrategy); - this.prepareStrategies.set(PrepareStrategyAlias.ETHEREUM, prepareOnEthereumStrategy); - this.prepareStrategies.set(PrepareStrategyAlias.BSC, prepareOnBscStrategy); - } - - getPayoutStrategy(criteria: Asset | PayoutStrategyAlias): PayoutStrategy { - return criteria instanceof Asset - ? this.getPayoutStrategyByAsset(criteria) - : this.getPayoutStrategyByAlias(criteria); - } - - getPrepareStrategy(criteria: Asset | PrepareStrategyAlias): PrepareStrategy { - return criteria instanceof Asset - ? this.getPrepareStrategyByAsset(criteria) - : this.getPrepareStrategyByAlias(criteria); - } - - //*** HELPER METHODS ***// - - private getPayoutStrategyByAlias(alias: PayoutStrategyAlias): PayoutStrategy { - const strategy = this.payoutStrategies.get(alias); - - if (!strategy) throw new Error(`No PayoutStrategy found. Alias: ${alias}`); - - return strategy; - } - - private getPayoutStrategyByAsset(asset: Asset): PayoutStrategy { - const alias = this.getPayoutStrategyAlias(asset); - - return this.getPayoutStrategyByAlias(alias); - } - - private getPrepareStrategyByAlias(alias: PrepareStrategyAlias): PrepareStrategy { - const strategy = this.prepareStrategies.get(alias); - - if (!strategy) throw new Error(`No PrepareStrategy found. Alias: ${alias}`); - - return strategy; - } - - private getPrepareStrategyByAsset(asset: Asset): PrepareStrategy { - const alias = this.getPrepareStrategyAlias(asset); - - return this.getPrepareStrategyByAlias(alias); - } - - private getPayoutStrategyAlias(asset: Asset): PayoutStrategyAlias { - const { blockchain, dexName: assetName } = asset; - - if (blockchain === Blockchain.ETHEREUM) return PayoutStrategyAlias.ETHEREUM_DEFAULT; - if (blockchain === Blockchain.BINANCE_SMART_CHAIN) return PayoutStrategyAlias.BSC_DEFAULT; - if (blockchain === Blockchain.DEFICHAIN && assetName === 'DFI') return PayoutStrategyAlias.DEFICHAIN_DFI; - if ((blockchain === Blockchain.DEFICHAIN || blockchain === Blockchain.BITCOIN) && assetName !== 'DFI') { - return PayoutStrategyAlias.DEFICHAIN_TOKEN; - } - } - - private getPrepareStrategyAlias(asset: Asset): PrepareStrategyAlias { - const { blockchain } = asset; - - if (blockchain === Blockchain.ETHEREUM) return PrepareStrategyAlias.ETHEREUM; - if (blockchain === Blockchain.BINANCE_SMART_CHAIN) return PrepareStrategyAlias.BSC; - if (blockchain === Blockchain.DEFICHAIN || blockchain === Blockchain.BITCOIN) return PrepareStrategyAlias.DEFICHAIN; - } -} diff --git a/src/payment/models/pricing/__tests__/pricing.integration.spec.ts b/src/payment/models/pricing/__tests__/pricing.integration.spec.ts index 0d3f7a9314..0fce47206f 100644 --- a/src/payment/models/pricing/__tests__/pricing.integration.spec.ts +++ b/src/payment/models/pricing/__tests__/pricing.integration.spec.ts @@ -1,5 +1,5 @@ import { mock } from 'jest-mock-extended'; -import { MailService } from 'src/shared/services/mail.service'; +import { NotificationService } from 'src/notification/services/notification.service'; import { Price } from '../../exchange/dto/price.dto'; import { createCustomPrice } from '../../exchange/dto/__mocks__/price.dto.mock'; import { BinanceService } from '../../exchange/services/binance.service'; @@ -9,10 +9,12 @@ import { CurrencyService } from '../../exchange/services/currency.service'; import { FixerService } from '../../exchange/services/fixer.service'; import { FtxService } from '../../exchange/services/ftx.service'; import { KrakenService } from '../../exchange/services/kraken.service'; +import { DfiPricingDexService } from '../services/dfi-pricing-dex.service'; +import { PriceRequestContext } from '../enums'; import { PricingService } from '../services/pricing.service'; describe('Pricing Module Integration Tests', () => { - let mailService: MailService; + let notificationService: NotificationService; let krakenService: KrakenService; let binanceService: BinanceService; let bitstampService: BitstampService; @@ -20,6 +22,7 @@ describe('Pricing Module Integration Tests', () => { let ftxService: FtxService; let currencyService: CurrencyService; let fixerService: FixerService; + let dfiDexService: DfiPricingDexService; let krakenServiceGetPriceSpy: jest.SpyInstance; let binanceServiceGetPriceSpy: jest.SpyInstance; @@ -28,11 +31,12 @@ describe('Pricing Module Integration Tests', () => { let ftxServiceGetPriceSpy: jest.SpyInstance; let currencyServiceGetPriceSpy: jest.SpyInstance; let fixerServiceGetPriceSpy: jest.SpyInstance; + let dfiDexServiceGetPriceSpy: jest.SpyInstance; let service: PricingService; beforeEach(() => { - mailService = mock(); + notificationService = mock(); krakenService = mock({ name: 'Kraken' }); binanceService = mock({ name: 'Binance' }); bitstampService = mock({ name: 'Bitstamp' }); @@ -40,9 +44,10 @@ describe('Pricing Module Integration Tests', () => { ftxService = mock({ name: 'Ftx' }); currencyService = mock({ name: 'CurrencyService' }); fixerService = mock({ name: 'FixerService' }); + dfiDexService = mock({ name: 'DfiPricingDexService' }); service = new PricingService( - mailService, + notificationService, krakenService, binanceService, bitstampService, @@ -50,6 +55,7 @@ describe('Pricing Module Integration Tests', () => { ftxService, currencyService, fixerService, + dfiDexService, ); krakenServiceGetPriceSpy = jest.spyOn(krakenService, 'getPrice'); @@ -59,6 +65,7 @@ describe('Pricing Module Integration Tests', () => { ftxServiceGetPriceSpy = jest.spyOn(ftxService, 'getPrice'); currencyServiceGetPriceSpy = jest.spyOn(currencyService, 'getPrice'); fixerServiceGetPriceSpy = jest.spyOn(fixerService, 'getPrice'); + dfiDexServiceGetPriceSpy = jest.spyOn(dfiDexService, 'getPrice'); }); afterEach(() => { @@ -69,10 +76,11 @@ describe('Pricing Module Integration Tests', () => { ftxServiceGetPriceSpy.mockClear(); currencyServiceGetPriceSpy.mockClear(); fixerServiceGetPriceSpy.mockClear(); + dfiDexServiceGetPriceSpy.mockClear(); }); it('calculates price path for MATCHING_ASSETS', async () => { - const request = { from: 'BTC', to: 'BTC' }; + const request = { context: PriceRequestContext.BUY_CRYPTO, correlationId: '1', from: 'BTC', to: 'BTC' }; const result = await service.getPrice(request); expect(result.price).toBeInstanceOf(Price); @@ -102,7 +110,7 @@ describe('Pricing Module Integration Tests', () => { .spyOn(binanceService, 'getPrice') .mockImplementationOnce(async (source, target) => createCustomPrice({ source, target, price: 0.00005 })); - const request = { from: 'USD', to: 'BTC' }; + const request = { context: PriceRequestContext.BUY_CRYPTO, correlationId: '1', from: 'USD', to: 'BTC' }; const result = await service.getPrice(request); expect(result.price).toBeInstanceOf(Price); @@ -132,7 +140,7 @@ describe('Pricing Module Integration Tests', () => { .spyOn(ftxService, 'getPrice') .mockImplementationOnce(async (source, target) => createCustomPrice({ source, target, price: 0.014 })); - const request = { from: 'BNB', to: 'BTC' }; + const request = { context: PriceRequestContext.BUY_CRYPTO, correlationId: '1', from: 'BNB', to: 'BTC' }; const result = await service.getPrice(request); expect(result.price).toBeInstanceOf(Price); @@ -175,7 +183,7 @@ describe('Pricing Module Integration Tests', () => { createCustomPrice({ source, target, price: 71.3 }), ); - const request = { from: 'GBP', to: 'BNB' }; + const request = { context: PriceRequestContext.BUY_CRYPTO, correlationId: '1', from: 'GBP', to: 'BNB' }; const result = await service.getPrice(request); expect(result.price).toBeInstanceOf(Price); @@ -224,7 +232,7 @@ describe('Pricing Module Integration Tests', () => { createCustomPrice({ source, target, price: 71.3 }), ); - const request = { from: 'ETH', to: 'BNB' }; + const request = { context: PriceRequestContext.BUY_CRYPTO, correlationId: '1', from: 'ETH', to: 'BNB' }; const result = await service.getPrice(request); expect(result.price).toBeInstanceOf(Price); @@ -263,7 +271,7 @@ describe('Pricing Module Integration Tests', () => { .spyOn(ftxService, 'getPrice') .mockImplementationOnce(async (source, target) => createCustomPrice({ source, target, price: 12.38 })); - const request = { from: 'BTC', to: 'ETH' }; + const request = { context: PriceRequestContext.BUY_CRYPTO, correlationId: '1', from: 'BTC', to: 'ETH' }; const result = await service.getPrice(request); expect(result.price).toBeInstanceOf(Price); @@ -285,7 +293,7 @@ describe('Pricing Module Integration Tests', () => { }); it('calculates price path for MATCHING_FIAT_TO_USD_STABLE_COIN', async () => { - const request = { from: 'USD', to: 'USDC' }; + const request = { context: PriceRequestContext.BUY_CRYPTO, correlationId: '1', from: 'USD', to: 'USDC' }; const result = await service.getPrice(request); expect(result.price).toBeInstanceOf(Price); @@ -306,6 +314,41 @@ describe('Pricing Module Integration Tests', () => { expect(result.path[0].timestamp).toBeInstanceOf(Date); }); + it('calculates price path for NON_MATCHING_FIAT_TO_BUSD', async () => { + krakenServiceGetPriceSpy = jest + .spyOn(krakenService, 'getPrice') + .mockImplementationOnce(async () => { + throw new Error(); + }) + .mockImplementationOnce(async (source, target) => createCustomPrice({ source, target, price: 1.1 })); + + fixerServiceGetPriceSpy = jest + .spyOn(fixerService, 'getPrice') + .mockImplementationOnce(async (source, target) => createCustomPrice({ source, target, price: 1.1 })); + + const request = { context: PriceRequestContext.BUY_CRYPTO, correlationId: '1', from: 'EUR', to: 'BUSD' }; + const result = await service.getPrice(request); + + expect(fixerServiceGetPriceSpy).toHaveBeenCalledWith('EUR', 'USD'); + + expect(result.price).toBeInstanceOf(Price); + expect(result.price.source).toBe('EUR'); + expect(result.price.target).toBe('BUSD'); + expect(result.price.price).toBe(1.1); + + expect(Array.isArray(result.path)).toBe(true); + expect(result.path.length).toBe(1); + + expect(result.path[0].provider).toBe('Kraken'); + + expect(result.path[0].price).toBeInstanceOf(Price); + expect(result.path[0].price.source).toBe('EUR'); + expect(result.path[0].price.target).toBe('USDC'); + expect(result.path[0].price.price).toBe(1.1); + + expect(result.path[0].timestamp).toBeInstanceOf(Date); + }); + it('calculates price path for NON_MATCHING_FIAT_TO_USD_STABLE_COIN', async () => { krakenServiceGetPriceSpy = jest .spyOn(krakenService, 'getPrice') @@ -315,7 +358,7 @@ describe('Pricing Module Integration Tests', () => { .spyOn(fixerService, 'getPrice') .mockImplementationOnce(async (source, target) => createCustomPrice({ source, target, price: 1.1 })); - const request = { from: 'EUR', to: 'USDC' }; + const request = { context: PriceRequestContext.BUY_CRYPTO, correlationId: '1', from: 'EUR', to: 'USDC' }; const result = await service.getPrice(request); expect(fixerServiceGetPriceSpy).toHaveBeenCalledWith('EUR', 'USD'); @@ -339,7 +382,7 @@ describe('Pricing Module Integration Tests', () => { }); it('calculates price path for NON_MATCHING_USD_STABLE_COIN_TO_USD_STABLE_COIN', async () => { - const request = { from: 'USDT', to: 'USDC' }; + const request = { context: PriceRequestContext.BUY_CRYPTO, correlationId: '1', from: 'USDT', to: 'USDC' }; const result = await service.getPrice(request); expect(result.price).toBeInstanceOf(Price); @@ -359,4 +402,49 @@ describe('Pricing Module Integration Tests', () => { expect(result.path[0].timestamp).toBeInstanceOf(Date); }); + + it('calculates price path for FIAT_TO_DFI', async () => { + krakenServiceGetPriceSpy = jest + .spyOn(krakenService, 'getPrice') + .mockImplementationOnce(async (source: string, target: string) => + createCustomPrice({ source, target, price: 0.000049 }), + ); + + dfiDexServiceGetPriceSpy = jest + .spyOn(dfiDexService, 'getPrice') + .mockImplementationOnce(async (source: string, target: string) => + createCustomPrice({ source, target, price: 23111 }), + ); + + const request = { context: PriceRequestContext.BUY_CRYPTO, correlationId: '1', from: 'EUR', to: 'DFI' }; + const result = await service.getPrice(request); + + expect(result.price).toBeInstanceOf(Price); + expect(result.price.source).toBe('EUR'); + expect(result.price.target).toBe('DFI'); + expect(result.price.price).toBe(1.132439); + + expect(Array.isArray(result.path)).toBe(true); + expect(result.path.length).toBe(2); + + expect(result.path[0].provider).toBe('Kraken'); + + expect(result.path[0].price).toBeInstanceOf(Price); + expect(result.path[0].price.source).toBe('EUR'); + expect(result.path[0].price.target).toBe('BTC'); + expect(result.path[0].price.price).toBe(0.000049); + + expect(result.path[0].timestamp).toBeInstanceOf(Date); + + expect(result.path[1].provider).toBe('DfiPricingDexService'); + + expect(result.path[1].price).toBeInstanceOf(Price); + expect(result.path[1].price.source).toBe('BTC'); + expect(result.path[1].price.target).toBe('DFI'); + expect(result.path[1].price.price).toBe(23111); + + expect(result.path[1].timestamp).toBeInstanceOf(Date); + + expect(result.path[1].provider).toBe('DfiPricingDexService'); + }); }); diff --git a/src/payment/models/pricing/enums/index.ts b/src/payment/models/pricing/enums/index.ts index a9255f3611..a0ab3867ed 100644 --- a/src/payment/models/pricing/enums/index.ts +++ b/src/payment/models/pricing/enums/index.ts @@ -1,3 +1,8 @@ +export enum PriceRequestContext { + BUY_CRYPTO = 'BuyCrypto', + STAKING_REWARD = 'StakingReward', +} + export enum Fiat { EUR = 'EUR', CHF = 'CHF', @@ -8,6 +13,7 @@ export enum Fiat { export enum USDStableCoin { USDC = 'USDC', USDT = 'USDT', + BUSD = 'BUSD', } export enum Altcoin { diff --git a/src/payment/models/pricing/interfaces/index.ts b/src/payment/models/pricing/interfaces/index.ts index af837fe047..1406f809be 100644 --- a/src/payment/models/pricing/interfaces/index.ts +++ b/src/payment/models/pricing/interfaces/index.ts @@ -1,6 +1,9 @@ import { Price } from '../../exchange/dto/price.dto'; +import { PriceRequestContext } from '../enums'; export interface PriceRequest { + context: PriceRequestContext; + correlationId: string; from: string; to: string; } diff --git a/src/payment/models/pricing/pricing.controller.ts b/src/payment/models/pricing/pricing.controller.ts new file mode 100644 index 0000000000..f21108686a --- /dev/null +++ b/src/payment/models/pricing/pricing.controller.ts @@ -0,0 +1,23 @@ +import { Controller, UseGuards, Get, Query } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; +import { RoleGuard } from 'src/shared/auth/role.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { PriceRequest, PriceResult } from './interfaces'; +import { PricingService } from './services/pricing.service'; + +@ApiTags('pricing') +@Controller('pricing') +export class PricingController { + constructor(private readonly pricingService: PricingService) {} + + @Get('price') + @ApiBearerAuth() + @ApiExcludeEndpoint() + @UseGuards(AuthGuard(), new RoleGuard(UserRole.ADMIN)) + async getPrice(@Query() dto: PriceRequest): Promise { + if (process.env.ENVIRONMENT === 'test') { + return this.pricingService.getPrice(dto); + } + } +} diff --git a/src/payment/models/pricing/pricing.module.ts b/src/payment/models/pricing/pricing.module.ts index 3866e0c399..d257405f0b 100644 --- a/src/payment/models/pricing/pricing.module.ts +++ b/src/payment/models/pricing/pricing.module.ts @@ -1,13 +1,16 @@ import { Module } from '@nestjs/common'; +import { NotificationModule } from 'src/notification/notification.module'; import { SharedModule } from 'src/shared/shared.module'; import { DexModule } from '../dex/dex.module'; import { ExchangeModule } from '../exchange/exchange.module'; +import { DfiPricingDexService } from './services/dfi-pricing-dex.service'; +import { PricingController } from './pricing.controller'; import { PricingService } from './services/pricing.service'; @Module({ - imports: [SharedModule, ExchangeModule, DexModule], - controllers: [], - providers: [PricingService], + imports: [SharedModule, ExchangeModule, DexModule, NotificationModule], + controllers: [PricingController], + providers: [PricingService, DfiPricingDexService], exports: [PricingService], }) export class PricingModule {} diff --git a/src/payment/models/pricing/services/dfi-pricing-dex.service.ts b/src/payment/models/pricing/services/dfi-pricing-dex.service.ts new file mode 100644 index 0000000000..75e8470ec3 --- /dev/null +++ b/src/payment/models/pricing/services/dfi-pricing-dex.service.ts @@ -0,0 +1,43 @@ +import { v4 as uuid } from 'uuid'; +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { LiquidityOrderContext } from '../../dex/entities/liquidity-order.entity'; +import { LiquidityRequest } from '../../dex/interfaces'; +import { DexService } from '../../dex/services/dex.service'; +import { Price } from '../../exchange/dto/price.dto'; +import { PriceProvider } from '../interfaces'; +import { Util } from 'src/shared/util'; + +@Injectable() +export class DfiPricingDexService implements PriceProvider { + name: string; + + constructor(private dexService: DexService, private assetService: AssetService) { + this.name = 'DfiDex'; + } + + async getPrice(from: string, to: string): Promise { + if (to !== 'DFI') { + throw new Error(`DfiPricingDexService supports only DFI as target asset, instead provided: ${to}`); + } + + const dfi = await this.assetService.getAssetByQuery({ dexName: 'DFI', blockchain: Blockchain.DEFICHAIN }); + + const liquidityRequest: LiquidityRequest = { + context: LiquidityOrderContext.PRICING, + correlationId: uuid(), + referenceAsset: from, + referenceAmount: 0.001, + targetAsset: dfi, + options: { + bypassAvailabilityCheck: true, + bypassSlippageProtection: true, + }, + }; + + const targetAmount = await this.dexService.checkLiquidity(liquidityRequest); + + return Price.create(from, to, Util.round(targetAmount / 0.001, 8)); + } +} diff --git a/src/payment/models/pricing/services/pricing.service.ts b/src/payment/models/pricing/services/pricing.service.ts index 8163baadaf..56555178f7 100644 --- a/src/payment/models/pricing/services/pricing.service.ts +++ b/src/payment/models/pricing/services/pricing.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { MailService } from 'src/shared/services/mail.service'; +import { MailContext, MailType } from 'src/notification/enums'; +import { NotificationService } from 'src/notification/services/notification.service'; import { PriceMismatchException } from '../../exchange/exceptions/price-mismatch.exception'; import { BinanceService } from '../../exchange/services/binance.service'; import { BitpandaService } from '../../exchange/services/bitpanda.service'; @@ -14,6 +15,7 @@ import { PathNotConfiguredException } from '../exceptions/path-not-configured.ex import { PriceRequest, PriceResult } from '../interfaces'; import { PricePath } from '../utils/price-path'; import { PriceStep } from '../utils/price-step'; +import { DfiPricingDexService } from './dfi-pricing-dex.service'; export enum PricingPathAlias { MATCHING_ASSETS = 'MatchingAssets', @@ -23,8 +25,10 @@ export enum PricingPathAlias { ALTCOIN_TO_ALTCOIN = 'AltcoinToAltcoin', BTC_TO_ALTCOIN = 'BTCToAltcoin', MATCHING_FIAT_TO_USD_STABLE_COIN = 'MatchingFiatToUSDStableCoin', + NON_MATCHING_FIAT_TO_BUSD = 'NonMatchingFiatToBUSD', NON_MATCHING_FIAT_TO_USD_STABLE_COIN = 'NonMatchingFiatToUSDStableCoin', NON_MATCHING_USD_STABLE_COIN_TO_USD_STABLE_COIN = 'NonMatchingUSDStableCoinToUSDStableCoin', + FIAT_TO_DFI = 'FiatToDfi', } @Injectable() @@ -32,7 +36,7 @@ export class PricingService { private readonly pricingPaths: Map = new Map(); constructor( - private readonly mailService: MailService, + private readonly notificationService: NotificationService, private readonly krakenService: KrakenService, private readonly binanceService: BinanceService, private readonly bitstampService: BitstampService, @@ -40,6 +44,7 @@ export class PricingService { private readonly ftxService: FtxService, private readonly currencyService: CurrencyService, private readonly fixerService: FixerService, + private readonly dfiDexService: DfiPricingDexService, ) { this.configurePaths(); } @@ -66,7 +71,17 @@ export class PricingService { return result; } catch (e) { if (e instanceof PriceMismatchException) { - await this.mailService.sendErrorMail('Exchange Price Mismatch', [e.message]); + await this.notificationService.sendMail({ + type: MailType.ERROR_MONITORING, + input: { subject: 'Exchange Price Mismatch', errors: [e.message] }, + metadata: { + context: MailContext.PRICING, + correlationId: `PriceMismatch&${request.context}&${request.correlationId}&${request.to}&${request.from}`, + }, + options: { + debounce: 1800000, + }, + }); } throw e; @@ -163,10 +178,23 @@ export class PricingService { ]), ); + this.addPath( + new PricePath(PricingPathAlias.NON_MATCHING_FIAT_TO_BUSD, [ + new PriceStep({ + overwriteReferenceTo: 'USD', + fallbackPrimaryTo: 'USDC', + providers: { + primary: [this.krakenService], + reference: [this.fixerService, this.currencyService], + }, + }), + ]), + ); + this.addPath( new PricePath(PricingPathAlias.NON_MATCHING_FIAT_TO_USD_STABLE_COIN, [ new PriceStep({ - referenceTo: 'USD', + overwriteReferenceTo: 'USD', providers: { primary: [this.krakenService], reference: [this.fixerService, this.currencyService], @@ -182,6 +210,25 @@ export class PricingService { }), ]), ); + + this.addPath( + new PricePath(PricingPathAlias.FIAT_TO_DFI, [ + new PriceStep({ + to: 'BTC', + providers: { + primary: [this.krakenService], + reference: [this.binanceService, this.bitstampService, this.bitpandaService], + }, + }), + new PriceStep({ + from: 'BTC', + providers: { + primary: [this.dfiDexService], + reference: [], + }, + }), + ]), + ); } //*** HELPER METHODS ***// @@ -224,12 +271,17 @@ export class PricingService { if (from === 'USD' && this.isUSDStablecoin(to)) return PricingPathAlias.MATCHING_FIAT_TO_USD_STABLE_COIN; + if (this.isFiat(from) && this.isUSDStablecoin(to) && to === 'BUSD') + return PricingPathAlias.NON_MATCHING_FIAT_TO_BUSD; + if (this.isFiat(from) && this.isUSDStablecoin(to)) return PricingPathAlias.NON_MATCHING_FIAT_TO_USD_STABLE_COIN; if (this.isUSDStablecoin(from) && this.isUSDStablecoin(to) && from !== to) { return PricingPathAlias.NON_MATCHING_USD_STABLE_COIN_TO_USD_STABLE_COIN; } + if (this.isFiat(from) && to === 'DFI') return PricingPathAlias.FIAT_TO_DFI; + throw new Error(`No matching pricing path alias found. From: ${request.from} to: ${request.to}`); } @@ -250,7 +302,9 @@ export class PricingService { } private isKnownAsset(asset: string): boolean { - return this.isFiat(asset) || this.isBTC(asset) || this.isAltcoin(asset) || this.isUSDStablecoin(asset); + return ( + this.isFiat(asset) || this.isBTC(asset) || this.isAltcoin(asset) || this.isUSDStablecoin(asset) || asset === 'DFI' + ); } private logPriceResult(request: PriceRequest, result: PriceResult, pathAlias: PricingPathAlias): void { diff --git a/src/payment/models/pricing/utils/__mocks__/price-step.mock.ts b/src/payment/models/pricing/utils/__mocks__/price-step.mock.ts index f10f90870e..43c5caa8a4 100644 --- a/src/payment/models/pricing/utils/__mocks__/price-step.mock.ts +++ b/src/payment/models/pricing/utils/__mocks__/price-step.mock.ts @@ -5,7 +5,7 @@ export function createDefaultPriceStep(): PriceStep { } export function createCustomPriceStep(customOptions: Partial): PriceStep { - const { from, to, referenceTo, providers, fixedPrice } = customOptions; + const { from, to, overwriteReferenceTo: referenceTo, providers, fixedPrice } = customOptions; const keys = Object.keys(customOptions); diff --git a/src/payment/models/pricing/utils/price-path.ts b/src/payment/models/pricing/utils/price-path.ts index e049bb801e..fc6566524f 100644 --- a/src/payment/models/pricing/utils/price-path.ts +++ b/src/payment/models/pricing/utils/price-path.ts @@ -34,26 +34,23 @@ export class PricePath { results.push(await step.execute()); } - return this.calculatePrice(results); + return this.calculatePrice(request, results); } //*** HELPER METHODS ***// - private calculatePrice(path: PriceStepResult[]): PriceResult { + private calculatePrice(request: PriceRequest, path: PriceStepResult[]): PriceResult { let result = 1; path.forEach((step) => { result = result * step.price.price; }); - return this.createPriceResult(path, result); + return this.createPriceResult(request, path, result); } - private createPriceResult(path: PriceStepResult[], targetPrice: number): PriceResult { - const firstStep = path[0]; - const lastStep = path[path.length - 1]; - - const price = Price.create(firstStep.price.source, lastStep.price.target, targetPrice); + private createPriceResult(request: PriceRequest, path: PriceStepResult[], targetPrice: number): PriceResult { + const price = Price.create(request.from, request.to, targetPrice); return { price, path }; } diff --git a/src/payment/models/pricing/utils/price-step.ts b/src/payment/models/pricing/utils/price-step.ts index fbdeb01291..af29b89ecc 100644 --- a/src/payment/models/pricing/utils/price-step.ts +++ b/src/payment/models/pricing/utils/price-step.ts @@ -7,7 +7,8 @@ import { PriceStepInitSpecification } from '../specifications/price-step-init.sp export interface PriceStepOptions { from?: string | 'input'; to?: string | 'output'; - referenceTo?: string; + overwriteReferenceTo?: string; + fallbackPrimaryTo?: string; providers?: PriceStepProviders; fixedPrice?: number; } @@ -24,7 +25,8 @@ export class PriceStep { this.options = { from: options.from || 'input', to: options.to || 'output', - referenceTo: options.referenceTo, + overwriteReferenceTo: options.overwriteReferenceTo, + fallbackPrimaryTo: options.fallbackPrimaryTo, providers: { primary: options.providers?.primary || [], reference: options.providers?.reference || [], @@ -102,14 +104,14 @@ export class PriceStep { private async getPrimaryPrice(fromCurrency: string, toCurrency: string): Promise<[Price, PriceProviderName]> { const primaryProviders = this.options.providers.primary; - const [price, providerName] = await this.tryProviders(fromCurrency, toCurrency, primaryProviders); + let [price, providerName] = await this.tryProviders(fromCurrency, toCurrency, primaryProviders); + + if (!price && this.options.fallbackPrimaryTo) { + [price, providerName] = await this.tryProviders(fromCurrency, this.options.fallbackPrimaryTo, primaryProviders); + } if (!price) { - throw new Error( - `Could not find primary price at: ${primaryProviders.map( - (p) => p.name + ' ', - )}. From ${fromCurrency} to ${toCurrency}`, - ); + throw new Error(this.createPrimaryPriceErrorMessage(primaryProviders, fromCurrency, toCurrency)); } return [price, providerName]; @@ -118,8 +120,8 @@ export class PriceStep { private async getReferencePrice(fromCurrency: string, toCurrency: string): Promise<[Price, PriceProviderName]> { const referenceProviders = this.options.providers.reference; - const [price, providerName] = this.options.referenceTo - ? await this.tryProviders(fromCurrency, this.options.referenceTo, referenceProviders) + const [price, providerName] = this.options.overwriteReferenceTo + ? await this.tryProviders(fromCurrency, this.options.overwriteReferenceTo, referenceProviders) : await this.tryProviders(fromCurrency, toCurrency, referenceProviders); if (!price) { @@ -149,6 +151,20 @@ export class PriceStep { return [null, null]; } + private createPrimaryPriceErrorMessage( + primaryProviders: PriceProvider[], + fromCurrency: string, + toCurrency: string, + ): string { + const mainMessage = `Could not find primary price at: ${primaryProviders.map( + (p) => p.name + ' ', + )}. From ${fromCurrency} to ${toCurrency}. `; + + const fallbackMessage = this.options.fallbackPrimaryTo && `Fallback to currency: ${this.options.fallbackPrimaryTo}`; + + return mainMessage + fallbackMessage; + } + //*** GETTERS ***// get _options(): PriceStepOptions { diff --git a/src/payment/models/staking-ref-reward/staking-ref-reward.service.ts b/src/payment/models/staking-ref-reward/staking-ref-reward.service.ts index 34cedb373e..6179f3ba0a 100644 --- a/src/payment/models/staking-ref-reward/staking-ref-reward.service.ts +++ b/src/payment/models/staking-ref-reward/staking-ref-reward.service.ts @@ -4,7 +4,6 @@ import { StakingRefRewardRepository } from './staking-ref-reward.repository'; import { StakingRefReward, StakingRefType } from './staking-ref-reward.entity'; import { UserService } from 'src/user/models/user/user.service'; import { Interval } from '@nestjs/schedule'; -import { MailService } from 'src/shared/services/mail.service'; import { User } from 'src/user/models/user/user.entity'; import { Config } from 'src/config/config'; import { Staking } from '../staking/staking.entity'; @@ -12,6 +11,9 @@ import { ConversionService } from 'src/shared/services/conversion.service'; import { NodeService, NodeType } from 'src/blockchain/ain/node/node.service'; import { PricingService } from '../pricing/services/pricing.service'; import { DeFiClient } from 'src/blockchain/ain/node/defi-client'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { MailType } from 'src/notification/enums'; +import { PriceRequestContext } from '../pricing/enums'; @Injectable() export class StakingRefRewardService { @@ -23,7 +25,7 @@ export class StakingRefRewardService { private readonly userService: UserService, private readonly conversionService: ConversionService, private readonly pricingService: PricingService, - private readonly mailService: MailService, + private readonly notificationService: NotificationService, ) { nodeService.getConnectedNode(NodeType.REF).subscribe((client) => (this.client = client)); } @@ -95,7 +97,8 @@ export class StakingRefRewardService { }); if (openRewards.length > 0) { - const { price } = await this.pricingService.getPrice({ from: 'EUR', to: 'BTC' }).catch((e) => { + const priceRequest = this.createPriceRequest(openRewards); + const { price } = await this.pricingService.getPrice(priceRequest).catch((e) => { console.error('Failed to get price:', e); throw e; }); @@ -145,13 +148,16 @@ export class StakingRefRewardService { for (const reward of confirmedRewards) { try { if (reward.user.userData.mail) { - await this.mailService.sendTranslatedMail({ - userData: reward.user.userData, - translationKey: `mail.stakingRef.${reward.stakingRefType.toString().toLowerCase()}`, - params: { - txId: reward.txId, - outputAmount: reward.outputAmount, - outputAsset: reward.outputAsset, + await this.notificationService.sendMail({ + type: MailType.USER, + input: { + userData: reward.user.userData, + translationKey: `mail.stakingRef.${reward.stakingRefType.toString().toLowerCase()}`, + translationParams: { + txId: reward.txId, + outputAmount: reward.outputAmount, + outputAsset: reward.outputAsset, + }, }, }); } else { @@ -216,4 +222,9 @@ export class StakingRefRewardService { return confirmedRewards; } + + private createPriceRequest(openRewards: StakingRefReward[]) { + const correlationId = 'StakingRefRewards&' + openRewards.reduce((acc, r) => acc + `|${r.id}|`, ''); + return { context: PriceRequestContext.STAKING_REWARD, correlationId, from: 'EUR', to: 'BTC' }; + } } diff --git a/src/payment/payment.module.ts b/src/payment/payment.module.ts index 1abd155e20..68d0b3a45b 100644 --- a/src/payment/payment.module.ts +++ b/src/payment/payment.module.ts @@ -65,6 +65,7 @@ import { FrickService } from './models/bank-tx/frick.service'; import { BankAccountController } from './models/bank-account/bank-account.controller'; import { ExchangeModule } from './models/exchange/exchange.module'; import { PricingModule } from './models/pricing/pricing.module'; +import { NotificationModule } from 'src/notification/notification.module'; import { BankTxReturnRepository } from './models/bank-tx-return/bank-tx-return.repository'; import { BankTxReturnService } from './models/bank-tx-return/bank-tx-return.service'; import { BankTxRepeatRepository } from './models/bank-tx-repeat/bank-tx-repeat.repository'; @@ -102,6 +103,7 @@ import { BankTxReturnController } from './models/bank-tx-return/bank-tx-return.c PayoutModule, ExchangeModule, PricingModule, + NotificationModule, ], controllers: [ BankTxController, diff --git a/src/shared/models/asset/__mocks__/asset.entity.mock.ts b/src/shared/models/asset/__mocks__/asset.entity.mock.ts index 305cc294a5..df8276d56a 100644 --- a/src/shared/models/asset/__mocks__/asset.entity.mock.ts +++ b/src/shared/models/asset/__mocks__/asset.entity.mock.ts @@ -1,12 +1,12 @@ import { Blockchain } from 'src/blockchain/shared/enums/blockchain.enum'; -import { Asset, AssetCategory } from '../asset.entity'; +import { Asset, AssetCategory, AssetType } from '../asset.entity'; export function createDefaultAsset(): Asset { return createCustomAsset({}); } export function createCustomAsset(customValues: Partial): Asset { - const { name, dexName, blockchain, category } = customValues; + const { name, dexName, blockchain, category, type } = customValues; const keys = Object.keys(customValues); const entity = new Asset(); @@ -15,6 +15,7 @@ export function createCustomAsset(customValues: Partial): Asset { entity.dexName = keys.includes('dexName') ? dexName : 'dTSLA'; entity.blockchain = keys.includes('blockchain') ? blockchain : Blockchain.DEFICHAIN; entity.category = keys.includes('category') ? category : AssetCategory.CRYPTO; + entity.type = keys.includes('type') ? type : AssetType.COIN; return entity; } diff --git a/src/shared/models/asset/asset.entity.ts b/src/shared/models/asset/asset.entity.ts index 3f5a231c75..ed12599f05 100644 --- a/src/shared/models/asset/asset.entity.ts +++ b/src/shared/models/asset/asset.entity.ts @@ -4,8 +4,7 @@ import { IEntity } from '../entity'; export enum AssetType { COIN = 'Coin', - DCT = 'DCT', - DAT = 'DAT', + TOKEN = 'Token', } export enum AssetCategory { @@ -19,8 +18,8 @@ export enum AssetCategory { unique: true, }) export class Asset extends IEntity { - @Column({ type: 'int', nullable: true }) - chainId: number; + @Column({ nullable: true }) + chainId: string; @Column({ length: 256 }) name: string; diff --git a/src/shared/services/mail.service.ts b/src/shared/services/mail.service.ts deleted file mode 100644 index 011d2136cc..0000000000 --- a/src/shared/services/mail.service.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { MailerOptions, MailerService } from '@nestjs-modules/mailer'; -import { Injectable } from '@nestjs/common'; -import { UserData } from 'src/user/models/user-data/user-data.entity'; -import { Config } from 'src/config/config'; -import { Util } from '../util'; -import { I18nService } from 'nestjs-i18n'; - -export interface MailOptions { - options: MailerOptions; - defaultMailTemplate: string; - contact: { - supportMail: string; - monitoringMail: string; - noReplyMail: string; - }; -} - -interface SendMailOptions { - to: string; - salutation: string; - subject: string; - body: string; - from?: string; - bcc?: string; - cc?: string; - displayName?: string; - template?: string; - telegramUrl?: string; - twitterUrl?: string; - linkedinUrl?: string; - instagramUrl?: string; -} - -interface TranslationOptions { - userData: UserData; - translationKey: string; - params?: object; -} - -interface KycMailContent { - salutation: string; - body: string; - subject: string; -} - -@Injectable() -export class MailService { - private readonly supportMail = Config.mail.contact.supportMail; - private readonly monitoringMail = Config.mail.contact.monitoringMail; - private readonly noReplyMail = Config.mail.contact.noReplyMail; - - constructor(private readonly mailerService: MailerService, private readonly i18n: I18nService) {} - - // --- KYC --- // - - async sendKycFailedMail(userData: UserData, kycCustomerId: number): Promise { - const body = ` -

a customer has failed or expired during progress ${userData.kycStatus}.

- - - - - - - - - -
Reference:${userData.id}
Customer ID:${kycCustomerId}
- `; - - await this.sendMail({ - to: this.supportMail, - salutation: 'Hi DFX Support', - subject: 'KYC failed or expired', - body, - }); - } - - // --- OTHER --- // - async sendErrorMail(subject: string, errors: string[]): Promise { - const env = Config.environment.toUpperCase(); - - const body = ` -

there seem to be some problems on ${env} API:

-
    - ${errors.reduce((prev, curr) => prev + '
  • ' + curr + '
  • ', '')} -
- `; - - await this.sendMail({ - to: this.monitoringMail, - salutation: 'Hi DFX Tech Support', - subject: `${subject} (${env})`, - body, - }); - } - - async sendTranslatedMail(translationOptions: TranslationOptions): Promise { - const { salutation, body, subject } = await this.t( - translationOptions.translationKey, - translationOptions.userData.language?.symbol.toLowerCase(), - translationOptions.params, - ); - - await this.sendMail({ to: translationOptions.userData.mail, salutation, subject, body, template: 'default' }); - } - - async sendMail(options: SendMailOptions) { - try { - await Util.retry( - () => - this.mailerService.sendMail({ - from: { name: options.displayName ?? 'DFX.swiss', address: options.from ?? this.noReplyMail }, - to: options.to, - cc: options.cc, - bcc: options.bcc, - template: options.template ?? Config.mail.defaultMailTemplate, - context: { - salutation: options.salutation, - body: options.body, - date: new Date().getFullYear(), - telegramUrl: options.telegramUrl ?? Config.defaultTelegramUrl, - twitterUrl: options.twitterUrl ?? Config.defaultTwitterUrl, - linkedinUrl: options.linkedinUrl ?? Config.defaultLinkedinUrl, - instagramUrl: options.instagramUrl ?? Config.defaultInstagramUrl, - }, - subject: options.subject, - }), - 3, - 1000, - ); - } catch (e) { - console.error( - `Exception during send mail: from:${options.from}, to:${options.to}, subject:${options.subject}:`, - e, - ); - throw e; - } - } - - private async t(key: string, lang: string, args?: any): Promise { - const salutation = await this.i18n.translate(`${key}.salutation`, { lang, args }); - const body = await this.i18n.translate(`${key}.body`, { lang, args }); - const subject = await this.i18n.translate(`${key}.title`, { lang, args }); - - return { salutation, body, subject }; - } -} diff --git a/src/shared/shared.module.ts b/src/shared/shared.module.ts index b65f6562ef..a0ac020537 100644 --- a/src/shared/shared.module.ts +++ b/src/shared/shared.module.ts @@ -16,10 +16,8 @@ import { LanguageController } from './models/language/language.controller'; import { CountryService } from './models/country/country.service'; import { LanguageService } from './models/language/language.service'; import { PassportModule } from '@nestjs/passport'; -import { MailService } from './services/mail.service'; import { JwtModule } from '@nestjs/jwt'; import { JwtStrategy } from './auth/jwt.strategy'; -import { MailerModule } from '@nestjs-modules/mailer'; import { ScheduleModule } from '@nestjs/schedule'; import { SettingRepository } from './models/setting/setting.repository'; import { SettingService } from './models/setting/setting.service'; @@ -48,14 +46,12 @@ import { BankService } from './models/bank/bank.service'; ]), PassportModule.register({ defaultStrategy: 'jwt', session: true }), JwtModule.register(GetConfig().auth.jwt), - MailerModule.forRoot(GetConfig().mail.options), I18nModule.forRoot(GetConfig().i18n), ScheduleModule.forRoot(), ], controllers: [AssetController, FiatController, CountryController, LanguageController, SettingController], providers: [ ConversionService, - MailService, HttpService, AssetService, FiatService, @@ -74,7 +70,6 @@ import { BankService } from './models/bank/bank.service'; JwtModule, ScheduleModule, ConversionService, - MailService, HttpService, AssetService, FiatService, diff --git a/src/user/models/kyc/kyc-process.service.ts b/src/user/models/kyc/kyc-process.service.ts index 16fb0345fb..ea96163121 100644 --- a/src/user/models/kyc/kyc-process.service.ts +++ b/src/user/models/kyc/kyc-process.service.ts @@ -3,12 +3,13 @@ import { SpiderDataRepository } from 'src/user/models/spider-data/spider-data.re import { KycInProgress, KycState, KycStatus, UserData } from 'src/user/models/user-data/user-data.entity'; import { KycDocument, KycDocuments, InitiateResponse } from '../../services/spider/dto/spider.dto'; import { AccountType } from 'src/user/models/user-data/account-type.enum'; -import { MailService } from 'src/shared/services/mail.service'; import { IdentResultDto } from 'src/user/models/ident/dto/ident-result.dto'; import { DocumentState, SpiderService } from 'src/user/services/spider/spider.service'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { UserRepository } from '../user/user.repository'; import { Config } from 'src/config/config'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { MailType } from 'src/notification/enums'; import { KycWebhookService } from './kyc-webhook.service'; @Injectable() @@ -16,7 +17,7 @@ export class KycProcessService { constructor( private readonly spiderDataRepo: SpiderDataRepository, private readonly spiderService: SpiderService, - private readonly mailService: MailService, + private readonly notificationService: NotificationService, private readonly userRepo: UserRepository, private readonly kycWebhookService: KycWebhookService, ) {} @@ -57,10 +58,9 @@ export class KycProcessService { if (status === KycStatus.MANUAL) { if (userData.mail) { - await this.mailService.sendTranslatedMail({ - userData: userData, - translationKey: 'mail.kyc.success', - params: {}, + await this.notificationService.sendMail({ + type: MailType.USER, + input: { translationKey: 'mail.kyc.success', translationParams: {}, userData }, }); } else { console.error(`Failed to send KYC completion mail for user data ${userData.id}: user has no email`); @@ -95,20 +95,24 @@ export class KycProcessService { if (userData.kycStatus === KycStatus.ONLINE_ID) { userData = await this.goToStatus(userData, KycStatus.VIDEO_ID); - await this.mailService - .sendTranslatedMail({ - userData, - translationKey: 'mail.kyc.failed', - params: { - url: `${Config.payment.url}/kyc?code=${userData.kycHash}`, + await this.notificationService + .sendMail({ + type: MailType.USER, + input: { + userData, + translationKey: 'mail.kyc.failed', + translationParams: { + url: `${Config.payment.url}/kyc?code=${userData.kycHash}`, + }, }, }) .catch(() => null); + return userData; } // notify support - await this.mailService.sendKycFailedMail(userData, userData.kycCustomerId); + await this.notificationService.sendMail({ type: MailType.KYC_SUPPORT, input: { userData } }); return this.updateKycState(userData, KycState.FAILED); } @@ -163,12 +167,13 @@ export class KycProcessService { async identCompleted(userData: UserData, result: IdentResultDto): Promise { userData = await this.storeIdentResult(userData, result); - await this.mailService - .sendTranslatedMail({ - userData, - translationKey: 'mail.kyc.ident', + await this.notificationService + .sendMail({ + type: MailType.USER, + input: { userData, translationKey: 'mail.kyc.ident', translationParams: {} }, }) .catch(() => null); + return await this.goToStatus(userData, KycStatus.CHECK); } diff --git a/src/user/models/link/link.service.ts b/src/user/models/link/link.service.ts index c4a518c66b..7a6e0e9cb4 100644 --- a/src/user/models/link/link.service.ts +++ b/src/user/models/link/link.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { Config } from 'src/config/config'; -import { MailService } from 'src/shared/services/mail.service'; +import { MailType } from 'src/notification/enums'; +import { NotificationService } from 'src/notification/services/notification.service'; import { Blank, BlankType, UserData } from '../user-data/user-data.entity'; import { UserDataService } from '../user-data/user-data.service'; import { User } from '../user/user.entity'; @@ -14,7 +15,7 @@ export class LinkService { private readonly linkAddressRepo: LinkAddressRepository, private readonly userRepo: UserRepository, private readonly userDataService: UserDataService, - private readonly mailService: MailService, + private readonly notificationService: NotificationService, ) {} async getLinkAddress(authentication: string): Promise { @@ -30,16 +31,19 @@ export class LinkService { const linkAddress = await this.linkAddressRepo.save(LinkAddress.create(existingAddress, newAddress)); - await this.mailService.sendTranslatedMail({ - userData: user, - translationKey: 'mail.link.address', - params: { - firstname: completedUser.firstname, - surname: completedUser.surname, - organizationName: completedUser.organizationName ?? '', - existingAddress: Blank(existingAddress, BlankType.WALLET_ADDRESS), - newAddress: Blank(newAddress, BlankType.WALLET_ADDRESS), - url: this.buildLinkUrl(linkAddress.authentication), + await this.notificationService.sendMail({ + type: MailType.USER, + input: { + userData: user, + translationKey: 'mail.link.address', + translationParams: { + firstname: completedUser.firstname, + surname: completedUser.surname, + organizationName: completedUser.organizationName ?? '', + existingAddress: Blank(existingAddress, BlankType.WALLET_ADDRESS), + newAddress: Blank(newAddress, BlankType.WALLET_ADDRESS), + url: this.buildLinkUrl(linkAddress.authentication), + }, }, }); } diff --git a/src/user/services/spider/spider-sync.service.ts b/src/user/services/spider/spider-sync.service.ts index bd16d7a7c8..926b8ab8cd 100644 --- a/src/user/services/spider/spider-sync.service.ts +++ b/src/user/services/spider/spider-sync.service.ts @@ -10,7 +10,6 @@ import { UserData, } from 'src/user/models/user-data/user-data.entity'; import { UserDataRepository } from 'src/user/models/user-data/user-data.repository'; -import { MailService } from '../../../shared/services/mail.service'; import { SpiderApiService } from './spider-api.service'; import { SettingService } from 'src/shared/models/setting/setting.service'; import { In, LessThan } from 'typeorm'; @@ -22,6 +21,8 @@ import { KycDocuments, KycDocumentState, KycContentType, KycDocument, DocumentVe import { IdentResultDto } from 'src/user/models/ident/dto/ident-result.dto'; import { DocumentState, SpiderService } from './spider.service'; import { KycProcessService } from 'src/user/models/kyc/kyc-process.service'; +import { NotificationService } from 'src/notification/services/notification.service'; +import { MailType } from 'src/notification/enums'; @Injectable() export class SpiderSyncService { @@ -33,7 +34,7 @@ export class SpiderSyncService { private readonly lock = new Lock(1800); constructor( - private readonly mailService: MailService, + private readonly notificationService: NotificationService, private readonly userDataRepo: UserDataRepository, private readonly kycProcess: KycProcessService, private readonly spiderApi: SpiderApiService, @@ -65,7 +66,11 @@ export class SpiderSyncService { await this.syncKycUser(user.id); } catch (e) { console.error(`Exception during KYC check for user ${user.id}:`, e); - await this.mailService.sendErrorMail('KYC Error', [`Exception during KYC check for user ${user.id}: ${e}`]); + + await this.notificationService.sendMail({ + type: MailType.ERROR_MONITORING, + input: { subject: 'KYC Error', errors: [`Exception during KYC check for user ${user.id}: ${e}`] }, + }); } } } @@ -103,7 +108,11 @@ export class SpiderSyncService { await this.syncKycUser(userDataId); } catch (e) { console.error(`Exception during KYC sync for user ${userDataId}:`, e); - await this.mailService.sendErrorMail('KYC Error', [`Exception during KYC sync for user ${userDataId}: ${e}`]); + + await this.notificationService.sendMail({ + type: MailType.ERROR_MONITORING, + input: { subject: 'KYC Error', errors: [`Exception during KYC sync for user ${userDataId}: ${e}`] }, + }); } } } @@ -178,12 +187,15 @@ export class SpiderSyncService { if (userData.kycStatus === KycStatus.CHATBOT) { userData = await this.kycProcess.chatbotCompleted(userData); - await this.mailService - .sendTranslatedMail({ - userData, - translationKey: 'mail.kyc.chatbot', - params: { - url: `${Config.payment.url}/kyc?code=${userData.kycHash}`, + await this.notificationService + .sendMail({ + type: MailType.USER, + input: { + userData, + translationKey: 'mail.kyc.chatbot', + translationParams: { + url: `${Config.payment.url}/kyc?code=${userData.kycHash}`, + }, }, }) .catch(() => null); @@ -200,13 +212,16 @@ export class SpiderSyncService { private async handleExpiring(userData: UserData): Promise { // send reminder - await this.mailService - .sendTranslatedMail({ - userData, - translationKey: 'mail.kyc.reminder', - params: { - status: this.kycStatusTranslation[userData.kycStatus], - url: `${Config.payment.url}/kyc?code=${userData.kycHash}`, + await this.notificationService + .sendMail({ + type: MailType.USER, + input: { + userData, + translationKey: 'mail.kyc.reminder', + translationParams: { + status: this.kycStatusTranslation[userData.kycStatus], + url: `${Config.payment.url}/kyc?code=${userData.kycHash}`, + }, }, }) .catch(() => null); diff --git a/src/user/user.module.ts b/src/user/user.module.ts index f87aad52cc..458d1e0ceb 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -33,6 +33,7 @@ import { GeoLocationService } from './services/geo-location.service'; import { LinkController } from './models/link/link.controller'; import { LinkService } from './models/link/link.service'; import { LinkAddressRepository } from './models/link/link-address.repository'; +import { NotificationModule } from 'src/notification/notification.module'; import { LimitRequestController } from './models/limit-request/limit-request.controller'; import { KycWebhookService } from './models/kyc/kyc-webhook.service'; @@ -49,6 +50,7 @@ import { KycWebhookService } from './models/kyc/kyc-webhook.service'; LinkAddressRepository, ]), SharedModule, + NotificationModule, AinModule, ], controllers: [ diff --git a/thunder-tests/thunderclient.json b/thunder-tests/thunderclient.json index 6737edd3c0..d3e345e18a 100644 --- a/thunder-tests/thunderclient.json +++ b/thunder-tests/thunderclient.json @@ -118,7 +118,7 @@ "method": "POST", "sortNum": 80000, "created": "2022-01-15T13:41:33.269Z", - "modified": "2022-10-12T08:47:09.606Z", + "modified": "2022-10-14T09:57:53.958Z", "headers": [], "params": [], "body": { @@ -1894,12 +1894,12 @@ "method": "PUT", "sortNum": 710000, "created": "2022-10-12T08:33:46.230Z", - "modified": "2022-10-12T08:46:18.982Z", + "modified": "2022-10-14T09:57:14.180Z", "headers": [], "params": [], "body": { "type": "json", - "raw": "{\n \"info\":\"BankTxReturn123\",\n \"chargebackBankTxId\": 14\n}", + "raw": "{\n \"info\":\"BankTxReturn123\",\n \"chargebackBankTxId\": 14,\n \"amountInEur\": 100,\n \"amountInUsd\": 100,\n \"amountInChf\": 100\n}", "form": [] }, "tests": []