diff --git a/migration/1761754328324-AddBuyCryptoBuyFiatPlatformFeeAmount.js b/migration/1761754328324-AddBuyCryptoBuyFiatPlatformFeeAmount.js new file mode 100644 index 0000000000..dc30a4b53e --- /dev/null +++ b/migration/1761754328324-AddBuyCryptoBuyFiatPlatformFeeAmount.js @@ -0,0 +1,35 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddBuyCryptoBuyFiatPlatformFeeAmount1761754328324 { + name = 'AddBuyCryptoBuyFiatPlatformFeeAmount1761754328324' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "wallet" ADD "ownerId" int`); + await queryRunner.query(`ALTER TABLE "buy_fiat" ADD "usedPartnerRef" nvarchar(256)`); + await queryRunner.query(`ALTER TABLE "buy_fiat" ADD "partnerFeeAmount" float`); + await queryRunner.query(`ALTER TABLE "buy_crypto" ADD "usedPartnerRef" nvarchar(256)`); + await queryRunner.query(`ALTER TABLE "buy_crypto" ADD "partnerFeeAmount" float`); + await queryRunner.query(`ALTER TABLE "user" ADD "partnerRefVolume" float NOT NULL CONSTRAINT "DF_1a5ab47a6107199fad3b55afb01" DEFAULT 0`); + await queryRunner.query(`ALTER TABLE "user" ADD "partnerRefCredit" float NOT NULL CONSTRAINT "DF_6ff0d03d287f896b917bb3d70ae" DEFAULT 0`); + await queryRunner.query(`ALTER TABLE "wallet" ADD CONSTRAINT "FK_9bf56f7989a7e5717c92221cce0" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "wallet" DROP CONSTRAINT "FK_9bf56f7989a7e5717c92221cce0"`); + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "DF_6ff0d03d287f896b917bb3d70ae"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "partnerRefCredit"`); + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "DF_1a5ab47a6107199fad3b55afb01"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "partnerRefVolume"`); + await queryRunner.query(`ALTER TABLE "buy_crypto" DROP COLUMN "partnerFeeAmount"`); + await queryRunner.query(`ALTER TABLE "buy_crypto" DROP COLUMN "usedPartnerRef"`); + await queryRunner.query(`ALTER TABLE "buy_fiat" DROP COLUMN "partnerFeeAmount"`); + await queryRunner.query(`ALTER TABLE "buy_fiat" DROP COLUMN "usedPartnerRef"`); + await queryRunner.query(`ALTER TABLE "wallet" DROP COLUMN "ownerId"`); + } +} diff --git a/migration/1773931596651-BankFeeSplit.js b/migration/1773931596651-BankFeeSplit.js new file mode 100644 index 0000000000..fa5bda4455 --- /dev/null +++ b/migration/1773931596651-BankFeeSplit.js @@ -0,0 +1,32 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class BankFeeSplit1773931596651 { + name = 'BankFeeSplit1773931596651' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "buy_fiat" ADD "bankFixedFeeAmount" float`); + await queryRunner.query(`ALTER TABLE "buy_fiat" ADD "bankPercentFeeAmount" float`); + await queryRunner.query(`ALTER TABLE "buy_crypto" ADD "bankFixedFeeAmount" float`); + await queryRunner.query(`ALTER TABLE "buy_crypto" ADD "bankPercentFeeAmount" float`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "buy_crypto" DROP COLUMN "bankPercentFeeAmount"`); + await queryRunner.query(`ALTER TABLE "buy_crypto" DROP COLUMN "bankFixedFeeAmount"`); + await queryRunner.query(`ALTER TABLE "buy_fiat" DROP COLUMN "bankPercentFeeAmount"`); + await queryRunner.query(`ALTER TABLE "buy_fiat" DROP COLUMN "bankFixedFeeAmount"`); + } +} diff --git a/src/config/config.ts b/src/config/config.ts index 3855dc390e..2d1db4f2ee 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -49,7 +49,6 @@ export class Configuration { defaultRef = '000-000'; defaultWalletId = 1; transactionRefundExpirySeconds = 300; // 5 minutes - enough time to fill out the refund form - refRewardManualCheckLimit = 3000; // EUR txRequestWaitingExpiryDays = 7; financeLogTotalBalanceChangeLimit = 5000; faucetAmount = 20; //CHF @@ -152,6 +151,7 @@ export class Configuration { sparkAddressFormat = 'spark1[a-z0-9]{6,250}'; arkAddressFormat = 'ark1[a-z0-9]{6,500}'; firoAddressFormat = 'a[a-zA-HJ-NP-Z0-9]{33}'; + firoSparkAddressFormat = 'sm1[a-z0-9]{100,500}'; moneroAddressFormat = '[48][0-9AB][1-9A-HJ-NP-Za-km-z]{93}'; ethereumAddressFormat = '0x\\w{40}'; liquidAddressFormat = '(VTp|VJL)[a-zA-HJ-NP-Z0-9]{77}'; @@ -165,7 +165,7 @@ export class Configuration { zanoAddressFormat = 'Z[a-zA-Z0-9]{96}|iZ[a-zA-Z0-9]{106}'; internetComputerPrincipalFormat = '[a-z0-9]{5}(-[a-z0-9]{5})*(-[a-z0-9]{1,5})?'; - allAddressFormat = `${this.bitcoinAddressFormat}|${this.lightningAddressFormat}|${this.sparkAddressFormat}|${this.arkAddressFormat}|${this.firoAddressFormat}|${this.moneroAddressFormat}|${this.ethereumAddressFormat}|${this.liquidAddressFormat}|${this.arweaveAddressFormat}|${this.cardanoAddressFormat}|${this.defichainAddressFormat}|${this.railgunAddressFormat}|${this.solanaAddressFormat}|${this.tronAddressFormat}|${this.zanoAddressFormat}|${this.internetComputerPrincipalFormat}`; + allAddressFormat = `${this.bitcoinAddressFormat}|${this.lightningAddressFormat}|${this.sparkAddressFormat}|${this.arkAddressFormat}|${this.firoSparkAddressFormat}|${this.firoAddressFormat}|${this.moneroAddressFormat}|${this.ethereumAddressFormat}|${this.liquidAddressFormat}|${this.arweaveAddressFormat}|${this.cardanoAddressFormat}|${this.defichainAddressFormat}|${this.railgunAddressFormat}|${this.solanaAddressFormat}|${this.tronAddressFormat}|${this.zanoAddressFormat}|${this.internetComputerPrincipalFormat}`; masterKeySignatureFormat = '[0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12}'; hashSignatureFormat = '[A-Fa-f0-9]{64}'; @@ -173,6 +173,7 @@ export class Configuration { lightningSignatureFormat = '[a-z0-9]{104}'; lightningCustodialSignatureFormat = '[a-z0-9]{140,146}'; firoSignatureFormat = '(.{87}=|[A-Za-z0-9+/]+={0,2})'; + firoSparkSignatureFormat = '[a-f0-9]{260}'; moneroSignatureFormat = 'SigV\\d[0-9a-zA-Z]{88}'; ethereumSignatureFormat = '(0x)?[a-f0-9]{130}'; arweaveSignatureFormat = '[\\w\\-]{683}'; @@ -183,7 +184,7 @@ export class Configuration { zanoSignatureFormat = '[a-f0-9]{128}'; internetComputerSignatureFormat = '[a-f0-9]{128,144}'; - allSignatureFormat = `${this.masterKeySignatureFormat}|${this.hashSignatureFormat}|${this.bitcoinSignatureFormat}|${this.lightningSignatureFormat}|${this.lightningCustodialSignatureFormat}|${this.firoSignatureFormat}|${this.moneroSignatureFormat}|${this.ethereumSignatureFormat}|${this.arweaveSignatureFormat}|${this.cardanoSignatureFormat}|${this.railgunSignatureFormat}|${this.solanaSignatureFormat}|${this.tronSignatureFormat}|${this.zanoSignatureFormat}|${this.internetComputerSignatureFormat}`; + allSignatureFormat = `${this.masterKeySignatureFormat}|${this.hashSignatureFormat}|${this.bitcoinSignatureFormat}|${this.lightningSignatureFormat}|${this.lightningCustodialSignatureFormat}|${this.firoSignatureFormat}|${this.firoSparkSignatureFormat}|${this.moneroSignatureFormat}|${this.ethereumSignatureFormat}|${this.arweaveSignatureFormat}|${this.cardanoSignatureFormat}|${this.railgunSignatureFormat}|${this.solanaSignatureFormat}|${this.tronSignatureFormat}|${this.zanoSignatureFormat}|${this.internetComputerSignatureFormat}`; arweaveKeyFormat = '[\\w\\-]{683}'; cardanoKeyFormat = '.*'; @@ -655,6 +656,8 @@ export class Configuration { defaultPaymentTimeout: +(process.env.PAYMENT_TIMEOUT ?? 60), defaultEvmHexPaymentTryCount: +(process.env.PAYMENT_EVM_HEX_TRY_COUNT ?? 15), + defaultTxConfirmationTryCount: +(process.env.PAYMENT_TX_CONFIRMATION_TRY_COUNT ?? 15), + defaultFiroTxIdPaymentTryCount: +(process.env.PAYMENT_FIRO_TX_TRY_COUNT ?? 5), defaultForexFee: 0.01, addressForexFee: 0.02, @@ -918,6 +921,11 @@ export class Configuration { password: process.env.FIRO_NODE_PASSWORD, walletPassword: process.env.FIRO_NODE_WALLET_PASSWORD, walletAddress: process.env.FIRO_WALLET_ADDRESS, + transparentTxSize: 225, // bytes (Legacy P2PKH, no SegWit, 1-in-1-out) + sparkMintTxSize: 480, // bytes (mintspark with SchnorrProof, 1 recipient) + inputSize: 225, // bytes per input for coin selection fee estimation + outputSize: 34, // bytes per P2PKH output + txOverhead: 10, // bytes fixed transaction overhead allowUnconfirmedUtxos: process.env.FIRO_ALLOW_UNCONFIRMED_UTXOS === 'true', cpfpFeeMultiplier: +(process.env.FIRO_CPFP_FEE_MULTIPLIER ?? '2.0'), defaultFeeMultiplier: +(process.env.FIRO_DEFAULT_FEE_MULTIPLIER ?? '1.5'), diff --git a/src/integration/blockchain/bitcoin/node/__tests__/node-client.spec.ts b/src/integration/blockchain/bitcoin/node/__tests__/node-client.spec.ts index d8688aa5f2..bdaf52d416 100644 --- a/src/integration/blockchain/bitcoin/node/__tests__/node-client.spec.ts +++ b/src/integration/blockchain/bitcoin/node/__tests__/node-client.spec.ts @@ -6,11 +6,11 @@ */ import { Currency } from '@uniswap/sdk-core'; -import { HttpService } from 'src/shared/services/http.service'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { HttpService } from 'src/shared/services/http.service'; import { BlockchainTokenBalance } from '../../../shared/dto/blockchain-token-balance.dto'; -import { BitcoinRpcClient } from '../rpc/bitcoin-rpc-client'; import { NodeClient, NodeClientConfig } from '../node-client'; +import { BitcoinRpcClient } from '../rpc/bitcoin-rpc-client'; // Concrete implementation for testing class TestNodeClient extends NodeClient { diff --git a/src/integration/blockchain/bitcoin/node/node-client.ts b/src/integration/blockchain/bitcoin/node/node-client.ts index 34912e3378..6f950bb11d 100644 --- a/src/integration/blockchain/bitcoin/node/node-client.ts +++ b/src/integration/blockchain/bitcoin/node/node-client.ts @@ -43,7 +43,7 @@ export enum NodeCommand { } export abstract class NodeClient extends BlockchainClient { - private readonly logger = new DfxLogger(NodeClient); + protected readonly logger = new DfxLogger(NodeClient); protected readonly rpc: BitcoinRpcClient; private readonly queue: QueueHandler; diff --git a/src/integration/blockchain/bitcoin/node/rpc/bitcoin-rpc-types.ts b/src/integration/blockchain/bitcoin/node/rpc/bitcoin-rpc-types.ts index a1bd00d661..32e50a8b54 100644 --- a/src/integration/blockchain/bitcoin/node/rpc/bitcoin-rpc-types.ts +++ b/src/integration/blockchain/bitcoin/node/rpc/bitcoin-rpc-types.ts @@ -124,6 +124,7 @@ export interface RawTransactionScriptPubKey { hex: string; type: string; address?: string; + addresses?: string[]; // Firo and older Bitcoin Core forks } export interface RawTransactionPrevout { diff --git a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts index 5e61dff8b2..1d87c3371d 100644 --- a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts +++ b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts @@ -147,6 +147,26 @@ describe('CryptoService', () => { expect(CryptoService.getBlockchainsBasedOn('a8MuyHBKL3nYZKAa82x13FxqtExP2sQCqu')).toEqual([Blockchain.FIRO]); }); + it('should return UserAddressType.FIRO for address a8MuyHBKL3nYZKAa82x13FxqtExP2sQCqu', () => { + expect(CryptoService.getAddressType('a8MuyHBKL3nYZKAa82x13FxqtExP2sQCqu')).toEqual(UserAddressType.FIRO); + }); + + it('should return Blockchain.FIRO for Spark address sm1qqp4u87yjmcd0mwfph0pg6jannk3z0wmhuzzuxgcrthqf0jrq9dqg8ht02gv2rssle7kgehhrglqn540rk8entqlsw3jmjrfrsc4xvz8u90q0z2uxe8zzpmzqx7qzf3', () => { + expect( + CryptoService.getBlockchainsBasedOn( + 'sm1qqp4u87yjmcd0mwfph0pg6jannk3z0wmhuzzuxgcrthqf0jrq9dqg8ht02gv2rssle7kgehhrglqn540rk8entqlsw3jmjrfrsc4xvz8u90q0z2uxe8zzpmzqx7qzf3', + ), + ).toEqual([Blockchain.FIRO]); + }); + + it('should return UserAddressType.FIRO_SPARK for Spark address sm1qqp4u87yjmcd0mwfph0pg6jannk3z0wmhuzzuxgcrthqf0jrq9dqg8ht02gv2rssle7kgehhrglqn540rk8entqlsw3jmjrfrsc4xvz8u90q0z2uxe8zzpmzqx7qzf3', () => { + expect( + CryptoService.getAddressType( + 'sm1qqp4u87yjmcd0mwfph0pg6jannk3z0wmhuzzuxgcrthqf0jrq9dqg8ht02gv2rssle7kgehhrglqn540rk8entqlsw3jmjrfrsc4xvz8u90q0z2uxe8zzpmzqx7qzf3', + ), + ).toEqual(UserAddressType.FIRO_SPARK); + }); + it('should return Blockchain.ETHEREUM and Blockchain.BINANCE_SMART_CHAIN for address 0x2d84553B3A4753009A314106d58F0CC21f441234', () => { expect(CryptoService.getBlockchainsBasedOn('0x2d84553B3A4753009A314106d58F0CC21f441234')).toEqual([ Blockchain.ETHEREUM, diff --git a/src/integration/blockchain/firo/firo-client.ts b/src/integration/blockchain/firo/firo-client.ts index 8274c97aa7..f3442dbf76 100644 --- a/src/integration/blockchain/firo/firo-client.ts +++ b/src/integration/blockchain/firo/firo-client.ts @@ -1,6 +1,7 @@ import { Config, GetConfig } from 'src/config/config'; import { HttpService } from 'src/shared/services/http.service'; import { BitcoinBasedClient, TestMempoolResult } from '../bitcoin/node/bitcoin-based-client'; +import { UTXO } from '../bitcoin/node/dto/bitcoin-transaction.dto'; import { Block, NodeClientConfig } from '../bitcoin/node/node-client'; import { FiroRawTransaction } from './rpc'; @@ -19,6 +20,8 @@ import { FiroRawTransaction } from './rpc'; * - getrawtransaction: boolean verbose, no multi-level verbosity, no prevout in result */ export class FiroClient extends BitcoinBasedClient { + private depositAddressProvider: () => Promise = async () => []; + constructor(http: HttpService, url: string) { const firoConfig = GetConfig().blockchain.firo; @@ -40,6 +43,10 @@ export class FiroClient extends BitcoinBasedClient { return Config.payment.firoAddress; } + setDepositAddressProvider(provider: () => Promise): void { + this.depositAddressProvider = provider; + } + // --- RPC Overrides for Firo compatibility --- // // Firo's getnewaddress only accepts an optional account parameter, no address type @@ -48,14 +55,11 @@ export class FiroClient extends BitcoinBasedClient { } // Firo's account-based getbalance with '' returns only the default account, which can be negative. - // Use listunspent filtered to the liquidity and payment addresses for an accurate spendable balance. + // Use listunspent filtered to all non-deposit addresses for an accurate spendable balance. async getBalance(): Promise { - const utxos = await this.getUtxoForAddresses( - [this.walletAddress, this.paymentAddress], - this.nodeConfig.allowUnconfirmedUtxos, - ); + const { utxos } = await this.getNonDepositUtxos(); - return this.roundAmount(utxos?.reduce((sum, u) => sum + u.amount, 0) ?? 0); + return this.roundAmount(utxos.reduce((sum, u) => sum + u.amount, 0)); } // Firo's getblock uses boolean verbose, not int verbosity (0/1/2) @@ -94,7 +98,7 @@ export class FiroClient extends BitcoinBasedClient { vout: number, feeRate: number, ): Promise<{ outTxId: string; feeAmount: number }> { - const feeAmount = (feeRate * 225) / 1e8; + const feeAmount = (feeRate * Config.blockchain.firo.transparentTxSize) / 1e8; const sendAmount = this.roundAmount(amount - feeAmount); const outTxId = await this.buildSignAndBroadcast([{ txid: txId, vout }], { [addressTo]: sendAmount }); @@ -102,9 +106,12 @@ export class FiroClient extends BitcoinBasedClient { return { outTxId, feeAmount }; } - // Delegates to sendManyFromAddress using the liquidity and payment addresses. + // Delegates to sendManyWithUtxos using all non-deposit UTXOs. async sendMany(payload: { addressTo: string; amount: number }[], feeRate: number): Promise { - return this.sendManyFromAddress([this.walletAddress, this.paymentAddress], payload, feeRate); + const { utxos } = await this.getNonDepositUtxos(); + if (!utxos.length) throw new Error('No non-deposit addresses with UTXOs available'); + + return this.sendManyWithUtxos(utxos, payload, feeRate); } // Use UTXOs from the specified addresses to avoid spending deposit UTXOs. @@ -114,19 +121,27 @@ export class FiroClient extends BitcoinBasedClient { payload: { addressTo: string; amount: number }[], feeRate: number, ): Promise { - const outputs = payload.reduce( - (acc, p) => ({ ...acc, [p.addressTo]: this.roundAmount(p.amount) }), - {} as Record, - ); - const outputTotal = payload.reduce((sum, p) => sum + p.amount, 0); - const utxos = await this.getUtxoForAddresses(fromAddresses, this.nodeConfig.allowUnconfirmedUtxos); + return this.sendManyWithUtxos(utxos, payload, feeRate); + } - if (!utxos || utxos.length === 0) { + private async sendManyWithUtxos( + utxos: UTXO[], + payload: { addressTo: string; amount: number }[], + feeRate: number, + ): Promise { + const outputs: Record = {}; + for (const p of payload) { + outputs[p.addressTo] = this.roundAmount((outputs[p.addressTo] ?? 0) + p.amount); + } + const outputTotal = payload.reduce((sum, p) => sum + p.amount, 0); + + if (utxos.length === 0) { throw new Error('No UTXOs available on the specified addresses'); } - // Select UTXOs to cover outputs + estimated fee (225 bytes per input, 34 per output, 10 overhead) + // Select UTXOs to cover outputs + estimated fee + const { inputSize, outputSize, txOverhead } = Config.blockchain.firo; const sortedUtxos = utxos.sort((a, b) => b.amount - a.amount); const selectedInputs: { txid: string; vout: number }[] = []; let inputTotal = 0; @@ -135,14 +150,14 @@ export class FiroClient extends BitcoinBasedClient { selectedInputs.push({ txid: utxo.txid, vout: utxo.vout }); inputTotal += utxo.amount; - const estimatedSize = selectedInputs.length * 225 + (payload.length + 1) * 34 + 10; + const estimatedSize = selectedInputs.length * inputSize + (payload.length + 1) * outputSize + txOverhead; const estimatedFee = (feeRate * estimatedSize) / 1e8; if (inputTotal >= outputTotal + estimatedFee) break; } // Calculate final fee and change - const txSize = selectedInputs.length * 225 + (payload.length + 1) * 34 + 10; + const txSize = selectedInputs.length * inputSize + (payload.length + 1) * outputSize + txOverhead; const fee = (feeRate * txSize) / 1e8; if (inputTotal < outputTotal + fee) { @@ -204,6 +219,65 @@ export class FiroClient extends BitcoinBasedClient { } } + // --- Spark Methods --- // + + async verifySparkSignature(address: string, message: string, signature: string): Promise { + return this.callNode(() => this.rpc.call('verifymessagewithsparkaddress', [address, signature, message])); + } + + // Mints transparent FIRO to Spark addresses using all non-deposit UTXOs. + // Change goes to a random wallet address (mintspark limitation), but these stray UTXOs + // are automatically consumed in subsequent mints since we use all non-deposit addresses. + async mintSpark(recipients: { address: string; amount: number }[]): Promise { + const fromAddresses = await this.getNonDepositAddresses(); + if (!fromAddresses.length) throw new Error('No non-deposit addresses with UTXOs available'); + + const sparkAddresses: Record = {}; + for (const r of recipients) { + sparkAddresses[r.address] = { + amount: this.roundAmount((sparkAddresses[r.address]?.amount ?? 0) + r.amount), + memo: '', + }; + } + + const mintTxIds = await this.callNode( + () => this.rpc.call('mintspark', [sparkAddresses, false, fromAddresses]), + true, + ); + + if (!mintTxIds?.length) { + throw new Error('mintspark returned no transaction IDs'); + } + + if (mintTxIds.length > 1) { + this.logger.warn(`mintspark returned ${mintTxIds.length} TXIDs, only tracking first: ${mintTxIds[0]}`); + } + + return mintTxIds[0]; + } + + private async getNonDepositAddresses(): Promise { + const { addresses } = await this.getNonDepositUtxos(); + return addresses; + } + + private async getNonDepositUtxos(): Promise<{ addresses: string[]; utxos: UTXO[] }> { + const allUtxos = await this.getUtxo(this.nodeConfig.allowUnconfirmedUtxos); + const depositSet = new Set(await this.depositAddressProvider()); + + const addressSet = new Set(); + const utxos: UTXO[] = []; + + for (const utxo of allUtxos) { + if (!depositSet.has(utxo.address)) { + addressSet.add(utxo.address); + utxos.push(utxo); + } + } + + return { addresses: [...addressSet], utxos }; + } + // Firo does not support testmempoolaccept RPC. // Emulate it using decoderawtransaction + input lookup to calculate fee and size. // Firo has no SegWit, so size == vsize. diff --git a/src/integration/blockchain/firo/services/firo.service.ts b/src/integration/blockchain/firo/services/firo.service.ts index d20ede4e9f..f56aee2f92 100644 --- a/src/integration/blockchain/firo/services/firo.service.ts +++ b/src/integration/blockchain/firo/services/firo.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Config } from 'src/config/config'; import { HttpService } from 'src/shared/services/http.service'; import { Util } from 'src/shared/utils/util'; @@ -23,4 +23,9 @@ export class FiroService extends BlockchainService { getPaymentRequest(address: string, amount: number): string { return `firo:${address}?amount=${Util.numberToFixedString(amount)}`; } + + async verifySparkSignature(message: string, address: string, signature: string): Promise { + if (!this.client) throw new ServiceUnavailableException('Firo node not configured'); + return this.client.verifySparkSignature(address, message, signature); + } } diff --git a/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts b/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts index fe1bd652d0..49afa8cbbd 100644 --- a/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts +++ b/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts @@ -133,7 +133,7 @@ describe('RealUnitBlockchainService', () => { const result = await service.getBrokerbotInfo('0xBB', '0xR', '0xZ'); - expect(result.pricePerShare).toBe('123.45'); + expect(result.pricePerShare).toBe(123.45); expect(result.currency).toBe(BrokerbotCurrency.CHF); expect(result.availableShares).toBe(200); }); @@ -143,7 +143,7 @@ describe('RealUnitBlockchainService', () => { const result = await service.getBrokerbotInfo('0xBB', '0xR', '0xZ', BrokerbotCurrency.EUR); - expect(result.pricePerShare).toBe('114'); + expect(result.pricePerShare).toBe(114); expect(result.currency).toBe(BrokerbotCurrency.EUR); }); @@ -172,7 +172,7 @@ describe('RealUnitBlockchainService', () => { it('should return CHF price by default', async () => { const result = await service.getBrokerbotPrice(); - expect(result.pricePerShare).toBe('100.5'); + expect(result.pricePerShare).toBe(100.5); expect(result.currency).toBe(BrokerbotCurrency.CHF); expect(result.availableShares).toBe(500); }); @@ -180,7 +180,7 @@ describe('RealUnitBlockchainService', () => { it('should return EUR price when currency is EUR', async () => { const result = await service.getBrokerbotPrice(BrokerbotCurrency.EUR); - expect(result.pricePerShare).toBe('92.3'); + expect(result.pricePerShare).toBe(92.3); expect(result.currency).toBe(BrokerbotCurrency.EUR); }); }); @@ -194,8 +194,8 @@ describe('RealUnitBlockchainService', () => { const result = await service.getBrokerbotBuyPrice(10); expect(result.shares).toBe(10); - expect(result.totalPrice).toBe('1000'); - expect(result.pricePerShare).toBe('100'); + expect(result.totalPrice).toBe(1000); + expect(result.pricePerShare).toBe(100); expect(result.currency).toBe(BrokerbotCurrency.CHF); }); @@ -203,8 +203,8 @@ describe('RealUnitBlockchainService', () => { const result = await service.getBrokerbotBuyPrice(10, BrokerbotCurrency.EUR); expect(result.shares).toBe(10); - expect(result.totalPrice).toBe('920'); - expect(result.pricePerShare).toBe('92'); + expect(result.totalPrice).toBe(920); + expect(result.pricePerShare).toBe(92); expect(result.currency).toBe(BrokerbotCurrency.EUR); }); }); @@ -215,20 +215,20 @@ describe('RealUnitBlockchainService', () => { }); it('should calculate shares from CHF amount by default', async () => { - const result = await service.getBrokerbotShares('1000'); + const result = await service.getBrokerbotBuyShares(1000); - expect(result.amount).toBe('1000'); + expect(result.amount).toBe(1000); expect(result.shares).toBe(10); - expect(result.pricePerShare).toBe('100'); + expect(result.pricePerShare).toBe(100); expect(result.currency).toBe(BrokerbotCurrency.CHF); }); it('should calculate shares from EUR amount when currency is EUR', async () => { - const result = await service.getBrokerbotShares('920', BrokerbotCurrency.EUR); + const result = await service.getBrokerbotBuyShares(920, BrokerbotCurrency.EUR); - expect(result.amount).toBe('920'); + expect(result.amount).toBe(920); expect(result.shares).toBe(10); - expect(result.pricePerShare).toBe('92'); + expect(result.pricePerShare).toBe(92); expect(result.currency).toBe(BrokerbotCurrency.EUR); }); }); diff --git a/src/integration/blockchain/realunit/dto/realunit-broker.dto.ts b/src/integration/blockchain/realunit/dto/realunit-broker.dto.ts index 479e95dec3..9091071b10 100644 --- a/src/integration/blockchain/realunit/dto/realunit-broker.dto.ts +++ b/src/integration/blockchain/realunit/dto/realunit-broker.dto.ts @@ -19,7 +19,7 @@ export class BrokerbotCurrencyQueryDto { export class BrokerbotPriceDto { @ApiProperty({ description: 'Current price per share' }) - pricePerShare: string; + pricePerShare: number; @ApiProperty({ description: 'Currency of the price', enum: BrokerbotCurrency }) currency: BrokerbotCurrency; @@ -33,10 +33,10 @@ export class BrokerbotBuyPriceDto { shares: number; @ApiProperty({ description: 'Total cost' }) - totalPrice: string; + totalPrice: number; @ApiProperty({ description: 'Price per share' }) - pricePerShare: string; + pricePerShare: number; @ApiProperty({ description: 'Currency of the prices', enum: BrokerbotCurrency }) currency: BrokerbotCurrency; @@ -45,15 +45,15 @@ export class BrokerbotBuyPriceDto { availableShares: number; } -export class BrokerbotSharesDto { +export class BrokerbotBuySharesDto { @ApiProperty({ description: 'Amount in specified currency' }) - amount: string; + amount: number; @ApiProperty({ description: 'Number of shares that can be purchased' }) shares: number; @ApiProperty({ description: 'Price per share' }) - pricePerShare: string; + pricePerShare: number; @ApiProperty({ description: 'Currency of the prices', enum: BrokerbotCurrency }) currency: BrokerbotCurrency; @@ -73,7 +73,7 @@ export class BrokerbotInfoDto { baseCurrencyAddress: string; @ApiProperty({ description: 'Current price per share' }) - pricePerShare: string; + pricePerShare: number; @ApiProperty({ description: 'Currency of the price', enum: BrokerbotCurrency }) currency: BrokerbotCurrency; @@ -87,3 +87,31 @@ export class BrokerbotInfoDto { @ApiProperty({ description: 'Available shares for purchase' }) availableShares: number; } + +export class BrokerbotSellPriceDto { + @ApiProperty({ description: 'Number of shares to sell' }) + shares: number; + + @ApiProperty({ description: 'Price per share (including fees)' }) + pricePerShare: number; + + @ApiProperty({ description: 'Estimated amount after fees' }) + estimatedAmount: number; + + @ApiProperty({ description: 'Currency of the prices', enum: BrokerbotCurrency }) + currency: BrokerbotCurrency; +} + +export class BrokerbotSellSharesDto { + @ApiProperty({ description: 'Target amount to receive after fees' }) + targetAmount: number; + + @ApiProperty({ description: 'Number of shares needed to sell' }) + shares: number; + + @ApiProperty({ description: 'Price per share (including fees)' }) + pricePerShare: number; + + @ApiProperty({ description: 'Currency of the prices', enum: BrokerbotCurrency }) + currency: BrokerbotCurrency; +} diff --git a/src/integration/blockchain/realunit/realunit-blockchain.service.ts b/src/integration/blockchain/realunit/realunit-blockchain.service.ts index a0edfdb3d7..0184c0dcd6 100644 --- a/src/integration/blockchain/realunit/realunit-blockchain.service.ts +++ b/src/integration/blockchain/realunit/realunit-blockchain.service.ts @@ -7,10 +7,10 @@ import { Blockchain } from '../shared/enums/blockchain.enum'; import { EvmUtil } from '../shared/evm/evm.util'; import { BrokerbotBuyPriceDto, + BrokerbotBuySharesDto, BrokerbotCurrency, BrokerbotInfoDto, BrokerbotPriceDto, - BrokerbotSharesDto, } from './dto/realunit-broker.dto'; const BROKERBOT_ABI = parseAbi([ @@ -89,7 +89,7 @@ export class RealUnitBlockchainService { const { priceInCHF, priceInEUR, availableShares } = await this.fetchPrice(); const price = currency === BrokerbotCurrency.EUR ? priceInEUR : priceInCHF; return { - pricePerShare: price.toString(), + pricePerShare: price, currency, availableShares, }; @@ -105,25 +105,25 @@ export class RealUnitBlockchainService { return { shares, - totalPrice: totalPrice.toString(), - pricePerShare: price.toString(), + totalPrice, + pricePerShare: price, currency, availableShares, }; } - async getBrokerbotShares( - amount: string, + async getBrokerbotBuyShares( + amount: number, currency: BrokerbotCurrency = BrokerbotCurrency.CHF, - ): Promise { + ): Promise { const { priceInCHF, priceInEUR, availableShares } = await this.fetchPrice(); const price = currency === BrokerbotCurrency.EUR ? priceInEUR : priceInCHF; - const shares = Math.floor(parseFloat(amount) / price); + const shares = Math.floor(amount / price); return { amount, shares, - pricePerShare: price.toString(), + pricePerShare: price, currency, availableShares, }; @@ -142,7 +142,7 @@ export class RealUnitBlockchainService { brokerbotAddress: brokerbotAddr, tokenAddress: realuAddr, baseCurrencyAddress: zchfAddr, - pricePerShare: price.toString(), + pricePerShare: price, currency, buyingEnabled: availableShares > 0, sellingEnabled: true, diff --git a/src/integration/blockchain/shared/__test__/crypto.service.spec.ts b/src/integration/blockchain/shared/__test__/crypto.service.spec.ts index 7dbd31d11f..0e6d312700 100644 --- a/src/integration/blockchain/shared/__test__/crypto.service.spec.ts +++ b/src/integration/blockchain/shared/__test__/crypto.service.spec.ts @@ -91,6 +91,26 @@ describe('CryptoService', () => { expect(getBlockchain('aEXoDuVy8YVAxkvPMFGeVL2NU2KSkfZCZf')).toEqual(Blockchain.FIRO); }); + it('should match firo spark addresses', async () => { + expect( + getBlockchain( + 'sm1qqp4u87yjmcd0mwfph0pg6jannk3z0wmhuzzuxgcrthqf0jrq9dqg8ht02gv2rssle7kgehhrglqn540rk8entqlsw3jmjrfrsc4xvz8u90q0z2uxe8zzpmzqx7qzf3', + ), + ).toEqual(Blockchain.FIRO); + }); + + it('should return FIRO address type for transparent firo addresses', () => { + expect(getAddressType('aEXoDuVy8YVAxkvPMFGeVL2NU2KSkfZCZf')).toEqual(UserAddressType.FIRO); + }); + + it('should return FIRO_SPARK address type for spark firo addresses', () => { + expect( + getAddressType( + 'sm1qqp4u87yjmcd0mwfph0pg6jannk3z0wmhuzzuxgcrthqf0jrq9dqg8ht02gv2rssle7kgehhrglqn540rk8entqlsw3jmjrfrsc4xvz8u90q0z2uxe8zzpmzqx7qzf3', + ), + ).toEqual(UserAddressType.FIRO_SPARK); + }); + it('should match liquid addresses', async () => { expect(getBlockchain('VTpwKsrwasw7VnNf4GHMmcjNY3MR2Q81GaxDv7EyhVS8rzj5exX5b5PF6g29Szb4jrMqKSUwP2ZGnXt4')).toEqual( Blockchain.LIQUID, diff --git a/src/integration/blockchain/shared/evm/evm.util.ts b/src/integration/blockchain/shared/evm/evm.util.ts index fc3450a9d8..141c9a7597 100644 --- a/src/integration/blockchain/shared/evm/evm.util.ts +++ b/src/integration/blockchain/shared/evm/evm.util.ts @@ -2,10 +2,10 @@ import { FeeAmount } from '@uniswap/v3-sdk'; import BigNumber from 'bignumber.js'; import { BigNumberish, ethers, BigNumber as EthersNumber } from 'ethers'; import { defaultPath } from 'ethers/lib/utils'; -import { Chain } from 'viem'; -import { arbitrum, base, bsc, gnosis, mainnet, optimism, polygon, sepolia } from 'viem/chains'; import { GetConfig } from 'src/config/config'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { Chain } from 'viem'; +import { arbitrum, base, bsc, gnosis, mainnet, optimism, polygon, sepolia } from 'viem/chains'; import { Blockchain } from '../enums/blockchain.enum'; import ERC20_ABI from './abi/erc20.abi.json'; import { WalletAccount } from './domain/wallet-account'; diff --git a/src/integration/blockchain/shared/services/crypto.service.ts b/src/integration/blockchain/shared/services/crypto.service.ts index a2d5971487..661cdca857 100644 --- a/src/integration/blockchain/shared/services/crypto.service.ts +++ b/src/integration/blockchain/shared/services/crypto.service.ts @@ -133,6 +133,7 @@ export class CryptoService { return UserAddressType.ARK; case Blockchain.FIRO: + if (CryptoService.isFiroSparkAddress(address)) return UserAddressType.FIRO_SPARK; return UserAddressType.FIRO; case Blockchain.MONERO: @@ -190,6 +191,7 @@ export class CryptoService { if (CryptoService.isLightningAddress(address)) return [Blockchain.LIGHTNING]; if (CryptoService.isSparkAddress(address)) return [Blockchain.SPARK]; if (CryptoService.isArkAddress(address)) return [Blockchain.ARK]; + if (CryptoService.isFiroSparkAddress(address)) return [Blockchain.FIRO]; if (CryptoService.isFiroAddress(address)) return [Blockchain.FIRO]; if (CryptoService.isMoneroAddress(address)) return [Blockchain.MONERO]; if (CryptoService.isZanoAddress(address)) return [Blockchain.ZANO]; @@ -230,6 +232,10 @@ export class CryptoService { return new RegExp(`^(${Config.firoAddressFormat})$`).test(address); } + public static isFiroSparkAddress(address: string): boolean { + return new RegExp(`^(${Config.firoSparkAddressFormat})$`).test(address); + } + private static isMoneroAddress(address: string): boolean { return RegExp(`^(${Config.moneroAddressFormat})$`).test(address); } @@ -298,8 +304,7 @@ export class CryptoService { if (detectedBlockchain === Blockchain.LIGHTNING) return await this.verifyLightning(address, message, signature); if (detectedBlockchain === Blockchain.SPARK) return await this.verifySpark(message, address, signature); if (detectedBlockchain === Blockchain.ARK) return await this.verifyArk(message, address, signature); - if (detectedBlockchain === Blockchain.FIRO) - return this.verifyBitcoinBased(message, address, signature, CryptoService.firoMessagePrefix); + if (detectedBlockchain === Blockchain.FIRO) return await this.verifyFiro(message, address, signature); if (detectedBlockchain === Blockchain.MONERO) return await this.verifyMonero(message, address, signature); if (detectedBlockchain === Blockchain.ZANO) return await this.verifyZano(message, address, signature); if (detectedBlockchain === Blockchain.SOLANA) return await this.verifySolana(message, address, signature); @@ -393,6 +398,13 @@ export class CryptoService { return this.arkService.verifySignature(message, address, signature); } + private async verifyFiro(message: string, address: string, signature: string): Promise { + if (CryptoService.isFiroSparkAddress(address)) + return this.firoService.verifySparkSignature(message, address, signature); + + return this.verifyBitcoinBased(message, address, signature, CryptoService.firoMessagePrefix); + } + private async verifyMonero(message: string, address: string, signature: string): Promise { return this.moneroService.verifySignature(message, address, signature); } diff --git a/src/shared/i18n/de/mail.json b/src/shared/i18n/de/mail.json index ebebbd5e9c..1fd1ae57e8 100644 --- a/src/shared/i18n/de/mail.json +++ b/src/shared/i18n/de/mail.json @@ -94,10 +94,10 @@ "manual_check_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen. Du kannst einen erneuten Anruf und eine bevorzugte Uhrzeit für das Telefongespräch selber hier beantragen: [url:https://app.dfx.swiss/settings?a=call]", "manual_check_ip_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen. Du kannst einen erneuten Anruf und eine bevorzugte Uhrzeit für das Telefongespräch selber hier beantragen: [url:https://app.dfx.swiss/settings?a=call]", "manual_check_ip_country_phone": "Wir konnten dich unter deiner angegebenen Telefonnummer nicht erreichen. Du kannst einen erneuten Anruf und eine bevorzugte Uhrzeit für das Telefongespräch selber hier beantragen: [url:https://app.dfx.swiss/settings?a=call]", - "manual_check_phone_rejected": "Das Telefonat war nicht erfolgreich oder wurde abgelehnt", + "manual_check_phone_rejected": "Das Telefonat war nicht erfolgreich, wurde abgelehnt oder du hast dem Telefonat nicht zugestimmt", "merge_incomplete": "Die Email Bestätigung wurde nicht akzeptiert", - "intermediary_without_sender": "Die Absenderbank (Wise/Revolut) hat nur den Banknamen übermittelt, nicht aber den Namen des Kontoinhabers. DFX kann daher den tatsächlichen Absender nicht verifizieren und die Transaktion nicht verarbeiten.", - "name_too_short": "Dein Name ist zu kurz für die Bankverarbeitung. Banken benötigen mindestens 4 Buchstaben im Namen des Kontoinhabers.", + "intermediary_without_sender": "Die Absenderbank (Wise/Revolut) hat nur den Banknamen übermittelt, nicht aber den Namen des Kontoinhabers. DFX kann daher den tatsächlichen Absender nicht verifizieren und die Transaktion nicht verarbeiten", + "name_too_short": "Dein Name ist zu kurz für die Bankverarbeitung. Banken benötigen mindestens 4 Buchstaben im Namen des Kontoinhabers", "asset_input_not_allowed": "Das Asset ist derzeit nicht für den Handel mit DFX verfügbar" }, "kyc_start": "Du kannst den KYC Prozess hier starten:
[url:{urlText}]" @@ -216,8 +216,8 @@ "salutation": "Es ist eine manuelle Überprüfung per Telefon erforderlich", "line1": "Wir haben deine Einzahlung erhalten.", "line2": "Wir werden uns in Kürze auf der Telefonnummer {phone} bei dir melden.", - "line3": "Wenn alle Fragen geklärt sind, wird deine Transaktion automatisch weiterverarbeitet.", - "line4": "Du kannst eine bevorzugte Uhrzeit für das Telefongespräch selber hier angeben: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Stimme dem Gespräch zu und wähle deine bevorzugte Uhrzeit für das Telefongespräch hier aus: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Wenn alle Fragen geklärt sind, wird deine Transaktion automatisch weiterverarbeitet.", "line5": "Wenn du stattdessen eine Rückzahlung anfordern möchtest:
[url:Klick hier]" }, "merge_incomplete": { @@ -243,8 +243,8 @@ "salutation": "Es ist eine manuelle Überprüfung per Telefon erforderlich", "line1": "Wir haben deine Einzahlung erhalten.", "line2": "Wir werden uns in Kürze auf der Telefonnummer {phone} bei dir melden.", - "line3": "Wenn alle Fragen geklärt sind, wird deine Transaktion automatisch weiterverarbeitet.", - "line4": "Du kannst eine bevorzugte Uhrzeit für das Telefongespräch selber hier angeben: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Stimme dem Gespräch zu und wähle deine bevorzugte Uhrzeit für das Telefongespräch hier aus: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Wenn alle Fragen geklärt sind, wird deine Transaktion automatisch weiterverarbeitet.", "line5": "Wenn du stattdessen eine Rückzahlung anfordern möchtest:
[url:Klick hier]" }, "manual_check_ip_country_phone": { @@ -252,8 +252,8 @@ "salutation": "Es ist eine manuelle Überprüfung per Telefon erforderlich", "line1": "Wir haben deine Einzahlung erhalten.", "line2": "Wir werden uns in Kürze auf der Telefonnummer {phone} bei dir melden.", - "line3": "Wenn alle Fragen geklärt sind, wird deine Transaktion automatisch weiterverarbeitet.", - "line4": "Du kannst eine bevorzugte Uhrzeit für das Telefongespräch selber hier angeben: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Stimme dem Gespräch zu und wähle deine bevorzugte Uhrzeit für das Telefongespräch hier aus: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Wenn alle Fragen geklärt sind, wird deine Transaktion automatisch weiterverarbeitet.", "line5": "Wenn du stattdessen eine Rückzahlung anfordern möchtest:
[url:Klick hier]" } }, diff --git a/src/shared/i18n/en/mail.json b/src/shared/i18n/en/mail.json index bf74208214..241352a3c4 100644 --- a/src/shared/i18n/en/mail.json +++ b/src/shared/i18n/en/mail.json @@ -94,10 +94,10 @@ "manual_check_phone": "We were unable to reach you at the phone number you provided. You can request a callback and a preferred time for the phone call yourself here: [url:https://app.dfx.swiss/settings?a=call]", "manual_check_ip_phone": "We were unable to reach you at the phone number you provided. You can request a callback and a preferred time for the phone call yourself here: [url:https://app.dfx.swiss/settings?a=call]", "manual_check_ip_country_phone": "We were unable to reach you at the phone number you provided. You can request a callback and a preferred time for the phone call yourself here: [url:https://app.dfx.swiss/settings?a=call]", - "manual_check_phone_rejected": "The phone call was unsuccessful or rejected", + "manual_check_phone_rejected": "The call was unsuccessful, was rejected, or you did not accept the call", "merge_incomplete": "The email confirmation was not accepted", - "intermediary_without_sender": "The sender bank (Wise/Revolut) only transmitted the bank name, not the account holder's name. DFX is therefore unable to verify the actual sender and cannot process the transaction.", - "name_too_short": "Your name is too short for bank processing. Banks require at least 4 letters in the account holder name.", + "intermediary_without_sender": "The sender bank (Wise/Revolut) only transmitted the bank name, not the account holder's name. DFX is therefore unable to verify the actual sender and cannot process the transaction", + "name_too_short": "Your name is too short for bank processing. Banks require at least 4 letters in the account holder name", "asset_input_not_allowed": "The asset is currently not available for trading with DFX" }, "kyc_start": "You can start the KYC process here:
[url:{urlText}]" @@ -216,8 +216,8 @@ "salutation": "A manual verification by phone is required", "line1": "We have received your deposit.", "line2": "We will contact you shortly at {phone}.", - "line3": "Once all questions have been clarified, your transaction will be processed automatically.", - "line4": "You can specify your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Agree to the call and select your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Once all questions have been clarified, your transaction will be processed automatically.", "line5": "If you would like to request a refund instead:
[url:click here]" }, "merge_incomplete": { @@ -243,8 +243,8 @@ "salutation": "A manual verification by phone is required", "line1": "We have received your deposit.", "line2": "We will contact you shortly at {phone}.", - "line3": "Once all questions have been clarified, your transaction will be processed automatically.", - "line4": "You can specify your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Agree to the call and select your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Once all questions have been clarified, your transaction will be processed automatically.", "line5": "If you would like to request a refund instead:
[url:click here]" }, "manual_check_ip_country_phone": { @@ -252,8 +252,8 @@ "salutation": "A manual verification by phone is required", "line1": "We have received your deposit.", "line2": "We will contact you shortly at {phone}.", - "line3": "Once all questions have been clarified, your transaction will be processed automatically.", - "line4": "You can specify your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Agree to the call and select your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Once all questions have been clarified, your transaction will be processed automatically.", "line5": "If you would like to request a refund instead:
[url:click here]" } }, diff --git a/src/shared/i18n/es/mail.json b/src/shared/i18n/es/mail.json index 111c77fec1..9815b2c5fd 100644 --- a/src/shared/i18n/es/mail.json +++ b/src/shared/i18n/es/mail.json @@ -94,10 +94,10 @@ "manual_check_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó. Aquí puede solicitar una llamada y elegir la hora que prefiera para recibirla: [url:https://app.dfx.swiss/settings?a=call]", "manual_check_ip_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó. Aquí puede solicitar una llamada y elegir la hora que prefiera para recibirla: [url:https://app.dfx.swiss/settings?a=call]", "manual_check_ip_country_phone": "No hemos podido contactar con usted al número de teléfono que nos facilitó. Aquí puede solicitar una llamada y elegir la hora que prefiera para recibirla: [url:https://app.dfx.swiss/settings?a=call]", - "manual_check_phone_rejected": "La llamada telefónica no se ha podido realizar o ha sido rechazada.", + "manual_check_phone_rejected": "La llamada no se ha realizado, ha sido rechazada o no la has aceptado", "merge_incomplete": "El correo electrónico de confirmación no fue aceptado", - "intermediary_without_sender": "El banco emisor (Wise/Revolut) solo transmitió el nombre del banco, no el nombre del titular de la cuenta. Por lo tanto, DFX no puede verificar el remitente real y no puede procesar la transacción.", - "name_too_short": "Tu nombre es demasiado corto para el procesamiento bancario. Los bancos requieren al menos 4 letras en el nombre del titular de la cuenta.", + "intermediary_without_sender": "El banco emisor (Wise/Revolut) solo transmitió el nombre del banco, no el nombre del titular de la cuenta. Por lo tanto, DFX no puede verificar el remitente real y no puede procesar la transacción", + "name_too_short": "Tu nombre es demasiado corto para el procesamiento bancario. Los bancos requieren al menos 4 letras en el nombre del titular de la cuenta", "asset_input_not_allowed": "Este activo no está actualmente disponible para comerciar con DFX" }, "kyc_start": "Puede iniciar el proceso KYC aquí:
[url:{urlText}]" @@ -216,8 +216,8 @@ "salutation": "Se requiere una verificación manual por teléfono", "line1": "Hemos recibido su depósito.", "line2": "Nos pondremos en contacto con usted en breve en el {phone}.", - "line3": "Una vez que se hayan aclarado todas las preguntas, su transacción se procesará automáticamente.", - "line4": "Aquí puede especificar la hora que prefiera para la llamada telefónica: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Acepta la llamada y selecciona aquí la hora que prefieras para la llamada telefónica: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Una vez que se hayan aclarado todas las preguntas, su transacción se procesará automáticamente.", "line5": "Si desea solicitar un reembolso en su lugar:
[url:haga clic aquí]" }, "merge_incomplete": { @@ -243,8 +243,8 @@ "salutation": "Se requiere una verificación manual por teléfono", "line1": "Hemos recibido su depósito.", "line2": "Nos pondremos en contacto con usted en breve en el {phone}.", - "line3": "Una vez que se hayan aclarado todas las preguntas, su transacción se procesará automáticamente.", - "line4": "Aquí puede especificar la hora que prefiera para la llamada telefónica: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Acepta la llamada y selecciona aquí la hora que prefieras para la llamada telefónica: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Una vez que se hayan aclarado todas las preguntas, su transacción se procesará automáticamente.", "line5": "Si desea solicitar un reembolso en su lugar:
[url:haga clic aquí]" }, "manual_check_ip_country_phone": { @@ -252,8 +252,8 @@ "salutation": "Se requiere una verificación manual por teléfono", "line1": "Hemos recibido su depósito.", "line2": "Nos pondremos en contacto con usted en breve en el {phone}.", - "line3": "Una vez que se hayan aclarado todas las preguntas, su transacción se procesará automáticamente.", - "line4": "Aquí puede especificar la hora que prefiera para la llamada telefónica: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Acepta la llamada y selecciona aquí la hora que prefieras para la llamada telefónica: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Una vez que se hayan aclarado todas las preguntas, su transacción se procesará automáticamente.", "line5": "Si desea solicitar un reembolso en su lugar:
[url:haga clic aquí]" } }, diff --git a/src/shared/i18n/fr/mail.json b/src/shared/i18n/fr/mail.json index 9343213558..b42298b303 100644 --- a/src/shared/i18n/fr/mail.json +++ b/src/shared/i18n/fr/mail.json @@ -94,10 +94,10 @@ "manual_check_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni. Vous pouvez demander ici à être rappelé et indiquer l'heure à laquelle vous souhaitez recevoir l'appel: [url:https://app.dfx.swiss/settings?a=call]", "manual_check_ip_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni. Vous pouvez demander ici à être rappelé et indiquer l'heure à laquelle vous souhaitez recevoir l'appel: [url:https://app.dfx.swiss/settings?a=call]", "manual_check_ip_country_phone": "Nous n'avons pas réussi à vous joindre au numéro de téléphone que vous avez fourni. Vous pouvez demander ici à être rappelé et indiquer l'heure à laquelle vous souhaitez recevoir l'appel: [url:https://app.dfx.swiss/settings?a=call]", - "manual_check_phone_rejected": "L'appel téléphonique n'a pas abouti ou a été rejeté", + "manual_check_phone_rejected": "L'appel n'a pas abouti, a été rejeté ou vous ne l'avez pas accepté", "merge_incomplete": "L'e-mail de confirmation n'a pas été accepté", - "intermediary_without_sender": "La banque émettrice (Wise/Revolut) n'a transmis que le nom de la banque, et non le nom du titulaire du compte. DFX ne peut donc pas vérifier l'expéditeur réel et ne peut pas traiter la transaction.", - "name_too_short": "Votre nom est trop court pour le traitement bancaire. Les banques exigent au moins 4 lettres dans le nom du titulaire du compte.", + "intermediary_without_sender": "La banque émettrice (Wise/Revolut) n'a transmis que le nom de la banque, et non le nom du titulaire du compte. DFX ne peut donc pas vérifier l'expéditeur réel et ne peut pas traiter la transaction", + "name_too_short": "Votre nom est trop court pour le traitement bancaire. Les banques exigent au moins 4 lettres dans le nom du titulaire du compte", "asset_input_not_allowed": "Cet actif n'est actuellement pas disponible à l'échange avec DFX" }, "kyc_start": "Vous pouvez commencer le processus KYC ici:
[url:{urlText}]" @@ -216,8 +216,8 @@ "salutation": "Une vérification manuelle par téléphone est requise", "line1": "Nous avons bien reçu votre acompte.", "line2": "Nous vous contacterons sous peu au {phone}.", - "line3": "Une fois toutes les questions clarifiées, votre transaction sera traitée automatiquement.", - "line4": "Vous pouvez indiquer ici l'heure à laquelle vous souhaitez recevoir l'appel téléphonique: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Acceptez l'appel et choisissez ici l'heure qui vous convient pour l'entretien téléphonique: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Une fois toutes les questions clarifiées, votre transaction sera traitée automatiquement.", "line5": "Si vous souhaitez demander un remboursement:
[url:cliquez ici]" }, "merge_incomplete": { @@ -243,8 +243,8 @@ "salutation": "Une vérification manuelle par téléphone est requise", "line1": "Nous avons bien reçu votre acompte.", "line2": "Nous vous contacterons sous peu au {phone}.", - "line3": "Une fois toutes les questions clarifiées, votre transaction sera traitée automatiquement.", - "line4": "Vous pouvez indiquer ici l'heure à laquelle vous souhaitez recevoir l'appel téléphonique: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Acceptez l'appel et choisissez ici l'heure qui vous convient pour l'entretien téléphonique: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Une fois toutes les questions clarifiées, votre transaction sera traitée automatiquement.", "line5": "Si vous souhaitez demander un remboursement:
[url:cliquez ici]" }, "manual_check_ip_country_phone": { @@ -252,8 +252,8 @@ "salutation": "Une vérification manuelle par téléphone est requise", "line1": "Nous avons bien reçu votre acompte.", "line2": "Nous vous contacterons sous peu au {phone}.", - "line3": "Une fois toutes les questions clarifiées, votre transaction sera traitée automatiquement.", - "line4": "Vous pouvez indiquer ici l'heure à laquelle vous souhaitez recevoir l'appel téléphonique: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Acceptez l'appel et choisissez ici l'heure qui vous convient pour l'entretien téléphonique: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Une fois toutes les questions clarifiées, votre transaction sera traitée automatiquement.", "line5": "Si vous souhaitez demander un remboursement:
[url:cliquez ici]" } }, diff --git a/src/shared/i18n/it/mail.json b/src/shared/i18n/it/mail.json index 6583dc282c..5f6432e607 100644 --- a/src/shared/i18n/it/mail.json +++ b/src/shared/i18n/it/mail.json @@ -94,10 +94,10 @@ "manual_check_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito. Puoi richiedere tu stesso una richiamata e indicare l'orario che preferisci per la telefonata qui: [url:https://app.dfx.swiss/settings?a=call]", "manual_check_ip_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito. Puoi richiedere tu stesso una richiamata e indicare l'orario che preferisci per la telefonata qui: [url:https://app.dfx.swiss/settings?a=call]", "manual_check_ip_country_phone": "Non siamo riusciti a contattarti al numero di telefono che ci hai fornito. Puoi richiedere tu stesso una richiamata e indicare l'orario che preferisci per la telefonata qui: [url:https://app.dfx.swiss/settings?a=call]", - "manual_check_phone_rejected": "La telefonata non è andata a buon fine o è stata rifiutata.", + "manual_check_phone_rejected": "La chiamata non è andata a buon fine, è stata rifiutata oppure non l'hai accettata", "merge_incomplete": "L'e-mail di conferma non è stata accettata", - "intermediary_without_sender": "La banca mittente (Wise/Revolut) ha trasmesso solo il nome della banca, non il nome del titolare del conto. DFX non può quindi verificare il mittente effettivo e non può elaborare la transazione.", - "name_too_short": "Il tuo nome è troppo corto per l'elaborazione bancaria. Le banche richiedono almeno 4 lettere nel nome del titolare del conto.", + "intermediary_without_sender": "La banca mittente (Wise/Revolut) ha trasmesso solo il nome della banca, non il nome del titolare del conto. DFX non può quindi verificare il mittente effettivo e non può elaborare la transazione", + "name_too_short": "Il tuo nome è troppo corto per l'elaborazione bancaria. Le banche richiedono almeno 4 lettere nel nome del titolare del conto", "asset_input_not_allowed": "L'asset non è al momento disponibile per lo scambio su DFX" }, "kyc_start": "Potete iniziare il processo KYC qui:
[url:{urlText}]" @@ -216,8 +216,8 @@ "salutation": "È richiesta una verifica manuale tramite telefono.", "line1": "Abbiamo ricevuto il tuo deposito.", "line2": "Ti contatteremo a breve al numero {phone}.", - "line3": "Una volta chiariti tutti i dubbi, la transazione verrà elaborata automaticamente.", - "line4": "Qui puoi specificare l'orario che preferisci per la telefonata: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Accetta la chiamata e seleziona qui l'orario che preferisci per la telefonata: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Una volta chiariti tutti i dubbi, la transazione verrà elaborata automaticamente.", "line5": "Se invece si desidera richiedere un rimborso:
[url:clicca qui]" }, "merge_incomplete": { @@ -243,8 +243,8 @@ "salutation": "È richiesta una verifica manuale tramite telefono.", "line1": "Abbiamo ricevuto il tuo deposito.", "line2": "Ti contatteremo a breve al numero {phone}.", - "line3": "Una volta chiariti tutti i dubbi, la transazione verrà elaborata automaticamente.", - "line4": "Qui puoi specificare l'orario che preferisci per la telefonata: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Accetta la chiamata e seleziona qui l'orario che preferisci per la telefonata: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Una volta chiariti tutti i dubbi, la transazione verrà elaborata automaticamente.", "line5": "Se invece si desidera richiedere un rimborso:
[url:clicca qui]" }, "manual_check_ip_country_phone": { @@ -252,8 +252,8 @@ "salutation": "È richiesta una verifica manuale tramite telefono.", "line1": "Abbiamo ricevuto il tuo deposito.", "line2": "Ti contatteremo a breve al numero {phone}.", - "line3": "Una volta chiariti tutti i dubbi, la transazione verrà elaborata automaticamente.", - "line4": "Qui puoi specificare l'orario che preferisci per la telefonata: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Accetta la chiamata e seleziona qui l'orario che preferisci per la telefonata: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Una volta chiariti tutti i dubbi, la transazione verrà elaborata automaticamente.", "line5": "Se invece si desidera richiedere un rimborso:
[url:clicca qui]" } }, diff --git a/src/shared/i18n/pt/mail.json b/src/shared/i18n/pt/mail.json index ac5f1c71c4..97b770f67f 100644 --- a/src/shared/i18n/pt/mail.json +++ b/src/shared/i18n/pt/mail.json @@ -94,10 +94,10 @@ "manual_check_phone": "We were unable to reach you at the phone number you provided. You can request a callback and a preferred time for the phone call yourself here: [url:https://app.dfx.swiss/settings?a=call]", "manual_check_ip_phone": "We were unable to reach you at the phone number you provided. You can request a callback and a preferred time for the phone call yourself here: [url:https://app.dfx.swiss/settings?a=call]", "manual_check_ip_country_phone": "We were unable to reach you at the phone number you provided. You can request a callback and a preferred time for the phone call yourself here: [url:https://app.dfx.swiss/settings?a=call]", - "manual_check_phone_rejected": "The phone call was unsuccessful or rejected", + "manual_check_phone_rejected": "The call was unsuccessful, was rejected, or you did not accept the call", "merge_incomplete": "The email confirmation was not accepted", - "intermediary_without_sender": "O banco remetente (Wise/Revolut) transmitiu apenas o nome do banco, não o nome do titular da conta. Portanto, a DFX não pode verificar o remetente real e não pode processar a transação.", - "name_too_short": "O seu nome é muito curto para o processamento bancário. Os bancos exigem pelo menos 4 letras no nome do titular da conta.", + "intermediary_without_sender": "O banco remetente (Wise/Revolut) transmitiu apenas o nome do banco, não o nome do titular da conta. Portanto, a DFX não pode verificar o remetente real e não pode processar a transação", + "name_too_short": "O seu nome é muito curto para o processamento bancário. Os bancos exigem pelo menos 4 letras no nome do titular da conta", "asset_input_not_allowed": "The asset is currently not available for trading with DFX" }, "kyc_start": "You can start the KYC process here:
[url:{urlText}]" @@ -216,8 +216,8 @@ "salutation": "A manual verification by phone is required", "line1": "We have received your deposit.", "line2": "We will contact you shortly at {phone}.", - "line3": "Once all questions have been clarified, your transaction will be processed automatically.", - "line4": "You can specify your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Agree to the call and select your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Once all questions have been clarified, your transaction will be processed automatically.", "line5": "If you would like to request a refund instead:
[url:click here]" }, "merge_incomplete": { @@ -243,8 +243,8 @@ "salutation": "A manual verification by phone is required", "line1": "We have received your deposit.", "line2": "We will contact you shortly at {phone}.", - "line3": "Once all questions have been clarified, your transaction will be processed automatically.", - "line4": "You can specify your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Agree to the call and select your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Once all questions have been clarified, your transaction will be processed automatically.", "line5": "If you would like to request a refund instead:
[url:click here]" }, "manual_check_ip_country_phone": { @@ -252,8 +252,8 @@ "salutation": "A manual verification by phone is required", "line1": "We have received your deposit.", "line2": "We will contact you shortly at {phone}.", - "line3": "Once all questions have been clarified, your transaction will be processed automatically.", - "line4": "You can specify your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", + "line3": "Agree to the call and select your preferred time for the phone call here: [url:https://app.dfx.swiss/settings?a=call]", + "line4": "Once all questions have been clarified, your transaction will be processed automatically.", "line5": "If you would like to request a refund instead:
[url:click here]" } }, diff --git a/src/subdomains/core/aml/enums/aml-error.enum.ts b/src/subdomains/core/aml/enums/aml-error.enum.ts index d84786af6a..def9f32603 100644 --- a/src/subdomains/core/aml/enums/aml-error.enum.ts +++ b/src/subdomains/core/aml/enums/aml-error.enum.ts @@ -65,6 +65,7 @@ export enum AmlError { BANK_RELEASE_DATE_MISSING = 'BankReleaseDateMissing', IP_COUNTRY_MISMATCH = 'IpCountryMismatch', USER_DATA_FAILED_CALL = 'UserDataFailedCall', + USER_DATA_REJECTED_CALL = 'UserDataRejectedCall', TRADE_APPROVAL_DATE_MISSING = 'TradeApprovalDateMissing', BANK_TX_CUSTOMER_NAME_MISSING = 'BankTxCustomerNameMissing', FORCE_MANUAL_CHECK = 'ForceManualCheck', @@ -320,6 +321,11 @@ export const AmlErrorResult: { amlCheck: CheckStatus.FAIL, amlReason: AmlReason.MANUAL_CHECK_PHONE_FAILED, }, + [AmlError.USER_DATA_REJECTED_CALL]: { + type: AmlErrorType.CRUCIAL, + amlCheck: CheckStatus.FAIL, + amlReason: AmlReason.MANUAL_CHECK_PHONE_FAILED, + }, [AmlError.TRADE_APPROVAL_DATE_MISSING]: { type: AmlErrorType.CRUCIAL, amlCheck: CheckStatus.PENDING, diff --git a/src/subdomains/core/aml/services/aml-helper.service.ts b/src/subdomains/core/aml/services/aml-helper.service.ts index 64197b5571..3345a46816 100644 --- a/src/subdomains/core/aml/services/aml-helper.service.ts +++ b/src/subdomains/core/aml/services/aml-helper.service.ts @@ -66,9 +66,11 @@ export class AmlHelperService { !entity.wallet.autoTradeApproval ) errors.push( - [PhoneCallStatus.USER_REJECTED, PhoneCallStatus.FAILED].includes(entity.userData.phoneCallStatus) + entity.userData.phoneCallStatus === PhoneCallStatus.FAILED ? AmlError.USER_DATA_FAILED_CALL - : AmlError.TRADE_APPROVAL_DATE_MISSING, + : entity.userData.phoneCallStatus === PhoneCallStatus.USER_REJECTED && !entity.userData.phoneCallAccepted + ? AmlError.USER_DATA_REJECTED_CALL + : AmlError.TRADE_APPROVAL_DATE_MISSING, ); if (entity.inputReferenceAmount < minVolume * 0.9) errors.push(AmlError.MIN_VOLUME_NOT_REACHED); if (entity.user.isBlocked) errors.push(AmlError.USER_BLOCKED); @@ -102,9 +104,11 @@ export class AmlHelperService { if (entity.userData.hasIpRisk && !entity.userData.phoneCallIpCheckDate) { if (entity.userData.kycLevel >= KycLevel.LEVEL_50) { errors.push( - [PhoneCallStatus.USER_REJECTED, PhoneCallStatus.FAILED].includes(entity.userData.phoneCallStatus) + entity.userData.phoneCallStatus === PhoneCallStatus.FAILED ? AmlError.USER_DATA_FAILED_CALL - : AmlError.IP_PHONE_VERIFICATION_NEEDED, + : entity.userData.phoneCallStatus === PhoneCallStatus.USER_REJECTED && !entity.userData.phoneCallAccepted + ? AmlError.USER_DATA_REJECTED_CALL + : AmlError.IP_PHONE_VERIFICATION_NEEDED, ); } else { errors.push(AmlError.IP_BLACKLISTED_WITHOUT_KYC); @@ -212,9 +216,11 @@ export class AmlHelperService { ) ) errors.push( - [PhoneCallStatus.USER_REJECTED, PhoneCallStatus.FAILED].includes(entity.userData.phoneCallStatus) + entity.userData.phoneCallStatus === PhoneCallStatus.FAILED ? AmlError.USER_DATA_FAILED_CALL - : AmlError.IP_COUNTRY_MISMATCH, + : entity.userData.phoneCallStatus === PhoneCallStatus.USER_REJECTED && !entity.userData.phoneCallAccepted + ? AmlError.USER_DATA_REJECTED_CALL + : AmlError.IP_COUNTRY_MISMATCH, ); if ( @@ -242,9 +248,11 @@ export class AmlHelperService { Util.yearsDiff(entity.userData.birthday) > 55 ) errors.push( - [PhoneCallStatus.USER_REJECTED, PhoneCallStatus.FAILED].includes(entity.userData.phoneCallStatus) + entity.userData.phoneCallStatus === PhoneCallStatus.FAILED ? AmlError.USER_DATA_FAILED_CALL - : AmlError.PHONE_VERIFICATION_NEEDED, + : entity.userData.phoneCallStatus === PhoneCallStatus.USER_REJECTED && !entity.userData.phoneCallAccepted + ? AmlError.USER_DATA_REJECTED_CALL + : AmlError.PHONE_VERIFICATION_NEEDED, ); if (entity.bankTx) { @@ -491,9 +499,11 @@ export class AmlHelperService { case AmlRule.RULE_16: if (entity instanceof BuyCrypto && entity.userData.isPersonalAccount && !entity.userData.phoneCallCheckDate) errors.push( - [PhoneCallStatus.USER_REJECTED, PhoneCallStatus.FAILED].includes(entity.userData.phoneCallStatus) + entity.userData.phoneCallStatus === PhoneCallStatus.FAILED ? AmlError.USER_DATA_FAILED_CALL - : AmlError.PHONE_VERIFICATION_NEEDED, + : entity.userData.phoneCallStatus === PhoneCallStatus.USER_REJECTED && !entity.userData.phoneCallAccepted + ? AmlError.USER_DATA_REJECTED_CALL + : AmlError.PHONE_VERIFICATION_NEEDED, ); break; } diff --git a/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts b/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts index e9381ec384..1a3872692d 100644 --- a/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts +++ b/src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity.ts @@ -25,12 +25,13 @@ import { FiatOutput } from 'src/subdomains/supporting/fiat-output/fiat-output.en import { CheckoutTx } from 'src/subdomains/supporting/fiat-payin/entities/checkout-tx.entity'; import { MailTranslationKey } from 'src/subdomains/supporting/notification/factories/mail.factory'; import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; -import { FeeDto, InternalFeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto'; +import { InternalFeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto'; import { CryptoPaymentMethod, FiatPaymentMethod, PaymentMethod, } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +import { FeeType } from 'src/subdomains/supporting/payment/entities/fee.entity'; import { SpecialExternalAccount } from 'src/subdomains/supporting/payment/entities/special-external-account.entity'; import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; import { Price, PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; @@ -133,6 +134,9 @@ export class BuyCrypto extends IEntity { @Column({ length: 256, nullable: true }) usedRef?: string; + @Column({ length: 256, nullable: true }) + usedPartnerRef?: string; + @Column({ type: 'float', nullable: true }) refProvision?: number; @@ -162,6 +166,15 @@ export class BuyCrypto extends IEntity { @Column({ type: 'float', nullable: true }) bankFeeAmount?: number; //inputReferenceAsset + @Column({ type: 'float', nullable: true }) + bankFixedFeeAmount?: number; //inputReferenceAsset + + @Column({ type: 'float', nullable: true }) + bankPercentFeeAmount?: number; //inputReferenceAsset + + @Column({ type: 'float', nullable: true }) + partnerFeeAmount?: number; //inputReferenceAsset + @Column({ type: 'float', nullable: true }) percentFeeAmount?: number; //inputReferenceAsset @@ -570,11 +583,11 @@ export class BuyCrypto extends IEntity { } setFeeAndFiatReference( - fee: InternalFeeDto & FeeDto, + fee: InternalFeeDto, minFeeAmountFiat: number, totalFeeAmountChf: number, ): UpdateResult { - const { usedRef, refProvision } = this.user.specifiedRef; + const partnerFee = fee.partner ? fee.fees.find((f) => f.type === FeeType.PARTNER) : undefined; const inputReferenceAmountMinusFee = this.inputReferenceAmount - fee.total; const update: Partial = @@ -590,10 +603,14 @@ export class BuyCrypto extends IEntity { totalFeeAmountChf, blockchainFee: fee.network, bankFeeAmount: fee.bank, + bankFixedFeeAmount: fee.bankFixed, + bankPercentFeeAmount: fee.bankPercent, + partnerFeeAmount: fee.partner, + usedPartnerRef: fee.partner ? partnerFee.wallet.owner.ref : undefined, inputReferenceAmountMinusFee, - usedRef, - refProvision, - refFactor: !fee.payoutRefBonus || usedRef === Config.defaultRef ? 0 : 1, + usedRef: this.user.usedRef, + refProvision: this.user.refFeePercent, + refFactor: !fee.payoutRefBonus || this.user.usedRef === Config.defaultRef ? 0 : 1, usedFees: fee.fees?.map((fee) => fee.id).join(';'), networkStartFeeAmount: fee.networkStart, status: this.status === BuyCryptoStatus.WAITING_FOR_LOWER_FEE ? BuyCryptoStatus.CREATED : undefined, @@ -692,6 +709,8 @@ export class BuyCrypto extends IEntity { chargebackAllowedBy: null, chargebackOutput: null, priceDefinitionAllowedDate: null, + partnerFeeAmount: null, + usedPartnerRef: null, }; Object.assign(this, update); @@ -709,6 +728,8 @@ export class BuyCrypto extends IEntity { totalFeeAmountChf: null, blockchainFee: null, bankFeeAmount: null, + bankFixedFeeAmount: null, + bankPercentFeeAmount: null, inputReferenceAmountMinusFee: null, usedRef: null, refProvision: null, diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts index 5d52682a97..3a6ff2f012 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts @@ -312,7 +312,7 @@ export class BuyCryptoPreparationService { if (isFirstRun) { await this.buyCryptoService.updateBuyVolume([entity.buy?.id]); await this.buyCryptoService.updateCryptoRouteVolume([entity.cryptoRoute?.id]); - await this.buyCryptoService.updateRefVolume([entity.usedRef]); + await this.buyCryptoService.updateRefVolume([entity.usedRef, entity.usedPartnerRef]); } } catch (e) { this.logger.error(`Error during buy-crypto ${entity.id} fee and fiat reference refresh:`, e); diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index f7267d8a6a..8669da9605 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -938,13 +938,18 @@ export class BuyCryptoService { for (const ref of refs) { const { volume: buyCryptoVolume, credit: buyCryptoCredit } = await this.getRefVolume(ref); + const { volume: buyCryptoPartnerVolume, credit: buyCryptoPartnerCredit } = await this.getPartnerFeeRefVolume(ref); const { volume: buyFiatVolume, credit: buyFiatCredit } = await this.buyFiatService.getRefVolume(ref); + const { volume: buyFiatPartnerVolume, credit: buyFiatPartnerCredit } = + await this.buyFiatService.getPartnerFeeRefVolume(ref); const { volume: manualVolume, credit: manualCredit } = await this.transactionService.getManualRefVolume(ref); await this.userService.updateRefVolume( ref, buyCryptoVolume + buyFiatVolume + manualVolume, buyCryptoCredit + buyFiatCredit + manualCredit, + buyCryptoPartnerVolume + buyFiatPartnerVolume, + buyCryptoPartnerCredit + buyFiatPartnerCredit, ); } } @@ -961,6 +966,18 @@ export class BuyCryptoService { return { volume: volume ?? 0, credit: credit ?? 0 }; } + async getPartnerFeeRefVolume(ref: string): Promise<{ volume: number; credit: number }> { + const { volume, credit } = await this.buyCryptoRepo + .createQueryBuilder('buyCrypto') + .select('SUM(amountInEur)', 'volume') + .addSelect('SUM(partnerFeeAmount * (amountInEur/inputReferenceAmount ))', 'credit') + .where('usedPartnerRef = :ref', { ref }) + .andWhere('amlCheck = :check', { check: CheckStatus.PASS }) + .getRawOne<{ volume: number; credit: number }>(); + + return { volume: volume ?? 0, credit: credit ?? 0 }; + } + // Admin Support Tool methods async getAllRefTransactions(refCodes: string[]): Promise { diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts index e8d9e5a164..e5b415cdb4 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.controller.ts @@ -300,7 +300,7 @@ export class BuyController { annualVolume: buy.annualVolume, bankUsage: buy.active ? buy.bankUsage : undefined, asset: AssetDtoMapper.toDto(buy.asset), - fee: Util.round(fee.rate * 100, Config.defaultPercentageDecimal), + fee: Util.round(fee.dfx.rate * 100, Config.defaultPercentageDecimal), minDeposits: [minDeposit], minFee: { amount: fee.network, asset: 'CHF' }, }; diff --git a/src/subdomains/core/buy-crypto/routes/swap/swap.controller.ts b/src/subdomains/core/buy-crypto/routes/swap/swap.controller.ts index c8eaf10bb1..dd546cea18 100644 --- a/src/subdomains/core/buy-crypto/routes/swap/swap.controller.ts +++ b/src/subdomains/core/buy-crypto/routes/swap/swap.controller.ts @@ -252,7 +252,7 @@ export class SwapController { deposit: swap.active ? DepositDtoMapper.entityToDto(swap.deposit) : undefined, asset: AssetDtoMapper.toDto(swap.asset), blockchain: swap.deposit.blockchainList[0], - fee: Util.round(fee.rate * 100, Config.defaultPercentageDecimal), + fee: Util.round(fee.dfx.rate * 100, Config.defaultPercentageDecimal), minDeposits: [minDeposit], minFee: { amount: fee.network, asset: 'CHF' }, }; diff --git a/src/subdomains/core/custody/dto/output/custody-order-list-entry.dto.ts b/src/subdomains/core/custody/dto/output/custody-order-list-entry.dto.ts index 37f563538d..f83939db16 100644 --- a/src/subdomains/core/custody/dto/output/custody-order-list-entry.dto.ts +++ b/src/subdomains/core/custody/dto/output/custody-order-list-entry.dto.ts @@ -1,5 +1,5 @@ -import { CustodyOrderStatus, CustodyOrderType } from '../../enums/custody'; import { CustodyOrder } from '../../entities/custody-order.entity'; +import { CustodyOrderStatus, CustodyOrderType } from '../../enums/custody'; export class CustodyOrderListEntry { id: number; @@ -9,22 +9,24 @@ export class CustodyOrderListEntry { inputAsset?: string; outputAmount?: number; outputAsset?: string; - userId?: number; + userDataId?: number; userName?: string; - created: Date; + updated: Date; static fromEntity(order: CustodyOrder): CustodyOrderListEntry { + const tr = order.transactionRequest; + return { id: order.id, type: order.type, status: order.status, - inputAmount: order.inputAmount, + inputAmount: order.inputAmount ?? tr?.estimatedAmount, inputAsset: order.inputAsset?.name, - outputAmount: order.outputAmount, + outputAmount: order.outputAmount ?? tr?.amount, outputAsset: order.outputAsset?.name, - userId: order.user?.userData?.id, + userDataId: order.user?.userData?.id, userName: order.user?.userData?.verifiedName, - created: order.created, + updated: order.updated, }; } } diff --git a/src/subdomains/core/custody/services/custody-order.service.ts b/src/subdomains/core/custody/services/custody-order.service.ts index 3978be4d6d..2bd534ccbf 100644 --- a/src/subdomains/core/custody/services/custody-order.service.ts +++ b/src/subdomains/core/custody/services/custody-order.service.ts @@ -123,6 +123,7 @@ export class CustodyOrderService { ); orderDto.swap = await this.swapService.getById(swapPaymentInfo.routeId); + orderDto.inputAsset = targetAsset; orderDto.outputAsset = sourceAsset; orderDto.outputAmount = swapPaymentInfo.amount; paymentInfo = CustodyOrderResponseDtoMapper.mapSwapPaymentInfo(swapPaymentInfo); diff --git a/src/subdomains/core/history/__tests__/transaction-helper.spec.ts b/src/subdomains/core/history/__tests__/transaction-helper.spec.ts index bc107910c9..c614eb0ac0 100644 --- a/src/subdomains/core/history/__tests__/transaction-helper.spec.ts +++ b/src/subdomains/core/history/__tests__/transaction-helper.spec.ts @@ -17,9 +17,9 @@ import { CardBankName, IbanBankName } from 'src/subdomains/supporting/bank/bank/ import { createDefaultCheckoutTx } from 'src/subdomains/supporting/fiat-payin/__mocks__/checkout-tx.entity.mock'; import { createDefaultCryptoInput } from 'src/subdomains/supporting/payin/entities/__mocks__/crypto-input.entity.mock'; import { - createCustomInternalChargebackFeeDto, - createInternalChargebackFeeDto, -} from 'src/subdomains/supporting/payment/__mocks__/internal-fee.dto.mock'; + createChargebackFeeInfo, + createCustomChargebackFeeInfo, +} from 'src/subdomains/supporting/payment/__mocks__/fee.dto.mock'; import { createCustomTransaction } from 'src/subdomains/supporting/payment/__mocks__/transaction.entity.mock'; import { TransactionSpecificationRepository } from 'src/subdomains/supporting/payment/repositories/transaction-specification.repository'; import { FeeService } from 'src/subdomains/supporting/payment/services/fee.service'; @@ -101,7 +101,7 @@ describe('TransactionHelper', () => { }); jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(createCustomFiat({ name: 'CHF' })); - jest.spyOn(feeService, 'getChargebackFee').mockResolvedValue(createInternalChargebackFeeDto()); + jest.spyOn(feeService, 'getChargebackFee').mockResolvedValue(createChargebackFeeInfo()); jest .spyOn(pricingService, 'getPrice') .mockResolvedValue(createCustomPrice({ source: 'CHF', target: 'CHF', price: 1 })); @@ -133,7 +133,7 @@ describe('TransactionHelper', () => { }); jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(createCustomFiat({ name: 'CHF' })); - jest.spyOn(feeService, 'getChargebackFee').mockResolvedValue(createInternalChargebackFeeDto()); + jest.spyOn(feeService, 'getChargebackFee').mockResolvedValue(createChargebackFeeInfo()); jest .spyOn(pricingService, 'getPrice') .mockResolvedValue(createCustomPrice({ source: 'CHF', target: 'CHF', price: 1 })); @@ -163,7 +163,7 @@ describe('TransactionHelper', () => { jest.spyOn(feeService, 'getBlockchainFee').mockResolvedValue(0.01); jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(createDefaultFiat()); - jest.spyOn(feeService, 'getChargebackFee').mockResolvedValue(createInternalChargebackFeeDto()); + jest.spyOn(feeService, 'getChargebackFee').mockResolvedValue(createChargebackFeeInfo()); jest.spyOn(pricingService, 'getPrice').mockResolvedValue(createCustomPrice({ price: 1 })); await expect( @@ -191,9 +191,7 @@ describe('TransactionHelper', () => { jest.spyOn(feeService, 'getBlockchainFee').mockResolvedValue(0.01); jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(createDefaultFiat()); - jest - .spyOn(feeService, 'getChargebackFee') - .mockResolvedValue(createCustomInternalChargebackFeeDto({ network: 0.01 })); + jest.spyOn(feeService, 'getChargebackFee').mockResolvedValue(createCustomChargebackFeeInfo({ network: 0.01 })); jest.spyOn(pricingService, 'getPrice').mockResolvedValue(createCustomPrice({ price: 1 })); await expect( @@ -222,9 +220,7 @@ describe('TransactionHelper', () => { jest.spyOn(feeService, 'getBlockchainFee').mockResolvedValue(0.01); jest.spyOn(fiatService, 'getFiatByName').mockResolvedValue(createDefaultFiat()); - jest - .spyOn(feeService, 'getChargebackFee') - .mockResolvedValue(createCustomInternalChargebackFeeDto({ network: 0.01 })); + jest.spyOn(feeService, 'getChargebackFee').mockResolvedValue(createCustomChargebackFeeInfo({ network: 0.01 })); jest.spyOn(pricingService, 'getPrice').mockResolvedValue(createCustomPrice({ price: 1 })); await expect( diff --git a/src/subdomains/core/history/mappers/transaction-dto.mapper.ts b/src/subdomains/core/history/mappers/transaction-dto.mapper.ts index b7d154eae3..205d7ad292 100644 --- a/src/subdomains/core/history/mappers/transaction-dto.mapper.ts +++ b/src/subdomains/core/history/mappers/transaction-dto.mapper.ts @@ -370,8 +370,14 @@ export class TransactionDtoMapper { entity.bankFeeAmount != null ? Util.roundReadable(entity.bankFeeAmount * referencePrice, feeAmountType(entity.inputAssetEntity)) : null, - bankFixed: null, - bankPercent: null, + bankFixed: + entity.bankFixedFeeAmount != null + ? Util.roundReadable(entity.bankFixedFeeAmount * referencePrice, feeAmountType(entity.inputAssetEntity)) + : null, + bankVariable: + entity.bankPercentFeeAmount != null + ? Util.roundReadable(entity.bankPercentFeeAmount * referencePrice, feeAmountType(entity.inputAssetEntity)) + : null, fixed: entity.absoluteFeeAmount != null ? Util.roundReadable(entity.absoluteFeeAmount * referencePrice, feeAmountType(entity.inputAssetEntity)) @@ -388,6 +394,10 @@ export class TransactionDtoMapper { feeAmountType(entity.inputAssetEntity), ) : null, + platform: + entity.partnerFeeAmount != null + ? Util.roundReadable(entity.partnerFeeAmount * referencePrice, feeAmountType(entity.inputAssetEntity)) + : null, total: entity.totalFeeAmount != null ? Util.roundReadable(totalFee * referencePrice, feeAmountType(entity.inputAssetEntity)) diff --git a/src/subdomains/core/payment-link/dto/payment-request.mapper.ts b/src/subdomains/core/payment-link/dto/payment-request.mapper.ts index b553d38bb9..5d0ae47222 100644 --- a/src/subdomains/core/payment-link/dto/payment-request.mapper.ts +++ b/src/subdomains/core/payment-link/dto/payment-request.mapper.ts @@ -53,6 +53,8 @@ export class PaymentRequestMapper { hint = `Approve the address from the URI for the required amount plus transfer fee using icrc2_approve. ` + `Then send your Principal ID as the sender parameter via the endpoint ${infoUrl}.`; + } else if (method === Blockchain.FIRO) { + hint = `Use this data to create a transaction and sign it. Either send the signed transaction back as HEX via the endpoint ${infoUrl}, or broadcast the transaction yourself and send the transaction hash (txId) back via the same endpoint.`; } else if (TxIdBlockchains.includes(method)) { hint = `Use this data to create a transaction and sign it. Broadcast the signed transaction to the blockchain and send the transaction hash back via the endpoint ${infoUrl}`; } else { diff --git a/src/subdomains/core/payment-link/enums/index.ts b/src/subdomains/core/payment-link/enums/index.ts index 50e632801b..0888c86a22 100644 --- a/src/subdomains/core/payment-link/enums/index.ts +++ b/src/subdomains/core/payment-link/enums/index.ts @@ -96,11 +96,9 @@ export enum PaymentMerchantStatus { } // Blockchains where user broadcasts tx and sends txId (not signed hex) -export const TxIdBlockchains = [ - Blockchain.MONERO, - Blockchain.ZANO, - Blockchain.SOLANA, - Blockchain.TRON, - Blockchain.CARDANO, - Blockchain.INTERNET_COMPUTER, -]; +export const UnverifiedTxIdBlockchains = [Blockchain.MONERO, Blockchain.ZANO, Blockchain.TRON, Blockchain.CARDANO]; + +// Blockchains where user broadcasts tx and sends txId, API verifies tx confirmation +export const VerifiedTxIdBlockchains = [Blockchain.SOLANA, Blockchain.INTERNET_COMPUTER]; + +export const TxIdBlockchains = [...UnverifiedTxIdBlockchains, ...VerifiedTxIdBlockchains]; diff --git a/src/subdomains/core/payment-link/services/__tests__/payment-quote-firo.spec.ts b/src/subdomains/core/payment-link/services/__tests__/payment-quote-firo.spec.ts new file mode 100644 index 0000000000..23156e87dd --- /dev/null +++ b/src/subdomains/core/payment-link/services/__tests__/payment-quote-firo.spec.ts @@ -0,0 +1,225 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; +import { RawTransaction } from 'src/integration/blockchain/bitcoin/node/rpc/bitcoin-rpc-types'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { TestSharedModule } from 'src/shared/utils/test.shared.module'; +import { Util } from 'src/shared/utils/util'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { PricingService } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { PaymentQuoteRepository } from '../../repositories/payment-quote.repository'; +import { PaymentQuoteService } from '../payment-quote.service'; +import { PaymentLinkFeeService } from '../payment-link-fee.service'; +import { C2BPaymentLinkService } from '../c2b-payment-link.service'; +import { PaymentBalanceService } from '../payment-balance.service'; +import { TxValidationService } from 'src/integration/blockchain/shared/services/tx-validation.service'; +import { PaymentQuote } from '../../entities/payment-quote.entity'; +import { PaymentQuoteStatus } from '../../enums'; +import * as ConfigModule from 'src/config/config'; + +const PAYMENT_ADDRESS = 'aFiroPaymentAddress123'; +const TX_ID = 'abc123def456'; + +function createRawTx(overrides: Partial = {}): RawTransaction { + return { + txid: TX_ID, + hash: TX_ID, + version: 2, + size: 225, + vsize: 225, + weight: 900, + locktime: 0, + vin: [], + vout: [ + { + value: 1.5, + n: 0, + scriptPubKey: { + asm: '', + hex: '', + type: 'pubkeyhash', + addresses: [PAYMENT_ADDRESS], + }, + }, + ], + confirmations: 0, + ...overrides, + }; +} + +function createQuoteMock(activations?: PaymentQuote['activations']): PaymentQuote { + const quote = new PaymentQuote(); + quote.uniqueId = 'test-quote-1'; + quote.status = PaymentQuoteStatus.TX_RECEIVED; + quote.activations = activations ?? null; + return quote; +} + +describe('PaymentQuoteService - doFiroTxIdPayment', () => { + let service: PaymentQuoteService; + let blockchainRegistryService: BlockchainRegistryService; + let mockGetRawTx: jest.Mock; + + beforeAll(() => { + // reduce retry count and eliminate delay for fast tests + (ConfigModule as Record).Config = { + payment: { defaultFiroTxIdPaymentTryCount: 2 }, + }; + jest.spyOn(Util, 'delay').mockResolvedValue(undefined as never); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(async () => { + mockGetRawTx = jest.fn(); + + blockchainRegistryService = createMock(); + jest.spyOn(blockchainRegistryService, 'getClient').mockReturnValue({ + getRawTx: mockGetRawTx, + } as never); + + const paymentBalanceService = createMock(); + jest.spyOn(paymentBalanceService, 'getDepositAddress').mockReturnValue(PAYMENT_ADDRESS); + + const module: TestingModule = await Test.createTestingModule({ + imports: [TestSharedModule], + providers: [ + PaymentQuoteService, + { provide: PaymentQuoteRepository, useValue: createMock() }, + { provide: BlockchainRegistryService, useValue: blockchainRegistryService }, + { provide: AssetService, useValue: createMock() }, + { provide: PricingService, useValue: createMock() }, + { provide: PaymentLinkFeeService, useValue: createMock() }, + { provide: C2BPaymentLinkService, useValue: createMock() }, + { provide: PaymentBalanceService, useValue: paymentBalanceService }, + { provide: TxValidationService, useValue: createMock() }, + { provide: InternetComputerService, useValue: createMock() }, + ], + }).compile(); + + service = module.get(PaymentQuoteService); + }); + + it('should set txInMempool when TX found with 0 confirmations', async () => { + const rawTx = createRawTx({ confirmations: 0 }); + mockGetRawTx.mockResolvedValueOnce(rawTx); + + const quote = createQuoteMock(); + await service['doFiroTxIdPayment'](TX_ID, quote); + + expect(quote.status).toBe(PaymentQuoteStatus.TX_MEMPOOL); + expect(quote.txId).toBe(TX_ID); + }); + + it('should set txInBlockchain when TX found with confirmations > 0', async () => { + const rawTx = createRawTx({ confirmations: 3 }); + mockGetRawTx.mockResolvedValueOnce(rawTx); + + const quote = createQuoteMock(); + await service['doFiroTxIdPayment'](TX_ID, quote); + + expect(quote.status).toBe(PaymentQuoteStatus.TX_BLOCKCHAIN); + expect(quote.txId).toBe(TX_ID); + }); + + it('should fail when TX does not pay to payment address', async () => { + const rawTx = createRawTx(); + rawTx.vout[0].scriptPubKey.addresses = ['someOtherAddress']; + mockGetRawTx.mockResolvedValueOnce(rawTx); + + const quote = createQuoteMock(); + await expect(service['doFiroTxIdPayment'](TX_ID, quote)).rejects.toThrow('does not pay to payment address'); + }); + + it('should fail on amount too small', async () => { + const rawTx = createRawTx(); // vout[0].value = 1.5 + mockGetRawTx.mockResolvedValueOnce(rawTx); + + const activations = [ + { method: Blockchain.FIRO, asset: { type: AssetType.COIN }, amount: 2.0 }, + ] as PaymentQuote['activations']; + + const quote = createQuoteMock(activations); + await expect(service['doFiroTxIdPayment'](TX_ID, quote)).rejects.toThrow('Amount too small'); + }); + + it('should succeed when amount is overpaid', async () => { + const rawTx = createRawTx(); + rawTx.vout[0].value = 2.0; // overpaid (expected 1.5) + mockGetRawTx.mockResolvedValueOnce(rawTx); + + const activations = [ + { method: Blockchain.FIRO, asset: { type: AssetType.COIN }, amount: 1.5 }, + ] as PaymentQuote['activations']; + + const quote = createQuoteMock(activations); + await service['doFiroTxIdPayment'](TX_ID, quote); + + expect(quote.status).toBe(PaymentQuoteStatus.TX_MEMPOOL); + }); + + it('should succeed when amount matches within tolerance', async () => { + const rawTx = createRawTx(); + rawTx.vout[0].value = 1.500000001; // within 0.00000001 tolerance + mockGetRawTx.mockResolvedValueOnce(rawTx); + + const activations = [ + { method: Blockchain.FIRO, asset: { type: AssetType.COIN }, amount: 1.5 }, + ] as PaymentQuote['activations']; + + const quote = createQuoteMock(activations); + await service['doFiroTxIdPayment'](TX_ID, quote); + + expect(quote.status).toBe(PaymentQuoteStatus.TX_MEMPOOL); + }); + + it('should succeed without amount check when no activation exists', async () => { + const rawTx = createRawTx({ confirmations: 1 }); + mockGetRawTx.mockResolvedValueOnce(rawTx); + + const quote = createQuoteMock(); // no activations + await service['doFiroTxIdPayment'](TX_ID, quote); + + expect(quote.status).toBe(PaymentQuoteStatus.TX_BLOCKCHAIN); + }); + + it('should throw when TX not found after all retries', async () => { + mockGetRawTx.mockResolvedValue(undefined); + + const quote = createQuoteMock(); + await expect(service['doFiroTxIdPayment'](TX_ID, quote)).rejects.toThrow('not found on Firo node'); + expect(mockGetRawTx).toHaveBeenCalledTimes(2); // tryCount = 2 + }); + + it('should retry and succeed on second attempt', async () => { + const rawTx = createRawTx({ confirmations: 0 }); + mockGetRawTx.mockResolvedValueOnce(undefined); + mockGetRawTx.mockResolvedValueOnce(rawTx); + + const quote = createQuoteMock(); + await service['doFiroTxIdPayment'](TX_ID, quote); + + expect(mockGetRawTx).toHaveBeenCalledTimes(2); + expect(quote.status).toBe(PaymentQuoteStatus.TX_MEMPOOL); + }); + + it('should ignore activations for non-FIRO or non-COIN types', async () => { + const rawTx = createRawTx(); // value = 1.5 + mockGetRawTx.mockResolvedValueOnce(rawTx); + + const activations = [ + { method: Blockchain.BITCOIN, asset: { type: AssetType.COIN }, amount: 99 }, + { method: Blockchain.FIRO, asset: { type: AssetType.TOKEN }, amount: 99 }, + ] as PaymentQuote['activations']; + + const quote = createQuoteMock(activations); + await service['doFiroTxIdPayment'](TX_ID, quote); + + // no amount mismatch because no matching activation + expect(quote.status).toBe(PaymentQuoteStatus.TX_MEMPOOL); + }); +}); diff --git a/src/subdomains/core/payment-link/services/payment-quote.service.ts b/src/subdomains/core/payment-link/services/payment-quote.service.ts index 13f51134fa..63f3440708 100644 --- a/src/subdomains/core/payment-link/services/payment-quote.service.ts +++ b/src/subdomains/core/payment-link/services/payment-quote.service.ts @@ -29,7 +29,7 @@ import { PaymentQuoteStatus, PaymentQuoteTxStates, PaymentStandard, - TxIdBlockchains, + UnverifiedTxIdBlockchains, } from '../enums'; import { PaymentQuoteRepository } from '../repositories/payment-quote.repository'; @@ -392,16 +392,23 @@ export class PaymentQuoteService { break; case Blockchain.BITCOIN: - case Blockchain.FIRO: await this.doBitcoinBasedHexPayment(transferInfo.method, transferInfo, quote); break; + case Blockchain.FIRO: + await this.doFiroPayment(transferInfo, quote); + break; + + case Blockchain.SOLANA: + await this.doVerifiedTxIdPayment(Blockchain.SOLANA, transferInfo, quote); + break; + case Blockchain.INTERNET_COMPUTER: await this.doIcpPayment(transferInfo, quote); break; default: - if (TxIdBlockchains.includes(transferInfo.method as Blockchain)) { + if (UnverifiedTxIdBlockchains.includes(transferInfo.method as Blockchain)) { await this.doTxIdPayment(transferInfo, quote); } else { throw new BadRequestException(`Invalid method ${transferInfo.method} for hex payment`); @@ -507,6 +514,61 @@ export class PaymentQuoteService { return result.isValid ? undefined : result.error; } + private async doFiroPayment(transferInfo: TransferInfo, quote: PaymentQuote): Promise { + // handle HEX (transparent wallets like Electrum) + if (transferInfo.hex) { + return this.doBitcoinBasedHexPayment(Blockchain.FIRO, transferInfo, quote); + } + + try { + // handle TX ID (Firo Spark wallets can't export signed hex) + if (transferInfo.tx) { + return await this.doFiroTxIdPayment(transferInfo.tx, quote); + } + + throw new BadRequestException('Firo payment requires either hex or tx'); + } catch (e) { + quote.txFailed(e.message); + } + } + + private async doFiroTxIdPayment(txId: string, quote: PaymentQuote): Promise { + const client = this.blockchainRegistryService.getClient(Blockchain.FIRO) as BitcoinBasedClient; + const paymentAddress = this.paymentBalanceService.getDepositAddress(Blockchain.FIRO); + const tryCount = Config.payment.defaultFiroTxIdPaymentTryCount; + + const rawTx = await Util.retry( + async () => { + const tx = await client.getRawTx(txId); + if (!tx) throw new NotFoundException(`Transaction ${txId} not found on Firo node`); + return tx; + }, + tryCount, + 1000, + ); + + // Firo uses `addresses` (array), not `address` (string) + const outputToPayment = rawTx.vout.find((o) => o.scriptPubKey?.addresses?.includes(paymentAddress)); + + if (!outputToPayment) { + throw new BadRequestException(`Transaction does not pay to payment address ${paymentAddress}`); + } + + const activation = (quote.activations ?? []) + .filter((a) => a.method === Blockchain.FIRO) + .find((a) => a.asset.type === AssetType.COIN); + + if (activation && outputToPayment.value < activation.amount - 0.00000001) { + throw new BadRequestException(`Amount too small: got ${outputToPayment.value}, expected ${activation.amount}`); + } + + if ((rawTx.confirmations ?? 0) > 0) { + quote.txInBlockchain(txId); + } else { + quote.txInMempool(txId); + } + } + private async doBitcoinBasedHexPayment( method: Blockchain, transferInfo: TransferInfo, @@ -570,9 +632,45 @@ export class PaymentQuoteService { } } + private async waitForTxConfirmation(method: Blockchain, txId: string): Promise { + const client = this.blockchainRegistryService.getClient(method); + + await Util.retry( + async () => { + const isComplete = await client.isTxComplete(txId); + if (!isComplete) throw new Error('not confirmed'); + }, + Config.payment.defaultTxConfirmationTryCount, + 1000, + ); + } + + private async doVerifiedTxIdPayment( + method: Blockchain, + transferInfo: TransferInfo, + quote: PaymentQuote, + ): Promise { + try { + if (!transferInfo.tx) { + quote.txFailed('Transaction Id not found'); + return; + } + + await this.waitForTxConfirmation(method, transferInfo.tx); + + quote.txInBlockchain(transferInfo.tx); + } catch (e) { + quote.txFailed( + e.message === 'not confirmed' + ? `Transaction ${transferInfo.tx} not confirmed in blockchain ${method}` + : e.message, + ); + } + } + private async doIcpPayment(transferInfo: TransferInfo, quote: PaymentQuote): Promise { if (!transferInfo.sender) { - return this.doTxIdPayment(transferInfo, quote); + return this.doVerifiedTxIdPayment(Blockchain.INTERNET_COMPUTER, transferInfo, quote); } try { diff --git a/src/subdomains/core/referral/reward/services/ref-reward.service.ts b/src/subdomains/core/referral/reward/services/ref-reward.service.ts index ab4b88423f..e76e1b406f 100644 --- a/src/subdomains/core/referral/reward/services/ref-reward.service.ts +++ b/src/subdomains/core/referral/reward/services/ref-reward.service.ts @@ -1,8 +1,8 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service'; import { AssetService } from 'src/shared/models/asset/asset.service'; +import { SettingService } from 'src/shared/models/setting/setting.service'; import { Util } from 'src/shared/utils/util'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; @@ -70,6 +70,7 @@ export class RefRewardService { private readonly pricingService: PricingService, private readonly assetService: AssetService, private readonly transactionService: TransactionService, + private readonly settingService: SettingService, ) {} async createManualRefReward(dto: CreateManualRefRewardDto): Promise { @@ -90,12 +91,14 @@ export class RefRewardService { PriceValidity.VALID_ONLY, ); + const refRewardManualCheckLimit = await this.settingService.getObj('refRewardManualCheckLimit', 3000); + const entity = this.rewardRepo.create({ user, targetAddress: user.address, outputAsset: asset, sourceTransaction, - status: dto.amountInEur > Config.refRewardManualCheckLimit ? RewardStatus.MANUAL_CHECK : RewardStatus.PREPARED, + status: dto.amountInEur > refRewardManualCheckLimit ? RewardStatus.MANUAL_CHECK : RewardStatus.PREPARED, targetBlockchain: asset.blockchain, amountInChf: eurChfPrice.convert(dto.amountInEur, 8), amountInEur: dto.amountInEur, @@ -146,15 +149,17 @@ export class RefRewardService { const payoutAsset = user.refAsset ?? defaultAsset; if (payoutAsset.blockchain !== blockchain) throw new Error('User ref asset blockchain mismatch'); - const refCreditEur = user.refCredit - user.paidRefCredit; + const refCreditEur = user.totalRefCredit - user.paidRefCredit; const minCredit = PayoutLimits[blockchain]; if (!(refCreditEur >= minCredit)) continue; + const refRewardManualCheckLimit = await this.settingService.getObj('refRewardManualCheckLimit', 3000); + const entity = this.rewardRepo.create({ outputAsset: payoutAsset, user, - status: refCreditEur > Config.refRewardManualCheckLimit ? RewardStatus.MANUAL_CHECK : RewardStatus.PREPARED, + status: refCreditEur > refRewardManualCheckLimit ? RewardStatus.MANUAL_CHECK : RewardStatus.PREPARED, targetAddress: user.address, targetBlockchain: blockchain, amountInChf: eurChfPrice.convert(refCreditEur, 8), diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts index e9d9fe3088..fca07dca00 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.entity.ts @@ -14,12 +14,13 @@ import { BankTx } from 'src/subdomains/supporting/bank-tx/bank-tx/entities/bank- import { IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; import { MailTranslationKey } from 'src/subdomains/supporting/notification/factories/mail.factory'; import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; -import { FeeDto, InternalFeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto'; +import { InternalFeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto'; import { CryptoPaymentMethod, FiatPaymentMethod, PaymentMethod, } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +import { FeeType } from 'src/subdomains/supporting/payment/entities/fee.entity'; import { SpecialExternalAccount } from 'src/subdomains/supporting/payment/entities/special-external-account.entity'; import { Price, PriceStep } from 'src/subdomains/supporting/pricing/domain/entities/price'; import { PriceCurrency } from 'src/subdomains/supporting/pricing/services/pricing.service'; @@ -87,6 +88,9 @@ export class BuyFiat extends IEntity { @Column({ length: 256, nullable: true }) usedRef?: string; + @Column({ length: 256, nullable: true }) + usedPartnerRef?: string; + @Column({ type: 'float', nullable: true }) refProvision?: number; @@ -116,6 +120,15 @@ export class BuyFiat extends IEntity { @Column({ type: 'float', nullable: true }) bankFeeAmount?: number; //inputAsset + @Column({ type: 'float', nullable: true }) + bankFixedFeeAmount?: number; //inputAsset + + @Column({ type: 'float', nullable: true }) + bankPercentFeeAmount?: number; //inputAsset + + @Column({ type: 'float', nullable: true }) + partnerFeeAmount?: number; //inputAsset + @Column({ type: 'float', nullable: true }) percentFeeAmount?: number; //inputAsset @@ -293,11 +306,11 @@ export class BuyFiat extends IEntity { } setFeeAndFiatReference( - fee: InternalFeeDto & FeeDto, + fee: InternalFeeDto, minFeeAmountFiat: number, totalFeeAmountChf: number, ): UpdateResult { - const { usedRef, refProvision } = this.user.specifiedRef; + const partnerFee = fee.partner ? fee.fees.find((f) => f.type === FeeType.PARTNER) : undefined; const inputReferenceAmountMinusFee = this.inputReferenceAmount - fee.total; const update: Partial = @@ -313,10 +326,14 @@ export class BuyFiat extends IEntity { totalFeeAmountChf, blockchainFee: fee.network, bankFeeAmount: fee.bank, + bankFixedFeeAmount: fee.bankFixed, + bankPercentFeeAmount: fee.bankPercent, + partnerFeeAmount: fee.partner, + usedPartnerRef: fee.partner ? partnerFee.wallet.owner.ref : undefined, inputReferenceAmountMinusFee, - usedRef, - refProvision, - refFactor: !fee.payoutRefBonus || usedRef === Config.defaultRef ? 0 : 1, + usedRef: this.user.usedRef, + refProvision: this.user.refFeePercent, + refFactor: !fee.payoutRefBonus || this.user.usedRef === Config.defaultRef ? 0 : 1, usedFees: fee.fees?.map((fee) => fee.id).join(';'), }; @@ -481,10 +498,14 @@ export class BuyFiat extends IEntity { chargebackAllowedDateUser: null, chargebackAmount: null, chargebackAllowedBy: null, + partnerFeeAmount: null, + usedPartnerRef: null, priceSteps: null, priceDefinitionAllowedDate: null, usedFees: null, bankFeeAmount: null, + bankFixedFeeAmount: null, + bankPercentFeeAmount: null, }; Object.assign(this, update); diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts index 3ce023ffff..f6947d1bf5 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts @@ -223,7 +223,7 @@ export class BuyFiatPreparationService { if (isFirstRun) { await this.buyFiatService.updateSellVolume([entity.sell?.id]); - await this.buyFiatService.updateRefVolume([entity.usedRef]); + await this.buyFiatService.updateRefVolume([entity.usedRef, entity.usedPartnerRef]); } } catch (e) { this.logger.error(`Error during buy-fiat ${entity.id} fee and fiat reference refresh:`, e); diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts index 327deb3177..87b7db4256 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts @@ -556,13 +556,18 @@ export class BuyFiatService { for (const ref of refs) { const { volume: buyFiatVolume, credit: buyFiatCredit } = await this.getRefVolume(ref); + const { volume: buyFiatPartnerVolume, credit: buyFiatPartnerCredit } = await this.getPartnerFeeRefVolume(ref); const { volume: buyCryptoVolume, credit: buyCryptoCredit } = await this.buyCryptoService.getRefVolume(ref); + const { volume: buyCryptoPartnerVolume, credit: buyCryptoPartnerCredit } = + await this.buyCryptoService.getPartnerFeeRefVolume(ref); const { volume: manualVolume, credit: manualCredit } = await this.transactionService.getManualRefVolume(ref); await this.userService.updateRefVolume( ref, buyFiatVolume + buyCryptoVolume + manualVolume, buyFiatCredit + buyCryptoCredit + manualCredit, + buyFiatPartnerVolume + buyCryptoPartnerVolume, + buyFiatPartnerCredit + buyCryptoPartnerCredit, ); } } @@ -579,6 +584,18 @@ export class BuyFiatService { return { volume: volume ?? 0, credit: credit ?? 0 }; } + async getPartnerFeeRefVolume(ref: string): Promise<{ volume: number; credit: number }> { + const { volume, credit } = await this.buyFiatRepo + .createQueryBuilder('buyFiat') + .select('SUM(amountInEur)', 'volume') + .addSelect('SUM(partnerFeeAmount * (amountInEur/inputAmount ))', 'credit') + .where('usedPartnerRef = :ref', { ref }) + .andWhere('amlCheck = :check', { check: CheckStatus.PASS }) + .getRawOne<{ volume: number; credit: number }>(); + + return { volume: volume ?? 0, credit: credit ?? 0 }; + } + // Statistics async getTransactions(dateFrom: Date = new Date(0), dateTo: Date = new Date()): Promise { diff --git a/src/subdomains/core/sell-crypto/route/sell.controller.ts b/src/subdomains/core/sell-crypto/route/sell.controller.ts index ecaa9693dc..750890c2cd 100644 --- a/src/subdomains/core/sell-crypto/route/sell.controller.ts +++ b/src/subdomains/core/sell-crypto/route/sell.controller.ts @@ -260,7 +260,7 @@ export class SellController { fiat: FiatDtoMapper.toDto(sell.fiat), currency: FiatDtoMapper.toDto(sell.fiat), deposit: sell.active ? DepositDtoMapper.entityToDto(sell.deposit) : undefined, - fee: Util.round(fee.rate * 100, Config.defaultPercentageDecimal), + fee: Util.round(fee.dfx.rate * 100, Config.defaultPercentageDecimal), blockchain: sell.deposit.blockchainList[0], minFee: { amount: fee.network, asset: 'CHF' }, minDeposits: [minDeposit], diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index e0f9abbf06..7c1a3c67ba 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -1446,6 +1446,14 @@ export class KycService { } else if (nationality) { const hasOpenSanctions = await this.nameCheckService.hasOpenNameChecks(userData); + if (hasOpenSanctions) { + this.logger.warn( + `Sanctions guard: blocked KYC Level 30 for userData ${userData.id} due to open sanctioned name checks`, + ); + } else if (userData.kycLevel <= KycLevel.LEVEL_30) { + await this.createKycLevelLog(userData, KycLevel.LEVEL_30); + } + await this.userDataService.updateUserDataInternal(userData, { ...(hasOpenSanctions ? {} : { kycLevel: KycLevel.LEVEL_30 }), birthday: data.birthday, @@ -1461,14 +1469,6 @@ export class KycService { nationality, }); - if (hasOpenSanctions) { - this.logger.warn( - `Sanctions guard: blocked KYC Level 30 for userData ${userData.id} due to open sanctioned name checks`, - ); - } else { - await this.createKycLevelLog(userData, KycLevel.LEVEL_30); - } - if (kycStep.isValidCreatingBankData && !DisabledProcess(Process.AUTO_CREATE_BANK_DATA)) await this.bankDataService.createBankDataInternal(kycStep.userData, { name: kycStep.userName, diff --git a/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts b/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts index f8a808a336..0d8f76a0ca 100644 --- a/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts +++ b/src/subdomains/generic/user/models/user/dto/user-dto.mapper.ts @@ -79,8 +79,8 @@ export class UserDtoMapper { const dto: ReferralDto = { code: user.ref, commission: Util.round(user.refFeePercent / 100, 4), - volume: user.refVolume, - credit: user.refCredit, + volume: user.totalRefVolume, + credit: user.totalRefCredit, paidCredit: user.paidRefCredit, userCount: userCount, activeUserCount: activeUserCount, diff --git a/src/subdomains/generic/user/models/user/user.entity.ts b/src/subdomains/generic/user/models/user/user.entity.ts index d3e9af129c..59f0f00c32 100644 --- a/src/subdomains/generic/user/models/user/user.entity.ts +++ b/src/subdomains/generic/user/models/user/user.entity.ts @@ -1,4 +1,3 @@ -import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service'; import { UserRole } from 'src/shared/auth/user-role.enum'; @@ -6,9 +5,9 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { IEntity, UpdateResult } from 'src/shared/models/entity'; import { Buy } from 'src/subdomains/core/buy-crypto/routes/buy/buy.entity'; import { Swap } from 'src/subdomains/core/buy-crypto/routes/swap/swap.entity'; +import { CustodyAccount } from 'src/subdomains/core/custody/entities/custody-account.entity'; import { CustodyBalance } from 'src/subdomains/core/custody/entities/custody-balance.entity'; import { CustodyOrder } from 'src/subdomains/core/custody/entities/custody-order.entity'; -import { CustodyAccount } from 'src/subdomains/core/custody/entities/custody-account.entity'; import { CustodyAddressType } from 'src/subdomains/core/custody/enums/custody'; import { RefReward } from 'src/subdomains/core/referral/reward/ref-reward.entity'; import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; @@ -137,6 +136,12 @@ export class User extends IEntity { @Column({ type: 'float', default: 0 }) refCredit: number; // EUR + @Column({ type: 'float', default: 0 }) + partnerRefVolume: number; // EUR + + @Column({ type: 'float', default: 0 }) + partnerRefCredit: number; // EUR + @Column({ type: 'float', default: 0 }) paidRefCredit: number; // EUR @@ -211,12 +216,6 @@ export class User extends IEntity { return [this.id, update]; } - get specifiedRef(): { usedRef: string; refProvision: number } { - return this.wallet?.name === 'CakeWallet' - ? { usedRef: '160-195', refProvision: 2 } - : { usedRef: this.usedRef, refProvision: this.usedRef === Config.defaultRef ? 0 : this.refFeePercent }; - } - get blockchains(): Blockchain[] { // wallet name / blockchain map const customChains = { @@ -237,6 +236,14 @@ export class User extends IEntity { get isDeleted(): boolean { return this.status === UserStatus.DELETED; } + + get totalRefVolume(): number { + return this.refVolume + this.partnerRefVolume; + } + + get totalRefCredit(): number { + return this.refCredit + this.partnerRefCredit; + } } export const UserSupportUpdateCols = ['status', 'setRef']; diff --git a/src/subdomains/generic/user/models/user/user.enum.ts b/src/subdomains/generic/user/models/user/user.enum.ts index 6dd37bff27..8228b916a6 100644 --- a/src/subdomains/generic/user/models/user/user.enum.ts +++ b/src/subdomains/generic/user/models/user/user.enum.ts @@ -17,6 +17,7 @@ export enum UserAddressType { SPARK = 'Spark', ARK = 'Ark', FIRO = 'Firo', + FIRO_SPARK = 'FiroSpark', MONERO = 'Monero', LIQUID = 'Liquid', ARWEAVE = 'Arweave', diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index 29098ce6e2..19abdc44e8 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -27,7 +27,7 @@ import { HistoryFilter, HistoryFilterKey } from 'src/subdomains/core/history/dto import { KycInputDataDto } from 'src/subdomains/generic/kyc/dto/input/kyc-data.dto'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { CardBankName, IbanBankName } from 'src/subdomains/supporting/bank/bank/dto/bank.dto'; -import { InternalFeeDto } from 'src/subdomains/supporting/payment/dto/fee.dto'; +import { FeeInfo } from 'src/subdomains/supporting/payment/dto/fee.dto'; import { PaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; import { FeeService } from 'src/subdomains/supporting/payment/services/fee.service'; import { Between, FindOptionsRelations, Not } from 'typeorm'; @@ -158,7 +158,7 @@ export class UserService { .createQueryBuilder('user') .leftJoinAndSelect('user.userData', 'userData') .leftJoinAndSelect('user.refAsset', 'refAsset') - .where('user.refCredit - user.paidRefCredit > 0') + .where('user.partnerRefCredit + user.refCredit - user.paidRefCredit > 0') .andWhere('user.status NOT IN (:...userStatus)', { userStatus: [UserStatus.BLOCKED, UserStatus.DELETED] }) .andWhere('userData.status NOT IN (:...userDataStatus)', { userDataStatus: [UserDataStatus.BLOCKED, UserDataStatus.DEACTIVATED], @@ -521,7 +521,7 @@ export class UserService { bankOut: CardBankName | IbanBankName, from: Active, to: Active, - ): Promise { + ): Promise { const user = await this.getUser(userId, { userData: true }); if (!user) throw new NotFoundException('User not found'); @@ -595,12 +595,20 @@ export class UserService { }; } - async updateRefVolume(ref: string, volume: number, credit: number): Promise { + async updateRefVolume( + ref: string, + volume: number, + credit: number, + partnerVolume?: number, + partnerCredit?: number, + ): Promise { await this.userRepo.update( { ref }, { refVolume: Util.round(volume, Config.defaultVolumeDecimal), refCredit: Util.round(credit, Config.defaultVolumeDecimal), + partnerRefVolume: Util.round(partnerVolume, Config.defaultVolumeDecimal), + partnerRefCredit: Util.round(partnerCredit, Config.defaultVolumeDecimal), }, ); } @@ -699,8 +707,8 @@ export class UserService { return { ref: user.ref, refFeePercent: user.refFeePercent, - refVolume: user.refVolume, - refCredit: user.refCredit, + refVolume: user.totalRefVolume, + refCredit: user.totalRefCredit, paidRefCredit: user.paidRefCredit, ...(await this.getRefUserCounts(user)), }; diff --git a/src/subdomains/generic/user/models/wallet/wallet.entity.ts b/src/subdomains/generic/user/models/wallet/wallet.entity.ts index 1c67e84331..6c481b17a4 100644 --- a/src/subdomains/generic/user/models/wallet/wallet.entity.ts +++ b/src/subdomains/generic/user/models/wallet/wallet.entity.ts @@ -3,7 +3,7 @@ import { AmlRule } from 'src/subdomains/core/aml/enums/aml-rule.enum'; import { KycStepType } from 'src/subdomains/generic/kyc/enums/kyc.enum'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { MailContextType } from 'src/subdomains/supporting/notification/enums'; -import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm'; import { WebhookType } from '../../services/webhook/dto/webhook.dto'; import { KycType } from '../user-data/user-data.enum'; @@ -21,6 +21,9 @@ export enum WebhookConfigOption { @Entity() export class Wallet extends IEntity { + @ManyToOne(() => User, { nullable: true }) + owner?: User; + @Column({ length: 256, nullable: true }) @Index({ unique: true, where: 'address IS NOT NULL' }) address?: string; diff --git a/src/subdomains/supporting/address-pool/deposit/deposit.service.ts b/src/subdomains/supporting/address-pool/deposit/deposit.service.ts index 16559cb579..1122444a65 100644 --- a/src/subdomains/supporting/address-pool/deposit/deposit.service.ts +++ b/src/subdomains/supporting/address-pool/deposit/deposit.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { BadRequestException, Injectable, InternalServerErrorException, OnModuleInit } from '@nestjs/common'; import { Config } from 'src/config/config'; import { AlchemyNetworkMapper } from 'src/integration/alchemy/alchemy-network-mapper'; import { AlchemyWebhookService } from 'src/integration/alchemy/services/alchemy-webhook.service'; @@ -29,7 +29,7 @@ import { Deposit } from './deposit.entity'; import { CreateDepositDto } from './dto/create-deposit.dto'; @Injectable() -export class DepositService { +export class DepositService implements OnModuleInit { private readonly bitcoinClient: BitcoinClient; private readonly lightningClient: LightningClient; private readonly firoClient: FiroClient; @@ -50,6 +50,15 @@ export class DepositService { this.moneroClient = moneroService.getDefaultClient(); } + onModuleInit(): void { + if (this.firoClient) { + this.firoClient.setDepositAddressProvider(async () => { + const deposits = await this.getUsedDepositsByBlockchain(Blockchain.FIRO); + return deposits.map((d) => d.address); + }); + } + } + async getDeposit(id: number): Promise { return this.depositRepo.findOneBy({ id }); } diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index 6752f86cbb..149962fce9 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -398,7 +398,7 @@ export class LogJobService { (b) => eurBankIbans.includes(b.accountIban) && b.creditDebitIndicator === BankTxIndicator.DEBIT, ); const eurReceiverScryptExchangeTx = recentScryptExchangeTx.filter( - (k) => k.type === ExchangeTxType.DEPOSIT && k.status === 'ok' && k.currency === 'EUR', + (k) => k.type === ExchangeTxType.DEPOSIT && k.status === 'ok' && k.currency === 'EUR' && k.txId, ); // CHF: Scrypt -> Yapeal diff --git a/src/subdomains/supporting/payment/__mocks__/fee.dto.mock.ts b/src/subdomains/supporting/payment/__mocks__/fee.dto.mock.ts new file mode 100644 index 0000000000..25201d62a8 --- /dev/null +++ b/src/subdomains/supporting/payment/__mocks__/fee.dto.mock.ts @@ -0,0 +1,35 @@ +import { FeeInfo } from '../dto/fee.dto'; +import { createDefaultFee } from './fee.entity.mock'; + +const defaultFeeInfo: FeeInfo = { + fees: [createDefaultFee()], + dfx: { fixed: 0, rate: 0.01 }, + bank: { fixed: 0, rate: 0 }, + partner: { fixed: 0, rate: 0 }, + network: 0, + payoutRefBonus: false, +}; + +const defaultChargebackFeeInfo: FeeInfo = { + fees: [createDefaultFee()], + dfx: { fixed: 0, rate: 0 }, + bank: { fixed: 0, rate: 0 }, + partner: { fixed: 0, rate: 0 }, + network: 0, + payoutRefBonus: false, +}; + +export function createFeeInfo(): FeeInfo { + return createCustomFeeInfo({}); +} + +export function createCustomFeeInfo(customValues: Partial): FeeInfo { + return { ...defaultFeeInfo, ...customValues }; +} +export function createChargebackFeeInfo(): FeeInfo { + return createCustomChargebackFeeInfo({}); +} + +export function createCustomChargebackFeeInfo(customValues: Partial): FeeInfo { + return { ...defaultChargebackFeeInfo, ...customValues }; +} diff --git a/src/subdomains/supporting/payment/__mocks__/internal-fee.dto.mock.ts b/src/subdomains/supporting/payment/__mocks__/internal-fee.dto.mock.ts deleted file mode 100644 index d8c1612b2b..0000000000 --- a/src/subdomains/supporting/payment/__mocks__/internal-fee.dto.mock.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { InternalChargebackFeeDto, InternalFeeDto } from '../dto/fee.dto'; -import { createDefaultFee } from './fee.entity.mock'; - -const defaultInternalFeeDto: Partial = { - fees: [createDefaultFee()], - bankFixed: 0, - bankRate: 0, - fixed: 0, - network: 0, - payoutRefBonus: false, - rate: 0.01, -}; - -const defaultInternalChargebackFeeDto: Partial = { - fees: [createDefaultFee()], - fixed: 0, - network: 0, - rate: 0, - bankFixed: 0, - bankRate: 0, -}; - -export function createInternalFeeDto(): InternalFeeDto { - return createCustomInternalFeeDto({}); -} - -export function createCustomInternalFeeDto(customValues: Partial): InternalFeeDto { - return Object.assign(new InternalFeeDto(), { ...defaultInternalFeeDto, ...customValues }); -} - -export function createInternalChargebackFeeDto(): InternalChargebackFeeDto { - return createCustomInternalChargebackFeeDto({}); -} - -export function createCustomInternalChargebackFeeDto(customValues: Partial): InternalChargebackFeeDto { - return Object.assign(new InternalChargebackFeeDto(), { ...defaultInternalChargebackFeeDto, ...customValues }); -} diff --git a/src/subdomains/supporting/payment/dto/fee.dto.ts b/src/subdomains/supporting/payment/dto/fee.dto.ts index 3fbc34bd21..82ca1276a8 100644 --- a/src/subdomains/supporting/payment/dto/fee.dto.ts +++ b/src/subdomains/supporting/payment/dto/fee.dto.ts @@ -1,23 +1,28 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Fee } from '../entities/fee.entity'; +import { TxSpec } from './transaction-helper/tx-spec.dto'; + +export class FeeDto { + @ApiProperty({ description: 'Minimum fee amount' }) + min: number; -export class BaseFeeDto { @ApiProperty({ description: 'Fee rate' }) rate: number; // final fee rate @ApiProperty({ description: 'Fixed fee amount' }) fixed: number; // final fixed fee + @ApiProperty({ description: 'DFX fee amount' }) + dfx: number; + @ApiProperty({ description: 'Network fee amount' }) network: number; // final network fee -} -export class FeeDto extends BaseFeeDto { - @ApiProperty({ description: 'Minimum fee amount' }) - min: number; + @ApiPropertyOptional({ description: 'Network start fee' }) + networkStart?: number; - @ApiProperty({ description: 'DFX fee amount' }) - dfx: number; + @ApiProperty({ description: 'Platform fee amount' }) + platform: number; @ApiProperty({ description: 'Bank fee amount' }) bank: number; // final bank fee addition @@ -25,24 +30,63 @@ export class FeeDto extends BaseFeeDto { @ApiPropertyOptional({ description: 'Bank fixed fee amount' }) bankFixed?: number; - @ApiPropertyOptional({ description: 'Bank percent fee amount' }) - bankPercent?: number; + @ApiPropertyOptional({ description: 'Bank variable fee amount' }) + bankVariable?: number; @ApiProperty({ description: 'Total fee amount (DFX + bank + network fee)' }) total: number; +} - @ApiPropertyOptional({ description: 'Network start fee' }) +export interface InternalFeeDto { + fees: Fee[]; + min: number; + rate: number; + fixed: number; + bank: number; + bankFixed: number; + bankPercent: number; + partner: number; + network: number; networkStart?: number; + total: number; + payoutRefBonus: boolean; } -export class InternalBaseFeeDto extends BaseFeeDto { - fees: Fee[]; - bankRate: number; // bank fee rate - bankFixed: number; // bank fixed fee +export interface FeeAmountsDto { + dfx: number; + bank: number; + bankFixed: number; + bankPercent: number; + partner: number; + total: number; } -export class InternalFeeDto extends InternalBaseFeeDto { +export interface FeeInfo { + fees: Fee[]; + dfx: FeeSpec; + bank: FeeSpec; + partner: FeeSpec; + network: number; payoutRefBonus: boolean; } -export class InternalChargebackFeeDto extends InternalBaseFeeDto {} +export interface FeeSpec { + rate: number; + fixed: number; +} + +export function toFeeDto(amounts: FeeAmountsDto, spec: TxSpec): FeeDto { + return Object.assign(new FeeDto(), { + min: spec.fee.min, + rate: spec.fee.dfx.rate, + fixed: spec.fee.dfx.fixed, + network: spec.fee.network, + networkStart: spec.fee.networkStart, + dfx: amounts.dfx, + platform: amounts.partner, + bank: amounts.bank, + bankFixed: amounts.bankFixed, + bankVariable: amounts.bankPercent, + total: amounts.total, + }); +} diff --git a/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util.ts b/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util.ts index 825d6e4c6d..1ef717f6be 100644 --- a/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util.ts +++ b/src/subdomains/supporting/payment/dto/transaction-helper/quote-error.util.ts @@ -1,3 +1,4 @@ +import { FeeDto } from '../fee.dto'; import { QuoteError } from './quote-error.enum'; import { StructuredErrorDto } from './structured-error.dto'; @@ -11,8 +12,8 @@ interface ErrorQuote { maxVolume: number; minVolumeTarget: number; maxVolumeTarget: number; - fees: { rate: number; fixed: number; network: number; min: number; dfx: number; bank: number; total: number }; - feesTarget: { rate: number; fixed: number; network: number; min: number; dfx: number; bank: number; total: number }; + fees: FeeDto; + feesTarget: FeeDto; priceSteps: []; isValid: false; /** @deprecated Use `errors` instead */ @@ -64,7 +65,18 @@ export class QuoteErrorUtil { static createErrorQuote(error: QuoteError | QuoteException): ErrorQuote { const quoteError = error instanceof QuoteException ? error.error : error; - const emptyFee = { rate: 0, fixed: 0, network: 0, min: 0, dfx: 0, bank: 0, total: 0 }; + const emptyFee: FeeDto = { + rate: 0, + fixed: 0, + network: 0, + min: 0, + dfx: 0, + bank: 0, + bankFixed: 0, + bankVariable: 0, + total: 0, + platform: 0, + }; return { feeAmount: 0, diff --git a/src/subdomains/supporting/payment/dto/transaction-helper/tx-spec.dto.ts b/src/subdomains/supporting/payment/dto/transaction-helper/tx-spec.dto.ts index 43f09232c2..a78a76594d 100644 --- a/src/subdomains/supporting/payment/dto/transaction-helper/tx-spec.dto.ts +++ b/src/subdomains/supporting/payment/dto/transaction-helper/tx-spec.dto.ts @@ -1,3 +1,5 @@ +import { FeeSpec } from '../fee.dto'; + export interface TxMinSpec { minVolume: number; minFee: number; @@ -10,8 +12,9 @@ export interface TxSpec { }; fee: { min: number; - fixed: number; - bankFixed: number; + dfx: FeeSpec; + partner: FeeSpec; + bank: FeeSpec; network: number; networkStart: number; }; diff --git a/src/subdomains/supporting/payment/entities/fee.entity.ts b/src/subdomains/supporting/payment/entities/fee.entity.ts index b0db4a6351..e151435a36 100644 --- a/src/subdomains/supporting/payment/entities/fee.entity.ts +++ b/src/subdomains/supporting/payment/entities/fee.entity.ts @@ -12,9 +12,11 @@ import { FeeRequest } from '../services/fee.service'; export enum FeeType { SPECIAL = 'Special', // Single use only, highest prio applies to all CUSTOM = 'Custom', // Single use only, second highest prio + CUSTOM_PARTNER = 'CustomPartner', // Single use only, second highest prio, can be combined with PartnerFee BASE = 'Base', // Single use only, absolute base fee DISCOUNT = 'Discount', // Single use only, absolute discount RELATIVE_DISCOUNT = 'RelativeDiscount', // Single use only, relative discount + PARTNER = 'Partner', // Single use only, additive partner fee ADDITION = 'Addition', // Multiple use possible, additive fee CHARGEBACK_BASE = 'ChargebackBase', // Single use only, absolute base fee CHARGEBACK_SPECIAL = 'ChargebackSpecial', // Single use only, highest prio applies to all diff --git a/src/subdomains/supporting/payment/services/fee.service.ts b/src/subdomains/supporting/payment/services/fee.service.ts index 8c7791b00b..b272be72b2 100644 --- a/src/subdomains/supporting/payment/services/fee.service.ts +++ b/src/subdomains/supporting/payment/services/fee.service.ts @@ -29,7 +29,7 @@ import { BankService } from '../../bank/bank/bank.service'; import { CardBankName, IbanBankName } from '../../bank/bank/dto/bank.dto'; import { PayoutService } from '../../payout/services/payout.service'; import { PriceCurrency, PriceValidity, PricingService } from '../../pricing/services/pricing.service'; -import { InternalChargebackFeeDto, InternalFeeDto } from '../dto/fee.dto'; +import { FeeInfo } from '../dto/fee.dto'; import { CreateFeeDto } from '../dto/input/create-fee.dto'; import { FiatPaymentMethod, PaymentMethod } from '../dto/payment-method.enum'; import { Fee, FeeType } from '../entities/fee.entity'; @@ -227,7 +227,7 @@ export class FeeService { return fee; } - async getChargebackFee(request: OptionalFeeRequest): Promise { + async getChargebackFee(request: OptionalFeeRequest): Promise { const userFees = await this.getValidFees(request); try { @@ -243,7 +243,7 @@ export class FeeService { } } - async getUserFee(request: UserFeeRequest): Promise { + async getUserFee(request: UserFeeRequest): Promise { const userFees = await this.getValidFees(request); try { @@ -261,7 +261,7 @@ export class FeeService { } } - async getDefaultFee(request: FeeRequestBase, accountType = AccountType.PERSONAL): Promise { + async getDefaultFee(request: FeeRequestBase, accountType = AccountType.PERSONAL): Promise { const defaultFees = await this.getValidFees({ ...request, accountType }); try { @@ -314,7 +314,7 @@ export class FeeService { } private async getAllFees(): Promise { - return this.feeRepo.findCached('all'); + return this.feeRepo.findCached('all', { relations: { wallet: { owner: true } } }); } private async calculateFee( @@ -324,7 +324,7 @@ export class FeeService { allowCachedBlockchainFee: boolean, paymentMethodIn: PaymentMethod, userDataId?: number, - ): Promise { + ): Promise { const [fromFee, toFee] = await Promise.all([ this.getBlockchainFeeInChf(from, allowCachedBlockchainFee), this.getBlockchainFeeInChf(to, allowCachedBlockchainFee), @@ -340,29 +340,34 @@ export class FeeService { if (specialFee) return { fees: [specialFee], - rate: specialFee.rate, - fixed: specialFee.fixed ?? 0, - bankRate: 0, - bankFixed: 0, + dfx: { rate: specialFee.rate, fixed: specialFee.fixed ?? 0 }, + bank: { rate: 0, fixed: 0 }, + partner: { rate: 0, fixed: 0 }, payoutRefBonus: specialFee.payoutRefBonus, network: Math.min(specialFee.blockchainFactor * blockchainFee, Config.maxBlockchainFee), }; + // get partner fee + const partnerFee = Util.minObj( + fees.filter((fee) => fee.type === FeeType.PARTNER), + 'rate', + ); + const partnerFeeSpec = { rate: partnerFee?.rate ?? 0, fixed: partnerFee?.fixed ?? 0 }; + // get min custom fee const customFee = Util.minObj( - fees.filter((fee) => fee.type === FeeType.CUSTOM), + fees.filter((fee) => [FeeType.CUSTOM, FeeType.CUSTOM_PARTNER].includes(fee.type)), 'rate', ); if (customFee) return { fees: [customFee], - rate: customFee.rate, - fixed: customFee.fixed ?? 0, - bankRate: 0, - bankFixed: 0, - payoutRefBonus: customFee.payoutRefBonus, + dfx: { rate: customFee.rate, fixed: customFee.fixed ?? 0 }, + bank: { rate: 0, fixed: 0 }, + partner: customFee.type === FeeType.CUSTOM_PARTNER ? partnerFeeSpec : { rate: 0, fixed: 0 }, network: Math.min(customFee.blockchainFactor * blockchainFee, Config.maxBlockchainFee), + payoutRefBonus: customFee.payoutRefBonus, }; // get min base fee @@ -390,6 +395,8 @@ export class FeeService { const combinedBankFeeRate = Util.sumObjValue(bankFees, 'rate'); const combinedBankFixedFee = Util.sumObjValue(bankFees, 'fixed'); + const bankFeeSpec = { rate: combinedBankFeeRate, fixed: combinedBankFixedFee }; + const combinedExtraFeeRate = Util.sumObjValue(additiveFees, 'rate') - (discountFee?.rate ?? 0); const combinedExtraFixedFee = Util.sumObjValue(additiveFees, 'fixed') - (discountFee?.fixed ?? 0); @@ -397,22 +404,20 @@ export class FeeService { if (baseFee.rate + combinedExtraFeeRate < 0) { this.logger.warn(`Discount is higher than base fee for user data ${userDataId}`); return { - fees: [baseFee], - rate: baseFee.rate, - fixed: baseFee.fixed, - bankRate: combinedBankFeeRate, - bankFixed: combinedBankFixedFee, + fees: [baseFee, partnerFee].filter((e) => e != null), + dfx: { rate: baseFee.rate, fixed: baseFee.fixed }, + bank: bankFeeSpec, + partner: partnerFeeSpec, payoutRefBonus: true, network: Math.min(baseFee.blockchainFactor * blockchainFee, Config.maxBlockchainFee), }; } return { - fees: [baseFee, discountFee, ...additiveFees].filter((e) => e != null), - rate: baseFee.rate + combinedExtraFeeRate, - fixed: Math.max(baseFee.fixed + combinedExtraFixedFee, 0), - bankRate: combinedBankFeeRate, - bankFixed: combinedBankFixedFee, + fees: [baseFee, discountFee, ...additiveFees, partnerFee].filter((e) => e != null), + dfx: { rate: baseFee.rate + combinedExtraFeeRate, fixed: Math.max(baseFee.fixed + combinedExtraFixedFee, 0) }, + bank: bankFeeSpec, + partner: partnerFeeSpec, payoutRefBonus: baseFee.payoutRefBonus && (discountFee?.payoutRefBonus ?? true) && @@ -435,7 +440,7 @@ export class FeeService { from: Active, allowCachedBlockchainFee: boolean, paymentMethodIn: PaymentMethod, - ): Promise { + ): Promise { const blockchainFee = await this.getBlockchainFeeInChf(from, allowCachedBlockchainFee); // get min special fee @@ -447,11 +452,11 @@ export class FeeService { if (specialFee) return { fees: [specialFee], - rate: specialFee.rate, - fixed: specialFee.fixed ?? 0, - bankRate: 0, - bankFixed: 0, + dfx: { rate: specialFee.rate, fixed: specialFee.fixed ?? 0 }, + bank: { rate: 0, fixed: 0 }, + partner: { rate: 0, fixed: 0 }, network: Math.min(specialFee.blockchainFactor * blockchainFee, Config.maxBlockchainFee), + payoutRefBonus: false, }; // get min custom fee @@ -463,11 +468,11 @@ export class FeeService { if (customFee) return { fees: [customFee], - rate: customFee.rate, - fixed: customFee.fixed ?? 0, - bankRate: 0, - bankFixed: 0, + dfx: { rate: customFee.rate, fixed: customFee.fixed ?? 0 }, + bank: { rate: 0, fixed: 0 }, + partner: { rate: 0, fixed: 0 }, network: Math.min(customFee.blockchainFactor * blockchainFee, Config.maxBlockchainFee), + payoutRefBonus: false, }; // get chargeback fees @@ -495,14 +500,17 @@ export class FeeService { if (!baseFee) throw new InternalServerErrorException('Chargeback base fee is missing'); return { fees: [baseFee, ...additiveFees], - rate: baseFee.rate + combinedAdditiveChargebackFeeRate, - fixed: (baseFee.fixed ?? 0) + (combinedAdditiveChargebackFixedFee ?? 0), - bankRate: combinedBankFeeRate, - bankFixed: combinedBankFixedFee ?? 0, + dfx: { + rate: baseFee.rate + combinedAdditiveChargebackFeeRate, + fixed: (baseFee.fixed ?? 0) + (combinedAdditiveChargebackFixedFee ?? 0), + }, + bank: { rate: combinedBankFeeRate, fixed: combinedBankFixedFee ?? 0 }, + partner: { rate: 0, fixed: 0 }, network: Math.min( (baseFee.blockchainFactor + combinedAdditiveChargebackBlockchainFee) * blockchainFee, Config.maxBlockchainFee, ), + payoutRefBonus: false, }; } @@ -545,6 +553,7 @@ export class FeeService { FeeType.CHARGEBACK_BANK, FeeType.BANK, FeeType.SPECIAL, + FeeType.PARTNER, FeeType.CHARGEBACK_SPECIAL, ].includes(f.type) && !f.specialCode) || diff --git a/src/subdomains/supporting/payment/services/transaction-helper.ts b/src/subdomains/supporting/payment/services/transaction-helper.ts index 6280a43a73..23121ba12c 100644 --- a/src/subdomains/supporting/payment/services/transaction-helper.ts +++ b/src/subdomains/supporting/payment/services/transaction-helper.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException, Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, forwardRef, Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { Config, Environment } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; @@ -40,7 +40,7 @@ import { BankTx } from '../../bank-tx/bank-tx/entities/bank-tx.entity'; import { CardBankName, IbanBankName } from '../../bank/bank/dto/bank.dto'; import { CryptoInput, PayInConfirmationType } from '../../payin/entities/crypto-input.entity'; import { PriceCurrency, PriceValidity, PricingService } from '../../pricing/services/pricing.service'; -import { FeeDto, InternalFeeDto } from '../dto/fee.dto'; +import { FeeAmountsDto, FeeInfo, FeeSpec, InternalFeeDto, toFeeDto } from '../dto/fee.dto'; import { FiatPaymentMethod, PaymentMethod } from '../dto/payment-method.enum'; import { QuoteError } from '../dto/transaction-helper/quote-error.enum'; import { TargetEstimation, TransactionDetails } from '../dto/transaction-helper/transaction-details.dto'; @@ -200,7 +200,7 @@ export class TransactionHelper implements OnModuleInit { bankIn: CardBankName | IbanBankName | undefined, bankOut: CardBankName | IbanBankName | undefined, user: User, - ): Promise { + ): Promise { // get fee const [fee, networkStartFee] = await this.getAllFees( user, @@ -222,32 +222,28 @@ export class TransactionHelper implements OnModuleInit { const specs: TxSpec = { fee: { min: minSpecs.minFee, - fixed: fee.fixed, network: fee.network, networkStart: networkStartFee, - bankFixed: fee.bankFixed, + dfx: fee.dfx, + bank: fee.bank, + partner: fee.partner, }, volume: { min: minSpecs.minVolume, max: Number.MAX_VALUE }, }; const sourceSpecs = await this.getSourceSpecs(fromReference, specs, PriceValidity.VALID_ONLY); - const { dfx, bank, bankFixed, bankPercent, total } = this.calculateTotalFee( - inputReferenceAmount, - fee.rate, - fee.bankRate, - sourceSpecs, - from, - ); + const amounts = this.calculateTotalFee(inputReferenceAmount, sourceSpecs, from); + + const feeDto = toFeeDto(amounts, sourceSpecs); return { - ...fee, - ...sourceSpecs.fee, - total, - dfx, - bank, - bankFixed, - bankPercent, + ...feeDto, + fees: fee.fees, + partner: amounts.partner, + bankFixed: amounts.bankFixed, + bankPercent: amounts.bankPercent, + payoutRefBonus: fee.payoutRefBonus, }; } @@ -317,11 +313,12 @@ export class TransactionHelper implements OnModuleInit { // target estimation const extendedSpecs: TxSpec = { fee: { - network: fee.network, - fixed: fee.fixed, min: specs.minFee, + network: fee.network, networkStart: networkStartFee, - bankFixed: fee.bankFixed, + dfx: fee.dfx, + bank: fee.bank, + partner: fee.partner, }, volume: { min: specs.minVolume, @@ -337,8 +334,6 @@ export class TransactionHelper implements OnModuleInit { const target = await this.getTargetEstimation( sourceAmount, targetAmount, - fee.rate, - fee.bankRate, sourceSpecs, targetSpecs, from, @@ -433,7 +428,7 @@ export class TransactionHelper implements OnModuleInit { userData, }); - const dfxFeeAmount = inputAmount * chargebackFee.rate + chfPrice.convert(chargebackFee.fixed); + const dfxFeeAmount = inputAmount * chargebackFee.dfx.rate + chfPrice.convert(chargebackFee.dfx.fixed); let networkFeeAmount = chfPrice.convert(chargebackFee.network); @@ -443,7 +438,7 @@ export class TransactionHelper implements OnModuleInit { const bankFeeAmount = refundEntity.paymentMethodIn === FiatPaymentMethod.BANK ? chfPrice.convert( - chargebackFee.bankRate * inputAmount + chargebackFee.bankFixed + refundEntity.chargebackBankFee * 1.01, + chargebackFee.bank.rate * inputAmount + chargebackFee.bank.fixed + refundEntity.chargebackBankFee * 1.01, ) : 0; // Bank fee buffer 1% @@ -683,7 +678,7 @@ export class TransactionHelper implements OnModuleInit { specialCodes: string[], exactPrice: boolean, allowCachedBlockchainFee: boolean, - ): Promise<[InternalFeeDto, number]> { + ): Promise<[FeeInfo, number]> { const [fee, networkStartFee] = await Promise.all([ this.getTxFee( user, @@ -785,7 +780,7 @@ export class TransactionHelper implements OnModuleInit { txVolumeChf: number, specialCodes: string[], allowCachedBlockchainFee: boolean, - ): Promise { + ): Promise { const feeRequest: UserFeeRequest = { user, wallet, @@ -806,8 +801,6 @@ export class TransactionHelper implements OnModuleInit { private async getTargetEstimation( inputAmount: number | undefined, outputAmount: number | undefined, - feeRate: number, - bankFeeRate: number, sourceSpecs: TxSpec, targetSpecs: TxSpec, from: Active, @@ -817,16 +810,17 @@ export class TransactionHelper implements OnModuleInit { const price = await this.pricingService.getPrice(from, to, priceValidity); const outputAmountSource = outputAmount && price.invert().convert(outputAmount); - const sourceAmount = inputAmount ?? this.getInputAmount(outputAmountSource, feeRate, bankFeeRate, sourceSpecs); - const sourceFees = this.calculateTotalFee(sourceAmount, feeRate, bankFeeRate, sourceSpecs, from); + const sourceAmount = inputAmount ?? this.getInputAmount(outputAmountSource, sourceSpecs); + const sourceFees = this.calculateTotalFee(sourceAmount, sourceSpecs, from); const targetAmount = outputAmount ?? price.convert(Math.max(inputAmount - sourceFees.total, 0)); - const targetFees = { + const targetFees: FeeAmountsDto = { dfx: this.convertFee(sourceFees.dfx, price, to), - total: this.convertFee(sourceFees.total, price, to), bank: this.convertFee(sourceFees.bank, price, to), bankFixed: this.convertFee(sourceFees.bankFixed, price, to), bankPercent: this.convertFee(sourceFees.bankPercent, price, to), + partner: this.convertFee(sourceFees.partner, price, to), + total: this.convertFee(sourceFees.total, price, to), }; return { @@ -837,27 +831,20 @@ export class TransactionHelper implements OnModuleInit { estimatedAmount: Util.roundReadable(targetAmount, amountType(to)), exactPrice: price.isValid, priceSteps: price.steps, - feeSource: { - rate: feeRate, - ...sourceSpecs.fee, - ...sourceFees, - }, - feeTarget: { - rate: feeRate, - ...targetSpecs.fee, - ...targetFees, - }, + feeSource: toFeeDto(sourceFees, sourceSpecs), + feeTarget: toFeeDto(targetFees, targetSpecs), }; } private getInputAmount( outputAmount: number, - rate: number, - bankRate: number, - { fee: { min, fixed, network, bankFixed, networkStart } }: TxSpec, + { fee: { min, network, dfx, bank, partner, networkStart } }: TxSpec, ): number { - const inputAmountNormal = (outputAmount + fixed + network + bankFixed + networkStart) / (1 - (rate + bankRate)); - const inputAmountWithMinFee = outputAmount + network + bankFixed + networkStart + min; + const inputAmountNormal = + (outputAmount + dfx.fixed + bank.fixed + partner.fixed + network + networkStart) / + (1 - (dfx.rate + bank.rate + partner.rate)); + const inputAmountWithMinFee = + (outputAmount + network + bank.fixed + partner.fixed + networkStart + min) / (1 - (bank.rate + partner.rate)); return Math.max(inputAmountNormal, inputAmountWithMinFee); } @@ -883,8 +870,9 @@ export class TransactionHelper implements OnModuleInit { return { fee: { min: this.convertFee(fee.min, price, from), - fixed: this.convertFee(fee.fixed, price, from), - bankFixed: fee.bankFixed, // no conversion - 1 CHF/EUR/USD = 1 unit + dfx: this.convertFeeSpec(fee.dfx, price, from), + bank: { rate: fee.bank.rate, fixed: fee.bank.fixed }, // no conversion - 1 CHF/EUR/USD = 1 unit + partner: this.convertFeeSpec(fee.partner, price, from), network: this.convertFee(fee.network, price, from), networkStart: fee.networkStart != null ? this.convertFee(fee.networkStart, price, from) : undefined, }, @@ -901,8 +889,9 @@ export class TransactionHelper implements OnModuleInit { return { fee: { min: this.convertFee(fee.min, price, to), - fixed: this.convertFee(fee.fixed, price, to), - bankFixed: fee.bankFixed, // no conversion - 1 CHF/EUR/USD = 1 unit + dfx: this.convertFeeSpec(fee.dfx, price, to), + bank: { rate: fee.bank.rate, fixed: fee.bank.fixed }, // no conversion - 1 CHF/EUR/USD = 1 unit + partner: this.convertFeeSpec(fee.partner, price, to), network: this.convertFee(fee.network, price, to), networkStart: fee.networkStart != null ? this.convertFee(fee.networkStart, price, to) : undefined, }, @@ -915,30 +904,39 @@ export class TransactionHelper implements OnModuleInit { private calculateTotalFee( amount: number, - rate: number, - bankRate: number, - { fee: { fixed, min, network, networkStart, bankFixed } }: TxSpec, + { fee: { min, network, networkStart, dfx, bank, partner } }: TxSpec, roundingActive: Active, - ): { dfx: number; bank: number; bankFixed: number; bankPercent: number; total: number } { - const bankPercentAmount = amount * bankRate; - const bank = bankPercentAmount + bankFixed; - const dfx = Math.max(amount * rate + fixed, min); - const total = dfx + bank + network + (networkStart ?? 0); + ): FeeAmountsDto { + const dfxAmount = Math.max(this.calculateFee(amount, dfx), min); + const bankPercentAmount = amount * bank.rate; + const bankFixedAmount = bank.fixed; + const bankAmount = bankPercentAmount + bankFixedAmount; + const partnerAmount = this.calculateFee(amount, partner); + const totalAmount = dfxAmount + partnerAmount + bankAmount + network + (networkStart ?? 0); return { - dfx: Util.roundReadable(dfx, feeAmountType(roundingActive)), - bank: Util.roundReadable(bank, feeAmountType(roundingActive)), - bankFixed: Util.roundReadable(bankFixed, feeAmountType(roundingActive)), + dfx: Util.roundReadable(dfxAmount, feeAmountType(roundingActive)), + bank: Util.roundReadable(bankAmount, feeAmountType(roundingActive)), + bankFixed: Util.roundReadable(bankFixedAmount, feeAmountType(roundingActive)), bankPercent: Util.roundReadable(bankPercentAmount, feeAmountType(roundingActive)), - total: Util.roundReadable(total, feeAmountType(roundingActive)), + partner: Util.roundReadable(partnerAmount, feeAmountType(roundingActive)), + total: Util.roundReadable(totalAmount, feeAmountType(roundingActive)), }; } + private calculateFee(amount: number, spec: FeeSpec): number { + return amount * spec.rate + spec.fixed; + } + private convert(amount: number, price: Price, roundingActive: Active): number { const targetAmount = price.convert(amount); return Util.roundReadable(targetAmount, amountType(roundingActive)); } + private convertFeeSpec(spec: FeeSpec, price: Price, roundingActive: Active): FeeSpec { + return { rate: spec.rate, fixed: this.convertFee(spec.fixed, price, roundingActive) }; + } + private convertFee(amount: number, price: Price, roundingActive: Active): number { const targetAmount = price.convert(amount); return Util.roundReadable(targetAmount, feeAmountType(roundingActive)); diff --git a/src/subdomains/supporting/payout/services/payout-firo.service.ts b/src/subdomains/supporting/payout/services/payout-firo.service.ts index 9a4de3339c..7f88b2f101 100644 --- a/src/subdomains/supporting/payout/services/payout-firo.service.ts +++ b/src/subdomains/supporting/payout/services/payout-firo.service.ts @@ -10,7 +10,7 @@ export class PayoutFiroService extends PayoutBitcoinBasedService { private readonly client: FiroClient; constructor( - private readonly firoService: FiroService, + firoService: FiroService, private readonly feeService: FiroFeeService, ) { super(); @@ -31,6 +31,11 @@ export class PayoutFiroService extends PayoutBitcoinBasedService { return this.client.sendMany(payout, feeRate); } + async mintSpark(payout: PayoutGroup): Promise { + const recipients = payout.map((p) => ({ address: p.addressTo, amount: p.amount })); + return this.client.mintSpark(recipients); + } + async getPayoutCompletionData(_context: PayoutOrderContext, payoutTxId: string): Promise<[boolean, number]> { const transaction = await this.client.getTx(payoutTxId); diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/firo.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/firo.strategy.ts index 83e51d69cf..4398565aa8 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/firo.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/firo.strategy.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -16,8 +18,6 @@ import { BitcoinBasedStrategy } from './base/bitcoin-based.strategy'; export class FiroStrategy extends BitcoinBasedStrategy { protected readonly logger = new DfxLogger(FiroStrategy); - private readonly averageTransactionSize = 225; // bytes (Firo Legacy P2PKH, no SegWit) - constructor( notificationService: NotificationService, protected readonly firoService: PayoutFiroService, @@ -35,15 +35,32 @@ export class FiroStrategy extends BitcoinBasedStrategy { return undefined; } - async estimateFee(): Promise { + async estimateFee(_targetAsset: Asset, address?: string): Promise { const feeRate = await this.firoService.getCurrentFeeRate(); - const satoshiFeeAmount = this.averageTransactionSize * feeRate; - const firoFeeAmount = Util.round(satoshiFeeAmount / 100000000, 8); + const { transparentTxSize, sparkMintTxSize } = Config.blockchain.firo; + const txSize = address && CryptoService.isFiroSparkAddress(address) ? sparkMintTxSize : transparentTxSize; + const firoFeeAmount = Util.round((txSize * feeRate) / 1e8, 8); return { asset: await this.feeAsset(), amount: firoFeeAmount }; } protected async doPayoutForContext(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + const sparkOrders: PayoutOrder[] = []; + const transparentOrders: PayoutOrder[] = []; + for (const o of orders) { + (CryptoService.isFiroSparkAddress(o.destinationAddress) ? sparkOrders : transparentOrders).push(o); + } + + if (sparkOrders.length > 0) { + await this.payoutOrderGroup(context, sparkOrders, 'Spark'); + } + + if (transparentOrders.length > 0) { + await this.payoutOrderGroup(context, transparentOrders, 'transparent'); + } + } + + private async payoutOrderGroup(context: PayoutOrderContext, orders: PayoutOrder[], type: string): Promise { const payoutGroups = this.createPayoutGroups(orders, 100); for (const group of payoutGroups) { @@ -52,21 +69,30 @@ export class FiroStrategy extends BitcoinBasedStrategy { continue; } - this.logger.verbose(`Paying out ${group.length} FIRO orders(s). Order ID(s): ${group.map((o) => o.id)}`); + this.logger.verbose( + `Paying out ${group.length} FIRO ${type} orders(s). Order ID(s): ${group.map((o) => o.id)}`, + ); await this.sendFIRO(context, group); } catch (e) { this.logger.error( - `Error in paying out a group of ${group.length} FIRO orders(s). Order ID(s): ${group.map((o) => o.id)}`, + `Error in paying out a group of ${group.length} FIRO ${type} orders(s). Order ID(s): ${group.map((o) => o.id)}`, e, ); - // continue with next group in case payout failed continue; } } } protected dispatchPayout(context: PayoutOrderContext, payout: PayoutGroup): Promise { + const isSpark = payout.length > 0 && CryptoService.isFiroSparkAddress(payout[0].addressTo); + + if (isSpark) { + const allSpark = payout.every((p) => CryptoService.isFiroSparkAddress(p.addressTo)); + if (!allSpark) throw new Error('Mixed Spark/transparent payout group detected'); + return this.firoService.mintSpark(payout); + } + return this.firoService.sendUtxoToMany(context, payout); } diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 7514822dec..cc465ba445 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -17,6 +17,7 @@ import { KycService } from 'src/subdomains/generic/kyc/services/kyc.service'; import { AccountMergeService } from 'src/subdomains/generic/user/models/account-merge/account-merge.service'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; +import { FeeService } from 'src/subdomains/supporting/payment/services/fee.service'; import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; @@ -162,6 +163,7 @@ describe('RealUnitService', () => { { provide: AccountMergeService, useValue: {} }, { provide: RealUnitDevService, useValue: {} }, { provide: SwissQRService, useValue: {} }, + { provide: FeeService, useValue: {} }, ], }).compile(); @@ -184,7 +186,7 @@ describe('RealUnitService', () => { brokerbotAddress: '0xBrokerbotAddress', tokenAddress: realuAsset.chainId, baseCurrencyAddress: zchfAsset.chainId, - pricePerShare: '100', + pricePerShare: 100, currency: BrokerbotCurrency.CHF, buyingEnabled: true, sellingEnabled: true, @@ -240,7 +242,7 @@ describe('RealUnitService', () => { brokerbotAddress: '0xBrokerbotAddress', tokenAddress: '0xRealuChainId', baseCurrencyAddress: '0xZchfChainId', - pricePerShare: '100', + pricePerShare: 100, currency: BrokerbotCurrency.CHF, buyingEnabled: true, sellingEnabled: true, diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index b13dbc27e4..368b517d5e 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -16,11 +16,13 @@ import { Response } from 'express'; import { Config, Environment } from 'src/config/config'; import { BrokerbotBuyPriceDto, + BrokerbotBuySharesDto, BrokerbotCurrency, BrokerbotCurrencyQueryDto, BrokerbotInfoDto, BrokerbotPriceDto, - BrokerbotSharesDto, + BrokerbotSellPriceDto, + BrokerbotSellSharesDto, } from 'src/integration/blockchain/realunit/dto/realunit-broker.dto'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; @@ -293,7 +295,7 @@ export class RealUnitController { return this.realunitService.getBrokerbotBuyPrice(Number(shares), currency); } - @Get('brokerbot/shares') + @Get('brokerbot/buyShares') @ApiOperation({ summary: 'Get shares for amount', description: 'Calculates how many REALU shares can be purchased for a given amount', @@ -305,12 +307,61 @@ export class RealUnitController { required: false, description: 'Currency for prices (CHF or EUR)', }) - @ApiOkResponse({ type: BrokerbotSharesDto }) - async getBrokerbotShares( - @Query('amount') amount: string, + @ApiOkResponse({ type: BrokerbotBuySharesDto }) + async getBrokerbotBuyShares( + @Query('amount') amount: number, @Query() { currency }: BrokerbotCurrencyQueryDto, - ): Promise { - return this.realunitService.getBrokerbotShares(amount, currency); + ): Promise { + return this.realunitService.getBrokerbotBuyShares(amount, currency); + } + + @Get('brokerbot/sellPrice') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Get sell price for shares including fees', + description: + 'Calculates the estimated payout when selling a specific number of REALU shares, including user-specific fees', + }) + @ApiQuery({ name: 'shares', type: Number, description: 'Number of shares to sell' }) + @ApiQuery({ + name: 'currency', + enum: BrokerbotCurrency, + required: false, + description: 'Currency for prices (CHF or EUR)', + }) + @ApiOkResponse({ type: BrokerbotSellPriceDto }) + async getBrokerbotSellPrice( + @GetJwt() jwt: JwtPayload, + @Query('shares') shares: number, + @Query() { currency }: BrokerbotCurrencyQueryDto, + ): Promise { + const user = await this.userService.getUser(jwt.user, { userData: true }); + return this.realunitService.getBrokerbotSellPrice(user, Number(shares), currency); + } + + @Get('brokerbot/sellShares') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.USER), UserActiveGuard()) + @ApiOperation({ + summary: 'Get shares needed to receive target amount including fees', + description: 'Calculates how many REALU shares need to be sold to receive a target amount after user-specific fees', + }) + @ApiQuery({ name: 'amount', type: Number, description: 'Target amount to receive after fees (e.g., 1000.50)' }) + @ApiQuery({ + name: 'currency', + enum: BrokerbotCurrency, + required: false, + description: 'Currency for prices (CHF or EUR)', + }) + @ApiOkResponse({ type: BrokerbotSellSharesDto }) + async getBrokerbotSellShares( + @GetJwt() jwt: JwtPayload, + @Query('amount') amount: number, + @Query() { currency }: BrokerbotCurrencyQueryDto, + ): Promise { + const user = await this.userService.getUser(jwt.user, { userData: true }); + return this.realunitService.getBrokerbotSellShares(user, Number(amount), currency); } // --- Buy Payment Info Endpoint --- diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 9e344df85f..ce618a6432 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -12,10 +12,12 @@ import { request } from 'graphql-request'; import { Config, Environment, GetConfig } from 'src/config/config'; import { BrokerbotBuyPriceDto, + BrokerbotBuySharesDto, BrokerbotCurrency, BrokerbotInfoDto, BrokerbotPriceDto, - BrokerbotSharesDto, + BrokerbotSellPriceDto, + BrokerbotSellSharesDto, } from 'src/integration/blockchain/realunit/dto/realunit-broker.dto'; import { RealUnitBlockchainService } from 'src/integration/blockchain/realunit/realunit-blockchain.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; @@ -44,9 +46,10 @@ import { KycLevel } from 'src/subdomains/generic/user/models/user-data/user-data import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; -import { FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; +import { CryptoPaymentMethod, FiatPaymentMethod } from 'src/subdomains/supporting/payment/dto/payment-method.enum'; import { QuoteError } from 'src/subdomains/supporting/payment/dto/transaction-helper/quote-error.enum'; import { TransactionRequestStatus } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; +import { FeeService } from 'src/subdomains/supporting/payment/services/fee.service'; import { SwissQRService } from 'src/subdomains/supporting/payment/services/swiss-qr.service'; import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; @@ -124,6 +127,7 @@ export class RealUnitService { private readonly accountMergeService: AccountMergeService, private readonly devService: RealUnitDevService, private readonly swissQrService: SwissQRService, + private readonly feeService: FeeService, ) { this.ponderUrl = GetConfig().blockchain.realunit.graphUrl; } @@ -281,8 +285,8 @@ export class RealUnitService { return this.blockchainService.getBrokerbotBuyPrice(shares, currency); } - async getBrokerbotShares(amount: string, currency?: BrokerbotCurrency): Promise { - return this.blockchainService.getBrokerbotShares(amount, currency); + async getBrokerbotBuyShares(amount: number, currency?: BrokerbotCurrency): Promise { + return this.blockchainService.getBrokerbotBuyShares(amount, currency); } async getBrokerbotInfo(currency?: BrokerbotCurrency): Promise { @@ -295,6 +299,86 @@ export class RealUnitService { ); } + async getBrokerbotSellPrice( + user: User, + shares: number, + currency?: BrokerbotCurrency, + ): Promise { + const currencyName = currency ?? BrokerbotCurrency.CHF; + const [realuAsset, fiat] = await Promise.all([this.getRealuAsset(), this.fiatService.getFiatByName(currencyName)]); + + const { pricePerShare } = await this.blockchainService.getBrokerbotPrice(currencyName); + const grossAmount = pricePerShare * shares; + + const fee = await this.feeService.getUserFee({ + user, + from: realuAsset, + to: fiat, + paymentMethodIn: CryptoPaymentMethod.CRYPTO, + paymentMethodOut: FiatPaymentMethod.BANK, + bankIn: undefined, + specialCodes: [], + allowCachedBlockchainFee: true, + }); + + const feeRate = fee.dfx.rate + fee.bank.rate + fee.partner.rate; + const feeFixed = fee.dfx.fixed + fee.bank.fixed + fee.partner.fixed; + + const totalFee = grossAmount * feeRate + feeFixed + fee.network; + const estimatedAmount = Math.max(grossAmount - totalFee, 0); + const pricePerShareAfterFees = shares > 0 ? estimatedAmount / shares : 0; + + return { + shares, + pricePerShare: Util.round(pricePerShareAfterFees, 2), + estimatedAmount: Util.round(estimatedAmount, 2), + currency: currencyName, + }; + } + + async getBrokerbotSellShares( + user: User, + targetAmount: number, + currency?: BrokerbotCurrency, + ): Promise { + const currencyName = currency ?? BrokerbotCurrency.CHF; + const [realuAsset, fiat] = await Promise.all([this.getRealuAsset(), this.fiatService.getFiatByName(currencyName)]); + + const { pricePerShare } = await this.blockchainService.getBrokerbotPrice(currencyName); + + const fee = await this.feeService.getUserFee({ + user, + from: realuAsset, + to: fiat, + paymentMethodIn: CryptoPaymentMethod.CRYPTO, + paymentMethodOut: FiatPaymentMethod.BANK, + bankIn: undefined, + specialCodes: [], + allowCachedBlockchainFee: true, + }); + + const feeRate = fee.dfx.rate + fee.bank.rate + fee.partner.rate; + const feeFixed = fee.dfx.fixed + fee.bank.fixed + fee.partner.fixed; + + // Calculate shares needed: targetAmount = grossAmount - fees + const divisor = 1 - feeRate; + const grossAmountRaw = divisor > 0 ? (targetAmount + feeFixed + fee.network) / divisor : targetAmount; + const shares = Math.max(1, Math.ceil(grossAmountRaw / pricePerShare)); + + // Recalculate actual estimated amount with rounded shares + const actualGrossAmount = shares * pricePerShare; + const totalFee = actualGrossAmount * feeRate + feeFixed + fee.network; + const estimatedAmount = actualGrossAmount - totalFee; + const pricePerShareAfterFees = shares > 0 ? estimatedAmount / shares : 0; + + return { + targetAmount, + shares, + pricePerShare: Util.round(pricePerShareAfterFees, 2), + currency: currencyName, + }; + } + // --- Buy Payment Info Methods --- async getPaymentInfo(user: User, dto: RealUnitBuyDto): Promise {