From 51c7cbf3ca5edd8a68e92280cd455f7ebbd4674f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 21 May 2026 16:23:15 +0200 Subject: [PATCH] feat(scrypt): tighten price deviation cap to 0.3% + notify on overshoot Scrypt embeds its commission into the quoted price ("price you see is what you get"), so the spread between Scrypt's executable price and our pricing reference is the only pre-trade signal that the implicit cost is too high. The default maxPriceDeviation of 5% in ScryptAdapter.getAndCheckTradePrice was too loose: on 2026-05-21 a 570'000 EUR BTC/EUR buy on Scrypt cleared at a 0.65% spread vs. our pricing reference, costing ~2'870 EUR more than Kraken would have. The check passed silently because 0.65% << 5%. Changes: - Add Config.scrypt.maxPriceDeviation = 0.003 (0.3%) - ScryptAdapter.getAndCheckTradePrice defaults to that cap - On overshoot: send ErrorMonitoring mail (isLiqMail) before throwing OrderFailedException, debounced 1h per asset pair to avoid spam --- src/config/config.ts | 5 ++ .../adapters/actions/scrypt.adapter.ts | 60 +++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index 73bf43e7b6..a81305d2b4 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1265,6 +1265,11 @@ export class Configuration { wsUrl: process.env.SCRYPT_WS_URL, apiKey: process.env.SCRYPT_API_KEY, apiSecret: process.env.SCRYPT_API_SECRET, + // Hard cap on the spread between Scrypt's executable price and our pricing reference. + // Scrypt embeds its fee in the quote ("price you see is what you get"), so a wide + // spread is the only pre-trade signal that the implicit cost is too high. Abort + alert + // when exceeded to avoid silent overpayment like the 2026-05-21 BTC/EUR incident. + maxPriceDeviation: 0.003, }; get evmWallets(): Map { 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 207160051c..8bab8b0420 100644 --- a/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/actions/scrypt.adapter.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, forwardRef } from '@nestjs/common'; +import { Config } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { ScryptOrderInfo, ScryptOrderSide, ScryptTransactionStatus } from 'src/integration/exchange/dto/scrypt.dto'; import { TradeChangedException } from 'src/integration/exchange/exceptions/trade-changed.exception'; @@ -10,6 +11,9 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; import { BuyCryptoService } from 'src/subdomains/core/buy-crypto/process/services/buy-crypto.service'; import { DexService } from 'src/subdomains/supporting/dex/services/dex.service'; +import { MailContext, MailType } from 'src/subdomains/supporting/notification/enums'; +import { MailRequest } from 'src/subdomains/supporting/notification/interfaces'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; import { PriceCurrency, PriceValidity, @@ -46,6 +50,7 @@ export class ScryptAdapter extends LiquidityActionAdapter { private readonly assetService: AssetService, private readonly ruleRepo: LiquidityManagementRuleRepository, private readonly balanceRepo: LiquidityBalanceRepository, + private readonly notificationService: NotificationService, @Inject(forwardRef(() => BuyCryptoService)) private readonly buyCryptoService: BuyCryptoService, ) { super(LiquidityManagementSystem.SCRYPT); @@ -449,17 +454,62 @@ export class ScryptAdapter extends LiquidityActionAdapter { return side === ScryptOrderSide.BUY ? availableBalance * 0.99 : availableBalance; } - private async getAndCheckTradePrice(from: Asset, to: Asset, maxPriceDeviation = 0.05): Promise { + private async getAndCheckTradePrice( + from: Asset, + to: Asset, + maxPriceDeviation = Config.scrypt.maxPriceDeviation, + ): Promise { const price = await this.scryptService.getCurrentPrice(from.name, to.name); const checkPrice = await this.pricingService.getPrice(from, to, PriceValidity.VALID_ONLY); - if (Math.abs((price - checkPrice.price) / checkPrice.price) > maxPriceDeviation) { - throw new OrderFailedException( - `Trade price out of range: exchange price ${price}, check price ${checkPrice.price}, max deviation ${maxPriceDeviation}`, - ); + const deviation = Math.abs((price - checkPrice.price) / checkPrice.price); + + if (deviation > maxPriceDeviation) { + const message = + `Scrypt ${from.name}/${to.name} price deviation ${(deviation * 100).toFixed(4)}% ` + + `exceeds max ${(maxPriceDeviation * 100).toFixed(4)}% ` + + `(exchange price ${price}, reference ${checkPrice.price}). Trade aborted.`; + + this.logger.error(message); + await this.notifyPriceDeviation(message, from, to, price, checkPrice.price, deviation, maxPriceDeviation); + + throw new OrderFailedException(message); } return price; } + + private async notifyPriceDeviation( + summary: string, + from: Asset, + to: Asset, + price: number, + referencePrice: number, + deviation: number, + maxPriceDeviation: number, + ): Promise { + const mailRequest: MailRequest = { + type: MailType.ERROR_MONITORING, + context: MailContext.LIQUIDITY_MANAGEMENT, + input: { + subject: `Scrypt ${from.name}/${to.name} price deviation too high`, + errors: [ + summary, + `Exchange price (Scrypt): ${price}`, + `Reference price (pricing service): ${referencePrice}`, + `Deviation: ${(deviation * 100).toFixed(4)} % (cap: ${(maxPriceDeviation * 100).toFixed(4)} %)`, + ], + isLiqMail: true, + }, + correlationId: `scrypt-price-deviation-${from.name}-${to.name}`, + options: { debounce: 60 * 60 * 1000, suppressRecurring: true }, + }; + + try { + await this.notificationService.sendMail(mailRequest); + } catch (e) { + this.logger.error('Failed to send Scrypt price deviation notification', e); + } + } }