From b60efba782e7398fc781698390ddc7fce4335d61 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:03:11 +0100 Subject: [PATCH 1/9] feat: centralize fee logic and add Bitcoin deposit forwarding (#3258) * feat: centralize fee logic and add Bitcoin deposit forwarding Move getSendFeeRate() into BitcoinBasedFeeService using template method pattern, eliminating duplicate fee multiplier logic across Bitcoin and Firo services. Add dedicated Bitcoin deposit forwarding with explicit UTXO selection and subtract_fee_from_outputs for accurate fee handling. * refactor: extract sendManyFromAddress to BitcoinBasedClient Move UTXO-from-address fetching and input mapping into a shared sendManyFromAddress method on BitcoinBasedClient. FiroClient overrides it with its raw-transaction approach. Simplifies forwardBitcoinDeposit to a single method call. * fix: throw error if no UTXOs are available --- .../bitcoin/node/bitcoin-based-client.ts | 27 ++++++++++++++- .../blockchain/bitcoin/node/node-client.ts | 5 +++ .../services/bitcoin-based-fee.service.ts | 17 ++++++++++ .../bitcoin/services/bitcoin-fee.service.ts | 7 +++- .../blockchain/firo/firo-client.ts | 33 ++++++++++--------- .../firo/services/firo-fee.service.ts | 11 ++----- .../services/payment-balance.service.ts | 23 ++++++++++++- .../dex/services/dex-bitcoin.service.ts | 2 +- .../dex/services/dex-firo.service.ts | 6 +--- .../payin/services/payin-bitcoin.service.ts | 2 +- .../payin/services/payin-firo.service.ts | 2 +- .../payout/services/payout-bitcoin.service.ts | 9 +---- 12 files changed, 101 insertions(+), 43 deletions(-) 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/subdomains/core/payment-link/services/payment-balance.service.ts b/src/subdomains/core/payment-link/services/payment-balance.service.ts index c98e891d58..e075309a2a 100644 --- a/src/subdomains/core/payment-link/services/payment-balance.service.ts +++ b/src/subdomains/core/payment-link/services/payment-balance.service.ts @@ -1,5 +1,7 @@ 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 { InternetComputerUtil } from 'src/integration/blockchain/icp/icp.util'; import { BlockchainTokenBalance } from 'src/integration/blockchain/shared/dto/blockchain-token-balance.dto'; @@ -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,6 +184,10 @@ export class PaymentBalanceService implements OnModuleInit { } private async forwardDeposit(asset: Asset, balance: number): Promise { + if (asset.blockchain === Blockchain.BITCOIN) { + return this.forwardBitcoinDeposit(); + } + const account = this.getPaymentAccount(asset.blockchain); const client = this.blockchainRegistryService.getClient(asset.blockchain) as | EvmClient @@ -193,6 +200,20 @@ export class PaymentBalanceService implements OnModuleInit { : client.sendTokenFromAccount(account, client.walletAddress, asset, balance); } + 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/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/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(); } } From cb58b81afda2662368617346af5bfee64bb08a30 Mon Sep 17 00:00:00 2001 From: Lam Nguyen <32935491+xlamn@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:36:35 +0100 Subject: [PATCH 2/9] feat: realunit brokerbot support eur (#3371) * feat: support different currencies for brokerbot. * test: fix. * formatting. * test: fix. --- .../realunit-blockchain.service.spec.ts | 82 ++++++++++++++++++- .../realunit/dto/realunit-broker.dto.ts | 43 ++++++++-- .../realunit/realunit-blockchain.service.ts | 50 +++++++---- .../__tests__/realunit.service.spec.ts | 20 ++++- .../controllers/realunit.controller.ts | 52 +++++++++--- .../supporting/realunit/realunit.service.ts | 22 +++-- 6 files changed, 227 insertions(+), 42 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 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/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..dd9a6c2091 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -11,6 +11,7 @@ import { request } from 'graphql-request'; import { Config, Environment, GetConfig } from 'src/config/config'; import { BrokerbotBuyPriceDto, + BrokerbotCurrency, BrokerbotInfoDto, BrokerbotPriceDto, BrokerbotSharesDto, @@ -270,21 +271,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 --- From 352d0e1d6f036290ba4b9c1362b7a4add4c9c81c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:20:39 +0100 Subject: [PATCH 3/9] feat: address change with document upload and manual review (#3373) --- .../generic/kyc/controllers/kyc.controller.ts | 13 +++++++ .../generic/kyc/dto/input/kyc-data.dto.ts | 19 ++++++++++ .../generic/kyc/dto/kyc-file.dto.ts | 1 + .../generic/kyc/entities/kyc-step.entity.ts | 3 ++ .../generic/kyc/enums/kyc-step-name.enum.ts | 1 + .../generic/kyc/services/kyc-admin.service.ts | 4 +++ .../generic/kyc/services/kyc.service.ts | 33 +++++++++++++++++ .../models/user-data/user-data.service.ts | 36 ++++++++++++++++++- 8 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/subdomains/generic/kyc/controllers/kyc.controller.ts b/src/subdomains/generic/kyc/controllers/kyc.controller.ts index c0c7aa4332..7972fa3ec6 100644 --- a/src/subdomains/generic/kyc/controllers/kyc.controller.ts +++ b/src/subdomains/generic/kyc/controllers/kyc.controller.ts @@ -41,6 +41,7 @@ import { Util } from 'src/shared/utils/util'; import { SignatoryPower } from '../../user/models/user-data/user-data.enum'; import { KycBeneficialData, + KycChangeAddressData, KycContactData, KycFileData, KycLegalEntityData, @@ -240,6 +241,18 @@ 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/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..7266274808 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,25 @@ 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 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..1dbb690c3b 100644 --- a/src/subdomains/generic/kyc/dto/kyc-file.dto.ts +++ b/src/subdomains/generic/kyc/dto/kyc-file.dto.ts @@ -19,6 +19,7 @@ export enum FileType { STATUTES = 'Statutes', ADDITIONAL_DOCUMENTS = 'AdditionalDocuments', AUTHORITY = 'Authority', + ADDRESS_CHANGE = 'AddressChange', } 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..3470b2bb67 100644 --- a/src/subdomains/generic/kyc/entities/kyc-step.entity.ts +++ b/src/subdomains/generic/kyc/entities/kyc-step.entity.ts @@ -136,6 +136,9 @@ 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 }; } } 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..3674354f63 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,7 @@ export enum KycStepName { PAYMENT_AGREEMENT = 'PaymentAgreement', RECALL_AGREEMENT = 'RecallAgreement', PHONE_CHANGE = 'PhoneChange', + ADDRESS_CHANGE = 'AddressChange', // 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..cc11a251d7 100644 --- a/src/subdomains/generic/kyc/services/kyc-admin.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-admin.service.ts @@ -85,6 +85,10 @@ export class KycAdminService { await this.kycService.completeFinancialData(kycStep); break; + case KycStepName.ADDRESS_CHANGE: + await this.kycService.completeAddressChange(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..ec518f8745 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -47,7 +47,9 @@ import { IdNowReason, IdNowResult, IdentShortResult, getIdNowIdentReason } from import { IdentDocument } from '../dto/ident.dto'; import { ContactPersonData, + KycAddress, KycBeneficialData, + KycChangeAddressData, KycContactData, KycFileData, KycLegalEntityData, @@ -702,6 +704,37 @@ 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 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..4d944806bf 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'; @@ -773,6 +773,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 From 478c468000d1a76274aa507198742875148a7778 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:16:24 +0100 Subject: [PATCH 4/9] fix: handle Aktionariat API errors in confirmBuy (#3377) --- .../supporting/realunit/realunit.service.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index dd9a6c2091..35073052a7 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -5,6 +5,7 @@ import { Inject, Injectable, NotFoundException, + ServiceUnavailableException, } from '@nestjs/common'; import { verifyTypedData } from 'ethers/lib/utils'; import { request } from 'graphql-request'; @@ -416,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)); From de9757654cac0998d560c57fef23de1077eb72b2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:39:46 +0100 Subject: [PATCH 5/9] fix: improve compliance name search (#3374) --- .../models/user-data/user-data.service.ts | 43 ++++++++++++------- .../bank-tx/services/bank-tx.service.ts | 37 ++++++++++++++-- 2 files changed, 60 insertions(+), 20 deletions(-) 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 4d944806bf..87660fb607 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 @@ -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(); - - 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), - }); + 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) }); + } + + 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 }); } 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 }, }); } From 016637d631b67fc164244b6228328c49cd2c3d69 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 10 Mar 2026 11:58:57 +0100 Subject: [PATCH 6/9] feat: add name change KYC step (#3376) --- .../generic/kyc/controllers/kyc.controller.ts | 13 +++++++ .../generic/kyc/dto/input/kyc-data.dto.ts | 25 +++++++++++++ .../generic/kyc/dto/kyc-file.dto.ts | 1 + .../generic/kyc/entities/kyc-step.entity.ts | 3 ++ .../generic/kyc/enums/kyc-step-name.enum.ts | 1 + .../generic/kyc/services/kyc-admin.service.ts | 4 ++ .../generic/kyc/services/kyc.service.ts | 37 +++++++++++++++++++ .../models/user-data/user-data.service.ts | 12 ++++-- 8 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/subdomains/generic/kyc/controllers/kyc.controller.ts b/src/subdomains/generic/kyc/controllers/kyc.controller.ts index 7972fa3ec6..8d2d2aff8d 100644 --- a/src/subdomains/generic/kyc/controllers/kyc.controller.ts +++ b/src/subdomains/generic/kyc/controllers/kyc.controller.ts @@ -42,6 +42,7 @@ import { SignatoryPower } from '../../user/models/user-data/user-data.enum'; import { KycBeneficialData, KycChangeAddressData, + KycChangeNameData, KycContactData, KycFileData, KycLegalEntityData, @@ -253,6 +254,18 @@ export class KycController { 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 7266274808..b4bb7aa4ac 100644 --- a/src/subdomains/generic/kyc/dto/input/kyc-data.dto.ts +++ b/src/subdomains/generic/kyc/dto/input/kyc-data.dto.ts @@ -89,6 +89,31 @@ export class KycChangeAddressData { 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 1dbb690c3b..8c28b89546 100644 --- a/src/subdomains/generic/kyc/dto/kyc-file.dto.ts +++ b/src/subdomains/generic/kyc/dto/kyc-file.dto.ts @@ -20,6 +20,7 @@ export enum FileType { 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 3470b2bb67..8d18a9c747 100644 --- a/src/subdomains/generic/kyc/entities/kyc-step.entity.ts +++ b/src/subdomains/generic/kyc/entities/kyc-step.entity.ts @@ -139,6 +139,9 @@ export class KycStep extends IEntity { 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 3674354f63..e1ac388dab 100644 --- a/src/subdomains/generic/kyc/enums/kyc-step-name.enum.ts +++ b/src/subdomains/generic/kyc/enums/kyc-step-name.enum.ts @@ -24,6 +24,7 @@ export enum KycStepName { 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 cc11a251d7..8e5f55c353 100644 --- a/src/subdomains/generic/kyc/services/kyc-admin.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-admin.service.ts @@ -89,6 +89,10 @@ export class KycAdminService { 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 ec518f8745..5d58510275 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -50,6 +50,7 @@ import { KycAddress, KycBeneficialData, KycChangeAddressData, + KycChangeNameData, KycContactData, KycFileData, KycLegalEntityData, @@ -735,6 +736,42 @@ export class KycService { 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 87660fb607..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 @@ -613,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 { From b774f065b3b7b8617241d8a959637c395af2f1f5 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:13:23 +0100 Subject: [PATCH 7/9] Remove ICP service passthrough methods (#3378) * fix: remove ICP service passthrough methods and use client directly * fix: pass Asset to ICP client instead of canisterId/decimals separately * fix: move ICP forward fee deduction from client to caller --- src/integration/blockchain/icp/icp-client.ts | 42 +++---- .../blockchain/icp/icp.controller.ts | 50 --------- src/integration/blockchain/icp/icp.module.ts | 2 - .../blockchain/icp/services/icp.service.ts | 105 ------------------ .../services/payment-balance.service.ts | 29 ++++- .../services/payment-quote.service.ts | 18 +-- .../payin/services/payin-icp.service.ts | 33 +++--- .../strategies/send/impl/base/icp.strategy.ts | 6 +- .../payout/services/payout-icp.service.ts | 23 ++-- .../payout/impl/base/icp.strategy.ts | 8 +- 10 files changed, 81 insertions(+), 235 deletions(-) delete mode 100644 src/integration/blockchain/icp/icp.controller.ts 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/subdomains/core/payment-link/services/payment-balance.service.ts b/src/subdomains/core/payment-link/services/payment-balance.service.ts index e075309a2a..186bf3cf23 100644 --- a/src/subdomains/core/payment-link/services/payment-balance.service.ts +++ b/src/subdomains/core/payment-link/services/payment-balance.service.ts @@ -3,6 +3,7 @@ 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'; @@ -12,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'; @@ -188,18 +188,35 @@ export class PaymentBalanceService implements OnModuleInit { 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; 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/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-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)); From 01f25b8f5c7b6b3445f0db76db4d1d21413ae84a Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:36:16 -0300 Subject: [PATCH 8/9] feat: add hb-keep-market container group parameters (#3380) Add deploy script and documentation entries plus dev/prd parameter files so the keep-market hummingbot instance can be deployed consistently. --- .../bicep/container-groups/README.md | 1 + .../bicep/container-groups/deploy.sh | 3 +- .../parameters/dev-hb-keep-market.json | 46 +++++++++++++++++++ .../parameters/prd-hb-keep-market.json | 46 +++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 infrastructure/bicep/container-groups/parameters/dev-hb-keep-market.json create mode 100644 infrastructure/bicep/container-groups/parameters/prd-hb-keep-market.json 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 From 6801600762c8eb9bf9b3d6215260296bffa374b9 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:44:19 +0100 Subject: [PATCH 9/9] feat: add autoheal service and sync firo node config (#3379) - Add autoheal service to auto-restart firod on healthcheck failure - Increase memory limit from 6144M to 8192M for Spark/Lelantus proofs - Add log rotation (100m, 3 files) - Adjust healthcheck: interval=60s, timeout=30s, retries=3 - Sync firo.conf with remote: mempool settings, buffer limits, dbcache, rpcworkqueue --- .../config/docker/docker-compose-firo.yml | 27 +++++++++++++++---- infrastructure/config/firo/firo.conf | 2 +- 2 files changed, 23 insertions(+), 6 deletions(-) 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