From e6a50df7ab2e80b9698490679306992f72ad0176 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:00:27 +0100 Subject: [PATCH] Fix finance log double-counting of exchange balances (#3462) * Fix finance log double-counting of exchange balances during pending withdrawals The finance log used Amount (total balance incl. locked funds) as the liquidity base and added pending exchange orders on top. For exchanges like Scrypt where Amount includes funds locked for pending withdrawals, this caused double-counting (~30k CHF overstatement). Add availableAmount to LiquidityBalance and use it in the finance log. The available balance excludes locked funds, so pending exchange orders correctly compensate for the difference. * chore: refactoring --------- Co-authored-by: David May --- ...00-AddAvailableAmountToLiquidityBalance.js | 11 +++++++++ .../controllers/exchange.controller.ts | 2 +- .../exchange/services/exchange.service.ts | 23 ++++++++++++------- .../exchange/services/scrypt.service.ts | 19 +++++++++++---- .../adapters/balances/exchange.adapter.ts | 7 +++--- .../entities/liquidity-balance.entity.ts | 9 ++++++-- .../liquidity-management-balance.service.ts | 2 +- .../supporting/log/log-job.service.ts | 4 ++-- 8 files changed, 55 insertions(+), 22 deletions(-) create mode 100644 migration/1774000000000-AddAvailableAmountToLiquidityBalance.js diff --git a/migration/1774000000000-AddAvailableAmountToLiquidityBalance.js b/migration/1774000000000-AddAvailableAmountToLiquidityBalance.js new file mode 100644 index 0000000000..3e8a3cc100 --- /dev/null +++ b/migration/1774000000000-AddAvailableAmountToLiquidityBalance.js @@ -0,0 +1,11 @@ +module.exports = class AddAvailableAmountToLiquidityBalance1774000000000 { + name = 'AddAvailableAmountToLiquidityBalance1774000000000'; + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "dbo"."liquidity_balance" ADD "availableAmount" float`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "dbo"."liquidity_balance" DROP COLUMN "availableAmount"`); + } +}; diff --git a/src/integration/exchange/controllers/exchange.controller.ts b/src/integration/exchange/controllers/exchange.controller.ts index 905eaa036b..c78d5bb5b5 100644 --- a/src/integration/exchange/controllers/exchange.controller.ts +++ b/src/integration/exchange/controllers/exchange.controller.ts @@ -50,7 +50,7 @@ export class ExchangeController { @ApiExcludeEndpoint() @UseGuards(AuthGuard(), RoleGuard(UserRole.ADMIN), UserActiveGuard()) async getBalance(@Param('exchange') exchange: string): Promise { - return this.call(exchange, (e) => e.getBalances()); + return this.call(exchange, (e) => e.getRawBalances()); } @Get(':exchange/price') diff --git a/src/integration/exchange/services/exchange.service.ts b/src/integration/exchange/services/exchange.service.ts index 3e988598a2..de51c0570b 100644 --- a/src/integration/exchange/services/exchange.service.ts +++ b/src/integration/exchange/services/exchange.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Inject, OnModuleInit } from '@nestjs/common'; import BigNumber from 'bignumber.js'; import { + Balance, Balances, ConstructorArgs, Dictionary, @@ -67,24 +68,30 @@ export abstract class ExchangeService extends PricingProvider implements OnModul return this.exchange.name; } - async getBalances(): Promise { + async getRawBalances(): Promise { return this.callApi((e) => e.fetchBalance()); } - async getTotalBalances(): Promise> { - const balances = await this.getBalances().then((b) => b.total); + async getBalances(): Promise<{ total: Dictionary; available: Dictionary }> { + const balances = await this.getRawBalances(); - const totalBalances = {}; + return { + total: this.aggregateBalances(balances.total), + available: this.aggregateBalances(balances.free), + }; + } + + private aggregateBalances(balances: Balance): Dictionary { + const result: Dictionary = {}; for (const [asset, amount] of Object.entries(balances)) { const [base, suffix] = asset.split('.'); - if (!suffix || suffix === 'F') totalBalances[base] = (totalBalances[base] ?? 0) + amount; + if (!suffix || suffix === 'F') result[base] = (result[base] ?? 0) + (amount as number); } - - return totalBalances; + return result; } async getAvailableBalance(currency: string): Promise { - return this.getBalances().then((b) => b.free[currency] ?? 0); + return this.getRawBalances().then((b) => b.free[currency] ?? 0); } async getPrice(from: string, to: string): Promise { diff --git a/src/integration/exchange/services/scrypt.service.ts b/src/integration/exchange/services/scrypt.service.ts index a39e593346..7ad4ad06fa 100644 --- a/src/integration/exchange/services/scrypt.service.ts +++ b/src/integration/exchange/services/scrypt.service.ts @@ -70,22 +70,31 @@ export class ScryptService extends PricingProvider { // --- BALANCES --- // - async getTotalBalances(): Promise> { + async getBalances(): Promise<{ total: Record; available: Record }> { const balances = await this.balances; - const totalBalances: Record = {}; + const total: Record = {}; + const available: Record = {}; + for (const balance of balances.values()) { - totalBalances[balance.Currency] = parseFloat(balance.Amount) || 0; + const amount = parseFloat(balance.Amount) || 0; + const availableAmount = parseFloat(balance.AvailableAmount) || amount; + + total[balance.Currency] = amount; + available[balance.Currency] = availableAmount; } - return totalBalances; + return { total, available }; } async getAvailableBalance(currency: string): Promise { const balances = await this.balances; const balance = balances.get(currency); - return balance ? parseFloat(balance.AvailableAmount) || 0 : 0; + if (!balance) return 0; + + const amount = parseFloat(balance.Amount) || 0; + return parseFloat(balance.AvailableAmount) || amount; } // --- WITHDRAWALS --- // diff --git a/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts b/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts index 4495afa0e0..cd99d63f98 100644 --- a/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/balances/exchange.adapter.ts @@ -59,13 +59,14 @@ export class ExchangeAdapter implements LiquidityBalanceIntegration { async getForExchange(exchange: string, assets: LiquidityManagementAsset[]): Promise { try { const exchangeService = this.exchangeRegistry.getExchange(exchange); - const balances = await exchangeService.getTotalBalances(); + const { total: totalBalances, available: availableBalances } = await exchangeService.getBalances(); return assets.map((a) => { const names = [a.dexName, ...(this.ASSET_MAPPINGS[a.dexName] ?? [])]; - const balance = Util.sum(names.map((n) => balances[n] ?? 0)); + const total = Util.sum(names.map((n) => totalBalances[n] ?? 0)); + const available = Util.sum(names.map((n) => availableBalances[n] ?? 0)); - return LiquidityBalance.create(a, balance); + return LiquidityBalance.create(a, total, available); }); } catch (e) { this.logger.error(`Failed to update liquidity management balance for ${exchange}:`, e); diff --git a/src/subdomains/core/liquidity-management/entities/liquidity-balance.entity.ts b/src/subdomains/core/liquidity-management/entities/liquidity-balance.entity.ts index 292ab7d049..6cbd03c33a 100644 --- a/src/subdomains/core/liquidity-management/entities/liquidity-balance.entity.ts +++ b/src/subdomains/core/liquidity-management/entities/liquidity-balance.entity.ts @@ -10,24 +10,29 @@ export class LiquidityBalance extends IEntity { @Column({ type: 'float', nullable: true }) amount?: number; + @Column({ type: 'float', nullable: true }) + availableAmount?: number; + @Column({ default: true }) isDfxOwned: boolean; // --- FACTORY METHODS --- // - static create(target: Asset, amount: number): LiquidityBalance { + static create(target: Asset, amount: number, availableAmount?: number): LiquidityBalance { const balance = new LiquidityBalance(); balance.asset = target; balance.amount = amount; + balance.availableAmount = availableAmount ?? amount; return balance; } // --- PUBLIC API --- // - updateBalance(amount: number): this { + updateBalance(amount: number, availableAmount?: number): this { this.amount = amount; + this.availableAmount = availableAmount ?? amount; return this; } diff --git a/src/subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts b/src/subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts index dbcb75f774..ee6341bac9 100644 --- a/src/subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts +++ b/src/subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts @@ -86,7 +86,7 @@ export class LiquidityManagementBalanceService implements OnModuleInit { if (existingBalance) { if (existingBalance.updated > startDate) continue; - existingBalance.updateBalance(balance.amount ?? 0); + existingBalance.updateBalance(balance.amount ?? 0, balance.availableAmount); await this.balanceRepo.save(existingBalance); continue; diff --git a/src/subdomains/supporting/log/log-job.service.ts b/src/subdomains/supporting/log/log-job.service.ts index 089c5fbe6b..b5ec2bd1ca 100644 --- a/src/subdomains/supporting/log/log-job.service.ts +++ b/src/subdomains/supporting/log/log-job.service.ts @@ -452,8 +452,8 @@ export class LogJobService { const manualLiqPosition = manualLiqPositions.find((p) => p.assetId === curr.id)?.value ?? 0; - // plus - const liquidity = (curr.balance?.amount ?? 0) + (paymentDepositBalance ?? 0) + (manualLiqPosition ?? 0); + // plus (use availableAmount to avoid double-counting with pending exchange orders) + const liquidity = (curr.balance?.availableAmount ?? 0) + (paymentDepositBalance ?? 0) + (manualLiqPosition ?? 0); const cryptoInput = [Blockchain.MONERO, Blockchain.LIGHTNING, Blockchain.ZANO].includes(curr.blockchain) ? 0