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/integration/exchange/services/__tests__/scrypt.service.spec.ts b/src/integration/exchange/services/__tests__/scrypt.service.spec.ts new file mode 100644 index 0000000000..b00dcbcb4a --- /dev/null +++ b/src/integration/exchange/services/__tests__/scrypt.service.spec.ts @@ -0,0 +1,49 @@ +import { ScryptOrderSide } from '../../dto/scrypt.dto'; +import { ScryptService } from '../scrypt.service'; + +describe('ScryptService.applyPriceCap', () => { + const orderbook = 66_500; + + describe('priceCap unset / invalid', () => { + it.each([ + ['undefined', undefined], + ['null', null], + ['zero', 0], + ['negative', -100], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['-Infinity', Number.NEGATIVE_INFINITY], + ])('returns orderbookPrice when priceCap is %s', (_label, cap) => { + expect(ScryptService.applyPriceCap(orderbook, ScryptOrderSide.BUY, cap as number | undefined)).toBe(orderbook); + expect(ScryptService.applyPriceCap(orderbook, ScryptOrderSide.SELL, cap as number | undefined)).toBe(orderbook); + }); + }); + + describe('side=BUY (cap = upper bound)', () => { + it('uses orderbook price when below cap (cheaper market)', () => { + expect(ScryptService.applyPriceCap(66_500, ScryptOrderSide.BUY, 66_700)).toBe(66_500); + }); + + it('uses cap when orderbook is above cap (clamp)', () => { + expect(ScryptService.applyPriceCap(66_900, ScryptOrderSide.BUY, 66_700)).toBe(66_700); + }); + + it('returns either when equal', () => { + expect(ScryptService.applyPriceCap(66_700, ScryptOrderSide.BUY, 66_700)).toBe(66_700); + }); + }); + + describe('side=SELL (cap = lower bound)', () => { + it('uses orderbook price when above cap (better market)', () => { + expect(ScryptService.applyPriceCap(66_500, ScryptOrderSide.SELL, 66_300)).toBe(66_500); + }); + + it('uses cap when orderbook is below cap (clamp)', () => { + expect(ScryptService.applyPriceCap(66_100, ScryptOrderSide.SELL, 66_300)).toBe(66_300); + }); + + it('returns either when equal', () => { + expect(ScryptService.applyPriceCap(66_300, ScryptOrderSide.SELL, 66_300)).toBe(66_300); + }); + }); +}); diff --git a/src/integration/exchange/services/scrypt.service.ts b/src/integration/exchange/services/scrypt.service.ts index 26fbac2c85..280c85bf12 100644 --- a/src/integration/exchange/services/scrypt.service.ts +++ b/src/integration/exchange/services/scrypt.service.ts @@ -242,11 +242,17 @@ export class ScryptService extends PricingProvider { return side === ScryptOrderSide.BUY ? price : 1 / price; } - async sell(from: string, to: string, amount: number): Promise { + async sell(from: string, to: string, amount: number, priceCap?: number): Promise { const { symbol, side } = await this.getTradePair(from, to); - const price = await this.getOrderBookPrice(symbol, side); + const orderbookPrice = await this.getOrderBookPrice(symbol, side); const sizeIncrement = await this.getSizeIncrement(symbol); + // Hard execution-price cap (Scrypt embeds its commission into the quote, so the + // only way to bound implicit cost is the LIMIT price itself). + // - BUY (we pay quote per base): cap is upper bound, LIMIT = min(orderbook, cap) + // - SELL (we receive quote per base): cap is lower bound, LIMIT = max(orderbook, cap) + const price = ScryptService.applyPriceCap(orderbookPrice, side, priceCap); + // OrderQty must be in base currency // SELL (from=base): orderQty = amount // BUY (from=quote): orderQty = amount / price @@ -256,6 +262,11 @@ export class ScryptService extends PricingProvider { return this.placeAndReturnId(symbol, side, orderQty, price); } + static applyPriceCap(orderbookPrice: number, side: ScryptOrderSide, priceCap?: number): number { + if (priceCap == null || !Number.isFinite(priceCap) || priceCap <= 0) return orderbookPrice; + return side === ScryptOrderSide.BUY ? Math.min(orderbookPrice, priceCap) : Math.max(orderbookPrice, priceCap); + } + async buy(from: string, to: string, amount: number): Promise { const { symbol, side } = await this.getTradePair(from, to); const price = await this.getOrderBookPrice(symbol, side); @@ -323,7 +334,13 @@ export class ScryptService extends PricingProvider { }; } - async checkTrade(clOrdId: string, from: string, to: string, orderCreated?: Date): Promise { + async checkTrade( + clOrdId: string, + from: string, + to: string, + orderCreated?: Date, + priceCap?: number, + ): Promise { const orderInfo = await this.getOrderStatus(clOrdId); if (!orderInfo) { // If the order is older than 1 hour and still not found, it's lost @@ -341,15 +358,17 @@ export class ScryptService extends PricingProvider { switch (orderInfo.status) { case ScryptOrderStatus.NEW: case ScryptOrderStatus.PARTIALLY_FILLED: { + const { side } = await this.getTradePair(from, to); const currentPrice = await this.getTradePrice(from, to); + const cappedPrice = ScryptService.applyPriceCap(currentPrice, side, priceCap); // Use tolerance for float comparison to avoid unnecessary updates due to rounding - const priceChanged = orderInfo.price && Math.abs(currentPrice - orderInfo.price) > 0.000001; + const priceChanged = orderInfo.price && Math.abs(cappedPrice - orderInfo.price) > 0.000001; if (priceChanged) { - this.logger.verbose(`Order ${clOrdId}: price changed ${orderInfo.price} -> ${currentPrice}, updating order`); + this.logger.verbose(`Order ${clOrdId}: price changed ${orderInfo.price} -> ${cappedPrice}, updating order`); try { - const newId = await this.editOrder(clOrdId, from, to, orderInfo.remainingQuantity, currentPrice); + const newId = await this.editOrder(clOrdId, from, to, orderInfo.remainingQuantity, cappedPrice); this.logger.verbose(`Order ${clOrdId} changed to ${newId}`); throw new TradeChangedException(newId); } catch (e) { @@ -364,7 +383,7 @@ export class ScryptService extends PricingProvider { } } } else { - this.logger.verbose(`Order ${clOrdId} open, price is still ${currentPrice}`); + this.logger.verbose(`Order ${clOrdId} open, price is still ${cappedPrice}`); } return false; } @@ -383,7 +402,8 @@ export class ScryptService extends PricingProvider { // Restart order with remaining amount (already in base currency) const { symbol, side } = await this.getTradePair(from, to); - const price = await this.getOrderBookPrice(symbol, side); + const orderbookPrice = await this.getOrderBookPrice(symbol, side); + const price = ScryptService.applyPriceCap(orderbookPrice, side, priceCap); this.logger.verbose(`Order ${clOrdId} cancelled, restarting with remaining ${remaining} (base currency)`); diff --git a/src/subdomains/core/liquidity-management/adapters/actions/__tests__/scrypt.adapter.spec.ts b/src/subdomains/core/liquidity-management/adapters/actions/__tests__/scrypt.adapter.spec.ts new file mode 100644 index 0000000000..76b1648439 --- /dev/null +++ b/src/subdomains/core/liquidity-management/adapters/actions/__tests__/scrypt.adapter.spec.ts @@ -0,0 +1,53 @@ +import { ScryptOrderSide } from 'src/integration/exchange/dto/scrypt.dto'; +import { ScryptAdapter } from '../scrypt.adapter'; + +describe('ScryptAdapter.toPriceCap', () => { + const maxDev = 0.003; + + // Anchor: at 2026-05-21 ~13:44 UTC, Kraken VWAP BTC/EUR was ~66'218 EUR/BTC. + // The pricingService convention is Price.price = source/target (= from/to), + // verified via Price.convert(amount) = amount/price. + // Scrypt LIMIT orders express price as quote-per-base, so: + // pricingService.getPrice(EUR, BTC).price = EUR-per-BTC (high number, e.g. 66_500) + // pricingService.getPrice(BTC, EUR).price = BTC-per-EUR (small number, e.g. 1.5e-5) + + describe('side=BUY (e.g. sellIfDeficit BTC: from=EUR=quote, to=BTC=base)', () => { + it('treats from-per-to reference directly as quote-per-base and returns upper bound', () => { + // ref 66'500 EUR/BTC → cap = 66'500 × 1.003 = 66'699.5 + expect(ScryptAdapter.toPriceCap(66_500, ScryptOrderSide.BUY, maxDev)).toBeCloseTo(66_699.5, 4); + }); + + it('cap is strictly greater than reference for BUY', () => { + const cap = ScryptAdapter.toPriceCap(66_500, ScryptOrderSide.BUY, maxDev); + expect(cap).toBeGreaterThan(66_500); + }); + }); + + describe('side=SELL (e.g. selling BTC for EUR: from=BTC=base, to=EUR=quote)', () => { + it('inverts from-per-to reference into quote-per-base and returns lower bound', () => { + // ref 1.5e-5 BTC/EUR → refQuotePerBase = 1 / 1.5e-5 = 66_666.67 → cap = ×0.997 = 66'466.67 + expect(ScryptAdapter.toPriceCap(1.5e-5, ScryptOrderSide.SELL, maxDev)).toBeCloseTo(66_466.67, 1); + }); + + it('cap is strictly less than the inverted reference for SELL', () => { + const ref = 1.5e-5; + const cap = ScryptAdapter.toPriceCap(ref, ScryptOrderSide.SELL, maxDev); + expect(cap).toBeLessThan(1 / ref); + }); + }); + + describe('symmetry sanity', () => { + it('BUY cap on (EUR, BTC) ≈ inverse of SELL cap on (BTC, EUR) within 1.6× the deviation window', () => { + // Refs are exact inverses of each other (66_500 ↔ 1/66_500) + const buyRef = 66_500; + const sellRef = 1 / buyRef; + + const buyCap = ScryptAdapter.toPriceCap(buyRef, ScryptOrderSide.BUY, maxDev); // 66_500 × 1.003 + const sellCap = ScryptAdapter.toPriceCap(sellRef, ScryptOrderSide.SELL, maxDev); // 66_500 × 0.997 + + // Both caps are in EUR/BTC (quote-per-base). They differ by 2×maxDev around the mid. + const ratio = buyCap / sellCap; + expect(ratio).toBeCloseTo((1 + maxDev) / (1 - maxDev), 5); + }); + }); +}); 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..cd0702fd70 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); @@ -131,7 +136,7 @@ export class ScryptAdapter extends LiquidityActionAdapter { const targetAsset = order.pipeline.rule.targetAsset; const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); - await this.getAndCheckTradePrice(targetAsset, tradeAssetEntity, maxPriceDeviation); + const { priceCap } = await this.getAndCheckTradePrice(targetAsset, tradeAssetEntity, maxPriceDeviation); const availableBalance = await this.scryptService.getAvailableBalance(targetAsset.dexName); const effectiveMax = Math.min(order.maxAmount, availableBalance); @@ -144,7 +149,7 @@ export class ScryptAdapter extends LiquidityActionAdapter { const amount = Util.floor(effectiveMax, 6); - return this.executeSell(order, amount, targetAsset.dexName, tradeAsset); + return this.executeSell(order, amount, targetAsset.dexName, tradeAsset, priceCap); } private async buy(order: LiquidityManagementOrder): Promise { @@ -153,9 +158,13 @@ export class ScryptAdapter extends LiquidityActionAdapter { const targetAssetEntity = order.pipeline.rule.targetAsset; const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); - const price = await this.getAndCheckTradePrice(tradeAssetEntity, targetAssetEntity, maxPriceDeviation); - const minSellAmount = Util.floor(order.minAmount * price, 6); - const maxSellAmount = Util.floor(order.maxAmount * price, 6); + const { scryptPrice, priceCap } = await this.getAndCheckTradePrice( + tradeAssetEntity, + targetAssetEntity, + maxPriceDeviation, + ); + const minSellAmount = Util.floor(order.minAmount * scryptPrice, 6); + const maxSellAmount = Util.floor(order.maxAmount * scryptPrice, 6); const availableBalance = await this.getAvailableTradeBalance(tradeAsset, targetAssetEntity.dexName); const fiatOrderCap = ['CHF', 'EUR'].includes(tradeAsset) ? 200_000 : Infinity; @@ -174,7 +183,7 @@ export class ScryptAdapter extends LiquidityActionAdapter { order.outputAsset = targetAssetEntity.dexName; try { - return await this.scryptService.sell(tradeAsset, targetAssetEntity.dexName, amount); + return await this.scryptService.sell(tradeAsset, targetAssetEntity.dexName, amount, priceCap); } catch (e) { if (this.isBalanceTooLowError(e)) { throw new OrderNotProcessableException(e.message); @@ -219,11 +228,15 @@ export class ScryptAdapter extends LiquidityActionAdapter { const targetAsset = order.pipeline.rule.targetAsset; const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); - const price = await this.getAndCheckTradePrice(targetAsset, tradeAssetEntity, maxPriceDeviation); + const { scryptPrice, priceCap } = await this.getAndCheckTradePrice( + targetAsset, + tradeAssetEntity, + maxPriceDeviation, + ); const availableBalance = await this.scryptService.getAvailableBalance(targetAsset.dexName); - // price = targetAsset per tradeAsset (e.g., EUR per BTC) - const sellAmount = Util.floor(deficitAmount * price, 6); + // scryptPrice = targetAsset per tradeAsset (e.g., EUR per BTC) + const sellAmount = Util.floor(deficitAmount * scryptPrice, 6); const amount = Util.floor(Math.min(sellAmount, order.maxAmount, availableBalance), 6); if (amount <= 0) { @@ -232,7 +245,7 @@ export class ScryptAdapter extends LiquidityActionAdapter { ); } - return this.executeSell(order, amount, targetAsset.dexName, tradeAsset); + return this.executeSell(order, amount, targetAsset.dexName, tradeAsset, priceCap); } // --- COMPLETION CHECKS --- // @@ -260,22 +273,33 @@ export class ScryptAdapter extends LiquidityActionAdapter { } private async checkSellCompletion(order: LiquidityManagementOrder): Promise { - const { tradeAsset } = this.parseTradeParams(order.action.paramMap); - const asset = order.pipeline.rule.targetAsset.dexName; + const { tradeAsset, maxPriceDeviation } = this.parseTradeParams(order.action.paramMap); + const targetAsset = order.pipeline.rule.targetAsset; + const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); + + const priceCap = await this.computePriceCap(targetAsset, tradeAssetEntity, maxPriceDeviation); - return this.checkTradeCompletion(order, asset, tradeAsset); + return this.checkTradeCompletion(order, targetAsset.dexName, tradeAsset, priceCap); } private async checkBuyCompletion(order: LiquidityManagementOrder): Promise { - const { tradeAsset } = this.parseTradeParams(order.action.paramMap); - const asset = order.pipeline.rule.targetAsset.dexName; + const { tradeAsset, maxPriceDeviation } = this.parseTradeParams(order.action.paramMap); + const targetAsset = order.pipeline.rule.targetAsset; + const tradeAssetEntity = await this.assetService.getAssetByUniqueName(`Scrypt/${tradeAsset}`); - return this.checkTradeCompletion(order, tradeAsset, asset); + const priceCap = await this.computePriceCap(tradeAssetEntity, targetAsset, maxPriceDeviation); + + return this.checkTradeCompletion(order, tradeAsset, targetAsset.dexName, priceCap); } - private async checkTradeCompletion(order: LiquidityManagementOrder, from: string, to: string): Promise { + private async checkTradeCompletion( + order: LiquidityManagementOrder, + from: string, + to: string, + priceCap?: number, + ): Promise { try { - const isComplete = await this.scryptService.checkTrade(order.correlationId, from, to, order.created); + const isComplete = await this.scryptService.checkTrade(order.correlationId, from, to, order.created, priceCap); if (isComplete) { order.outputAmount = await this.aggregateTradeOutput(order); @@ -420,13 +444,14 @@ export class ScryptAdapter extends LiquidityActionAdapter { amount: number, fromAsset: string, toAsset: string, + priceCap?: number, ): Promise { order.inputAmount = amount; order.inputAsset = fromAsset; order.outputAsset = toAsset; try { - return await this.scryptService.sell(fromAsset, toAsset, amount); + return await this.scryptService.sell(fromAsset, toAsset, amount, priceCap); } catch (e) { if (this.isBalanceTooLowError(e)) { throw new OrderNotProcessableException(e.message); @@ -449,17 +474,86 @@ export class ScryptAdapter extends LiquidityActionAdapter { return side === ScryptOrderSide.BUY ? availableBalance * 0.99 : availableBalance; } - private async getAndCheckTradePrice(from: Asset, to: Asset, maxPriceDeviation = 0.05): Promise { - const price = await this.scryptService.getCurrentPrice(from.name, to.name); + private async getAndCheckTradePrice( + from: Asset, + to: Asset, + maxPriceDeviation = Config.scrypt.maxPriceDeviation, + ): Promise<{ scryptPrice: number; priceCap: number; side: ScryptOrderSide }> { + const { side } = await this.scryptService.getTradePair(from.name, to.name); + const scryptPrice = 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((scryptPrice - 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 ${scryptPrice}, reference ${checkPrice.price}). Trade aborted.`; + + this.logger.error(message); + await this.notifyPriceDeviation(message, from, to, scryptPrice, checkPrice.price, deviation, maxPriceDeviation); + + throw new OrderFailedException(message); } - return price; + return { scryptPrice, priceCap: ScryptAdapter.toPriceCap(checkPrice.price, side, maxPriceDeviation), side }; + } + + private async computePriceCap( + from: Asset, + to: Asset, + maxPriceDeviation = Config.scrypt.maxPriceDeviation, + ): Promise { + const { side } = await this.scryptService.getTradePair(from.name, to.name); + const checkPrice = await this.pricingService.getPrice(from, to, PriceValidity.VALID_ONLY); + return ScryptAdapter.toPriceCap(checkPrice.price, side, maxPriceDeviation); + } + + static toPriceCap(referencePrice: number, side: ScryptOrderSide, maxPriceDeviation: number): number { + // pricingService.getPrice(from, to) returns Price.price = source/target = from-per-to + // (verified via Price.convert(amount) = amount / price, see Price entity). + // Scrypt LIMIT orders use quote-per-base. + // + // side=BUY (from=quote, to=base): from-per-to = quote-per-base → no inversion + // side=SELL (from=base, to=quote): from-per-to = base-per-quote → invert + const refQuotePerBase = side === ScryptOrderSide.SELL ? 1 / referencePrice : referencePrice; + return side === ScryptOrderSide.BUY + ? refQuotePerBase * (1 + maxPriceDeviation) + : refQuotePerBase * (1 - maxPriceDeviation); + } + + 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 }, + }; + + try { + await this.notificationService.sendMail(mailRequest); + } catch (e) { + this.logger.error('Failed to send Scrypt price deviation notification', e); + } } }