diff --git a/migration/1774100000000-AddScryptSellIfDeficitAction.js b/migration/1774100000000-AddScryptSellIfDeficitAction.js new file mode 100644 index 0000000000..1ff35eab27 --- /dev/null +++ b/migration/1774100000000-AddScryptSellIfDeficitAction.js @@ -0,0 +1,36 @@ +module.exports = class AddScryptSellIfDeficitAction1774100000000 { + name = 'AddScryptSellIfDeficitAction1774100000000'; + + async up(queryRunner) { + // Create sell-if-deficit action: sells EUR for BTC only when Bitcoin on-chain has a deficit, falls back to existing EUR→USDT sell (action 233) + await queryRunner.query(` + INSERT INTO "dbo"."liquidity_management_action" ("system", "command", "tag", "params", "onSuccessId", "onFailId") + VALUES ('Scrypt', 'sell-if-deficit', 'SCRYPT EUR->BTC if deficit', '{"tradeAsset":"BTC","checkAssetId":113}', NULL, 233) + `); + + // Update rule 313 (Scrypt/EUR) to use new action as redundancy start + await queryRunner.query(` + UPDATE "dbo"."liquidity_management_rule" + SET "redundancyStartActionId" = ( + SELECT "id" FROM "dbo"."liquidity_management_action" + WHERE "system" = 'Scrypt' AND "command" = 'sell-if-deficit' + ) + WHERE "id" = 313 + `); + } + + async down(queryRunner) { + // Restore rule 313 to original action (233 = sell EUR→USDT) + await queryRunner.query(` + UPDATE "dbo"."liquidity_management_rule" + SET "redundancyStartActionId" = 233 + WHERE "id" = 313 + `); + + // Remove sell-if-deficit action + await queryRunner.query(` + DELETE FROM "dbo"."liquidity_management_action" + WHERE "system" = 'Scrypt' AND "command" = 'sell-if-deficit' + `); + } +}; 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 88e8831dfb..6143787557 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts @@ -14,13 +14,16 @@ import { LiquidityManagementSystem } from '../../enums'; import { OrderFailedException } from '../../exceptions/order-failed.exception'; import { OrderNotProcessableException } from '../../exceptions/order-not-processable.exception'; import { Command, CorrelationId } from '../../interfaces'; +import { LiquidityBalanceRepository } from '../../repositories/liquidity-balance.repository'; import { LiquidityManagementOrderRepository } from '../../repositories/liquidity-management-order.repository'; +import { LiquidityManagementRuleRepository } from '../../repositories/liquidity-management-rule.repository'; import { LiquidityActionAdapter } from './base/liquidity-action.adapter'; export enum ScryptAdapterCommands { WITHDRAW = 'withdraw', SELL = 'sell', BUY = 'buy', + SELL_IF_DEFICIT = 'sell-if-deficit', } @Injectable() @@ -35,12 +38,15 @@ export class ScryptAdapter extends LiquidityActionAdapter { private readonly orderRepo: LiquidityManagementOrderRepository, private readonly pricingService: PricingService, private readonly assetService: AssetService, + private readonly ruleRepo: LiquidityManagementRuleRepository, + private readonly balanceRepo: LiquidityBalanceRepository, ) { super(LiquidityManagementSystem.SCRYPT); this.commands.set(ScryptAdapterCommands.WITHDRAW, this.withdraw.bind(this)); this.commands.set(ScryptAdapterCommands.SELL, this.sell.bind(this)); this.commands.set(ScryptAdapterCommands.BUY, this.buy.bind(this)); + this.commands.set(ScryptAdapterCommands.SELL_IF_DEFICIT, this.sellIfDeficit.bind(this)); } async checkCompletion(order: LiquidityManagementOrder): Promise { @@ -54,6 +60,9 @@ export class ScryptAdapter extends LiquidityActionAdapter { case ScryptAdapterCommands.BUY: return this.checkBuyCompletion(order); + case ScryptAdapterCommands.SELL_IF_DEFICIT: + return this.checkSellCompletion(order); + default: return false; } @@ -68,6 +77,9 @@ export class ScryptAdapter extends LiquidityActionAdapter { case ScryptAdapterCommands.BUY: return this.validateTradeParams(params); + case ScryptAdapterCommands.SELL_IF_DEFICIT: + return this.validateSellIfDeficitParams(params); + default: throw new Error(`Command ${command} not supported by ScryptAdapter`); } @@ -176,6 +188,61 @@ export class ScryptAdapter extends LiquidityActionAdapter { } } + private async sellIfDeficit(order: LiquidityManagementOrder): Promise { + const { tradeAsset, checkAssetId, maxPriceDeviation } = this.parseSellIfDeficitParams(order.action.paramMap); + + // Check if the referenced asset has a deficit + const checkRule = await this.ruleRepo.findOneBy({ targetAsset: { id: checkAssetId } }); + if (!checkRule) { + throw new OrderNotProcessableException(`No rule found for asset ${checkAssetId}`); + } + + const checkBalance = await this.balanceRepo.findOneBy({ asset: { id: checkAssetId } }); + if (!checkBalance || checkBalance.amount >= (checkRule.minimal ?? 0)) { + throw new OrderNotProcessableException( + `No deficit for asset ${checkAssetId} (balance: ${checkBalance?.amount}, minimal: ${checkRule.minimal})`, + ); + } + + // Calculate how much of the trade asset is needed to reach optimal + const deficitAmount = (checkRule.optimal ?? 0) - (checkBalance.amount ?? 0); + if (deficitAmount <= 0) { + throw new OrderNotProcessableException(`No deficit to optimal for asset ${checkAssetId}`); + } + + // Get price and convert deficit to source asset amount + const targetAssetEntity = order.pipeline.rule.targetAsset; + const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); + + const price = await this.getAndCheckTradePrice(targetAssetEntity, tradeAssetEntity, maxPriceDeviation); + // price = tradeAsset per targetAsset (e.g., BTC per EUR) + const sellAmount = Util.floor(deficitAmount / price, 6); + + // Cap by available balance and order limits + const availableBalance = await this.scryptService.getAvailableBalance(targetAssetEntity.dexName); + const amount = Util.floor(Math.min(sellAmount, order.maxAmount, availableBalance), 6); + + if (amount <= 0) { + throw new OrderNotProcessableException( + `Scrypt: insufficient amount for sell-if-deficit (needed: ${sellAmount}, available: ${availableBalance})`, + ); + } + + order.inputAmount = amount; + order.inputAsset = targetAssetEntity.dexName; + order.outputAsset = tradeAsset; + + try { + return await this.scryptService.sell(targetAssetEntity.dexName, tradeAsset, amount); + } catch (e) { + if (this.isBalanceTooLowError(e)) { + throw new OrderNotProcessableException(e.message); + } + + throw e; + } + } + // --- COMPLETION CHECKS --- // private async checkWithdrawCompletion(order: LiquidityManagementOrder): Promise { @@ -325,6 +392,30 @@ export class ScryptAdapter extends LiquidityActionAdapter { return { tradeAsset, maxPriceDeviation }; } + private validateSellIfDeficitParams(params: Record): boolean { + try { + this.parseSellIfDeficitParams(params); + return true; + } catch { + return false; + } + } + + private parseSellIfDeficitParams(params: Record): { + tradeAsset: string; + checkAssetId: number; + maxPriceDeviation?: number; + } { + const { tradeAsset, maxPriceDeviation } = this.parseTradeParams(params); + const checkAssetId = params.checkAssetId as number | undefined; + + if (!checkAssetId) { + throw new Error('Params provided to ScryptAdapter sell-if-deficit command are missing checkAssetId.'); + } + + return { tradeAsset, checkAssetId, maxPriceDeviation }; + } + // --- HELPER METHODS --- // private isBalanceTooLowError(e: Error): boolean {