diff --git a/lib/db/models/Referral.ts b/lib/db/models/Referral.ts index 1ff8ecbe..c6133f12 100644 --- a/lib/db/models/Referral.ts +++ b/lib/db/models/Referral.ts @@ -1,13 +1,21 @@ import { DataTypes, Model, Sequelize } from 'sequelize'; -import { SwapType } from '../../consts/Enums'; +import { OrderSide, SwapType } from '../../consts/Enums'; type Limits = { minimal?: number; maximal?: number; }; -// TODO: direction of chain swaps -type Premiums = Partial>; +type DirectionalPremium = { + [OrderSide.BUY]: number; + [OrderSide.SELL]: number; +}; + +type Premiums = Partial<{ + [SwapType.Submarine]: number; + [SwapType.ReverseSubmarine]: number; + [SwapType.Chain]: DirectionalPremium; +}>; type LimitsPerType = Partial>; @@ -131,27 +139,55 @@ class Referral extends Model implements ReferralType { return this.config?.limits?.[type]; }; - public premium = (pair: string, type: SwapType): number | undefined => { - return ( + public premium = ( + pair: string, + type: SwapType, + orderSide?: OrderSide, + ): number | undefined => { + const premium = this.config?.pairs?.[pair]?.premiums?.[type] || - this.config?.premiums?.[type] - ); + this.config?.premiums?.[type]; + + if (type === SwapType.Chain && orderSide !== undefined) { + return premium ? premium[orderSide] : undefined; + } + + return premium as number | undefined; }; public premiumForPairs = ( pairs: string[], type: SwapType, + orderSide?: OrderSide, ): number | undefined => { + if (type === SwapType.Chain && orderSide === undefined) { + throw new Error('chain swap premiums require an order side'); + } + for (const pair of pairs) { const premium = this.config?.pairs?.[pair]?.premiums?.[type]; if (premium !== undefined) { - return premium; + if (type === SwapType.Chain) { + return premium[orderSide!]; + } + return premium as number | undefined; } } - return this.config?.premiums?.[type]; + const premium = this.config?.premiums?.[type]; + if (type === SwapType.Chain && premium !== undefined) { + return premium[orderSide!]; + } + + return premium as number | undefined; }; } export default Referral; -export { ReferralType, ReferralConfig, ReferralPairConfig }; +export { + ReferralType, + ReferralConfig, + ReferralPairConfig, + DirectionalPremium, + Premiums, +}; diff --git a/lib/db/repositories/ReferralRepository.ts b/lib/db/repositories/ReferralRepository.ts index 9c7f1dd4..e743beed 100644 --- a/lib/db/repositories/ReferralRepository.ts +++ b/lib/db/repositories/ReferralRepository.ts @@ -1,7 +1,12 @@ import { QueryTypes } from 'sequelize'; -import { SuccessSwapUpdateEvents } from '../../consts/Enums'; +import { + OrderSide, + SuccessSwapUpdateEvents, + SwapType, +} from '../../consts/Enums'; import Database from '../Database'; import Referral, { + DirectionalPremium, ReferralConfig, ReferralPairConfig, ReferralType, @@ -112,23 +117,56 @@ class ReferralRepository { private static sanityCheckConfig = ( config: ReferralConfig | null | undefined, ) => { + const MAX_PREMIUM_PERCENTAGE = 100; + const MIN_PREMIUM_PERCENTAGE = -100; + const MIN_EXPIRATION = 120; + const MAX_EXPIRATION = 60 * 60 * 24; + const MAX_ROUTING_FEE = 0.005; + const sanityCheckPairConfig = (cfg: ReferralPairConfig) => { - if (cfg.maxRoutingFee) { - if (cfg.maxRoutingFee < 0 || cfg.maxRoutingFee > 0.005) { - throw 'maxRoutingFee out of range'; - } + if ( + cfg.maxRoutingFee && + (cfg.maxRoutingFee < 0 || cfg.maxRoutingFee > MAX_ROUTING_FEE) + ) { + throw 'maxRoutingFee out of range'; } if (cfg.premiums) { - if (Object.values(cfg.premiums).some((p) => p < -100 || p > 100)) { - throw 'premium out of range'; + for (const [typeStr, premium] of Object.entries(cfg.premiums)) { + const type = Number(typeStr) as SwapType; + + if (type === SwapType.Chain) { + const directionalPremium = premium as DirectionalPremium; + + if ( + directionalPremium[OrderSide.BUY] === undefined || + directionalPremium[OrderSide.SELL] === undefined + ) { + throw 'Chain swap premiums must specify both BUY and SELL values'; + } + + if ( + directionalPremium[OrderSide.BUY] < MIN_PREMIUM_PERCENTAGE || + directionalPremium[OrderSide.BUY] > MAX_PREMIUM_PERCENTAGE || + directionalPremium[OrderSide.SELL] < MIN_PREMIUM_PERCENTAGE || + directionalPremium[OrderSide.SELL] > MAX_PREMIUM_PERCENTAGE + ) { + throw 'premium out of range'; + } + } else if ( + typeof premium === 'number' && + (premium < MIN_PREMIUM_PERCENTAGE || + premium > MAX_PREMIUM_PERCENTAGE) + ) { + throw 'premium out of range'; + } } } if (cfg.expirations) { if ( Object.values(cfg.expirations).some( - (e) => e < 120 || e > 60 * 60 * 24, + (e) => e < MIN_EXPIRATION || e > MAX_EXPIRATION, ) ) { throw 'expiration out of range'; @@ -140,9 +178,7 @@ class ReferralRepository { sanityCheckPairConfig(config); if (config.pairs) { - for (const pair of Object.values(config.pairs)) { - sanityCheckPairConfig(pair); - } + Object.values(config.pairs).forEach(sanityCheckPairConfig); } } }; diff --git a/lib/rates/FeeProvider.ts b/lib/rates/FeeProvider.ts index a2c8a38d..c9107800 100644 --- a/lib/rates/FeeProvider.ts +++ b/lib/rates/FeeProvider.ts @@ -242,7 +242,9 @@ class FeeProvider { typeof percentageType === 'number' ? percentageType : percentageType[orderSide], - referral?.premium(pair, type), + type === SwapType.Chain + ? referral?.premium(pair, type, orderSide) + : referral?.premium(pair, type), ); return feeType === PercentageFeeType.Calculation diff --git a/lib/rates/providers/RateProviderTaproot.ts b/lib/rates/providers/RateProviderTaproot.ts index 05554240..20922c4b 100644 --- a/lib/rates/providers/RateProviderTaproot.ts +++ b/lib/rates/providers/RateProviderTaproot.ts @@ -511,7 +511,25 @@ class RateProviderTaproot extends RateProviderBase { [to, from], ].map(([base, quote]) => getPairId({ base, quote })); - const premium = referral?.premiumForPairs(pairIds, type); + const getConfigPairId = (pairIds: string[]) => { + for (const pairId of pairIds) { + if (this.pairConfigs.has(pairId)) { + return pairId; + } + } + throw new Error('could not find pair id'); + }; + + const { quote } = splitPairId(getConfigPairId(pairIds)); + + const premium = + type === SwapType.Chain + ? referral?.premiumForPairs( + pairIds, + type, + to === quote ? OrderSide.SELL : OrderSide.BUY, + ) + : referral?.premiumForPairs(pairIds, type); const limits = referral?.limitsForPairs(pairIds, type); const result = { diff --git a/test/integration/db/models/Referral.spec.ts b/test/integration/db/models/Referral.spec.ts index d9ce0a96..007c710a 100644 --- a/test/integration/db/models/Referral.spec.ts +++ b/test/integration/db/models/Referral.spec.ts @@ -1,6 +1,6 @@ import Logger from '../../../../lib/Logger'; import { createApiCredential } from '../../../../lib/Utils'; -import { SwapType } from '../../../../lib/consts/Enums'; +import { OrderSide, SwapType } from '../../../../lib/consts/Enums'; import Database from '../../../../lib/db/Database'; import ReferralRepository, { ReferralType, @@ -19,6 +19,7 @@ describe('Referral', () => { premiums: { [SwapType.Submarine]: -15, [SwapType.ReverseSubmarine]: 21, + [SwapType.Chain]: { [OrderSide.BUY]: -15, [OrderSide.SELL]: -10 }, }, limits: { [SwapType.ReverseSubmarine]: { @@ -30,7 +31,7 @@ describe('Referral', () => { 'RBTC/BTC': { maxRoutingFee: 0.0025, premiums: { - [SwapType.Chain]: -25, + [SwapType.Chain]: { [OrderSide.BUY]: -25, [OrderSide.SELL]: -20 }, }, limits: { [SwapType.Submarine]: { @@ -108,29 +109,36 @@ describe('Referral', () => { }); test.each` - pair | type | expected - ${'BTC/BTC'} | ${SwapType.Submarine} | ${referralValues.config!.premiums![SwapType.Submarine]} - ${'BTC/BTC'} | ${SwapType.ReverseSubmarine} | ${referralValues.config!.premiums![SwapType.ReverseSubmarine]} - ${'BTC/BTC'} | ${SwapType.Chain} | ${referralValues.config!.premiums![SwapType.Chain]} - ${'RBTC/BTC'} | ${SwapType.Chain} | ${referralValues.config!.pairs!['RBTC/BTC']!.premiums![SwapType.Chain]} + pair | type | orderSide | expected + ${'BTC/BTC'} | ${SwapType.Submarine} | ${undefined} | ${referralValues.config!.premiums![SwapType.Submarine]} + ${'BTC/BTC'} | ${SwapType.ReverseSubmarine} | ${undefined} | ${referralValues.config!.premiums![SwapType.ReverseSubmarine]} + ${'BTC/BTC'} | ${SwapType.Chain} | ${OrderSide.BUY} | ${referralValues.config!.premiums![SwapType.Chain]![OrderSide.BUY]} + ${'BTC/BTC'} | ${SwapType.Chain} | ${OrderSide.SELL} | ${referralValues.config!.premiums![SwapType.Chain]![OrderSide.SELL]} + ${'RBTC/BTC'} | ${SwapType.Chain} | ${OrderSide.BUY} | ${referralValues.config!.pairs!['RBTC/BTC']!.premiums![SwapType.Chain]![OrderSide.BUY]} + ${'RBTC/BTC'} | ${SwapType.Chain} | ${OrderSide.SELL} | ${referralValues.config!.pairs!['RBTC/BTC']!.premiums![SwapType.Chain]![OrderSide.SELL]} `( 'should get premium for pair $pair and type $type', - async ({ pair, type, expected }) => { + async ({ pair, type, orderSide, expected }) => { const ref = await ReferralRepository.getReferralById(referralValues.id); expect(ref).not.toBeNull(); - expect(ref!.premium(pair, type)).toEqual(expected); + expect(ref!.premium(pair, type, orderSide)).toEqual(expected); }, ); test.each` - pairs | type | expected - ${['BTC/BTC']} | ${SwapType.Chain} | ${referralValues.config!.premiums![SwapType.Chain]} - ${['BTC/BTC', 'RBTC/BTC']} | ${SwapType.Chain} | ${referralValues.config!.pairs!['RBTC/BTC']!.premiums![SwapType.Chain]} - `('should get premium for pairs', async ({ pairs, type, expected }) => { - const ref = await ReferralRepository.getReferralById(referralValues.id); + pairs | type | orderSide | expected + ${['BTC/BTC']} | ${SwapType.Chain} | ${OrderSide.BUY} | ${referralValues.config!.premiums![SwapType.Chain]![OrderSide.BUY]} + ${['BTC/BTC']} | ${SwapType.Chain} | ${OrderSide.SELL} | ${referralValues.config!.premiums![SwapType.Chain]![OrderSide.SELL]} + ${['BTC/BTC', 'RBTC/BTC']} | ${SwapType.Chain} | ${OrderSide.BUY} | ${referralValues.config!.pairs!['RBTC/BTC']!.premiums![SwapType.Chain]![OrderSide.BUY]} + ${['BTC/BTC', 'RBTC/BTC']} | ${SwapType.Chain} | ${OrderSide.SELL} | ${referralValues.config!.pairs!['RBTC/BTC']!.premiums![SwapType.Chain]![OrderSide.SELL]} + `( + 'should get premium for pairs', + async ({ pairs, type, orderSide, expected }) => { + const ref = await ReferralRepository.getReferralById(referralValues.id); - expect(ref).not.toBeNull(); - expect(ref!.premiumForPairs(pairs, type)).toEqual(expected); - }); + expect(ref).not.toBeNull(); + expect(ref!.premiumForPairs(pairs, type, orderSide)).toEqual(expected); + }, + ); }); diff --git a/test/integration/db/repositories/ReferralRepository.spec.ts b/test/integration/db/repositories/ReferralRepository.spec.ts index 73f86d4a..904dfbdc 100644 --- a/test/integration/db/repositories/ReferralRepository.spec.ts +++ b/test/integration/db/repositories/ReferralRepository.spec.ts @@ -1,5 +1,5 @@ import Logger from '../../../../lib/Logger'; -import { SwapType } from '../../../../lib/consts/Enums'; +import { OrderSide, SwapType } from '../../../../lib/consts/Enums'; import Database from '../../../../lib/db/Database'; import Referral from '../../../../lib/db/models/Referral'; import ReferralRepository from '../../../../lib/db/repositories/ReferralRepository'; @@ -148,6 +148,97 @@ describe('ReferralRepository', () => { ).rejects.toEqual(`${field} out of range`); }, ); + + test('should throw if chain swap premium is not a DirectionalPremium object', async () => { + await expect( + ReferralRepository.addReferral({ + ...fixture, + config: { + premiums: { + [SwapType.Chain]: 10 as any, + }, + }, + }), + ).rejects.toEqual( + 'Chain swap premiums must specify both BUY and SELL values', + ); + }); + + test.each` + premiumValue + ${10} + ${{ [OrderSide.BUY]: 10 }} + ${{ [OrderSide.SELL]: 10 }} + `( + 'should throw if chain swap premium is invalid or incomplete ($premiumValue)', + async ({ premiumValue }) => { + await expect( + ReferralRepository.addReferral({ + ...fixture, + config: { + premiums: { + [SwapType.Chain]: premiumValue, + }, + }, + }), + ).rejects.toEqual( + 'Chain swap premiums must specify both BUY and SELL values', + ); + }, + ); + + test.each` + buy | sell + ${101} | ${10} + ${10} | ${-101} + ${-101} | ${10} + ${10} | ${101} + `( + 'should throw if chain swap premium values are out of range (buy: $buy, sell: $sell)', + async ({ buy, sell }) => { + await expect( + ReferralRepository.addReferral({ + ...fixture, + config: { + premiums: { + [SwapType.Chain]: { + [OrderSide.BUY]: buy, + [OrderSide.SELL]: sell, + }, + }, + }, + }), + ).rejects.toEqual('premium out of range'); + }, + ); + + test.each` + buy | sell + ${10} | ${20} + ${-100} | ${-100} + ${0} | ${0} + ${100} | ${100} + `( + 'should accept valid chain swap premiums (buy: $buy, sell: $sell)', + async ({ buy, sell }) => { + const ref = await ReferralRepository.addReferral({ + ...fixture, + config: { + premiums: { + [SwapType.Chain]: { + [OrderSide.BUY]: buy, + [OrderSide.SELL]: sell, + }, + }, + }, + }); + + expect(ref.config?.premiums?.[SwapType.Chain]).toEqual({ + [OrderSide.BUY]: buy, + [OrderSide.SELL]: sell, + }); + }, + ); }); }); }); diff --git a/test/unit/rates/FeeProvider.spec.ts b/test/unit/rates/FeeProvider.spec.ts index ea554183..bef8ee35 100644 --- a/test/unit/rates/FeeProvider.spec.ts +++ b/test/unit/rates/FeeProvider.spec.ts @@ -331,6 +331,46 @@ describe('FeeProvider', () => { ), ).toEqual(1.2); }); + + test('should apply directional premiums for chain swaps', () => { + const referral = { + premium: jest.fn().mockImplementation((pair, type, orderSide) => { + if (orderSide === OrderSide.BUY) return -50; + return 50; + }), + } as unknown as Referral; + + expect( + feeProvider.getPercentageFee( + 'BTC/BTC', + OrderSide.BUY, + SwapType.Chain, + PercentageFeeType.Calculation, + referral, + ), + ).toEqual(0.005); + + expect( + feeProvider.getPercentageFee( + 'BTC/BTC', + OrderSide.SELL, + SwapType.Chain, + PercentageFeeType.Calculation, + referral, + ), + ).toEqual(0.025); + + expect(referral.premium).toHaveBeenCalledWith( + 'BTC/BTC', + SwapType.Chain, + OrderSide.BUY, + ); + expect(referral.premium).toHaveBeenCalledWith( + 'BTC/BTC', + SwapType.Chain, + OrderSide.SELL, + ); + }); }); test('should update miner fees', async () => { diff --git a/test/unit/rates/providers/RateProviderTaproot.spec.ts b/test/unit/rates/providers/RateProviderTaproot.spec.ts index 2ce18b2d..ac0965d6 100644 --- a/test/unit/rates/providers/RateProviderTaproot.spec.ts +++ b/test/unit/rates/providers/RateProviderTaproot.spec.ts @@ -140,10 +140,18 @@ describe('RateProviderTaproot', () => { getPairs.call(provider, referral).get(from)!.get(to)!.fees.percentage, ).toEqual(expectedFee); - expect(referral.premiumForPairs).toHaveBeenCalledWith( - expect.arrayContaining(['L-BTC/BTC', 'BTC/L-BTC']), - type, - ); + if (type === SwapType.Chain) { + expect(referral.premiumForPairs).toHaveBeenCalledWith( + expect.arrayContaining(['L-BTC/BTC', 'BTC/L-BTC']), + type, + expect.any(Number), + ); + } else { + expect(referral.premiumForPairs).toHaveBeenCalledWith( + expect.arrayContaining(['L-BTC/BTC', 'BTC/L-BTC']), + type, + ); + } expect( getPairs.call(provider).get(from)!.get(to)!.fees.percentage,