diff --git a/lib/db/models/Referral.ts b/lib/db/models/Referral.ts index 1ff8ecbe..4594ac3b 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 = Partial<{ + [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) { + return premium !== undefined ? 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..3355bbd1 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, @@ -38,6 +43,13 @@ class ReferralRepository { ORDER BY year, month; `; + private static readonly maxPremiumPercentage = 100; + private static readonly minPremiumPercentage = -100; + private static readonly minExpiration = 120; + private static readonly maxExpiration = 60 * 60 * 24; + private static readonly minRoutingFee = 0; + private static readonly maxRoutingFee = 0.005; + public static addReferral = async ( referral: ReferralType, ): Promise => { @@ -112,23 +124,67 @@ class ReferralRepository { private static sanityCheckConfig = ( config: ReferralConfig | null | undefined, ) => { - const sanityCheckPairConfig = (cfg: ReferralPairConfig) => { - if (cfg.maxRoutingFee) { - if (cfg.maxRoutingFee < 0 || cfg.maxRoutingFee > 0.005) { - throw 'maxRoutingFee out of range'; + const validateDirectionalPremium = ( + premium: unknown, + ): DirectionalPremium => { + if (typeof premium === 'object') { + const premiumObj = premium as Record; + if ( + (OrderSide.BUY in premiumObj && + typeof premiumObj[OrderSide.BUY] !== 'number') || + (OrderSide.SELL in premiumObj && + typeof premiumObj[OrderSide.SELL] !== 'number') + ) { + throw 'premium values must be numbers'; } } + return premium as DirectionalPremium; + }; + + const sanityCheckPairConfig = (cfg: ReferralPairConfig) => { + if ( + cfg.maxRoutingFee !== undefined && + (cfg.maxRoutingFee < ReferralRepository.minRoutingFee || + cfg.maxRoutingFee > ReferralRepository.maxRoutingFee) + ) { + 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 = validateDirectionalPremium(premium); + Object.values(directionalPremium).forEach((p) => { + if ( + p < ReferralRepository.minPremiumPercentage || + p > ReferralRepository.maxPremiumPercentage + ) { + throw 'premium out of range'; + } + }); + } else { + if (typeof premium !== 'number') { + throw 'premium must be a number'; + } + if ( + premium < ReferralRepository.minPremiumPercentage || + premium > ReferralRepository.maxPremiumPercentage + ) { + throw 'premium out of range'; + } + } } } if (cfg.expirations) { if ( Object.values(cfg.expirations).some( - (e) => e < 120 || e > 60 * 60 * 24, + (e) => + e < ReferralRepository.minExpiration || + e > ReferralRepository.maxExpiration, ) ) { throw 'expiration out of range'; @@ -140,9 +196,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 5a5c67be..e27af1e7 100644 --- a/lib/rates/FeeProvider.ts +++ b/lib/rates/FeeProvider.ts @@ -228,7 +228,7 @@ class FeeProvider { typeof percentageType === 'number' ? percentageType : percentageType[orderSide], - referral?.premium(pair, type), + referral?.premium(pair, type, orderSide), ); return feeType === PercentageFeeType.Calculation diff --git a/lib/rates/providers/RateProviderTaproot.ts b/lib/rates/providers/RateProviderTaproot.ts index 05554240..fe56213e 100644 --- a/lib/rates/providers/RateProviderTaproot.ts +++ b/lib/rates/providers/RateProviderTaproot.ts @@ -511,7 +511,22 @@ 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 = referral?.premiumForPairs( + pairIds, + type, + to === quote ? OrderSide.SELL : OrderSide.BUY, + ); 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..91c6aa7f 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,80 @@ describe('ReferralRepository', () => { ).rejects.toEqual(`${field} out of range`); }, ); + + test.each` + premium | description + ${{ [OrderSide.BUY]: 'not-a-number' }} | ${'invalid BUY value type'} + ${{ [OrderSide.SELL]: 'not-a-number' }} | ${'invalid SELL value type'} + ${{ [OrderSide.BUY]: true }} | ${'invalid BUY value type'} + ${{ [OrderSide.SELL]: {} }} | ${'invalid SELL value type'} + ${{ [OrderSide.BUY]: () => {} }} | ${'invalid BUY value type'} + ${{ [OrderSide.SELL]: [1, 2] }} | ${'invalid SELL value type'} + `( + 'should throw if chain swap premium has $description', + async ({ premium }) => { + await expect( + ReferralRepository.addReferral({ + ...fixture, + config: { + premiums: { + [SwapType.Chain]: premium, + }, + }, + }), + ).rejects.toEqual('premium values must be numbers'); + }, + ); + + test.each` + premium | description + ${{}} | ${'empty object'} + ${{ [OrderSide.BUY]: 10 }} | ${'only BUY'} + ${{ [OrderSide.SELL]: 10 }} | ${'only SELL'} + ${{ [OrderSide.BUY]: 10, [OrderSide.SELL]: 10 }} | ${'both BUY and SELL'} + `( + 'should accept valid chain swap premium ($description)', + async ({ premium }) => { + const ref = await ReferralRepository.addReferral({ + ...fixture, + config: { + premiums: { + [SwapType.Chain]: premium, + }, + }, + }); + + expect(ref.config?.premiums?.[SwapType.Chain]).toEqual(premium); + }, + ); + + 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 13d8a34e..16c62ad2 100644 --- a/test/unit/rates/FeeProvider.spec.ts +++ b/test/unit/rates/FeeProvider.spec.ts @@ -322,6 +322,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..756cfb19 100644 --- a/test/unit/rates/providers/RateProviderTaproot.spec.ts +++ b/test/unit/rates/providers/RateProviderTaproot.spec.ts @@ -143,6 +143,7 @@ describe('RateProviderTaproot', () => { expect(referral.premiumForPairs).toHaveBeenCalledWith( expect.arrayContaining(['L-BTC/BTC', 'BTC/L-BTC']), type, + expect.any(Number), ); expect(