Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: utility to calculate quote amounts and costs #210

Merged
merged 7 commits into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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',
shoom3301 marked this conversation as resolved.
Show resolved Hide resolved
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
}
Loading