diff --git a/packages/swap/src/helpers/orders/getBuyAmount.ts b/packages/swap/src/helpers/orders/getBuyAmount.ts index 27975dca64..97a746d963 100644 --- a/packages/swap/src/helpers/orders/getBuyAmount.ts +++ b/packages/swap/src/helpers/orders/getBuyAmount.ts @@ -30,8 +30,8 @@ export const getBuyAmount = ( return { tokenId, quantity: asQuantity( - BigNumber(sell.quantity) - .dividedToIntegerBy(BigNumber(limit)) + new BigNumber(sell.quantity) + .dividedToIntegerBy(new BigNumber(limit)) .toString(), ), } diff --git a/packages/swap/src/helpers/orders/getFrontendFee.test.ts b/packages/swap/src/helpers/orders/getFrontendFee.test.ts new file mode 100644 index 0000000000..3287a54c8e --- /dev/null +++ b/packages/swap/src/helpers/orders/getFrontendFee.test.ts @@ -0,0 +1,387 @@ +import {Balance} from '@yoroi/types' + +import {getFrontendFee} from './getFrontendFee' +import {milkHoldersDiscountTiers} from '../../translators/constants' +import {Quantities} from '../../utils/quantities' +import {asQuantity} from '../../utils/asQuantity' + +describe('getFrontendFee', () => { + const primaryTokenInfo: Balance.TokenInfo = { + id: '', + decimals: 6, + description: 'primary', + fingerprint: '', + image: '', + group: '', + icon: '', + kind: 'ft', + name: 'ttADA', + symbol: 'ttADA', + ticker: 'ttADA', + metadatas: {}, + } + + const notPrimaryTokenAmount: Balance.Amount = { + tokenId: 'not.primary.token', + quantity: '99', + } + + describe('selling side is primary token', () => { + it('< 100 and whatever milk in balance', () => { + // arrange + const sellAmount: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(99_999_999), + } + // act + const fee = getFrontendFee({ + sellAmount: sellAmount, + buyAmount: notPrimaryTokenAmount, + sellInPrimaryTokenValue: sellAmount, + milkBalance: '999999999999999999', + buyInPrimaryTokenValue: sellAmount, + primaryTokenInfo, + discountTiers: milkHoldersDiscountTiers, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: Quantities.zero, + }) + }) + + it('>= 100 and milk in balance = 0', () => { + // arrange + const sellPrimaryAmountOver99: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(100_000_000), + } + // act + const fee = getFrontendFee({ + sellAmount: sellPrimaryAmountOver99, + buyAmount: notPrimaryTokenAmount, + sellInPrimaryTokenValue: sellPrimaryAmountOver99, + milkBalance: Quantities.zero, + buyInPrimaryTokenValue: sellPrimaryAmountOver99, + primaryTokenInfo, + discountTiers: milkHoldersDiscountTiers, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: asQuantity(1_050_000), // no milk, 100 ADA * 0.05% + 1 = 1.05 ADA + }) + }) + + it('>= 100 and milk in balance >= 100', () => { + // arrange + const sellPrimaryAmountOver99: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(100_000_000), + } + // act + const fee = getFrontendFee({ + sellAmount: sellPrimaryAmountOver99, + buyAmount: notPrimaryTokenAmount, + milkBalance: '499', + sellInPrimaryTokenValue: sellPrimaryAmountOver99, + buyInPrimaryTokenValue: sellPrimaryAmountOver99, + primaryTokenInfo, + discountTiers: milkHoldersDiscountTiers, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: asQuantity(1_025_000), // hold 100-499 milk, 100 ADA * 0.025% + 1 = 1.025 ADA + }) + }) + + it('>= 100 and milk in balance >= 500', () => { + // arrange + const sellPrimaryAmountOver99: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(100_000_000), + } + // act + const fee = getFrontendFee({ + sellAmount: sellPrimaryAmountOver99, + buyAmount: notPrimaryTokenAmount, + milkBalance: '500', + sellInPrimaryTokenValue: sellPrimaryAmountOver99, + buyInPrimaryTokenValue: sellPrimaryAmountOver99, + primaryTokenInfo, + discountTiers: milkHoldersDiscountTiers, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: asQuantity(1_020_000), // hold 500+ milk, 100 ADA * 0.020% + 1 = 1.02 ADA + }) + }) + }) + + describe('buying side is primary token', () => { + it('< 100 and whatever milk in balance', () => { + // arrange + const buyPrimaryTokenAmount: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(99_999_999), + } + // act + const fee = getFrontendFee({ + sellAmount: notPrimaryTokenAmount, + buyAmount: buyPrimaryTokenAmount, + sellInPrimaryTokenValue: buyPrimaryTokenAmount, + milkBalance: '999999999999999999', + buyInPrimaryTokenValue: buyPrimaryTokenAmount, + primaryTokenInfo, + discountTiers: milkHoldersDiscountTiers, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: Quantities.zero, + }) + }) + + it('>= 100 and milk in balance = 0', () => { + // arrange + const buyPrimaryAmountOver99: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(100_000_000), + } + // act + const fee = getFrontendFee({ + sellAmount: notPrimaryTokenAmount, + buyAmount: buyPrimaryAmountOver99, + sellInPrimaryTokenValue: buyPrimaryAmountOver99, + milkBalance: Quantities.zero, + buyInPrimaryTokenValue: buyPrimaryAmountOver99, + primaryTokenInfo, + discountTiers: milkHoldersDiscountTiers, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: asQuantity(1_050_000), // no milk, 100 ADA * 0.05% + 1 = 1.05 ADA + }) + }) + + it('>= 100 and milk in balance >= 100', () => { + // arrange + const buyPrimaryAmountOver99: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(100_000_000), + } + // act + const fee = getFrontendFee({ + sellAmount: notPrimaryTokenAmount, + buyAmount: buyPrimaryAmountOver99, + milkBalance: '499', + sellInPrimaryTokenValue: buyPrimaryAmountOver99, + buyInPrimaryTokenValue: buyPrimaryAmountOver99, + primaryTokenInfo, + discountTiers: milkHoldersDiscountTiers, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: asQuantity(1_025_000), // hold 100-499 milk, 100 ADA * 0.025% + 1 = 1.025 ADA + }) + }) + + it('>= 100 and milk in balance >= 500', () => { + // arrange + const buyPrimaryAmountOver99: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(100_000_000), + } + // act + const fee = getFrontendFee({ + sellAmount: notPrimaryTokenAmount, + buyAmount: buyPrimaryAmountOver99, + milkBalance: '500', + sellInPrimaryTokenValue: buyPrimaryAmountOver99, + buyInPrimaryTokenValue: buyPrimaryAmountOver99, + primaryTokenInfo, + discountTiers: milkHoldersDiscountTiers, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: asQuantity(1_020_000), // hold 500+ milk, 100 ADA * 0.020% + 1= 1.02 ADA + }) + }) + }) + it('should calc 0 fee if no tier', () => { + // arrange + const buyPrimaryAmountOver99: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(100_000_000), + } + // act + const fee = getFrontendFee({ + sellAmount: notPrimaryTokenAmount, + buyAmount: buyPrimaryAmountOver99, + milkBalance: '999999999999999', + sellInPrimaryTokenValue: buyPrimaryAmountOver99, + buyInPrimaryTokenValue: buyPrimaryAmountOver99, + primaryTokenInfo, + discountTiers: [], + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: Quantities.zero, + }) + }) + + it('should fallback - coverage only', () => { + // arrange + const buyPrimaryAmountOver99: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(1_000_000), + } + // act + const fee = getFrontendFee({ + sellAmount: notPrimaryTokenAmount, + buyAmount: buyPrimaryAmountOver99, + milkBalance: '999999999999999', + sellInPrimaryTokenValue: buyPrimaryAmountOver99, + buyInPrimaryTokenValue: buyPrimaryAmountOver99, + primaryTokenInfo, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: Quantities.zero, + }) + }) + + // TODO: check with openswap + describe('neither sell nor buy are primary token, it should use the value in ADA (paired)', () => { + it('< 100 and whatever milk in balance', () => { + // arrange + const sellNotPrimaryAmount: Balance.Amount = { + tokenId: 'not.primary.token', + quantity: asQuantity(99_999_999), + } + const sellValueInPrimaryToken: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(99_999_999), + } + const buyValueInPrimaryToken: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(99_999_998), + } + // act + const fee = getFrontendFee({ + sellAmount: sellNotPrimaryAmount, + buyAmount: notPrimaryTokenAmount, + sellInPrimaryTokenValue: sellValueInPrimaryToken, + milkBalance: '999999999999999999', + buyInPrimaryTokenValue: buyValueInPrimaryToken, + primaryTokenInfo, + discountTiers: milkHoldersDiscountTiers, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: Quantities.zero, + }) + }) + + it('>= 100 and milk in balance = 0', () => { + // arrange + const sellNotPrimaryAmountOver99: Balance.Amount = { + tokenId: 'not.primary.token', + quantity: asQuantity(100_000_000), + } + const sellValueInPrimaryToken: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(100_000_000), + } + const buyValueInPrimaryToken: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(99_999_998), + } + // act + const fee = getFrontendFee({ + sellAmount: sellNotPrimaryAmountOver99, + buyAmount: notPrimaryTokenAmount, + sellInPrimaryTokenValue: sellValueInPrimaryToken, + milkBalance: Quantities.zero, + buyInPrimaryTokenValue: buyValueInPrimaryToken, + primaryTokenInfo, + discountTiers: milkHoldersDiscountTiers, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: asQuantity(1_050_000), // no milk, 100 ADA * 0.05% + 1 = 1.05 ADA + }) + }) + + it('>= 100 and milk in balance >= 100 (buy side higher)', () => { + // arrange + const sellNotPrimaryAmountOver99: Balance.Amount = { + tokenId: 'not.primary.token', + quantity: asQuantity(100_000_000), + } + const sellValueInPrimaryToken: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(99_000_000), + } + const buyValueInPrimaryToken: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(100_000_000), + } + // act + const fee = getFrontendFee({ + sellAmount: sellNotPrimaryAmountOver99, + buyAmount: notPrimaryTokenAmount, + milkBalance: '499', + sellInPrimaryTokenValue: sellValueInPrimaryToken, + buyInPrimaryTokenValue: buyValueInPrimaryToken, + primaryTokenInfo, + discountTiers: milkHoldersDiscountTiers, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: asQuantity(1_025_000), // hold 100-499 milk, 100 ADA * 0.025% + 1= 1.025 ADA + }) + }) + + it('>= 100 and milk in balance >= 500 (50/50)', () => { + // arrange + const sellNotPrimaryAmountOver99: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(100_000_000), + } + const sellValueInPrimaryToken: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(100_000_000), + } + const buyValueInPrimaryToken: Balance.Amount = { + tokenId: primaryTokenInfo.id, + quantity: asQuantity(100_000_000), + } + // act + const fee = getFrontendFee({ + sellAmount: sellNotPrimaryAmountOver99, + buyAmount: notPrimaryTokenAmount, + milkBalance: '500', + sellInPrimaryTokenValue: sellValueInPrimaryToken, + buyInPrimaryTokenValue: buyValueInPrimaryToken, + primaryTokenInfo, + discountTiers: milkHoldersDiscountTiers, + }) + // assert + expect(fee).toEqual({ + tokenId: primaryTokenInfo.id, + quantity: asQuantity(1_020_000), // hold 500+ milk, 100 ADA * 0.020% + 1 = 1.02 ADA + }) + }) + }) +}) diff --git a/packages/swap/src/helpers/orders/getFrontendFee.ts b/packages/swap/src/helpers/orders/getFrontendFee.ts new file mode 100644 index 0000000000..f2af647210 --- /dev/null +++ b/packages/swap/src/helpers/orders/getFrontendFee.ts @@ -0,0 +1,65 @@ +import {Balance} from '@yoroi/types' +import BigNumber from 'bignumber.js' + +import {Quantities} from '../../utils/quantities' +import { + DiscountTier, + milkHoldersDiscountTiers, +} from '../../translators/constants' +import {asQuantity} from '../../utils/asQuantity' + +export const getFrontendFee = ({ + sellAmount, + buyAmount, + milkBalance, + sellInPrimaryTokenValue, + buyInPrimaryTokenValue, + primaryTokenInfo, + discountTiers = milkHoldersDiscountTiers, +}: { + sellAmount: Balance.Amount + buyAmount: Balance.Amount + milkBalance: Balance.Quantity + sellInPrimaryTokenValue: Balance.Amount + buyInPrimaryTokenValue: Balance.Amount + primaryTokenInfo: Balance.TokenInfo + discountTiers?: ReadonlyArray +}): Balance.Amount => { + // discover trade value in ADA (sell/buy/max by pairing) + // it should range around 50/50 + const maxPrimaryValueSellBuy = Quantities.max( + sellInPrimaryTokenValue.quantity, + buyInPrimaryTokenValue.quantity, + ) + const primaryTokenBiggerTradingValue = + sellAmount.tokenId === primaryTokenInfo.id + ? sellAmount.quantity + : buyAmount.tokenId === primaryTokenInfo.id + ? buyInPrimaryTokenValue.quantity + : maxPrimaryValueSellBuy + + // identify the discount + const defaultTier = discountTiers[discountTiers.length - 1] // expects max fee as last record + const discountTier = + discountTiers.find( + (tier) => + Quantities.isGreaterThanOrEqualTo( + milkBalance, + tier.secondaryTokenBalanceThreshold, + ) && + Quantities.isGreaterThanOrEqualTo( + primaryTokenBiggerTradingValue, + tier.primaryTokenValueThreshold, + ), + ) ?? defaultTier + + // calculate the fee + const fee = asQuantity( + new BigNumber(primaryTokenBiggerTradingValue) + .times(discountTier?.variableFeeMultiplier ?? 0) + .integerValue(BigNumber.ROUND_UP) + .plus(discountTier?.fixedFee ?? 0), + ) + + return {tokenId: primaryTokenInfo.id, quantity: fee} +} diff --git a/packages/swap/src/helpers/orders/getMuesliswapFrontendFee.ts b/packages/swap/src/helpers/orders/getMuesliswapFrontendFee.ts deleted file mode 100644 index e314d3bc6b..0000000000 --- a/packages/swap/src/helpers/orders/getMuesliswapFrontendFee.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {Balance} from '@yoroi/types' -import {Quantities} from '../../utils/quantities' -import {asQuantity} from '../../utils/asQuantity' - -export const getMuesliswapFrontendFee = ( - adaPrice: string, - milkInWallet: Balance.Quantity, - sell: Balance.Amount, -): Balance.Amount => { - const value = Number(sell.quantity) * Number(adaPrice) - const variable = - Number(milkInWallet) > 500 - ? 0.0002 * value - : Number(milkInWallet) > 100 - ? 0.00025 * value - : 0.0005 * value - - const fee = - value > 99 ? asQuantity(1_000_000 + Math.round(variable)) : Quantities.zero - - return {tokenId: '', quantity: fee} -} diff --git a/packages/swap/src/helpers/orders/getSellAmount.ts b/packages/swap/src/helpers/orders/getSellAmount.ts index 4787b13975..34b985ba1a 100644 --- a/packages/swap/src/helpers/orders/getSellAmount.ts +++ b/packages/swap/src/helpers/orders/getSellAmount.ts @@ -31,8 +31,8 @@ export const getSellAmount = ( return { tokenId, quantity: asQuantity( - BigNumber(buy.quantity) - .times(BigNumber(limit)) + new BigNumber(buy.quantity) + .times(new BigNumber(limit)) .integerValue(BigNumber.ROUND_CEIL) .toString(), ), diff --git a/packages/swap/src/index.ts b/packages/swap/src/index.ts index 8ccb32e912..e2d78b9544 100644 --- a/packages/swap/src/index.ts +++ b/packages/swap/src/index.ts @@ -39,4 +39,8 @@ export { swapStorageSlippageKey, } from './adapters/async-storage/storage' -export {supportedProviders} from './translators/constants' +export { + supportedProviders, + milkHoldersDiscountTiers, + milkTokenId, +} from './translators/constants' diff --git a/packages/swap/src/translators/constants.ts b/packages/swap/src/translators/constants.ts index 576abf128c..6097c9c9dc 100644 --- a/packages/swap/src/translators/constants.ts +++ b/packages/swap/src/translators/constants.ts @@ -1,4 +1,7 @@ -import {Swap} from '@yoroi/types' +import {Balance, Swap} from '@yoroi/types' + +import {asQuantity} from '../utils/asQuantity' +import {Quantities} from '../utils/quantities' export const supportedProviders: ReadonlyArray = [ 'minswap', @@ -8,3 +11,52 @@ export const supportedProviders: ReadonlyArray = [ 'muesliswap_v2', 'vyfi', ] as const + +export type DiscountTier = { + primaryTokenValueThreshold: Balance.Quantity // primary token trade value threshold + secondaryTokenBalanceThreshold: Balance.Quantity // secodary token balance (holding) + variableFeeMultiplier: number + variableFeeVisual: number + fixedFee: Balance.Quantity +} + +// table of discounts based on MILK token holdings + value in ADA +export const milkHoldersDiscountTiers: ReadonlyArray = [ + // MILK 500+, VALUE ADA 100+, FFEE = 1 ADA + 0.020 % + { + primaryTokenValueThreshold: asQuantity(100_000_000), + secondaryTokenBalanceThreshold: '500', + variableFeeMultiplier: 0.0002, + variableFeeVisual: 0.02, + fixedFee: asQuantity(1_000_000), + }, + // MILK 100+, VALUE ADA 100+, FFEE = 1 ADA + 0.025 % + { + primaryTokenValueThreshold: asQuantity(100_000_000), + secondaryTokenBalanceThreshold: '100', + variableFeeMultiplier: 0.00025, + variableFeeVisual: 0.025, + fixedFee: asQuantity(1_000_000), + }, + // VALUE ADA 100+, FFEE = 1 ADA + 0.050 % + { + primaryTokenValueThreshold: asQuantity(100_000_000), + secondaryTokenBalanceThreshold: Quantities.zero, + variableFeeMultiplier: 0.0005, + variableFeeVisual: 0.05, + fixedFee: asQuantity(1_000_000), + }, + // VALUE ADA 0-99, FFEE = 0% + { + primaryTokenValueThreshold: Quantities.zero, + secondaryTokenBalanceThreshold: Quantities.zero, + variableFeeMultiplier: 0.0, + variableFeeVisual: 0.0, + fixedFee: Quantities.zero, + }, +] as const + +export const milkTokenId = { + mainnet: '8a1cfae21368b8bebbbed9800fec304e95cce39a2a57dc35e2e3ebaa.4d494c4b', + preprod: '8a1cfae21368b8bebbbed9800fec304e95cce39a2a57dc35e2e3ebaa.4d494c4b', +} as const diff --git a/packages/swap/src/utils/quantities.test.ts b/packages/swap/src/utils/quantities.test.ts index 7f7fea5140..eda0af30e2 100644 --- a/packages/swap/src/utils/quantities.test.ts +++ b/packages/swap/src/utils/quantities.test.ts @@ -34,6 +34,26 @@ describe('Quantities', () => { }) }) + describe('isGreaterThanOrEqualTo', () => { + it('should correctly compare two quantities', () => { + const quantity1 = '100' + const quantity2 = '50' + expect(Quantities.isGreaterThanOrEqualTo(quantity1, quantity2)).toBe(true) + }) + + it('should handle equal quantities', () => { + const quantity1 = '100' + const quantity2 = '100' + expect(Quantities.isGreaterThanOrEqualTo(quantity1, quantity2)).toBe(true) + }) + + it('should handle negative quantities', () => { + const quantity1 = '-50' + const quantity2 = '-100' + expect(Quantities.isGreaterThanOrEqualTo(quantity1, quantity2)).toBe(true) + }) + }) + describe('isGreaterThan', () => { it('should correctly compare two quantities', () => { const quantity1 = '100' diff --git a/packages/swap/src/utils/quantities.ts b/packages/swap/src/utils/quantities.ts index d7ba961464..e1a79a234c 100644 --- a/packages/swap/src/utils/quantities.ts +++ b/packages/swap/src/utils/quantities.ts @@ -33,6 +33,14 @@ export const Quantities = { isGreaterThan: (quantity1: Balance.Quantity, quantity2: Balance.Quantity) => { return new BigNumber(quantity1).isGreaterThan(new BigNumber(quantity2)) }, + isGreaterThanOrEqualTo: ( + quantity1: Balance.Quantity, + quantity2: Balance.Quantity, + ) => { + return new BigNumber(quantity1).isGreaterThanOrEqualTo( + new BigNumber(quantity2), + ) + }, decimalPlaces: (quantity: Balance.Quantity, precision: number) => { return new BigNumber(quantity) .decimalPlaces(precision)