Skip to content

Commit

Permalink
feat: utility to calculate quote amounts and costs (#210)
Browse files Browse the repository at this point in the history
  • Loading branch information
shoom3301 authored Jun 6, 2024
1 parent 16155d1 commit 38ee550
Show file tree
Hide file tree
Showing 5 changed files with 339 additions and 2 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -100,4 +100,4 @@
"typescript",
"subgraph"
]
}
}
1 change: 1 addition & 0 deletions src/order-book/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './api'
export * from './types'
export * from './generated'
export * from './request'
export * from './quoteAmountsAndCostsUtils'
173 changes: 173 additions & 0 deletions src/order-book/quoteAmountsAndCostsUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
})
126 changes: 126 additions & 0 deletions src/order-book/quoteAmountsAndCostsUtils.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
37 changes: 37 additions & 0 deletions src/order-book/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

0 comments on commit 38ee550

Please sign in to comment.