diff --git a/infrastructure/bicep/container-groups/README.md b/infrastructure/bicep/container-groups/README.md index 06c910fd4d..6328d12879 100644 --- a/infrastructure/bicep/container-groups/README.md +++ b/infrastructure/bicep/container-groups/README.md @@ -13,6 +13,7 @@ Container Instances are: - hb-deuro-usdt: Hummingbot (dEURO/USDT) - hb-jusd-usdt: Hummingbot (JUSD/BTC) - hb-deps-usdt: Hummingbot (dEPS/USDT) +- hb-keep-market: Hummingbot (Cross pair bot to keep a certain 24h volumen) - rk: RangeKeeper Liquidity Bot ### Fileshare diff --git a/infrastructure/bicep/container-groups/deploy.sh b/infrastructure/bicep/container-groups/deploy.sh index c64a9d3485..0fa8da8a5e 100755 --- a/infrastructure/bicep/container-groups/deploy.sh +++ b/infrastructure/bicep/container-groups/deploy.sh @@ -11,8 +11,9 @@ environmentOptions=("loc" "dev" "prd") # "hb-deuro-usdt": Hummingbot (dEURO/USDT) # "hb-jusd-usdt": Hummingbot (JuiceDollar/USDT) # "hb-deps-usdt": Hummingbot (dEPS/USDT) +# "hb-keep-market": Hummingbot (Cross pair bot to keep a certain 24h volumen) # "rk": RangeKeeper Liquidity Bot -instanceNameOptions=("hb-deuro-usdt" "hb-jusd-usdt" "hb-deps-usdt" "rk") +instanceNameOptions=("hb-deuro-usdt" "hb-jusd-usdt" "hb-deps-usdt" "hb-keep-market" "rk") # --- ARGUMENTS --- # DOCKER_USERNAME= diff --git a/infrastructure/bicep/container-groups/parameters/dev-hb-keep-market.json b/infrastructure/bicep/container-groups/parameters/dev-hb-keep-market.json new file mode 100644 index 0000000000..72519aed1a --- /dev/null +++ b/infrastructure/bicep/container-groups/parameters/dev-hb-keep-market.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "fileShareQuota": { + "value": 100 + }, + "containerImage": { + "value": "dfxswiss/hummingbot:latest" + }, + "containerVolumeMounts": { + "value": [ + { + "name": "volume", + "mountPath": "/mnt/hummingbot", + "readOnly": false + } + ] + }, + "containerCPU": { + "value": "0.5" + }, + "containerMemory": { + "value": 1 + }, + "containerEnv": { + "value": [ + { + "name": "BOT_DIR", + "value": "keep-market-dev" + }, + { + "name": "STRATEGY_FILE", + "value": "conf_v2_with_controllers_2.yml.yml" + }, + { + "name": "SCRIPT_FILE", + "value": "v2_with_controllers.py" + } + ] + }, + "containerCommand": { + "value": [] + } + } +} \ No newline at end of file diff --git a/infrastructure/bicep/container-groups/parameters/prd-hb-keep-market.json b/infrastructure/bicep/container-groups/parameters/prd-hb-keep-market.json new file mode 100644 index 0000000000..8a5660a91f --- /dev/null +++ b/infrastructure/bicep/container-groups/parameters/prd-hb-keep-market.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "fileShareQuota": { + "value": 100 + }, + "containerImage": { + "value": "dfxswiss/hummingbot:latest" + }, + "containerVolumeMounts": { + "value": [ + { + "name": "volume", + "mountPath": "/mnt/hummingbot", + "readOnly": false + } + ] + }, + "containerCPU": { + "value": "0.5" + }, + "containerMemory": { + "value": 1 + }, + "containerEnv": { + "value": [ + { + "name": "BOT_DIR", + "value": "keep-market" + }, + { + "name": "STRATEGY_FILE", + "value": "conf_v2_with_controllers_2.yml.yml" + }, + { + "name": "SCRIPT_FILE", + "value": "v2_with_controllers.py" + } + ] + }, + "containerCommand": { + "value": [] + } + } +} \ No newline at end of file diff --git a/infrastructure/config/docker/docker-compose-firo.yml b/infrastructure/config/docker/docker-compose-firo.yml index 3eecb5c835..9f2b475c1f 100644 --- a/infrastructure/config/docker/docker-compose-firo.yml +++ b/infrastructure/config/docker/docker-compose-firo.yml @@ -1,25 +1,42 @@ name: 'firo' services: + autoheal: + image: willfarrell/autoheal:latest + restart: unless-stopped + environment: + - AUTOHEAL_CONTAINER_LABEL=autoheal + - AUTOHEAL_INTERVAL=30 + - AUTOHEAL_START_PERIOD=120 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + firod: image: firoorg/firod:0.14.15.2 restart: unless-stopped + labels: + - autoheal=true deploy: resources: limits: - memory: 2048M + memory: 8192M reservations: - memory: 1024M + memory: 4096M volumes: - ./volumes/firo:/home/firod/.firo ports: - '8168:8168' - '8888:8888' healthcheck: - test: firo-cli -conf=/home/firod/.firo/firo.conf getblockcount || exit 1 + test: firo-cli -conf=/home/firod/.firo/firo.conf getblockchaininfo || exit 1 start_period: 120s - interval: 120s - timeout: 10s + interval: 60s + timeout: 30s retries: 3 + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '3' command: > -conf=/home/firod/.firo/firo.conf diff --git a/infrastructure/config/firo/firo.conf b/infrastructure/config/firo/firo.conf index 3f141c0085..e6306f31e6 100644 --- a/infrastructure/config/firo/firo.conf +++ b/infrastructure/config/firo/firo.conf @@ -30,7 +30,7 @@ mempoolexpiry=24 maxorphantx=10 # Performance (defaults: dbcache=450MB, rpcthreads=4, rpcworkqueue=16) -dbcache=256 +dbcache=1024 rpcthreads=8 rpcworkqueue=64 diff --git a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts index ba3a7bdb28..545d404125 100644 --- a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts +++ b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts @@ -53,13 +53,20 @@ export abstract class BitcoinBasedClient extends NodeClient implements CoinOnly return { outTxId: result?.txid ?? '', feeAmount }; } - async sendMany(payload: { addressTo: string; amount: number }[], feeRate: number): Promise { + async sendMany( + payload: { addressTo: string; amount: number }[], + feeRate: number, + inputs?: Array<{ txid: string; vout: number }>, + subtractFeeFromOutputs?: number[], + ): Promise { const outputs = payload.map((p) => ({ [p.addressTo]: p.amount })); const options = { replaceable: true, change_address: this.walletAddress, ...(this.nodeConfig.allowUnconfirmedUtxos && { include_unsafe: true }), + ...(inputs && { inputs, add_inputs: false }), + ...(subtractFeeFromOutputs && { subtract_fee_from_outputs: subtractFeeFromOutputs }), }; const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); @@ -67,6 +74,24 @@ export abstract class BitcoinBasedClient extends NodeClient implements CoinOnly return result?.txid ?? ''; } + async sendManyFromAddress( + fromAddresses: string[], + payload: { addressTo: string; amount: number }[], + feeRate: number, + subtractFeeFromOutputs?: number[], + ): Promise { + const utxos = await this.getUtxoForAddresses(fromAddresses, this.nodeConfig.allowUnconfirmedUtxos); + if (!utxos.length) throw new Error('No UTXOs available'); + + const inputs = utxos.map((u) => ({ txid: u.txid, vout: u.vout })); + const utxoBalance = utxos.reduce((sum, u) => sum + u.amount, 0); + + // resolve zero-amount entries with full UTXO balance (sweep mode) + const resolvedPayload = payload.map((p) => ({ addressTo: p.addressTo, amount: p.amount || utxoBalance })); + + return this.sendMany(resolvedPayload, feeRate, inputs, subtractFeeFromOutputs); + } + async testMempoolAccept(hex: string): Promise { const result = await this.callNode(() => this.rpc.testMempoolAccept([hex]), true); diff --git a/src/integration/blockchain/bitcoin/node/node-client.ts b/src/integration/blockchain/bitcoin/node/node-client.ts index 65c5bd0a48..34912e3378 100644 --- a/src/integration/blockchain/bitcoin/node/node-client.ts +++ b/src/integration/blockchain/bitcoin/node/node-client.ts @@ -158,6 +158,11 @@ export abstract class NodeClient extends BlockchainClient { return this.callNode(() => this.rpc.listUnspent(minConf), true); } + async getUtxoForAddresses(addresses: string[], includeUnconfirmed = false): Promise { + const minConf = includeUnconfirmed ? 0 : 1; + return this.callNode(() => this.rpc.listUnspent(minConf, 9999999, addresses), true); + } + async getBalance(): Promise { // Include unconfirmed UTXOs when configured // Bitcoin Core's getbalances returns: trusted (confirmed + own unconfirmed), untrusted_pending (others' unconfirmed), immature (coinbase) diff --git a/src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service.ts b/src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service.ts index a4a9775b7d..9d0d95952c 100644 --- a/src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service.ts +++ b/src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service.ts @@ -9,6 +9,12 @@ export interface TxFeeRateResult { feeRate?: number; } +export interface FeeConfig { + allowUnconfirmedUtxos: boolean; + cpfpFeeMultiplier: number; + defaultFeeMultiplier: number; +} + export abstract class BitcoinBasedFeeService { private readonly logger = new DfxLogger(BitcoinBasedFeeService); @@ -17,6 +23,8 @@ export abstract class BitcoinBasedFeeService { constructor(protected readonly client: NodeClient) {} + protected abstract get feeConfig(): FeeConfig; + async getRecommendedFeeRate(): Promise { return this.feeRateCache.get( 'fastestFee', @@ -74,4 +82,13 @@ export abstract class BitcoinBasedFeeService { return results; } + + async getSendFeeRate(): Promise { + const baseRate = await this.getRecommendedFeeRate(); + + const { allowUnconfirmedUtxos, cpfpFeeMultiplier, defaultFeeMultiplier } = this.feeConfig; + const multiplier = allowUnconfirmedUtxos ? cpfpFeeMultiplier : defaultFeeMultiplier; + + return baseRate * multiplier; + } } diff --git a/src/integration/blockchain/bitcoin/services/bitcoin-fee.service.ts b/src/integration/blockchain/bitcoin/services/bitcoin-fee.service.ts index ac6eeb95d4..08620eff47 100644 --- a/src/integration/blockchain/bitcoin/services/bitcoin-fee.service.ts +++ b/src/integration/blockchain/bitcoin/services/bitcoin-fee.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { BitcoinBasedFeeService } from './bitcoin-based-fee.service'; +import { Config } from 'src/config/config'; +import { BitcoinBasedFeeService, FeeConfig } from './bitcoin-based-fee.service'; import { BitcoinNodeType, BitcoinService } from './bitcoin.service'; export { TxFeeRateResult, TxFeeRateStatus } from './bitcoin-based-fee.service'; @@ -9,4 +10,8 @@ export class BitcoinFeeService extends BitcoinBasedFeeService { constructor(bitcoinService: BitcoinService) { super(bitcoinService.getDefaultClient(BitcoinNodeType.BTC_INPUT)); } + + protected get feeConfig(): FeeConfig { + return Config.blockchain.default; + } } diff --git a/src/integration/blockchain/firo/firo-client.ts b/src/integration/blockchain/firo/firo-client.ts index 55cbd8cfc3..8274c97aa7 100644 --- a/src/integration/blockchain/firo/firo-client.ts +++ b/src/integration/blockchain/firo/firo-client.ts @@ -50,10 +50,9 @@ 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. async getBalance(): Promise { - const minConf = this.nodeConfig.allowUnconfirmedUtxos ? 0 : 1; - const utxos = await this.callNode( - () => this.rpc.listUnspent(minConf, 9999999, [this.walletAddress, this.paymentAddress]), - true, + const utxos = await this.getUtxoForAddresses( + [this.walletAddress, this.paymentAddress], + this.nodeConfig.allowUnconfirmedUtxos, ); return this.roundAmount(utxos?.reduce((sum, u) => sum + u.amount, 0) ?? 0); @@ -103,24 +102,28 @@ export class FiroClient extends BitcoinBasedClient { return { outTxId, feeAmount }; } - // Use UTXOs from the liquidity and payment addresses to avoid spending deposit UTXOs. - // Change is sent back to the liquidity address, naturally consolidating funds over time. + // Delegates to sendManyFromAddress using the liquidity and payment addresses. async sendMany(payload: { addressTo: string; amount: number }[], feeRate: number): Promise { + return this.sendManyFromAddress([this.walletAddress, this.paymentAddress], payload, feeRate); + } + + // Use UTXOs from the specified addresses to avoid spending deposit UTXOs. + // Change is sent back to the liquidity address, naturally consolidating funds over time. + async sendManyFromAddress( + fromAddresses: string[], + 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); - // Get UTXOs from liquidity and payment addresses (excludes deposit address UTXOs) - const minConf = this.nodeConfig.allowUnconfirmedUtxos ? 0 : 1; - const utxos = await this.callNode( - () => this.rpc.listUnspent(minConf, 9999999, [this.walletAddress, this.paymentAddress]), - true, - ); + const utxos = await this.getUtxoForAddresses(fromAddresses, this.nodeConfig.allowUnconfirmedUtxos); if (!utxos || utxos.length === 0) { - throw new Error('No UTXOs available on the liquidity/payment addresses'); + 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) @@ -143,9 +146,7 @@ export class FiroClient extends BitcoinBasedClient { const fee = (feeRate * txSize) / 1e8; if (inputTotal < outputTotal + fee) { - throw new Error( - `Insufficient funds on liquidity/payment addresses: have ${inputTotal}, need ${outputTotal + fee}`, - ); + throw new Error(`Insufficient funds on specified addresses: have ${inputTotal}, need ${outputTotal + fee}`); } const change = this.roundAmount(inputTotal - outputTotal - fee); diff --git a/src/integration/blockchain/firo/services/firo-fee.service.ts b/src/integration/blockchain/firo/services/firo-fee.service.ts index d7d08e3d96..2d985ec1a2 100644 --- a/src/integration/blockchain/firo/services/firo-fee.service.ts +++ b/src/integration/blockchain/firo/services/firo-fee.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Config } from 'src/config/config'; -import { BitcoinBasedFeeService } from '../../bitcoin/services/bitcoin-based-fee.service'; +import { BitcoinBasedFeeService, FeeConfig } from '../../bitcoin/services/bitcoin-based-fee.service'; import { FiroService } from './firo.service'; @Injectable() @@ -9,12 +9,7 @@ export class FiroFeeService extends BitcoinBasedFeeService { super(firoService.getDefaultClient()); } - async getSendFeeRate(): Promise { - const baseRate = await this.getRecommendedFeeRate(); - - const { allowUnconfirmedUtxos, cpfpFeeMultiplier, defaultFeeMultiplier } = Config.blockchain.firo; - const multiplier = allowUnconfirmedUtxos ? cpfpFeeMultiplier : defaultFeeMultiplier; - - return baseRate * multiplier; + protected get feeConfig(): FeeConfig { + return Config.blockchain.firo; } } diff --git a/src/integration/blockchain/icp/icp-client.ts b/src/integration/blockchain/icp/icp-client.ts index d9f29bdb6f..8c66ac0087 100644 --- a/src/integration/blockchain/icp/icp-client.ts +++ b/src/integration/blockchain/icp/icp-client.ts @@ -3,7 +3,7 @@ import { IcpLedgerCanister } from '@dfinity/ledger-icp'; import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; import { Principal } from '@dfinity/principal'; import { Config, GetConfig } from 'src/config/config'; -import { Asset } from 'src/shared/models/asset/asset.entity'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { HttpService } from 'src/shared/services/http.service'; import { Util } from 'src/shared/utils/util'; @@ -342,13 +342,7 @@ export class InternetComputerClient extends BlockchainClient { async sendNativeCoinFromAccount(account: WalletAccount, toAddress: string, amount: number): Promise { const wallet = InternetComputerWallet.fromSeed(account.seed, account.index); - const balance = await this.getNativeCoinBalanceForAddress(wallet.address); - - const sendAmount = Math.min(amount, balance) - this.transferFee; - if (sendAmount <= 0) - throw new Error(`Insufficient balance for payment forward: balance=${balance}, fee=${this.transferFee}`); - - return this.sendNativeCoin(wallet, toAddress, sendAmount); + return this.sendNativeCoin(wallet, toAddress, amount); } async sendNativeCoinFromDepositWallet(accountIndex: number, toAddress: string, amount: number): Promise { @@ -379,14 +373,7 @@ export class InternetComputerClient extends BlockchainClient { async sendTokenFromAccount(account: WalletAccount, toAddress: string, token: Asset, amount: number): Promise { const wallet = InternetComputerWallet.fromSeed(account.seed, account.index); - const balance = await this.getTokenBalance(token, wallet.address); - const fee = await this.getCurrentGasCostForTokenTransaction(token); - - const sendAmount = Math.min(amount, balance) - fee; - if (sendAmount <= 0) - throw new Error(`Insufficient token balance for payment forward: balance=${balance}, fee=${fee}`); - - return this.sendToken(wallet, toAddress, token, sendAmount); + return this.sendToken(wallet, toAddress, token, amount); } async sendTokenFromDepositWallet( @@ -430,9 +417,10 @@ export class InternetComputerClient extends BlockchainClient { async checkAllowance( ownerPrincipal: string, spenderPrincipal: string, - canisterId: string, - decimals: number, + asset: Asset, ): Promise<{ allowance: number; expiresAt?: number }> { + const canisterId = this.getCanisterId(asset); + const tokenLedger = IcrcLedgerCanister.create({ agent: this.agent, canisterId: Principal.fromText(canisterId), @@ -445,7 +433,7 @@ export class InternetComputerClient extends BlockchainClient { }); return { - allowance: InternetComputerUtil.fromSmallestUnit(result.allowance, decimals), + allowance: InternetComputerUtil.fromSmallestUnit(result.allowance, asset.decimals), expiresAt: result.expires_at?.[0] ? Number(result.expires_at[0]) : undefined, }; } @@ -455,12 +443,13 @@ export class InternetComputerClient extends BlockchainClient { ownerPrincipal: string, toAddress: string, amount: number, - canisterId: string, - decimals: number, + asset: Asset, ): Promise { const wallet = InternetComputerWallet.fromSeed(account.seed, account.index); const agent = wallet.getAgent(this.host); + const canisterId = this.getCanisterId(asset); + const tokenLedger = IcrcLedgerCanister.create({ agent, canisterId: Principal.fromText(canisterId), @@ -469,11 +458,16 @@ export class InternetComputerClient extends BlockchainClient { const blockIndex = await tokenLedger.transferFrom({ from: { owner: Principal.fromText(ownerPrincipal), subaccount: [] }, to: { owner: Principal.fromText(toAddress), subaccount: [] }, - amount: InternetComputerUtil.toSmallestUnit(amount, decimals), + amount: InternetComputerUtil.toSmallestUnit(amount, asset.decimals), }); - const isNative = canisterId === Config.blockchain.internetComputer.internetComputerLedgerCanisterId; - return isNative ? blockIndex.toString() : `${canisterId}:${blockIndex}`; + return asset.type === AssetType.COIN ? blockIndex.toString() : `${canisterId}:${blockIndex}`; + } + + private getCanisterId(asset: Asset): string { + return asset.type === AssetType.COIN + ? Config.blockchain.internetComputer.internetComputerLedgerCanisterId + : asset.chainId; } // --- Misc --- diff --git a/src/integration/blockchain/icp/icp.controller.ts b/src/integration/blockchain/icp/icp.controller.ts deleted file mode 100644 index 0be20264e5..0000000000 --- a/src/integration/blockchain/icp/icp.controller.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Controller, Get, Param } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; -import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; -import { Blockchain } from '../shared/enums/blockchain.enum'; -import { InternetComputerService } from './services/icp.service'; - -@ApiTags('Internet Computer') -@Controller('icp') -export class InternetComputerController { - constructor(private readonly internetComputerService: InternetComputerService) {} - - @Get('address') - getWalletAddress(): string { - return this.internetComputerService.getWalletAddress(); - } - - @Get('balance') - async getBalance(): Promise { - return this.internetComputerService.getNativeCoinBalance(); - } - - @Get('balance/tokens') - async getTokenBalances(): Promise { - const assets = [ - this.createToken('ckBTC', 'mxzaz-hqaaa-aaaar-qaada-cai', 8), - this.createToken('ckETH', 'ss2fx-dyaaa-aaaar-qacoq-cai', 18), - this.createToken('ckUSDC', 'xevnm-gaaaa-aaaar-qafnq-cai', 6), - this.createToken('ckUSDT', 'cngnf-vqaaa-aaaar-qag4q-cai', 6), - ]; - return this.internetComputerService.getDefaultClient().getTokenBalances(assets); - } - - @Get('tx/:blockIndex/complete') - async isTxComplete(@Param('blockIndex') blockIndex: string): Promise { - return this.internetComputerService.getDefaultClient().isTxComplete(blockIndex); - } - - private createToken(name: string, canisterId: string, decimals: number): Asset { - const asset = new Asset(); - asset.chainId = canisterId; - asset.blockchain = Blockchain.INTERNET_COMPUTER; - asset.type = AssetType.TOKEN; - asset.decimals = decimals; - asset.name = name; - asset.uniqueName = `${name}/${Blockchain.INTERNET_COMPUTER}`; - - return asset; - } -} diff --git a/src/integration/blockchain/icp/icp.module.ts b/src/integration/blockchain/icp/icp.module.ts index 18bb498c61..7157fdaf1d 100644 --- a/src/integration/blockchain/icp/icp.module.ts +++ b/src/integration/blockchain/icp/icp.module.ts @@ -1,11 +1,9 @@ import { Module } from '@nestjs/common'; import { SharedModule } from 'src/shared/shared.module'; -import { InternetComputerController } from './icp.controller'; import { InternetComputerService } from './services/icp.service'; @Module({ imports: [SharedModule], - controllers: [InternetComputerController], providers: [InternetComputerService], exports: [InternetComputerService], }) diff --git a/src/integration/blockchain/icp/services/icp.service.ts b/src/integration/blockchain/icp/services/icp.service.ts index 733efe6275..97bbeb1fa8 100644 --- a/src/integration/blockchain/icp/services/icp.service.ts +++ b/src/integration/blockchain/icp/services/icp.service.ts @@ -2,14 +2,11 @@ import { Principal } from '@dfinity/principal'; import { Injectable } from '@nestjs/common'; import { secp256k1 } from '@noble/curves/secp256k1'; import { sha256 } from '@noble/hashes/sha2'; -import { Asset } from 'src/shared/models/asset/asset.entity'; import { HttpService } from 'src/shared/services/http.service'; import { Util } from 'src/shared/utils/util'; import nacl from 'tweetnacl'; -import { WalletAccount } from '../../shared/evm/domain/wallet-account'; import { SignatureException } from '../../shared/exceptions/signature.exception'; import { BlockchainService } from '../../shared/util/blockchain.service'; -import { IcpTransfer, IcpTransferQueryResult } from '../dto/icp.dto'; import { InternetComputerClient } from '../icp-client'; @Injectable() @@ -26,10 +23,6 @@ export class InternetComputerService extends BlockchainService { return this.client; } - getWalletAddress(): string { - return this.client.walletAddress; - } - getPaymentRequest(address: string, amount: number): string { return `icp:${address}?amount=${Util.numberToFixedString(amount)}`; } @@ -87,102 +80,4 @@ export class InternetComputerService extends BlockchainService { return false; } } - - async getBlockHeight(): Promise { - return this.client.getBlockHeight(); - } - - async getTransfers(start: number, count: number): Promise { - return this.client.getTransfers(start, count); - } - - async getNativeTransfersForAddress( - accountIdentifier: string, - maxBlock?: number, - limit?: number, - ): Promise { - return this.client.getNativeTransfersForAddress(accountIdentifier, maxBlock, limit); - } - - async getIcrcBlockHeight(canisterId: string): Promise { - return this.client.getIcrcBlockHeight(canisterId); - } - - async getIcrcTransfers( - canisterId: string, - decimals: number, - start: number, - count: number, - ): Promise { - return this.client.getIcrcTransfers(canisterId, decimals, start, count); - } - - async getNativeCoinBalance(): Promise { - return this.client.getNativeCoinBalance(); - } - - async getNativeCoinBalanceForAddress(address: string): Promise { - return this.client.getNativeCoinBalanceForAddress(address); - } - - async getTokenBalance(asset: Asset, address?: string): Promise { - return this.client.getTokenBalance(asset, address ?? this.client.walletAddress); - } - - async getCurrentGasCostForCoinTransaction(): Promise { - return this.client.getCurrentGasCostForCoinTransaction(); - } - - async getCurrentGasCostForTokenTransaction(token?: Asset): Promise { - return this.client.getCurrentGasCostForTokenTransaction(token); - } - - async sendNativeCoinFromDex(toAddress: string, amount: number): Promise { - return this.client.sendNativeCoinFromDex(toAddress, amount); - } - - async sendNativeCoinFromDepositWallet(accountIndex: number, toAddress: string, amount: number): Promise { - return this.client.sendNativeCoinFromDepositWallet(accountIndex, toAddress, amount); - } - - async sendTokenFromDex(toAddress: string, token: Asset, amount: number): Promise { - return this.client.sendTokenFromDex(toAddress, token, amount); - } - - async sendTokenFromDepositWallet( - accountIndex: number, - toAddress: string, - token: Asset, - amount: number, - ): Promise { - return this.client.sendTokenFromDepositWallet(accountIndex, toAddress, token, amount); - } - - async checkAllowance( - ownerPrincipal: string, - spenderPrincipal: string, - canisterId: string, - decimals: number, - ): Promise<{ allowance: number; expiresAt?: number }> { - return this.client.checkAllowance(ownerPrincipal, spenderPrincipal, canisterId, decimals); - } - - async transferFromWithAccount( - account: WalletAccount, - ownerPrincipal: string, - toAddress: string, - amount: number, - canisterId: string, - decimals: number, - ): Promise { - return this.client.transferFromWithAccount(account, ownerPrincipal, toAddress, amount, canisterId, decimals); - } - - async isTxComplete(blockIndex: string): Promise { - return this.client.isTxComplete(blockIndex); - } - - async getTxActualFee(blockIndex: string): Promise { - return this.client.getTxActualFee(blockIndex); - } } 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 451d88c577..fe1bd652d0 100644 --- a/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts +++ b/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { HttpService } from 'src/shared/services/http.service'; +import { BrokerbotCurrency } from '../dto/realunit-broker.dto'; import { RealUnitBlockchainService } from '../realunit-blockchain.service'; // Mock viem @@ -127,15 +128,25 @@ describe('RealUnitBlockchainService', () => { expect(result.baseCurrencyAddress).toBe('0xZCHF'); }); - it('should return price from fetchPrice', async () => { + it('should return CHF price by default', async () => { httpService.post.mockResolvedValue({ priceInCHF: 123.45, priceInEUR: 114, availableShares: 200 }); const result = await service.getBrokerbotInfo('0xBB', '0xR', '0xZ'); expect(result.pricePerShare).toBe('123.45'); + expect(result.currency).toBe(BrokerbotCurrency.CHF); expect(result.availableShares).toBe(200); }); + it('should return EUR price when currency is EUR', async () => { + httpService.post.mockResolvedValue({ priceInCHF: 123.45, priceInEUR: 114, availableShares: 200 }); + + const result = await service.getBrokerbotInfo('0xBB', '0xR', '0xZ', BrokerbotCurrency.EUR); + + expect(result.pricePerShare).toBe('114'); + expect(result.currency).toBe(BrokerbotCurrency.EUR); + }); + it('should set buyingEnabled to false when availableShares is 0', async () => { httpService.post.mockResolvedValue({ priceInCHF: 100, priceInEUR: 92, availableShares: 0 }); @@ -152,4 +163,73 @@ describe('RealUnitBlockchainService', () => { expect(result.sellingEnabled).toBe(true); }); }); + + describe('getBrokerbotPrice', () => { + beforeEach(() => { + httpService.post.mockResolvedValue({ priceInCHF: 100.5, priceInEUR: 92.3, availableShares: 500 }); + }); + + it('should return CHF price by default', async () => { + const result = await service.getBrokerbotPrice(); + + expect(result.pricePerShare).toBe('100.5'); + expect(result.currency).toBe(BrokerbotCurrency.CHF); + expect(result.availableShares).toBe(500); + }); + + 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.currency).toBe(BrokerbotCurrency.EUR); + }); + }); + + describe('getBrokerbotBuyPrice', () => { + beforeEach(() => { + httpService.post.mockResolvedValue({ priceInCHF: 100, priceInEUR: 92, availableShares: 500 }); + }); + + it('should calculate total price in CHF by default', async () => { + const result = await service.getBrokerbotBuyPrice(10); + + expect(result.shares).toBe(10); + expect(result.totalPrice).toBe('1000'); + expect(result.pricePerShare).toBe('100'); + expect(result.currency).toBe(BrokerbotCurrency.CHF); + }); + + it('should calculate total price in EUR when currency is EUR', async () => { + 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.currency).toBe(BrokerbotCurrency.EUR); + }); + }); + + describe('getBrokerbotShares', () => { + beforeEach(() => { + httpService.post.mockResolvedValue({ priceInCHF: 100, priceInEUR: 92, availableShares: 500 }); + }); + + it('should calculate shares from CHF amount by default', async () => { + const result = await service.getBrokerbotShares('1000'); + + expect(result.amount).toBe('1000'); + expect(result.shares).toBe(10); + 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); + + expect(result.amount).toBe('920'); + expect(result.shares).toBe(10); + 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 7591b9bb37..479e95dec3 100644 --- a/src/integration/blockchain/realunit/dto/realunit-broker.dto.ts +++ b/src/integration/blockchain/realunit/dto/realunit-broker.dto.ts @@ -1,9 +1,29 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; + +export enum BrokerbotCurrency { + CHF = 'CHF', + EUR = 'EUR', +} + +export class BrokerbotCurrencyQueryDto { + @ApiPropertyOptional({ + enum: BrokerbotCurrency, + description: 'Currency for prices (CHF or EUR)', + default: BrokerbotCurrency.CHF, + }) + @IsOptional() + @IsEnum(BrokerbotCurrency) + currency?: BrokerbotCurrency; +} export class BrokerbotPriceDto { - @ApiProperty({ description: 'Current price per share in CHF' }) + @ApiProperty({ description: 'Current price per share' }) pricePerShare: string; + @ApiProperty({ description: 'Currency of the price', enum: BrokerbotCurrency }) + currency: BrokerbotCurrency; + @ApiProperty({ description: 'Available shares for purchase' }) availableShares: number; } @@ -12,26 +32,32 @@ export class BrokerbotBuyPriceDto { @ApiProperty({ description: 'Number of shares' }) shares: number; - @ApiProperty({ description: 'Total cost in CHF' }) + @ApiProperty({ description: 'Total cost' }) totalPrice: string; - @ApiProperty({ description: 'Price per share in CHF' }) + @ApiProperty({ description: 'Price per share' }) pricePerShare: string; + @ApiProperty({ description: 'Currency of the prices', enum: BrokerbotCurrency }) + currency: BrokerbotCurrency; + @ApiProperty({ description: 'Available shares for purchase' }) availableShares: number; } export class BrokerbotSharesDto { - @ApiProperty({ description: 'Amount in CHF' }) + @ApiProperty({ description: 'Amount in specified currency' }) amount: string; @ApiProperty({ description: 'Number of shares that can be purchased' }) shares: number; - @ApiProperty({ description: 'Price per share in CHF' }) + @ApiProperty({ description: 'Price per share' }) pricePerShare: string; + @ApiProperty({ description: 'Currency of the prices', enum: BrokerbotCurrency }) + currency: BrokerbotCurrency; + @ApiProperty({ description: 'Available shares for purchase' }) availableShares: number; } @@ -46,9 +72,12 @@ export class BrokerbotInfoDto { @ApiProperty({ description: 'Base currency (ZCHF) address' }) baseCurrencyAddress: string; - @ApiProperty({ description: 'Current price per share in CHF' }) + @ApiProperty({ description: 'Current price per share' }) pricePerShare: string; + @ApiProperty({ description: 'Currency of the price', enum: BrokerbotCurrency }) + currency: BrokerbotCurrency; + @ApiProperty({ description: 'Whether buying is enabled' }) buyingEnabled: boolean; diff --git a/src/integration/blockchain/realunit/realunit-blockchain.service.ts b/src/integration/blockchain/realunit/realunit-blockchain.service.ts index 82192d8b6f..a0edfdb3d7 100644 --- a/src/integration/blockchain/realunit/realunit-blockchain.service.ts +++ b/src/integration/blockchain/realunit/realunit-blockchain.service.ts @@ -7,6 +7,7 @@ import { Blockchain } from '../shared/enums/blockchain.enum'; import { EvmUtil } from '../shared/evm/evm.util'; import { BrokerbotBuyPriceDto, + BrokerbotCurrency, BrokerbotInfoDto, BrokerbotPriceDto, BrokerbotSharesDto, @@ -84,46 +85,65 @@ export class RealUnitBlockchainService { // --- Brokerbot Methods --- - async getBrokerbotPrice(): Promise { - const { priceInCHF, availableShares } = await this.fetchPrice(); + async getBrokerbotPrice(currency: BrokerbotCurrency = BrokerbotCurrency.CHF): Promise { + const { priceInCHF, priceInEUR, availableShares } = await this.fetchPrice(); + const price = currency === BrokerbotCurrency.EUR ? priceInEUR : priceInCHF; return { - pricePerShare: priceInCHF.toString(), + pricePerShare: price.toString(), + currency, availableShares, }; } - async getBrokerbotBuyPrice(shares: number): Promise { - const { priceInCHF, availableShares } = await this.fetchPrice(); - const totalPrice = priceInCHF * shares; + async getBrokerbotBuyPrice( + shares: number, + currency: BrokerbotCurrency = BrokerbotCurrency.CHF, + ): Promise { + const { priceInCHF, priceInEUR, availableShares } = await this.fetchPrice(); + const price = currency === BrokerbotCurrency.EUR ? priceInEUR : priceInCHF; + const totalPrice = price * shares; return { shares, totalPrice: totalPrice.toString(), - pricePerShare: priceInCHF.toString(), + pricePerShare: price.toString(), + currency, availableShares, }; } - async getBrokerbotShares(amountChf: string): Promise { - const { priceInCHF, availableShares } = await this.fetchPrice(); - const shares = Math.floor(parseFloat(amountChf) / priceInCHF); + async getBrokerbotShares( + amount: string, + currency: BrokerbotCurrency = BrokerbotCurrency.CHF, + ): Promise { + const { priceInCHF, priceInEUR, availableShares } = await this.fetchPrice(); + const price = currency === BrokerbotCurrency.EUR ? priceInEUR : priceInCHF; + const shares = Math.floor(parseFloat(amount) / price); return { - amount: amountChf, + amount, shares, - pricePerShare: priceInCHF.toString(), + pricePerShare: price.toString(), + currency, availableShares, }; } - async getBrokerbotInfo(brokerbotAddr: string, realuAddr: string, zchfAddr: string): Promise { - const { priceInCHF, availableShares } = await this.fetchPrice(); + async getBrokerbotInfo( + brokerbotAddr: string, + realuAddr: string, + zchfAddr: string, + currency: BrokerbotCurrency = BrokerbotCurrency.CHF, + ): Promise { + const { priceInCHF, priceInEUR, availableShares } = await this.fetchPrice(); + const price = currency === BrokerbotCurrency.EUR ? priceInEUR : priceInCHF; return { brokerbotAddress: brokerbotAddr, tokenAddress: realuAddr, baseCurrencyAddress: zchfAddr, - pricePerShare: priceInCHF.toString(), + pricePerShare: price.toString(), + currency, buyingEnabled: availableShares > 0, sellingEnabled: true, availableShares, diff --git a/src/subdomains/core/payment-link/services/payment-balance.service.ts b/src/subdomains/core/payment-link/services/payment-balance.service.ts index c98e891d58..186bf3cf23 100644 --- a/src/subdomains/core/payment-link/services/payment-balance.service.ts +++ b/src/subdomains/core/payment-link/services/payment-balance.service.ts @@ -1,6 +1,9 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { Config } from 'src/config/config'; +import { BitcoinFeeService } from 'src/integration/blockchain/bitcoin/services/bitcoin-fee.service'; +import { BitcoinNodeType } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; import { CardanoUtil } from 'src/integration/blockchain/cardano/cardano.util'; +import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; import { InternetComputerUtil } from 'src/integration/blockchain/icp/icp.util'; import { BlockchainTokenBalance } from 'src/integration/blockchain/shared/dto/blockchain-token-balance.dto'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; @@ -10,7 +13,6 @@ import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; import { SolanaClient } from 'src/integration/blockchain/solana/solana-client'; import { SolanaUtil } from 'src/integration/blockchain/solana/solana.util'; -import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; import { TronClient } from 'src/integration/blockchain/tron/tron-client'; import { TronUtil } from 'src/integration/blockchain/tron/tron.util'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; @@ -46,6 +48,7 @@ export class PaymentBalanceService implements OnModuleInit { constructor( private readonly assetService: AssetService, private readonly blockchainRegistryService: BlockchainRegistryService, + private readonly bitcoinFeeService: BitcoinFeeService, ) {} onModuleInit() { @@ -161,7 +164,7 @@ export class PaymentBalanceService implements OnModuleInit { } async forwardDeposits() { - const chainsWithoutForwarding = [Blockchain.BITCOIN, Blockchain.FIRO, ...this.chainsWithoutPaymentBalance]; + const chainsWithoutForwarding = [Blockchain.FIRO, ...this.chainsWithoutPaymentBalance]; const paymentAssets = await this.assetService .getPaymentAssets() @@ -181,18 +184,53 @@ export class PaymentBalanceService implements OnModuleInit { } private async forwardDeposit(asset: Asset, balance: number): Promise { + if (asset.blockchain === Blockchain.BITCOIN) { + return this.forwardBitcoinDeposit(); + } + + if (asset.blockchain === Blockchain.INTERNET_COMPUTER) { + return this.forwardInternetComputerDeposit(asset, balance); + } + const account = this.getPaymentAccount(asset.blockchain); - const client = this.blockchainRegistryService.getClient(asset.blockchain) as - | EvmClient - | SolanaClient - | TronClient - | InternetComputerClient; + const client = this.blockchainRegistryService.getClient(asset.blockchain) as EvmClient | SolanaClient | TronClient; return asset.type === AssetType.COIN ? client.sendNativeCoinFromAccount(account, client.walletAddress, balance) : client.sendTokenFromAccount(account, client.walletAddress, asset, balance); } + private async forwardInternetComputerDeposit(asset: Asset, balance: number): Promise { + const account = this.getPaymentAccount(asset.blockchain); + const client = this.blockchainRegistryService.getClient(asset.blockchain) as InternetComputerClient; + + const forwardFee = + asset.type === AssetType.COIN + ? await client.getCurrentGasCostForCoinTransaction() + : await client.getCurrentGasCostForTokenTransaction(asset); + + const sendAmount = balance - forwardFee; + if (sendAmount <= 0) throw new Error(`Insufficient balance for ICP forward: balance=${balance}, fee=${forwardFee}`); + + return asset.type === AssetType.COIN + ? client.sendNativeCoinFromAccount(account, client.walletAddress, sendAmount) + : client.sendTokenFromAccount(account, client.walletAddress, asset, sendAmount); + } + + private async forwardBitcoinDeposit(): Promise { + const client = this.blockchainRegistryService.getBitcoinClient(Blockchain.BITCOIN, BitcoinNodeType.BTC_INPUT); + const outputAddress = Config.blockchain.default.btcOutput.address; + const feeRate = await this.bitcoinFeeService.getSendFeeRate(); + + // sweep all payment UTXOs: amount 0 = use full UTXO balance, fee subtracted from output + return client.sendManyFromAddress( + [Config.payment.bitcoinAddress], + [{ addressTo: outputAddress, amount: 0 }], + feeRate, + [0], + ); + } + getPaymentAccount(chain: Blockchain): WalletAccount { switch (chain) { case Blockchain.ETHEREUM: 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 8104037d5b..13f51134fa 100644 --- a/src/subdomains/core/payment-link/services/payment-quote.service.ts +++ b/src/subdomains/core/payment-link/services/payment-quote.service.ts @@ -576,6 +576,7 @@ export class PaymentQuoteService { } try { + const icpClient = this.internetComputerService.getDefaultClient(); const userPrincipal = transferInfo.sender; const paymentAccount = this.paymentBalanceService.getPaymentAccount(Blockchain.INTERNET_COMPUTER); const paymentAddress = this.paymentBalanceService.getDepositAddress(Blockchain.INTERNET_COMPUTER); @@ -589,19 +590,9 @@ export class PaymentQuoteService { return; } - const canisterId = - activation.asset.type === AssetType.COIN - ? Config.blockchain.internetComputer.internetComputerLedgerCanisterId - : activation.asset.chainId; - await Util.retry( async () => { - const result = await this.internetComputerService.checkAllowance( - userPrincipal, - paymentAddress, - canisterId, - activation.asset.decimals, - ); + const result = await icpClient.checkAllowance(userPrincipal, paymentAddress, activation.asset); if (result.allowance < activation.amount) { throw new Error(`Insufficient allowance: ${result.allowance}, need ${activation.amount}`); } @@ -610,13 +601,12 @@ export class PaymentQuoteService { 2000, ); - const txId = await this.internetComputerService.transferFromWithAccount( + const txId = await icpClient.transferFromWithAccount( paymentAccount, userPrincipal, paymentAddress, activation.amount, - canisterId, - activation.asset.decimals, + activation.asset, ); quote.txInBlockchain(txId); diff --git a/src/subdomains/generic/kyc/controllers/kyc.controller.ts b/src/subdomains/generic/kyc/controllers/kyc.controller.ts index c0c7aa4332..8d2d2aff8d 100644 --- a/src/subdomains/generic/kyc/controllers/kyc.controller.ts +++ b/src/subdomains/generic/kyc/controllers/kyc.controller.ts @@ -41,6 +41,8 @@ import { Util } from 'src/shared/utils/util'; import { SignatoryPower } from '../../user/models/user-data/user-data.enum'; import { KycBeneficialData, + KycChangeAddressData, + KycChangeNameData, KycContactData, KycFileData, KycLegalEntityData, @@ -240,6 +242,30 @@ export class KycController { return this.kycService.updateLegalData(code, +id, data, FileType.COMMERCIAL_REGISTER); } + @Put('data/address/:id') + @ApiOkResponse({ type: KycStepBase }) + @ApiUnauthorizedResponse(MergedResponse) + async updateAddressChangeData( + @Headers(CodeHeaderName) code: string, + @Param('id') id: string, + @Body() data: KycChangeAddressData, + ): Promise { + data.fileName = this.fileName('address-change', data.fileName); + return this.kycService.updateAddressChangeData(code, +id, data); + } + + @Put('data/name/:id') + @ApiOkResponse({ type: KycStepBase }) + @ApiUnauthorizedResponse(MergedResponse) + async updateNameChangeData( + @Headers(CodeHeaderName) code: string, + @Param('id') id: string, + @Body() data: KycChangeNameData, + ): Promise { + data.fileName = this.fileName('name-change', data.fileName); + return this.kycService.updateNameChangeData(code, +id, data); + } + @Put('data/confirmation/:id') @ApiOkResponse({ type: KycStepBase }) @ApiUnauthorizedResponse(MergedResponse) diff --git a/src/subdomains/generic/kyc/dto/input/kyc-data.dto.ts b/src/subdomains/generic/kyc/dto/input/kyc-data.dto.ts index 03772480f7..b4bb7aa4ac 100644 --- a/src/subdomains/generic/kyc/dto/input/kyc-data.dto.ts +++ b/src/subdomains/generic/kyc/dto/input/kyc-data.dto.ts @@ -70,6 +70,50 @@ export class KycAddress { country: Country; } +export class KycChangeAddressData { + @ApiProperty({ description: 'Base64 encoded address proof file' }) + @IsNotEmpty() + @IsString() + file: string; + + @ApiProperty({ description: 'Name of the address proof file' }) + @IsNotEmpty() + @IsString() + @Transform(Util.sanitize) + fileName: string; + + @ApiProperty({ type: KycAddress }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => KycAddress) + address: KycAddress; +} + +export class KycChangeNameData { + @ApiProperty({ description: 'Base64 encoded name proof file' }) + @IsNotEmpty() + @IsString() + file: string; + + @ApiProperty({ description: 'Name of the name proof file' }) + @IsNotEmpty() + @IsString() + @Transform(Util.sanitize) + fileName: string; + + @ApiProperty({ description: 'New first name' }) + @IsNotEmpty() + @IsString() + @Transform(Util.sanitize) + firstName: string; + + @ApiProperty({ description: 'New last name' }) + @IsNotEmpty() + @IsString() + @Transform(Util.sanitize) + lastName: string; +} + export class KycPersonalData { @ApiProperty({ enum: AccountType }) @IsNotEmpty() diff --git a/src/subdomains/generic/kyc/dto/kyc-file.dto.ts b/src/subdomains/generic/kyc/dto/kyc-file.dto.ts index 3cf50a939b..8c28b89546 100644 --- a/src/subdomains/generic/kyc/dto/kyc-file.dto.ts +++ b/src/subdomains/generic/kyc/dto/kyc-file.dto.ts @@ -19,6 +19,8 @@ export enum FileType { STATUTES = 'Statutes', ADDITIONAL_DOCUMENTS = 'AdditionalDocuments', AUTHORITY = 'Authority', + ADDRESS_CHANGE = 'AddressChange', + NAME_CHANGE = 'NameChange', } export enum FileSubType { diff --git a/src/subdomains/generic/kyc/entities/kyc-step.entity.ts b/src/subdomains/generic/kyc/entities/kyc-step.entity.ts index e6c1c7aee3..8d18a9c747 100644 --- a/src/subdomains/generic/kyc/entities/kyc-step.entity.ts +++ b/src/subdomains/generic/kyc/entities/kyc-step.entity.ts @@ -136,6 +136,12 @@ export class KycStep extends IEntity { case KycStepName.PHONE_CHANGE: return { url: '', type: UrlType.NONE }; + + case KycStepName.ADDRESS_CHANGE: + return { url: `${apiUrl}/data/address/${this.id}`, type: UrlType.API }; + + case KycStepName.NAME_CHANGE: + return { url: `${apiUrl}/data/name/${this.id}`, type: UrlType.API }; } } diff --git a/src/subdomains/generic/kyc/enums/kyc-step-name.enum.ts b/src/subdomains/generic/kyc/enums/kyc-step-name.enum.ts index ac26e6e434..e1ac388dab 100644 --- a/src/subdomains/generic/kyc/enums/kyc-step-name.enum.ts +++ b/src/subdomains/generic/kyc/enums/kyc-step-name.enum.ts @@ -23,6 +23,8 @@ export enum KycStepName { PAYMENT_AGREEMENT = 'PaymentAgreement', RECALL_AGREEMENT = 'RecallAgreement', PHONE_CHANGE = 'PhoneChange', + ADDRESS_CHANGE = 'AddressChange', + NAME_CHANGE = 'NameChange', // external registrations REALUNIT_REGISTRATION = 'RealUnitRegistration', diff --git a/src/subdomains/generic/kyc/services/kyc-admin.service.ts b/src/subdomains/generic/kyc/services/kyc-admin.service.ts index 7ce094c946..8e5f55c353 100644 --- a/src/subdomains/generic/kyc/services/kyc-admin.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-admin.service.ts @@ -85,6 +85,14 @@ export class KycAdminService { await this.kycService.completeFinancialData(kycStep); break; + case KycStepName.ADDRESS_CHANGE: + await this.kycService.completeAddressChange(kycStep); + break; + + case KycStepName.NAME_CHANGE: + await this.kycService.completeNameChange(kycStep); + break; + case KycStepName.DFX_APPROVAL: if (await this.nameCheckService.hasOpenNameChecks(kycStep.userData)) { await this.kycStepRepo.update(...kycStep.manualReview(KycError.OPEN_SANCTIONED_NAME_CHECK)); diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 958d808b70..5d58510275 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -47,7 +47,10 @@ import { IdNowReason, IdNowResult, IdentShortResult, getIdNowIdentReason } from import { IdentDocument } from '../dto/ident.dto'; import { ContactPersonData, + KycAddress, KycBeneficialData, + KycChangeAddressData, + KycChangeNameData, KycContactData, KycFileData, KycLegalEntityData, @@ -702,6 +705,73 @@ export class KycService { return KycStepMapper.toStepBase(kycStep); } + async updateAddressChangeData(kycHash: string, stepId: number, data: KycChangeAddressData): Promise { + const user = await this.getUser(kycHash); + const kycStep = user.getPendingStepOrThrow(stepId); + + // upload file + const { contentType, buffer } = Util.fromBase64(data.file); + const { url } = await this.documentService.uploadUserFile( + user, + FileType.ADDRESS_CHANGE, + data.fileName, + buffer, + contentType as ContentType, + false, + kycStep, + ); + + await this.kycStepRepo.update(...kycStep.manualReview(undefined, { url, address: data.address })); + + await this.createStepLog(user, kycStep); + await this.updateProgress(user, false); + + return KycStepMapper.toStepBase(kycStep); + } + + async completeAddressChange(kycStep: KycStep): Promise { + const result = kycStep.getResult<{ url: string; address: KycAddress }>(); + if (!result?.address) return; + + await this.userDataService.updateUserAddress(kycStep.userData, result.address); + } + + async updateNameChangeData(kycHash: string, stepId: number, data: KycChangeNameData): Promise { + const user = await this.getUser(kycHash); + const kycStep = user.getPendingStepOrThrow(stepId); + + // upload file + const { contentType, buffer } = Util.fromBase64(data.file); + const { url } = await this.documentService.uploadUserFile( + user, + FileType.NAME_CHANGE, + data.fileName, + buffer, + contentType as ContentType, + false, + kycStep, + ); + + await this.kycStepRepo.update( + ...kycStep.manualReview(undefined, { url, firstName: data.firstName, lastName: data.lastName }), + ); + + await this.createStepLog(user, kycStep); + await this.updateProgress(user, false); + + return KycStepMapper.toStepBase(kycStep); + } + + async completeNameChange(kycStep: KycStep): Promise { + const result = kycStep.getResult<{ url: string; firstName: string; lastName: string }>(); + if (!result?.firstName || !result?.lastName) return; + + await this.userDataService.updateUserName(kycStep.userData, { + firstName: result.firstName, + lastName: result.lastName, + }); + } + async getFinancialData(kycHash: string, ip: string, stepId: number, lang?: string): Promise { const user = await this.getUser(kycHash); const kycStep = user.getPendingStepOrThrow(stepId); diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index b0a9222ffe..a79997bd87 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -31,7 +31,7 @@ import { DefaultPaymentLinkConfig, PaymentLinkConfig, } from 'src/subdomains/core/payment-link/entities/payment-link.config'; -import { KycPersonalData } from 'src/subdomains/generic/kyc/dto/input/kyc-data.dto'; +import { KycAddress, KycPersonalData } from 'src/subdomains/generic/kyc/dto/input/kyc-data.dto'; import { KycError } from 'src/subdomains/generic/kyc/dto/kyc-error.enum'; import { MergedDto } from 'src/subdomains/generic/kyc/dto/output/kyc-merged.dto'; import { KycStepName } from 'src/subdomains/generic/kyc/enums/kyc-step-name.enum'; @@ -197,22 +197,33 @@ export class UserDataService { { ...where, organization: { name: Util.contains(name) } }, ]; - const nameParts = name.split(' '); - const first = nameParts.shift(); - const last = nameParts.pop(); + const nameParts = name + .split(' ') + .filter((p) => p) + .slice(0, 5); + const namePartsWithoutTitles = nameParts.filter((p) => !p.endsWith('.')); + + // try all split points on original input and additionally without title-like words (e.g. "Dr.", "Prof.") + const splitVariants = [nameParts]; + if (namePartsWithoutTitles.length < nameParts.length && namePartsWithoutTitles.length >= 2) + splitVariants.push(namePartsWithoutTitles); + + for (const parts of splitVariants) { + const joined = parts.join(' '); + if (joined !== name) { + wheres.push({ ...where, verifiedName: Util.contains(joined) }); + } - if (last) - wheres.push({ - ...where, - firstname: Util.contains(first), - surname: Util.contains([...nameParts, last].join(' ')), - }); - if (nameParts.length) - wheres.push({ - ...where, - firstname: Util.contains([first, ...nameParts].join(' ')), - surname: Util.contains(last), - }); + for (let i = 1; i < parts.length && i < 5; i++) { + const firstPart = parts.slice(0, i).join(' '); + const lastPart = parts.slice(i).join(' '); + + wheres.push( + { ...where, firstname: Util.contains(firstPart), surname: Util.contains(lastPart) }, + { ...where, firstname: Util.contains(lastPart), surname: Util.contains(firstPart) }, + ); + } + } return this.userDataRepo.find({ where: wheres }); } @@ -602,15 +613,21 @@ export class UserDataService { } async updateUserName(userData: UserData, dto: UserNameDto) { + const update: Partial = { + firstname: transliterate(dto.firstName), + surname: transliterate(dto.lastName), + }; + + await this.userDataRepo.update(userData.id, update); + Object.assign(userData, update); + for (const user of userData.users) { this.siftService.updateAccount({ $user_id: user.id.toString(), $time: Date.now(), - $name: `${dto.firstName} ${dto.lastName}`, + $name: `${update.firstname} ${update.surname}`, } as CreateAccount); } - - await this.userDataRepo.update(userData.id, { firstname: dto.firstName, surname: dto.lastName }); } async deactivateUserData(userData: UserData): Promise { @@ -773,6 +790,40 @@ export class UserDataService { }); } + // --- ADDRESS UPDATE --- // + async updateUserAddress(userData: UserData, address: KycAddress): Promise { + const country = await this.countryService.getCountry(address.country.id); + if (!country) throw new BadRequestException('Country not found'); + if (!country.isEnabled(userData.kycType)) + throw new BadRequestException(`Country not allowed for ${userData.kycType}`); + + const update: Partial = { + street: transliterate(address.street), + houseNumber: transliterate(address.houseNumber), + location: transliterate(address.city), + zip: transliterate(address.zip), + country, + }; + + await this.userDataRepo.update(userData.id, update); + Object.assign(userData, update); + + // update Sift + for (const user of userData.users) { + this.siftService.updateAccount({ + $user_id: user.id.toString(), + $time: Date.now(), + $billing_address: { + $name: `${userData.firstname} ${userData.surname}`, + $address_1: `${update.street} ${update.houseNumber}`, + $city: update.location, + $country: country.symbol, + $zipcode: update.zip, + }, + }); + } + } + // --- SETTINGS UPDATE --- // async updateUserSettings(userData: UserData, dto: UpdateUserDto): Promise { // check language diff --git a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts index 72df8ba579..9a928ab339 100644 --- a/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts +++ b/src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts @@ -553,11 +553,40 @@ export class BankTxService implements OnModuleInit { creditDebitIndicator: BankTxIndicator.CREDIT, }; + const wheres: FindOptionsWhere[] = [ + { ...request, name: Like(`%${name}%`) }, + { ...request, ultimateName: Like(`%${name}%`) }, + ]; + + const nameParts = name + .split(' ') + .filter((p) => p) + .slice(0, 5); + const namePartsWithoutTitles = nameParts.filter((p) => !p.endsWith('.')); + + const splitVariants = [nameParts]; + if (namePartsWithoutTitles.length < nameParts.length && namePartsWithoutTitles.length >= 2) + splitVariants.push(namePartsWithoutTitles); + + for (const parts of splitVariants) { + // full-string search for title-filtered variant (e.g. "John Peter Doe" without "Dr.") + const joined = parts.join(' '); + if (joined !== name) { + wheres.push({ ...request, name: Like(`%${joined}%`) }, { ...request, ultimateName: Like(`%${joined}%`) }); + } + + // reversed splits (e.g. "Doe John" for input "John Doe") + for (let i = 1; i < parts.length && i < 5; i++) { + const firstPart = parts.slice(0, i).join(' '); + const lastPart = parts.slice(i).join(' '); + const reversed = `${lastPart} ${firstPart}`; + + wheres.push({ ...request, name: Like(`%${reversed}%`) }, { ...request, ultimateName: Like(`%${reversed}%`) }); + } + } + return this.bankTxRepo.find({ - where: [ - { ...request, name: Like(`%${name}%`) }, - { ...request, ultimateName: Like(`%${name}%`) }, - ], + where: wheres, relations: { transaction: true }, }); } diff --git a/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts b/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts index 11c9dbfd2a..528d30c19b 100644 --- a/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts +++ b/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts @@ -21,7 +21,7 @@ export class DexBitcoinService { } async sendUtxoToMany(payout: { addressTo: string; amount: number }[]): Promise { - const feeRate = await this.feeService.getRecommendedFeeRate(); + const feeRate = await this.feeService.getSendFeeRate(); return this.client.sendMany(payout, feeRate); } diff --git a/src/subdomains/supporting/dex/services/dex-firo.service.ts b/src/subdomains/supporting/dex/services/dex-firo.service.ts index ebf9e01fad..2c56279957 100644 --- a/src/subdomains/supporting/dex/services/dex-firo.service.ts +++ b/src/subdomains/supporting/dex/services/dex-firo.service.ts @@ -21,7 +21,7 @@ export class DexFiroService { } async sendUtxoToMany(payout: { addressTo: string; amount: number }[]): Promise { - const feeRate = await this.getFeeRate(); + const feeRate = await this.feeService.getSendFeeRate(); return this.client.sendMany(payout, feeRate); } @@ -44,10 +44,6 @@ export class DexFiroService { //*** HELPER METHODS ***// - private async getFeeRate(): Promise { - return this.feeService.getSendFeeRate(); - } - private async getPendingAmount(): Promise { const pendingOrders = await this.liquidityOrderRepo.findBy({ isComplete: false, diff --git a/src/subdomains/supporting/payin/services/payin-bitcoin.service.ts b/src/subdomains/supporting/payin/services/payin-bitcoin.service.ts index 7a393d8a40..c84885f8d4 100644 --- a/src/subdomains/supporting/payin/services/payin-bitcoin.service.ts +++ b/src/subdomains/supporting/payin/services/payin-bitcoin.service.ts @@ -99,7 +99,7 @@ export class PayInBitcoinService extends PayInBitcoinBasedService { input.inTxId, input.sendingAmount, input.txSequence, - await this.feeService.getRecommendedFeeRate(), + await this.feeService.getSendFeeRate(), ); } diff --git a/src/subdomains/supporting/payin/services/payin-firo.service.ts b/src/subdomains/supporting/payin/services/payin-firo.service.ts index 55ba2f270e..8d84d260fc 100644 --- a/src/subdomains/supporting/payin/services/payin-firo.service.ts +++ b/src/subdomains/supporting/payin/services/payin-firo.service.ts @@ -90,7 +90,7 @@ export class PayInFiroService extends PayInBitcoinBasedService { } async sendTransfer(input: CryptoInput): Promise<{ outTxId: string; feeAmount: number }> { - const feeRate = await this.feeService.getRecommendedFeeRate(); + const feeRate = await this.feeService.getSendFeeRate(); return this.client.send( input.destinationAddress.address, input.inTxId, diff --git a/src/subdomains/supporting/payin/services/payin-icp.service.ts b/src/subdomains/supporting/payin/services/payin-icp.service.ts index f67b0e952f..1cf66f2bdd 100644 --- a/src/subdomains/supporting/payin/services/payin-icp.service.ts +++ b/src/subdomains/supporting/payin/services/payin-icp.service.ts @@ -1,22 +1,27 @@ import { Injectable } from '@nestjs/common'; import { IcpTransfer, IcpTransferQueryResult } from 'src/integration/blockchain/icp/dto/icp.dto'; +import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; import { Asset } from 'src/shared/models/asset/asset.entity'; @Injectable() export class PayInInternetComputerService { - constructor(private readonly internetComputerService: InternetComputerService) {} + private readonly client: InternetComputerClient; + + constructor(internetComputerService: InternetComputerService) { + this.client = internetComputerService.getDefaultClient(); + } getWalletAddress(): string { - return this.internetComputerService.getWalletAddress(); + return this.client.walletAddress; } async getBlockHeight(): Promise { - return this.internetComputerService.getBlockHeight(); + return this.client.getBlockHeight(); } async getTransfers(start: number, count: number): Promise { - return this.internetComputerService.getTransfers(start, count); + return this.client.getTransfers(start, count); } async getNativeTransfersForAddress( @@ -24,11 +29,11 @@ export class PayInInternetComputerService { maxBlock?: number, limit?: number, ): Promise { - return this.internetComputerService.getNativeTransfersForAddress(accountIdentifier, maxBlock, limit); + return this.client.getNativeTransfersForAddress(accountIdentifier, maxBlock, limit); } async getIcrcBlockHeight(canisterId: string): Promise { - return this.internetComputerService.getIcrcBlockHeight(canisterId); + return this.client.getIcrcBlockHeight(canisterId); } async getIcrcTransfers( @@ -37,27 +42,27 @@ export class PayInInternetComputerService { start: number, count: number, ): Promise { - return this.internetComputerService.getIcrcTransfers(canisterId, decimals, start, count); + return this.client.getIcrcTransfers(canisterId, decimals, start, count); } async getNativeCoinBalanceForAddress(address: string): Promise { - return this.internetComputerService.getNativeCoinBalanceForAddress(address); + return this.client.getNativeCoinBalanceForAddress(address); } async getTokenBalance(asset: Asset, address: string): Promise { - return this.internetComputerService.getTokenBalance(asset, address); + return this.client.getTokenBalance(asset, address); } async getCurrentGasCostForCoinTransaction(): Promise { - return this.internetComputerService.getCurrentGasCostForCoinTransaction(); + return this.client.getCurrentGasCostForCoinTransaction(); } async sendNativeCoinFromDepositWallet(accountIndex: number, toAddress: string, amount: number): Promise { - return this.internetComputerService.sendNativeCoinFromDepositWallet(accountIndex, toAddress, amount); + return this.client.sendNativeCoinFromDepositWallet(accountIndex, toAddress, amount); } async getCurrentGasCostForTokenTransaction(token: Asset): Promise { - return this.internetComputerService.getCurrentGasCostForTokenTransaction(token); + return this.client.getCurrentGasCostForTokenTransaction(token); } async sendTokenFromDepositWallet( @@ -66,10 +71,10 @@ export class PayInInternetComputerService { token: Asset, amount: number, ): Promise { - return this.internetComputerService.sendTokenFromDepositWallet(accountIndex, toAddress, token, amount); + return this.client.sendTokenFromDepositWallet(accountIndex, toAddress, token, amount); } async checkTransactionCompletion(blockIndex: string, _minConfirmations?: number): Promise { - return this.internetComputerService.isTxComplete(blockIndex); + return this.client.isTxComplete(blockIndex); } } diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts index 7c72d33eb1..0ddbc01dc9 100644 --- a/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts @@ -1,5 +1,4 @@ import { Config } from 'src/config/config'; -import { AssetType } from 'src/shared/models/asset/asset.entity'; import { LogLevel } from 'src/shared/services/dfx-logger'; import { CryptoInput, @@ -28,10 +27,7 @@ export abstract class InternetComputerStrategy extends SendStrategy { feeAmount: number = null, ): Promise { if (type === SendType.FORWARD) { - const feeAsset = - payIn.asset.type === AssetType.TOKEN - ? payIn.asset - : await this.assetService.getNativeAsset(payIn.asset.blockchain); + const feeAsset = payIn.asset; const feeAmountChf = feeAmount ? await this.pricingService .getPrice(feeAsset, PriceCurrency.CHF, PriceValidity.ANY) diff --git a/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts b/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts index cb946dc201..900b0c5f93 100644 --- a/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts +++ b/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { Config } from 'src/config/config'; import { BitcoinClient } from 'src/integration/blockchain/bitcoin/node/bitcoin-client'; import { BitcoinFeeService } from 'src/integration/blockchain/bitcoin/services/bitcoin-fee.service'; import { BitcoinNodeType, BitcoinService } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; @@ -44,12 +43,6 @@ export class PayoutBitcoinService extends PayoutBitcoinBasedService { } async getCurrentFeeRate(): Promise { - const baseRate = await this.feeService.getRecommendedFeeRate(); - - // Use higher multiplier when unconfirmed UTXOs are enabled (CPFP effect) - const { allowUnconfirmedUtxos, cpfpFeeMultiplier, defaultFeeMultiplier } = Config.blockchain.default; - const multiplier = allowUnconfirmedUtxos ? cpfpFeeMultiplier : defaultFeeMultiplier; - - return baseRate * multiplier; + return this.feeService.getSendFeeRate(); } } diff --git a/src/subdomains/supporting/payout/services/payout-icp.service.ts b/src/subdomains/supporting/payout/services/payout-icp.service.ts index 96b651f10c..7728a4f16a 100644 --- a/src/subdomains/supporting/payout/services/payout-icp.service.ts +++ b/src/subdomains/supporting/payout/services/payout-icp.service.ts @@ -1,21 +1,26 @@ import { Injectable } from '@nestjs/common'; +import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; import { Asset } from 'src/shared/models/asset/asset.entity'; @Injectable() export class PayoutInternetComputerService { - constructor(private readonly internetComputerService: InternetComputerService) {} + private readonly client: InternetComputerClient; + + constructor(internetComputerService: InternetComputerService) { + this.client = internetComputerService.getDefaultClient(); + } async sendNativeCoin(address: string, amount: number): Promise { - return this.internetComputerService.sendNativeCoinFromDex(address, amount); + return this.client.sendNativeCoinFromDex(address, amount); } async sendToken(address: string, token: Asset, amount: number): Promise { - return this.internetComputerService.sendTokenFromDex(address, token, amount); + return this.client.sendTokenFromDex(address, token, amount); } async getPayoutCompletionData(txHash: string, token?: Asset): Promise<[boolean, number]> { - const isComplete = await this.internetComputerService.isTxComplete(txHash); + const isComplete = await this.client.isTxComplete(txHash); if (!isComplete) return [false, 0]; // ICP tokens use Reverse Gas Model: fee is paid in the token itself @@ -23,20 +28,20 @@ export class PayoutInternetComputerService { try { payoutFee = token - ? await this.internetComputerService.getCurrentGasCostForTokenTransaction(token) - : await this.internetComputerService.getTxActualFee(txHash); + ? await this.client.getCurrentGasCostForTokenTransaction(token) + : await this.client.getTxActualFee(txHash); } catch { - payoutFee = await this.internetComputerService.getCurrentGasCostForCoinTransaction(); + payoutFee = await this.client.getCurrentGasCostForCoinTransaction(); } return [isComplete, payoutFee]; } async getCurrentGasForCoinTransaction(): Promise { - return this.internetComputerService.getCurrentGasCostForCoinTransaction(); + return this.client.getCurrentGasCostForCoinTransaction(); } async getCurrentGasForTokenTransaction(token: Asset): Promise { - return this.internetComputerService.getCurrentGasCostForTokenTransaction(token); + return this.client.getCurrentGasCostForTokenTransaction(token); } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts index 60f384054e..a6decb1a0b 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts @@ -27,10 +27,7 @@ export abstract class InternetComputerStrategy extends PayoutStrategy { async estimateFee(asset: Asset): Promise { const gasPerTransaction = await this.txFees.get(asset.id.toString(), () => this.getCurrentGasForTransaction(asset)); - // ICP tokens use Reverse Gas Model: fee is paid in the token itself - const feeAsset = asset.type === AssetType.TOKEN ? asset : await this.feeAsset(); - - return { asset: feeAsset, amount: gasPerTransaction }; + return { asset, amount: gasPerTransaction }; } async estimateBlockchainFee(asset: Asset): Promise { @@ -62,8 +59,7 @@ export abstract class InternetComputerStrategy extends PayoutStrategy { if (isComplete) { order.complete(); - // ICP tokens use Reverse Gas Model: fee is paid in the token itself - const feeAsset = isToken ? order.asset : await this.feeAsset(); + const feeAsset = order.asset; const price = await this.pricingService.getPrice(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); order.recordPayoutFee(feeAsset, payoutFee, price.convert(payoutFee, Config.defaultVolumeDecimal)); diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 286d336d2c..7514822dec 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1,8 +1,9 @@ import { BadRequestException, ConflictException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { BrokerbotCurrency } 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'; import { Eip7702DelegationService } from 'src/integration/blockchain/shared/evm/delegation/eip7702-delegation.service'; -import { RealUnitBlockchainService } from 'src/integration/blockchain/realunit/realunit-blockchain.service'; import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; @@ -184,6 +185,7 @@ describe('RealUnitService', () => { tokenAddress: realuAsset.chainId, baseCurrencyAddress: zchfAsset.chainId, pricePerShare: '100', + currency: BrokerbotCurrency.CHF, buyingEnabled: true, sellingEnabled: true, availableShares: 500, @@ -214,6 +216,21 @@ describe('RealUnitService', () => { '0xBrokerbotAddress', '0xRealuChainId', '0xZchfChainId', + undefined, + ); + }); + + it('should pass currency parameter to blockchainService', async () => { + assetService.getAssetByQuery.mockResolvedValueOnce(realuAsset).mockResolvedValueOnce(zchfAsset); + blockchainService.getBrokerbotInfo.mockResolvedValue({} as any); + + await service.getBrokerbotInfo(BrokerbotCurrency.EUR); + + expect(blockchainService.getBrokerbotInfo).toHaveBeenCalledWith( + '0xBrokerbotAddress', + '0xRealuChainId', + '0xZchfChainId', + BrokerbotCurrency.EUR, ); }); @@ -224,6 +241,7 @@ describe('RealUnitService', () => { tokenAddress: '0xRealuChainId', baseCurrencyAddress: '0xZchfChainId', pricePerShare: '100', + currency: BrokerbotCurrency.CHF, buyingEnabled: true, sellingEnabled: true, availableShares: 500, diff --git a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts index e83c4b8212..b13dbc27e4 100644 --- a/src/subdomains/supporting/realunit/controllers/realunit.controller.ts +++ b/src/subdomains/supporting/realunit/controllers/realunit.controller.ts @@ -16,6 +16,8 @@ import { Response } from 'express'; import { Config, Environment } from 'src/config/config'; import { BrokerbotBuyPriceDto, + BrokerbotCurrency, + BrokerbotCurrencyQueryDto, BrokerbotInfoDto, BrokerbotPriceDto, BrokerbotSharesDto, @@ -244,9 +246,15 @@ export class RealUnitController { summary: 'Get Brokerbot info', description: 'Retrieves general information about the REALU Brokerbot (addresses, settings)', }) + @ApiQuery({ + name: 'currency', + enum: BrokerbotCurrency, + required: false, + description: 'Currency for prices (CHF or EUR)', + }) @ApiOkResponse({ type: BrokerbotInfoDto }) - async getBrokerbotInfo(): Promise { - return this.realunitService.getBrokerbotInfo(); + async getBrokerbotInfo(@Query() { currency }: BrokerbotCurrencyQueryDto): Promise { + return this.realunitService.getBrokerbotInfo(currency); } @Get('brokerbot/price') @@ -254,9 +262,15 @@ export class RealUnitController { summary: 'Get current Brokerbot price', description: 'Retrieves the current price per REALU share from the Brokerbot smart contract', }) + @ApiQuery({ + name: 'currency', + enum: BrokerbotCurrency, + required: false, + description: 'Currency for prices (CHF or EUR)', + }) @ApiOkResponse({ type: BrokerbotPriceDto }) - async getBrokerbotPrice(): Promise { - return this.realunitService.getBrokerbotPrice(); + async getBrokerbotPrice(@Query() { currency }: BrokerbotCurrencyQueryDto): Promise { + return this.realunitService.getBrokerbotPrice(currency); } @Get('brokerbot/buyPrice') @@ -265,20 +279,38 @@ export class RealUnitController { description: 'Calculates the total cost to buy a specific number of REALU shares (includes price increment)', }) @ApiQuery({ name: 'shares', type: Number, description: 'Number of shares to buy' }) + @ApiQuery({ + name: 'currency', + enum: BrokerbotCurrency, + required: false, + description: 'Currency for prices (CHF or EUR)', + }) @ApiOkResponse({ type: BrokerbotBuyPriceDto }) - async getBrokerbotBuyPrice(@Query('shares') shares: number): Promise { - return this.realunitService.getBrokerbotBuyPrice(Number(shares)); + async getBrokerbotBuyPrice( + @Query('shares') shares: number, + @Query() { currency }: BrokerbotCurrencyQueryDto, + ): Promise { + return this.realunitService.getBrokerbotBuyPrice(Number(shares), currency); } @Get('brokerbot/shares') @ApiOperation({ summary: 'Get shares for amount', - description: 'Calculates how many REALU shares can be purchased for a given CHF amount', + description: 'Calculates how many REALU shares can be purchased for a given amount', + }) + @ApiQuery({ name: 'amount', type: String, description: 'Amount in specified currency (e.g., "1000.50")' }) + @ApiQuery({ + name: 'currency', + enum: BrokerbotCurrency, + required: false, + description: 'Currency for prices (CHF or EUR)', }) - @ApiQuery({ name: 'amount', type: String, description: 'Amount in CHF (e.g., "1000.50")' }) @ApiOkResponse({ type: BrokerbotSharesDto }) - async getBrokerbotShares(@Query('amount') amount: string): Promise { - return this.realunitService.getBrokerbotShares(amount); + async getBrokerbotShares( + @Query('amount') amount: string, + @Query() { currency }: BrokerbotCurrencyQueryDto, + ): Promise { + return this.realunitService.getBrokerbotShares(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 e8e8b2d2e7..35073052a7 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -5,12 +5,14 @@ import { Inject, Injectable, NotFoundException, + ServiceUnavailableException, } from '@nestjs/common'; import { verifyTypedData } from 'ethers/lib/utils'; import { request } from 'graphql-request'; import { Config, Environment, GetConfig } from 'src/config/config'; import { BrokerbotBuyPriceDto, + BrokerbotCurrency, BrokerbotInfoDto, BrokerbotPriceDto, BrokerbotSharesDto, @@ -270,21 +272,26 @@ export class RealUnitService { // --- Brokerbot Methods --- - async getBrokerbotPrice(): Promise { - return this.blockchainService.getBrokerbotPrice(); + async getBrokerbotPrice(currency?: BrokerbotCurrency): Promise { + return this.blockchainService.getBrokerbotPrice(currency); } - async getBrokerbotBuyPrice(shares: number): Promise { - return this.blockchainService.getBrokerbotBuyPrice(shares); + async getBrokerbotBuyPrice(shares: number, currency?: BrokerbotCurrency): Promise { + return this.blockchainService.getBrokerbotBuyPrice(shares, currency); } - async getBrokerbotShares(amountChf: string): Promise { - return this.blockchainService.getBrokerbotShares(amountChf); + async getBrokerbotShares(amount: string, currency?: BrokerbotCurrency): Promise { + return this.blockchainService.getBrokerbotShares(amount, currency); } - async getBrokerbotInfo(): Promise { + async getBrokerbotInfo(currency?: BrokerbotCurrency): Promise { const [realuAsset, zchfAsset] = await Promise.all([this.getRealuAsset(), this.getZchfAsset()]); - return this.blockchainService.getBrokerbotInfo(this.getBrokerbotAddress(), realuAsset.chainId, zchfAsset.chainId); + return this.blockchainService.getBrokerbotInfo( + this.getBrokerbotAddress(), + realuAsset.chainId, + zchfAsset.chainId, + currency, + ); } // --- Buy Payment Info Methods --- @@ -410,14 +417,24 @@ export class RealUnitService { // Aktionariat API aufrufen const fiat = await this.fiatService.getFiat(request.sourceId); - const aktionariatResponse = [Environment.DEV, Environment.LOC].includes(Config.environment) - ? { reference: `DEV-${request.id}-${Date.now()}`, mock: true } - : await this.blockchainService.requestPaymentInstructions({ - currency: fiat.name, - address: request.user.address, - shares: Math.floor(request.estimatedAmount), - price: Math.round(request.amount * 100), - }); + + let aktionariatResponse: { reference: string; [key: string]: any }; + try { + aktionariatResponse = [Environment.DEV, Environment.LOC].includes(Config.environment) + ? { reference: `DEV-${request.id}-${Date.now()}`, mock: true } + : await this.blockchainService.requestPaymentInstructions({ + currency: fiat.name, + address: request.user.address, + shares: Math.floor(request.estimatedAmount), + price: Math.round(request.amount * 100), + }); + } catch (error) { + const message = error?.response?.data ? JSON.stringify(error.response.data) : error?.message || error; + this.logger.error( + `Failed to request payment instructions from Aktionariat for request ${requestId} (currency: ${fiat.name}, shares: ${Math.floor(request.estimatedAmount)}, price: ${Math.round(request.amount * 100)}): ${message}`, + ); + throw new ServiceUnavailableException(`Aktionariat API error: ${message}`); + } // Status + Response speichern await this.transactionRequestService.confirmTransactionRequest(request, JSON.stringify(aktionariatResponse));