Skip to content

Commit

Permalink
feat: directional premiums for referrals
Browse files Browse the repository at this point in the history
  • Loading branch information
maybeast committed Mar 4, 2025
1 parent 2219654 commit 1a6998d
Show file tree
Hide file tree
Showing 7 changed files with 260 additions and 28 deletions.
56 changes: 46 additions & 10 deletions lib/db/models/Referral.ts
Original file line number Diff line number Diff line change
@@ -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<Record<SwapType, number>>;
type DirectionalPremium = {
[OrderSide.BUY]: number;
[OrderSide.SELL]: number;
};

type Premiums = Partial<{
[SwapType.Submarine]: number;
[SwapType.ReverseSubmarine]: number;
[SwapType.Chain]: DirectionalPremium;
}>;

type LimitsPerType = Partial<Record<SwapType, Limits>>;

Expand Down Expand Up @@ -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,
};
58 changes: 47 additions & 11 deletions lib/db/repositories/ReferralRepository.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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';
Expand All @@ -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);
}
}
};
Expand Down
4 changes: 3 additions & 1 deletion lib/rates/FeeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion lib/rates/providers/RateProviderTaproot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,25 @@ class RateProviderTaproot extends RateProviderBase<SwapTypes> {
[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 = {
Expand Down
91 changes: 90 additions & 1 deletion test/integration/db/repositories/ReferralRepository.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -148,6 +148,95 @@ 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('should throw if chain swap premium is missing BUY or SELL', async () => {
await expect(
ReferralRepository.addReferral({
...fixture,
config: {
premiums: {
[SwapType.Chain]: { [OrderSide.BUY]: 10 } as any,
},
},
}),
).rejects.toEqual(
'Chain swap premiums must specify both BUY and SELL values',
);

await expect(
ReferralRepository.addReferral({
...fixture,
config: {
premiums: {
[SwapType.Chain]: { [OrderSide.SELL]: 10 } as any,
},
},
}),
).rejects.toEqual(
'Chain swap premiums must specify both BUY and SELL values',
);
});

test('should throw if chain swap premium values are out of range', async () => {
await expect(
ReferralRepository.addReferral({
...fixture,
config: {
premiums: {
[SwapType.Chain]: {
[OrderSide.BUY]: 101,
[OrderSide.SELL]: 10,
},
},
},
}),
).rejects.toEqual('premium out of range');

await expect(
ReferralRepository.addReferral({
...fixture,
config: {
premiums: {
[SwapType.Chain]: {
[OrderSide.BUY]: 10,
[OrderSide.SELL]: -101,
},
},
},
}),
).rejects.toEqual('premium out of range');
});

test('should accept valid chain swap premiums', async () => {
const ref = await ReferralRepository.addReferral({
...fixture,
config: {
premiums: {
[SwapType.Chain]: { [OrderSide.BUY]: 10, [OrderSide.SELL]: 20 },
},
},
});

expect(ref.config?.premiums?.[SwapType.Chain]).toEqual({
[OrderSide.BUY]: 10,
[OrderSide.SELL]: 20,
});
});
});
});
});
43 changes: 43 additions & 0 deletions test/unit/rates/FeeProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,49 @@ 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 5;
return 10;
}),
} as unknown as Referral;

// Test BUY direction
expect(
feeProvider.getPercentageFee(
'BTC/BTC',
OrderSide.BUY,
SwapType.Chain,
PercentageFeeType.Calculation,
referral,
),
).toEqual(0.0105); // 1% base fee + 0.05% premium

// Test SELL direction
expect(
feeProvider.getPercentageFee(
'BTC/BTC',
OrderSide.SELL,
SwapType.Chain,
PercentageFeeType.Calculation,
referral,
),
).toEqual(0.021); // 2% base fee + 0.1% premium

// Verify the premium method was called with the correct orderSide
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 () => {
Expand Down
16 changes: 12 additions & 4 deletions test/unit/rates/providers/RateProviderTaproot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 1a6998d

Please sign in to comment.