diff --git a/package.json b/package.json index 5a8b7ca8..1c3eb5db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cowprotocol/cow-sdk", - "version": "5.3.0", + "version": "5.3.1-RC.3", "license": "(MIT OR Apache-2.0)", "files": [ "/dist" @@ -100,4 +100,4 @@ "typescript", "subgraph" ] -} \ No newline at end of file +} diff --git a/src/order-book/index.ts b/src/order-book/index.ts index 4ec413f8..7690dec7 100644 --- a/src/order-book/index.ts +++ b/src/order-book/index.ts @@ -2,3 +2,4 @@ export * from './api' export * from './types' export * from './generated' export * from './request' +export * from './quoteAmountsAndCostsUtils' diff --git a/src/order-book/quoteAmountsAndCostsUtils.test.ts b/src/order-book/quoteAmountsAndCostsUtils.test.ts new file mode 100644 index 00000000..a954954f --- /dev/null +++ b/src/order-book/quoteAmountsAndCostsUtils.test.ts @@ -0,0 +1,173 @@ +import { OrderParameters, OrderKind, SigningScheme, BuyTokenDestination, SellTokenSource } from './generated' +import { getQuoteAmountsAndCosts } from './quoteAmountsAndCostsUtils' + +const otherFields = { + buyToken: '0xdef1ca1fb7fbcdc777520aa7f396b4e015f497ab', + sellToken: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + buyTokenBalance: BuyTokenDestination.ERC20, + sellTokenBalance: SellTokenSource.ERC20, + signingScheme: SigningScheme.EIP712, + partiallyFillable: false, + receiver: '0x0000000000000000000000000000000000000000', + validTo: 1716904696, + appData: '{}', + appDataHash: '0x0', +} + +const sellDecimals = 18 +const buyDecimals = 6 + +/** + * Since we have partner fees, now it's not clear what does it mean `feeAmount`? + * To avoid confusion, we should consider this `feeAmount` as `networkCosts` + * + * Fee is always taken from sell token (for sell/buy orders): + * 3855544038281082 + 156144455961718918 = 160000000000000000 + * + * Again, to avoid confusion, we should take this `sellAmount` as `sellAmountBeforeNetworkCosts` + * Hence, `buyAmount` is `buyAmountAfterNetworkCosts` because this amount is what you will get for the sell amount + * + * In this order we are selling 0.16 WETH for 1863 COW - network costs + */ +const SELL_ORDER: OrderParameters = { + kind: OrderKind.SELL, + sellAmount: '156144455961718918', + feeAmount: '3855544038281082', + buyAmount: '18632013982', + ...otherFields, +} + +/** + * In this order we are buying 2000 COW for 1.6897 WETH + network costs + */ +const BUY_ORDER: OrderParameters = { + kind: OrderKind.BUY, + sellAmount: '168970833896526983', + feeAmount: '2947344072902629', + buyAmount: '2000000000', + ...otherFields, +} + +describe('Calculation of before/after fees amounts', () => { + describe('Network costs', () => { + describe.each(['sell', 'buy'])('%s order', (type: string) => { + const orderParams = type === 'sell' ? SELL_ORDER : BUY_ORDER + + it('Sell amount after network costs should be sellAmount + feeAmount', () => { + const result = getQuoteAmountsAndCosts({ + orderParams, + sellDecimals, + buyDecimals, + slippagePercentBps: 0, + partnerFeeBps: undefined, + }) + + expect(result.afterNetworkCosts.sellAmount.toString()).toBe( + String(BigInt(orderParams.sellAmount) + BigInt(orderParams.feeAmount)) + ) + }) + + it('Buy amount before network costs should be SellAmountAfterNetworkCosts * Price', () => { + const result = getQuoteAmountsAndCosts({ + orderParams, + sellDecimals, + buyDecimals, + slippagePercentBps: 0, + partnerFeeBps: undefined, + }) + + expect(result.beforeNetworkCosts.buyAmount.toString()).toBe( + ( + (+orderParams.sellAmount + +orderParams.feeAmount) * // SellAmountAfterNetworkCosts + (+orderParams.buyAmount / +orderParams.sellAmount) + ) // Price + .toFixed() + ) + }) + }) + }) + + describe('Partner fee', () => { + const partnerFeeBps = 100 + + describe('Sell order', () => { + it('Partner fee should be substracted from buy amount after network costs', () => { + const orderParams = SELL_ORDER + const result = getQuoteAmountsAndCosts({ + orderParams, + sellDecimals, + buyDecimals, + partnerFeeBps, + slippagePercentBps: 0, + }) + + const buyAmountBeforeNetworkCosts = + (+orderParams.sellAmount + +orderParams.feeAmount) * // SellAmountAfterNetworkCosts + (+orderParams.buyAmount / +orderParams.sellAmount) // Price + + const partnerFeeAmount = Math.floor((buyAmountBeforeNetworkCosts * partnerFeeBps) / 100 / 100) + + expect(Number(result.costs.partnerFee.amount)).toBe(partnerFeeAmount) + }) + }) + + describe('Buy order', () => { + it('Partner fee should be added on top of sell amount after network costs', () => { + const orderParams = BUY_ORDER + const result = getQuoteAmountsAndCosts({ + orderParams, + sellDecimals, + buyDecimals, + partnerFeeBps, + slippagePercentBps: 0, + }) + + const partnerFeeAmount = Math.floor((+orderParams.sellAmount * partnerFeeBps) / 100 / 100) + + expect(Number(result.costs.partnerFee.amount)).toBe(partnerFeeAmount) + }) + }) + }) + + describe('Slippage', () => { + const slippagePercentBps = 200 // 2% + + describe('Sell order', () => { + it('Slippage should be substracted from buy amount after partner fees', () => { + const orderParams = SELL_ORDER + const result = getQuoteAmountsAndCosts({ + orderParams, + sellDecimals, + buyDecimals, + partnerFeeBps: undefined, + slippagePercentBps, + }) + + const buyAmountAfterNetworkCosts = +orderParams.buyAmount + + const slippageAmount = (buyAmountAfterNetworkCosts * slippagePercentBps) / 100 / 100 + + expect(Number(result.afterSlippage.buyAmount)).toBe(Math.ceil(buyAmountAfterNetworkCosts - slippageAmount)) + }) + }) + + describe('Buy order', () => { + it('Slippage should be added on top of sell amount after partner costs', () => { + const orderParams = BUY_ORDER + const result = getQuoteAmountsAndCosts({ + orderParams, + sellDecimals, + buyDecimals, + partnerFeeBps: undefined, + slippagePercentBps, + }) + + const sellAmountAfterNetworkCosts = +orderParams.sellAmount + +orderParams.feeAmount + const slippageAmount = (sellAmountAfterNetworkCosts * slippagePercentBps) / 100 / 100 + + // We are loosing precision here, because of using numbers and we have to use toBeCloseTo() + expect(Number(result.afterSlippage.sellAmount)).toBeCloseTo(sellAmountAfterNetworkCosts + slippageAmount, -2) + }) + }) + }) +}) diff --git a/src/order-book/quoteAmountsAndCostsUtils.ts b/src/order-book/quoteAmountsAndCostsUtils.ts new file mode 100644 index 00000000..d4544984 --- /dev/null +++ b/src/order-book/quoteAmountsAndCostsUtils.ts @@ -0,0 +1,126 @@ +import { QuoteAmountsAndCosts } from './types' +import { OrderKind, type OrderParameters } from './generated' + +interface Params { + orderParams: OrderParameters + sellDecimals: number + buyDecimals: number + slippagePercentBps: number + partnerFeeBps: number | undefined +} + +export function getQuoteAmountsAndCosts(params: Params): QuoteAmountsAndCosts { + const { orderParams, sellDecimals, buyDecimals, slippagePercentBps } = params + const partnerFeeBps = params.partnerFeeBps ?? 0 + const isSell = orderParams.kind === OrderKind.SELL + /** + * Wrap raw values into CurrencyAmount objects + * We also make amounts names more specific with "beforeNetworkCosts" and "afterNetworkCosts" suffixes + */ + const networkCostAmount = getBigNumber(orderParams.feeAmount, sellDecimals) + const sellAmountBeforeNetworkCosts = getBigNumber(orderParams.sellAmount, sellDecimals) + const buyAmountAfterNetworkCosts = getBigNumber(orderParams.buyAmount, buyDecimals) + + /** + * This is an actual price of the quote since it's derrived only from the quote sell and buy amounts + */ + const quotePrice = buyAmountAfterNetworkCosts.num / sellAmountBeforeNetworkCosts.num + + /** + * Before networkCosts + networkCosts = After networkCosts :) + */ + const sellAmountAfterNetworkCosts = getBigNumber( + sellAmountBeforeNetworkCosts.big + networkCostAmount.big, + sellDecimals + ) + + /** + * Since the quote contains only buy amount after network costs + * we have to calculate the buy amount before network costs from the quote price + */ + const buyAmountBeforeNetworkCosts = getBigNumber(quotePrice * sellAmountAfterNetworkCosts.num, buyDecimals) + + /** + * Partner fee is always added on the surplus amount, for sell-orders it's buy amount, for buy-orders it's sell amount + */ + const surplusAmount = isSell ? buyAmountBeforeNetworkCosts.big : sellAmountBeforeNetworkCosts.big + const partnerFeeAmount = partnerFeeBps > 0 ? surplusAmount / BigInt(partnerFeeBps) : BigInt(0) + + /** + * Partner fee is always added on the surplus token, for sell-orders it's buy token, for buy-orders it's sell token + */ + const afterPartnerFees = isSell + ? { + sellAmount: sellAmountAfterNetworkCosts.big, + buyAmount: buyAmountAfterNetworkCosts.big - partnerFeeAmount, + } + : { + sellAmount: sellAmountAfterNetworkCosts.big + partnerFeeAmount, + buyAmount: buyAmountAfterNetworkCosts.big, + } + + const getSlippageAmount = (amount: bigint) => (amount * BigInt(slippagePercentBps)) / BigInt(100 * 100) + + /** + * Same rules apply for slippage as for partner fees + */ + const afterSlippage = isSell + ? { + sellAmount: afterPartnerFees.sellAmount, + buyAmount: afterPartnerFees.buyAmount - getSlippageAmount(afterPartnerFees.buyAmount), + } + : { + sellAmount: afterPartnerFees.sellAmount + getSlippageAmount(afterPartnerFees.sellAmount), + buyAmount: afterPartnerFees.buyAmount, + } + + return { + isSell, + costs: { + networkFee: { + amountInSellCurrency: networkCostAmount.big, + amountInBuyCurrency: getBigNumber(quotePrice * networkCostAmount.num, buyDecimals).big, + }, + partnerFee: { + amount: partnerFeeAmount, + bps: partnerFeeBps, + }, + }, + beforeNetworkCosts: { + sellAmount: sellAmountBeforeNetworkCosts.big, + buyAmount: buyAmountBeforeNetworkCosts.big, + }, + afterNetworkCosts: { + sellAmount: sellAmountAfterNetworkCosts.big, + buyAmount: buyAmountAfterNetworkCosts.big, + }, + afterPartnerFees, + afterSlippage, + } +} + +type BigNumber = { + big: bigint + num: number +} + +/** + * BigInt works well with subtraction and addition, but it's not very good with multiplication and division + * To multiply/divide token amounts we have to convert them to numbers, but we have to be careful with precision + * @param value + * @param decimals + */ +function getBigNumber(value: string | bigint | number, decimals: number): BigNumber { + if (typeof value === 'number') { + const bigAsNumber = value * 10 ** decimals + const bigAsNumberString = bigAsNumber.toFixed() + const big = BigInt(bigAsNumberString.includes('e') ? bigAsNumber : bigAsNumberString) + + return { big, num: value } + } + + const big = BigInt(value) + const num = Number(big) / 10 ** decimals + + return { big, num } +} diff --git a/src/order-book/types.ts b/src/order-book/types.ts index dd45528d..5e367c93 100644 --- a/src/order-book/types.ts +++ b/src/order-book/types.ts @@ -6,3 +6,40 @@ import { Order } from './generated' export interface EnrichedOrder extends Order { totalFee: string } + +/** + * CoW Protocol quote has amounts (sell/buy) and costs (network fee), there is also partner fees. + * Besides that, CoW Protocol supports both sell and buy orders and the fees and costs are calculated differently. + * + * The order of adding fees and costs is as follows: + * 1. Network fee is always added to the sell amount + * 2. Partner fee is added to the surplus amount (sell amount for sell-orders, buy amount for buy-orders) + * + * For sell-orders the partner fee is subtracted from the buy amount after network costs. + * For buy-orders the partner fee is added on top of the sell amount after network costs. + */ +export interface QuoteAmountsAndCosts< + AmountType = bigint, + Amounts = { + sellAmount: AmountType + buyAmount: AmountType + } +> { + isSell: boolean + + costs: { + networkFee: { + amountInSellCurrency: AmountType + amountInBuyCurrency: AmountType + } + partnerFee: { + amount: AmountType + bps: number + } + } + + beforeNetworkCosts: Amounts + afterNetworkCosts: Amounts + afterPartnerFees: Amounts + afterSlippage: Amounts +}