From b64b360fb201d2bcf05e78707fc3f55d1ab92508 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:06:41 +0200 Subject: [PATCH] fix(liquidity): include balance amounts in exchange rejection errors (#4358) * fix(liquidity): include balance amounts in exchange rejection errors When an exchange rejects an order for insufficient balance, the adapters re-threw the raw exchange message. The replenish step that many actions chain via onFail (LiquidityPipelineAdapter.buy) recovers the shortfall by parsing "(balance: X, min. requested: Y, max. requested: Z)" out of the previous order's error message. The local pre-check in the same methods already emits that format; the exchange-rejection branch did not. The parse then failed, the step threw a generic Error, the pipeline failed and the whole rule was paused - including its opposite direction, since pause is per rule. Append the structured suffix in the balance-too-low branches, reusing each method's own pre-check values: ccxt-exchange (withdraw, buy, sell, transfer; inherited by Binance, MEXC, Kraken, XT) and scrypt (withdraw, buy). Only the message is extended - control flow, exception types and amounts are untouched. ScryptAdapter.executeSell is left as is: the values are not in its scope, and the only Scrypt sell action in production has no onFail chaining, so its message never reaches the parser. * test(liquidity): move parser spec to __tests__ and cover noisy raw messages Follow-up to review feedback: - Move liquidity-pipeline.adapter.spec.ts into __tests__/, matching the convention of the other adapter specs (imports adjusted). - Add a case where the raw exchange message contains parentheses/JSON before the appended suffix, locking in that the parser still extracts the right amounts (the regex anchors on the literal appended at the end). - Apply prettier formatting. - Document in ScryptAdapter.executeSell why the structured suffix is deliberately omitted there: the values are not in scope, and the only production Scrypt sell action has no onFail/onSuccess chain, so its message never reaches the parser. --- .../liquidity-pipeline.adapter.spec.ts | 78 +++++++++++++++++++ .../actions/base/ccxt-exchange.adapter.ts | 16 +++- .../adapters/actions/scrypt.adapter.ts | 10 ++- 3 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 src/subdomains/core/liquidity-management/adapters/actions/__tests__/liquidity-pipeline.adapter.spec.ts diff --git a/src/subdomains/core/liquidity-management/adapters/actions/__tests__/liquidity-pipeline.adapter.spec.ts b/src/subdomains/core/liquidity-management/adapters/actions/__tests__/liquidity-pipeline.adapter.spec.ts new file mode 100644 index 0000000000..cd875a35fc --- /dev/null +++ b/src/subdomains/core/liquidity-management/adapters/actions/__tests__/liquidity-pipeline.adapter.spec.ts @@ -0,0 +1,78 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Equal } from 'typeorm'; +import { LiquidityManagementAction } from '../../../entities/liquidity-management-action.entity'; +import { LiquidityManagementOrder } from '../../../entities/liquidity-management-order.entity'; +import { LiquidityManagementOrderRepository } from '../../../repositories/liquidity-management-order.repository'; +import { LiquidityManagementPipelineRepository } from '../../../repositories/liquidity-management-pipeline.repository'; +import { LiquidityManagementService } from '../../../services/liquidity-management.service'; +import { LiquidityPipelineAdapter } from '../liquidity-pipeline.adapter'; + +describe('LiquidityPipelineAdapter', () => { + let adapter: LiquidityPipelineAdapter; + let liquidityManagementService: LiquidityManagementService; + let pipelineRepo: LiquidityManagementPipelineRepository; + let orderRepo: LiquidityManagementOrderRepository; + + beforeEach(() => { + liquidityManagementService = createMock(); + pipelineRepo = createMock(); + orderRepo = createMock(); + + adapter = new LiquidityPipelineAdapter(liquidityManagementService, pipelineRepo, orderRepo); + }); + + describe('buy', () => { + it('parses the structured balance/min/max suffix appended by the exchange adapters and calls buyLiquidity with the correct amounts', async () => { + const previousOrderId = 10; + const order = Object.assign(new LiquidityManagementOrder(), { + action: Object.assign(new LiquidityManagementAction(), { + params: JSON.stringify({ assetId: 1, orderIndex: 1, demandOnly: false }), + }), + previousOrderId, + }); + + const previousOrder = Object.assign(new LiquidityManagementOrder(), { + id: previousOrderId, + errorMessage: 'Binance: not enough balance for BTC (balance: 0.5, min. requested: 1.2, max. requested: 2.5)', + }); + + jest.spyOn(orderRepo, 'findOneBy').mockResolvedValue(previousOrder); + const buyLiquidityMock = jest + .spyOn(liquidityManagementService, 'buyLiquidity') + .mockResolvedValue({ id: 42 } as Awaited>); + + const correlationId = await adapter['buy'](order); + + expect(orderRepo.findOneBy).toHaveBeenCalledWith({ id: Equal(previousOrderId) }); + expect(buyLiquidityMock).toHaveBeenCalledWith(1, expect.closeTo(0.7), expect.closeTo(2.0), true); + expect(correlationId).toBe('42'); + }); + + it('still extracts the correct amounts when the raw exchange message contains parentheses/JSON before the appended suffix', async () => { + const previousOrderId = 11; + const order = Object.assign(new LiquidityManagementOrder(), { + action: Object.assign(new LiquidityManagementAction(), { + params: JSON.stringify({ assetId: 1, orderIndex: 1, demandOnly: false }), + }), + previousOrderId, + }); + + const previousOrder = Object.assign(new LiquidityManagementOrder(), { + id: previousOrderId, + errorMessage: + 'binance {"code":-2010,"msg":"(balance issue)"} (balance: 0.5, min. requested: 1.2, max. requested: 2.5)', + }); + + jest.spyOn(orderRepo, 'findOneBy').mockResolvedValue(previousOrder); + const buyLiquidityMock = jest + .spyOn(liquidityManagementService, 'buyLiquidity') + .mockResolvedValue({ id: 43 } as Awaited>); + + const correlationId = await adapter['buy'](order); + + expect(orderRepo.findOneBy).toHaveBeenCalledWith({ id: Equal(previousOrderId) }); + expect(buyLiquidityMock).toHaveBeenCalledWith(1, expect.closeTo(0.7), expect.closeTo(2.0), true); + expect(correlationId).toBe('43'); + }); + }); +}); diff --git a/src/subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts index 593b09a121..0526346fc1 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/base/ccxt-exchange.adapter.ts @@ -114,7 +114,9 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { return response.id; } catch (e) { if (this.isBalanceTooLowError(e)) { - throw new OrderNotProcessableException(e.message); + throw new OrderNotProcessableException( + `${e.message} (balance: ${balance}, min. requested: ${order.minAmount}, max. requested: ${order.maxAmount})`, + ); } throw e; @@ -173,7 +175,9 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { return await this.exchangeService.sell(tradeAsset, targetAssetEntity.name, amount); } catch (e) { if (this.isBalanceTooLowError(e)) { - throw new OrderNotProcessableException(e.message); + throw new OrderNotProcessableException( + `${e.message} (balance: ${availableBalance}, min. requested: ${minSellAmount}, max. requested: ${maxSellAmount})`, + ); } if (e.message?.includes('Illegal characters found')) { @@ -218,7 +222,9 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { return await this.exchangeService.sell(asset, tradeAsset, amount); } catch (e) { if (this.isBalanceTooLowError(e)) { - throw new OrderNotProcessableException(e.message); + throw new OrderNotProcessableException( + `${e.message} (balance: ${availableBalance}, min. requested: ${order.minAmount}, max. requested: ${order.maxAmount})`, + ); } if (e.message?.includes('Illegal characters found')) { @@ -276,7 +282,9 @@ export abstract class CcxtExchangeAdapter extends LiquidityActionAdapter { return response.id; } catch (e) { if (this.isBalanceTooLowError(e)) { - throw new OrderNotProcessableException(e.message); + throw new OrderNotProcessableException( + `${e.message} (balance: ${sourceBalance}, min. requested: ${minAmount}, max. requested: ${maxAmount})`, + ); } throw e; diff --git a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts index f60dc692f1..f68de2cf2f 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts @@ -100,7 +100,9 @@ export class ScryptAdapter extends LiquidityActionAdapter { return response.id; } catch (e) { if (this.isBalanceTooLowError(e)) { - throw new OrderNotProcessableException(e.message); + throw new OrderNotProcessableException( + `${e.message} (balance: ${balance}, min. requested: ${order.minAmount}, max. requested: ${order.maxAmount})`, + ); } throw e; @@ -176,7 +178,9 @@ export class ScryptAdapter extends LiquidityActionAdapter { return await this.scryptService.sell(tradeAsset, targetAssetEntity.dexName, amount); } catch (e) { if (this.isBalanceTooLowError(e)) { - throw new OrderNotProcessableException(e.message); + throw new OrderNotProcessableException( + `${e.message} (balance: ${availableBalance}, min. requested: ${minSellAmount}, max. requested: ${maxSellAmount})`, + ); } throw e; @@ -355,6 +359,8 @@ export class ScryptAdapter extends LiquidityActionAdapter { try { return await this.scryptService.sell(fromAsset, toAsset, amount); } catch (e) { + // No "(balance: ..., min. requested: ..., max. requested: ...)" suffix: balance/min/max are not in scope here. + // The only production Scrypt 'sell' action has no onFail/onSuccess chain, so its error never reaches the liquidity-pipeline regex parser. if (this.isBalanceTooLowError(e)) { throw new OrderNotProcessableException(e.message); }