From c44c6696b81cf4f32aafd4751648953a1dbd548e Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:01:11 +0100 Subject: [PATCH 01/11] Add full Firo Spark address support (#3428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add full Firo Spark address support Add Spark address recognition (sm1...), signature verification via verifymessagewithsparkaddress RPC, and mintSpark payouts. Split payout strategy into Spark/transparent groups. Filter deposit UTXOs from spending. Add OCP payment TxId fallback for Spark wallets that cannot export signed hex. Move TX size constants to config. * Address PR review: use Util.retry, accept overpayment, avoid double UTXO fetch - Replace manual retry loop with Util.retry in doFiroTxIdPayment - Only fail on underpayment, accept overpayment for Firo TX ID payments - Avoid double UTXO fetch in getBalance and sendMany via getNonDepositUtxos - Update tests for new error handling and add overpayment test case * Add Firo Spark address tests to CryptoService specs - Test blockchain detection for sm1 Spark addresses → Blockchain.FIRO - Test address type detection for transparent → FIRO and Spark → FIRO_SPARK --- src/config/config.ts | 12 +- .../node/__tests__/node-client.spec.ts | 4 +- .../blockchain/bitcoin/node/node-client.ts | 2 +- .../bitcoin/node/rpc/bitcoin-rpc-types.ts | 1 + .../services/__tests__/crypto.service.spec.ts | 20 ++ .../blockchain/firo/firo-client.ts | 112 +++++++-- .../blockchain/firo/services/firo.service.ts | 7 +- .../shared/__test__/crypto.service.spec.ts | 20 ++ .../shared/services/crypto.service.ts | 16 +- .../__tests__/payment-quote-firo.spec.ts | 225 ++++++++++++++++++ .../services/payment-quote.service.ts | 60 ++++- .../generic/user/models/user/user.enum.ts | 1 + .../address-pool/deposit/deposit.service.ts | 13 +- .../payout/services/payout-firo.service.ts | 7 +- .../strategies/payout/impl/firo.strategy.ts | 42 +++- 15 files changed, 503 insertions(+), 39 deletions(-) create mode 100644 src/subdomains/core/payment-link/services/__tests__/payment-quote-firo.spec.ts diff --git a/src/config/config.ts b/src/config/config.ts index 3855dc390e..5075955f77 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -152,6 +152,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 +166,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 +174,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 +185,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 +657,7 @@ export class Configuration { defaultPaymentTimeout: +(process.env.PAYMENT_TIMEOUT ?? 60), defaultEvmHexPaymentTryCount: +(process.env.PAYMENT_EVM_HEX_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/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/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/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..26ff1c1c59 100644 --- a/src/subdomains/core/payment-link/services/payment-quote.service.ts +++ b/src/subdomains/core/payment-link/services/payment-quote.service.ts @@ -392,10 +392,13 @@ 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.INTERNET_COMPUTER: await this.doIcpPayment(transferInfo, quote); break; @@ -507,6 +510,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, 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/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/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); } From b99b656027c903ad8659fbaa920f4fc9ee3a3934 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:27:44 +0100 Subject: [PATCH 02/11] fix: add dual-mode hint for Firo payment requests (#3475) Firo supports both signed hex (Electrum) and txId (Campfire/Spark) submission, but the hint only described the hex flow. This was misleading for Spark wallet users who broadcast transactions themselves. --- src/subdomains/core/payment-link/dto/payment-request.mapper.ts | 2 ++ 1 file changed, 2 insertions(+) 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 { From 00893a506454e6fa68ed27976f1968a55e4f334d Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:04:48 +0100 Subject: [PATCH 03/11] [DEV-3957] partner fee (#2491) * [DEV-3957] partner fee * [DEV-3957] add partnerFeeAmount * [DEV-3957] find partnerFee in default * [DEV-3957] fix dev build * [DEV-3957] Renaming to platform fee * [DEV-3957] fix build * [DEV-3957] add migration * [DEV-3957] Refactoring (#2534) * [DEV-3957] partner fee payout * [DEV-3957] add partnerRef cols * [DEV-3957] Fixed tests * [DEV-3957] Refactoring 2 * [DEV-3957] Refactoring 3 * [DEV-3957] adapt migration * [DEV-3957] fix build * [DEV-3957] fix small bugs * fix: set new bank fee fields in DTO * feat: bank fee split * feat: renaming + migration * chore: rename * fix: format --------- Co-authored-by: David May <85513542+davidleomay@users.noreply.github.com> Co-authored-by: David May --- ...24-AddBuyCryptoBuyFiatPlatformFeeAmount.js | 35 +++++ migration/1773931596651-BankFeeSplit.js | 32 +++++ .../blockchain/shared/evm/evm.util.ts | 4 +- .../process/entities/buy-crypto.entity.ts | 33 ++++- .../buy-crypto-preparation.service.ts | 2 +- .../process/services/buy-crypto.service.ts | 17 +++ .../buy-crypto/routes/buy/buy.controller.ts | 2 +- .../buy-crypto/routes/swap/swap.controller.ts | 2 +- .../__tests__/transaction-helper.spec.ts | 20 ++- .../history/mappers/transaction-dto.mapper.ts | 14 +- .../reward/services/ref-reward.service.ts | 2 +- .../sell-crypto/process/buy-fiat.entity.ts | 33 ++++- .../services/buy-fiat-preparation.service.ts | 2 +- .../process/services/buy-fiat.service.ts | 17 +++ .../core/sell-crypto/route/sell.controller.ts | 2 +- .../user/models/user/dto/user-dto.mapper.ts | 4 +- .../generic/user/models/user/user.entity.ts | 23 ++-- .../generic/user/models/user/user.service.ts | 20 ++- .../user/models/wallet/wallet.entity.ts | 5 +- .../payment/__mocks__/fee.dto.mock.ts | 35 +++++ .../__mocks__/internal-fee.dto.mock.ts | 37 ----- .../supporting/payment/dto/fee.dto.ts | 76 ++++++++--- .../transaction-helper/quote-error.util.ts | 18 ++- .../dto/transaction-helper/tx-spec.dto.ts | 7 +- .../supporting/payment/entities/fee.entity.ts | 1 + .../payment/services/fee.service.ts | 89 ++++++------ .../payment/services/transaction-helper.ts | 127 +++++++++--------- 27 files changed, 445 insertions(+), 214 deletions(-) create mode 100644 migration/1761754328324-AddBuyCryptoBuyFiatPlatformFeeAmount.js create mode 100644 migration/1773931596651-BankFeeSplit.js create mode 100644 src/subdomains/supporting/payment/__mocks__/fee.dto.mock.ts delete mode 100644 src/subdomains/supporting/payment/__mocks__/internal-fee.dto.mock.ts 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/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/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/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/referral/reward/services/ref-reward.service.ts b/src/subdomains/core/referral/reward/services/ref-reward.service.ts index ab4b88423f..e47a2f7295 100644 --- a/src/subdomains/core/referral/reward/services/ref-reward.service.ts +++ b/src/subdomains/core/referral/reward/services/ref-reward.service.ts @@ -146,7 +146,7 @@ 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; 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/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.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/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..f02304b118 100644 --- a/src/subdomains/supporting/payment/entities/fee.entity.ts +++ b/src/subdomains/supporting/payment/entities/fee.entity.ts @@ -15,6 +15,7 @@ export enum FeeType { 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..00a90dc45a 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,13 +324,20 @@ 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), ]); const blockchainFee = fromFee + toFee; + // 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 special fee const specialFee = Util.minObj( fees.filter((fee) => fee.type === FeeType.SPECIAL), @@ -339,11 +346,10 @@ export class FeeService { if (specialFee) return { - fees: [specialFee], - rate: specialFee.rate, - fixed: specialFee.fixed ?? 0, - bankRate: 0, - bankFixed: 0, + fees: [specialFee, partnerFee].filter((e) => e != null), + dfx: { rate: specialFee.rate, fixed: specialFee.fixed ?? 0 }, + bank: { rate: 0, fixed: 0 }, + partner: partnerFeeSpec, payoutRefBonus: specialFee.payoutRefBonus, network: Math.min(specialFee.blockchainFactor * blockchainFee, Config.maxBlockchainFee), }; @@ -356,13 +362,12 @@ export class FeeService { if (customFee) return { - fees: [customFee], - rate: customFee.rate, - fixed: customFee.fixed ?? 0, - bankRate: 0, - bankFixed: 0, - payoutRefBonus: customFee.payoutRefBonus, + fees: [customFee, partnerFee].filter((e) => e != null), + dfx: { rate: customFee.rate, fixed: customFee.fixed ?? 0 }, + bank: { rate: 0, fixed: 0 }, + partner: partnerFeeSpec, 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..0e62411183 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,19 @@ 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; return Math.max(inputAmountNormal, inputAmountWithMinFee); } @@ -883,8 +869,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 +888,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 +903,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)); From 1aba74b17789ed026211ed34be13fa1dfb641b6e Mon Sep 17 00:00:00 2001 From: Lam Nguyen <32935491+xlamn@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:16:09 +0100 Subject: [PATCH 04/11] feat: create sell realu endpoints (#3464) * feat: add GET brokerbot/sellPrice and GET brokerbot/sellShares endpoints. * feat: use user-specific-fee for calculation instead of default. * chore: rename GET brokerbot/shares to brokerbot/buyShares. * test: add FeeService. * refactor: update datatype for pricePerShare and totalPrice to number instead of string. --- .../realunit-blockchain.service.spec.ts | 28 +++--- .../realunit/dto/realunit-broker.dto.ts | 42 +++++++-- .../realunit/realunit-blockchain.service.ts | 20 ++--- .../__tests__/realunit.service.spec.ts | 6 +- .../controllers/realunit.controller.ts | 65 ++++++++++++-- .../supporting/realunit/realunit.service.ts | 86 ++++++++++++++++++- 6 files changed, 203 insertions(+), 44 deletions(-) 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/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..6a3db10236 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,80 @@ 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 totalFee = grossAmount * fee.rate + fee.fixed + 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, + }); + + // Calculate shares needed: targetAmount = grossAmount - fees + const divisor = 1 - fee.rate; + const grossAmountRaw = divisor > 0 ? (targetAmount + fee.fixed + 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 * fee.rate + fee.fixed + 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 { From 3d3beaa1ae07769b3e170dd735262014f649a470 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:24:26 +0100 Subject: [PATCH 05/11] Separate verified and unverified TxId payment flows (#3477) * refactor: separate verified and unverified TxId payment flows Split TxIdBlockchains into UnverifiedTxIdBlockchains (Monero, Zano, Tron, Cardano) and VerifiedTxIdBlockchains (Solana, ICP). Verified blockchains now use doVerifiedTxIdPayment with tx confirmation retry via extracted waitForTxConfirmation helper. * refactor: add separate config key for tx confirmation retry count --- src/config/config.ts | 1 + .../core/payment-link/enums/index.ts | 14 +++--- .../services/payment-quote.service.ts | 46 +++++++++++++++++-- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 5075955f77..b2fb04bdb5 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -657,6 +657,7 @@ 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, 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/payment-quote.service.ts b/src/subdomains/core/payment-link/services/payment-quote.service.ts index 26ff1c1c59..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'; @@ -399,12 +399,16 @@ export class PaymentQuoteService { 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`); @@ -628,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 { From 9f02ce5a4362e5fcc2c76592ecd291ed4bc5f34a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:27:06 +0100 Subject: [PATCH 06/11] fix: use FeeInfo sub-properties for brokerbot fee calculation (#3479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FeeInfo no longer has direct rate/fixed properties — access dfx, bank, and partner FeeSpec fields instead. --- .../supporting/realunit/realunit.service.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 6a3db10236..ce618a6432 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -321,7 +321,10 @@ export class RealUnitService { allowCachedBlockchainFee: true, }); - const totalFee = grossAmount * fee.rate + fee.fixed + fee.network; + 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; @@ -354,14 +357,17 @@ export class RealUnitService { 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 - fee.rate; - const grossAmountRaw = divisor > 0 ? (targetAmount + fee.fixed + fee.network) / divisor : targetAmount; + 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 * fee.rate + fee.fixed + fee.network; + const totalFee = actualGrossAmount * feeRate + feeFixed + fee.network; const estimatedAmount = actualGrossAmount - totalFee; const pricePerShareAfterFees = shares > 0 ? estimatedAmount / shares : 0; From fa86bd3bf65b3247d367bf4f37c13274089e584c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:55:18 +0100 Subject: [PATCH 07/11] fix: exclude third-party deposits from Scrypt EUR pending balance (#3480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party EUR deposits to Scrypt (identifiable by txId=null) were included in the receiver list, causing toScrypt to go negative and get clamped to 0 — making pending DFX transfers invisible in the FinancialDataLog. --- src/subdomains/supporting/log/log-job.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 96d5348eb2bd6b24b5fd61312dff996fb2a18e96 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:11:17 +0100 Subject: [PATCH 08/11] [NOTASK] partnerFee refactoring (#3483) * [NOTASK] partnerFee refactoring * [NOTASK] prettier fix --- .../supporting/payment/entities/fee.entity.ts | 1 + .../payment/services/fee.service.ts | 24 +++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/subdomains/supporting/payment/entities/fee.entity.ts b/src/subdomains/supporting/payment/entities/fee.entity.ts index f02304b118..e151435a36 100644 --- a/src/subdomains/supporting/payment/entities/fee.entity.ts +++ b/src/subdomains/supporting/payment/entities/fee.entity.ts @@ -12,6 +12,7 @@ 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 diff --git a/src/subdomains/supporting/payment/services/fee.service.ts b/src/subdomains/supporting/payment/services/fee.service.ts index 00a90dc45a..b272be72b2 100644 --- a/src/subdomains/supporting/payment/services/fee.service.ts +++ b/src/subdomains/supporting/payment/services/fee.service.ts @@ -331,13 +331,6 @@ export class FeeService { ]); const blockchainFee = fromFee + toFee; - // 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 special fee const specialFee = Util.minObj( fees.filter((fee) => fee.type === FeeType.SPECIAL), @@ -346,26 +339,33 @@ export class FeeService { if (specialFee) return { - fees: [specialFee, partnerFee].filter((e) => e != null), + fees: [specialFee], dfx: { rate: specialFee.rate, fixed: specialFee.fixed ?? 0 }, bank: { rate: 0, fixed: 0 }, - partner: partnerFeeSpec, + 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, partnerFee].filter((e) => e != null), + fees: [customFee], dfx: { rate: customFee.rate, fixed: customFee.fixed ?? 0 }, bank: { rate: 0, fixed: 0 }, - partner: partnerFeeSpec, + partner: customFee.type === FeeType.CUSTOM_PARTNER ? partnerFeeSpec : { rate: 0, fixed: 0 }, network: Math.min(customFee.blockchainFactor * blockchainFee, Config.maxBlockchainFee), payoutRefBonus: customFee.payoutRefBonus, }; From 5765124a25a23f295882718643f8cd2ce62376cc Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:33:07 +0100 Subject: [PATCH 09/11] [NOTASK] phoneCall Refactoring (#3482) * [NOTASK] phoneCall Refactoring * [NOTASK] Mail text refactoring * [NOTASK] small fix --- src/shared/i18n/de/mail.json | 18 +++++------ src/shared/i18n/en/mail.json | 18 +++++------ src/shared/i18n/es/mail.json | 18 +++++------ src/shared/i18n/fr/mail.json | 18 +++++------ src/shared/i18n/it/mail.json | 18 +++++------ src/shared/i18n/pt/mail.json | 18 +++++------ .../core/aml/enums/aml-error.enum.ts | 6 ++++ .../core/aml/services/aml-helper.service.ts | 30 ++++++++++++------- 8 files changed, 80 insertions(+), 64 deletions(-) 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; } From 513c3adbb1e8a92c9ea9f8415a18db76df81ae53 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:24:48 +0100 Subject: [PATCH 10/11] Improve custody order list entry DTO (#3481) * feat: improve custody order list entry DTO - Rename userId to userDataId for clarity - Return updated timestamp instead of created - Use TransactionRequest amount as fallback for input/output amounts * fix: set inputAsset on SWAP and SEND custody orders * fix: remove input asset on send --- .../dto/output/custody-order-list-entry.dto.ts | 14 ++++++++------ .../core/custody/services/custody-order.service.ts | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) 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..369f13c9e0 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 @@ -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?.amount, inputAsset: order.inputAsset?.name, - outputAmount: order.outputAmount, + outputAmount: order.outputAmount ?? tr?.estimatedAmount, 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); From 6920696435036e4615d4de674b17e44dbc21d58a Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:34:36 +0100 Subject: [PATCH 11/11] [NOTASK] refReward refactoring (#3485) * [NOTASK] refReward refactoring * [NOTASK] kyc refactoring * fix: input amount calculation with min fee * [NOTASK] remove unused code * fix: custody order amount fallback --------- Co-authored-by: David May --- src/config/config.ts | 1 - .../dto/output/custody-order-list-entry.dto.ts | 6 +++--- .../reward/services/ref-reward.service.ts | 11 ++++++++--- .../generic/kyc/services/kyc.service.ts | 16 ++++++++-------- .../payment/services/transaction-helper.ts | 3 ++- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index b2fb04bdb5..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 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 369f13c9e0..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; @@ -20,9 +20,9 @@ export class CustodyOrderListEntry { id: order.id, type: order.type, status: order.status, - inputAmount: order.inputAmount ?? tr?.amount, + inputAmount: order.inputAmount ?? tr?.estimatedAmount, inputAsset: order.inputAsset?.name, - outputAmount: order.outputAmount ?? tr?.estimatedAmount, + outputAmount: order.outputAmount ?? tr?.amount, outputAsset: order.outputAsset?.name, userDataId: order.user?.userData?.id, userName: order.user?.userData?.verifiedName, 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 e47a2f7295..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, @@ -151,10 +154,12 @@ export class RefRewardService { 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/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/supporting/payment/services/transaction-helper.ts b/src/subdomains/supporting/payment/services/transaction-helper.ts index 0e62411183..23121ba12c 100644 --- a/src/subdomains/supporting/payment/services/transaction-helper.ts +++ b/src/subdomains/supporting/payment/services/transaction-helper.ts @@ -843,7 +843,8 @@ export class TransactionHelper implements OnModuleInit { 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; + const inputAmountWithMinFee = + (outputAmount + network + bank.fixed + partner.fixed + networkStart + min) / (1 - (bank.rate + partner.rate)); return Math.max(inputAmountNormal, inputAmountWithMinFee); }