Commit 96a412f6 authored by Noah Zinsmeister's avatar Noah Zinsmeister

finalize trade details

further segment file structure

introduce orchestration
parent cdaa8df1
......@@ -4,5 +4,8 @@
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": ["plugin:@typescript-eslint/recommended", "prettier", "prettier/@typescript-eslint"]
"extends": ["plugin:@typescript-eslint/recommended", "prettier", "prettier/@typescript-eslint"],
"rules": {
"@typescript-eslint/class-name-casing": "off"
}
}
......@@ -31,6 +31,7 @@
"license": "GPL-3.0-or-later",
"devDependencies": {
"@types/jest": "^24.0.13",
"@types/lodash.clonedeepwith": "^4.5.6",
"@typescript-eslint/eslint-plugin": "^1.9.0",
"@typescript-eslint/parser": "^1.9.0",
"coveralls": "^3.0.3",
......@@ -43,6 +44,7 @@
},
"dependencies": {
"bignumber.js": "^9.0.0",
"ethers": "^4.0.28"
"ethers": "^4.0.28",
"lodash.clonedeepwith": "^4.5.0"
}
}
import BigNumber from 'bignumber.js'
import { BigNumberish, TokenReserves } from '../types'
import { TRADE_TYPE } from '../constants'
import { getMarketRate } from '../computation'
import { BigNumberish, TokenReserves, MarketDetails, OptionalReserves, TradeDetails } from '../types'
import { TRADE_TYPE, TRADE_EXACT, _10 } from '../constants'
import { getMarketDetails, getTradeDetails } from '../computation'
function constructTokenReserves(
decimals: number,
......@@ -17,52 +17,34 @@ function constructTokenReserves(
}
function testMarketRates(
inputTokenReserves: TokenReserves | null,
outputTokenReserves: TokenReserves | null,
tradeType: TRADE_TYPE,
inputTokenReserves: OptionalReserves,
outputTokenReserves: OptionalReserves,
expectedMarketRate: string,
expectedMarketRateInverted: string
): void {
describe('regular', (): void => {
test('not inverted', (): void => {
const marketRate: BigNumber = getMarketRate(inputTokenReserves, outputTokenReserves, tradeType, false)
expect(marketRate.toFixed(18)).toBe(expectedMarketRate)
})
test('inverted', (): void => {
const marketRateInverted: BigNumber = getMarketRate(inputTokenReserves, outputTokenReserves, tradeType, true)
expect(marketRateInverted.toFixed(18)).toBe(expectedMarketRateInverted)
})
test('not inverted', (): void => {
const marketDetails: MarketDetails = getMarketDetails(tradeType, inputTokenReserves, outputTokenReserves)
expect(marketDetails.marketRate.rate.toFixed(18)).toBe(expectedMarketRate)
expect(marketDetails.marketRate.rateInverted.toFixed(18)).toBe(expectedMarketRateInverted)
})
describe('manually inverted', (): void => {
test('manually inverted', (): void => {
const tradeTypeInverted = tradeType === TRADE_TYPE.ETH_TO_TOKEN ? TRADE_TYPE.TOKEN_TO_ETH : TRADE_TYPE.ETH_TO_TOKEN
test('not inverted', (): void => {
const manuallyInvertedMarketRate: BigNumber = getMarketRate(
outputTokenReserves,
inputTokenReserves,
tradeType === TRADE_TYPE.TOKEN_TO_TOKEN ? TRADE_TYPE.TOKEN_TO_TOKEN : tradeTypeInverted,
false
)
expect(manuallyInvertedMarketRate.toFixed(18)).toBe(expectedMarketRateInverted)
})
test('inverted', (): void => {
const manuallyInvertedInvertedMarketRate: BigNumber = getMarketRate(
outputTokenReserves,
inputTokenReserves,
tradeType === TRADE_TYPE.TOKEN_TO_TOKEN ? TRADE_TYPE.TOKEN_TO_TOKEN : tradeTypeInverted,
true
)
expect(manuallyInvertedInvertedMarketRate.toFixed(18)).toBe(expectedMarketRate)
})
const marketDetails: MarketDetails = getMarketDetails(
tradeType === TRADE_TYPE.TOKEN_TO_TOKEN ? TRADE_TYPE.TOKEN_TO_TOKEN : tradeTypeInverted,
outputTokenReserves,
inputTokenReserves
)
expect(marketDetails.marketRate.rate.toFixed(18)).toBe(expectedMarketRateInverted)
expect(marketDetails.marketRate.rateInverted.toFixed(18)).toBe(expectedMarketRate)
})
}
describe('getMarketRate', (): void => {
describe('getMarketDetails', (): void => {
describe('dummy ETH/DAI and DAI/ETH', (): void => {
const tokenReserves: TokenReserves = constructTokenReserves(
18,
......@@ -72,7 +54,7 @@ describe('getMarketRate', (): void => {
const expectedMarketRate = '270.825818409480102284'
const expectedMarketRateInverted = '0.003692410147130181'
testMarketRates(null, tokenReserves, TRADE_TYPE.ETH_TO_TOKEN, expectedMarketRate, expectedMarketRateInverted)
testMarketRates(TRADE_TYPE.ETH_TO_TOKEN, null, tokenReserves, expectedMarketRate, expectedMarketRateInverted)
})
describe('dummy ETH/USDC and USDC/ETH', (): void => {
......@@ -80,7 +62,7 @@ describe('getMarketRate', (): void => {
const expectedMarketRate = '0.003678674143683891'
const expectedMarketRateInverted = '271.837069808684359442'
testMarketRates(tokenReserves, null, TRADE_TYPE.TOKEN_TO_ETH, expectedMarketRate, expectedMarketRateInverted)
testMarketRates(TRADE_TYPE.TOKEN_TO_ETH, tokenReserves, null, expectedMarketRate, expectedMarketRateInverted)
})
describe('dummy DAI/USDC and USDC/DAI', (): void => {
......@@ -94,11 +76,61 @@ describe('getMarketRate', (): void => {
const expectedMarketRateInverted = '0.996279935624983143'
testMarketRates(
TRADE_TYPE.TOKEN_TO_TOKEN,
DAITokenReserves,
USDCTokenReserves,
TRADE_TYPE.TOKEN_TO_TOKEN,
expectedMarketRate,
expectedMarketRateInverted
)
})
})
function testTradeDetails(
tradeExact: TRADE_EXACT,
tradeAmount: BigNumber,
marketDetails: MarketDetails,
expectedInputValue: string,
expectedExecutionRate: string,
expectedExecutionRateInverted: string,
expectedMarketRateSlippage: string,
expectedExecutionRateSlippage: string
): void {
test('test trade', (): void => {
const tradeDetails: TradeDetails = getTradeDetails(tradeExact, tradeAmount, marketDetails)
expect(tradeDetails.inputAmount.amount.toFixed(0)).toBe(expectedInputValue)
expect(tradeDetails.executionRate.rate.toFixed(18)).toBe(expectedExecutionRate)
expect(tradeDetails.executionRate.rateInverted.toFixed(18)).toBe(expectedExecutionRateInverted)
expect(tradeDetails.marketRateSlippage.toFixed(18)).toBe(expectedMarketRateSlippage)
expect(tradeDetails.executionRateSlippage.toFixed(18)).toBe(expectedExecutionRateSlippage)
})
}
describe('getTradeDetails', (): void => {
describe('dummy ETH/DAI and DAI/ETH', (): void => {
const tokenReserves: TokenReserves = constructTokenReserves(
18,
'4039700561005906883487',
'1094055210563660633471343'
)
const expectedInputValue = '370385925334764803'
const expectedExecutionRate = '269.988660907180258319'
const expectedExecutionRateInverted = '0.003703859253347648'
const expectedMarketRateSlippage = '1.830727602963479922'
const expectedExecutionRateSlippage = '30.911288562381013644'
const marketDetails: MarketDetails = getMarketDetails(TRADE_TYPE.ETH_TO_TOKEN, null, tokenReserves)
testTradeDetails(
TRADE_EXACT.OUTPUT,
new BigNumber(100).multipliedBy(_10.exponentiatedBy(18)),
marketDetails,
expectedInputValue,
expectedExecutionRate,
expectedExecutionRateInverted,
expectedMarketRateSlippage,
expectedExecutionRateSlippage
)
})
})
......@@ -22,7 +22,7 @@ const DAI_EXCHANGE: Token = {
describe('getTokenReserves', (): void => {
test('DAI', async (done: jest.DoneCallback): Promise<void> => {
jest.setTimeout(20000) // 20 seconds
jest.setTimeout(10000) // 10 seconds
const tokenReserves: TokenReserves = await getTokenReserves(DAI.address as string)
expect(tokenReserves.token).toEqual(DAI)
......
import BigNumber from 'bignumber.js'
import { TradeDetails } from '../types'
import { _10, ETH, TRADE_TYPE, TRADE_EXACT } from '../constants'
import { getTrade } from '../orchestration'
describe('getTrade', (): void => {
test('DAI', async (done: jest.DoneCallback): Promise<void> => {
jest.setTimeout(10000) // 10 seconds
const tradeDetails: TradeDetails = await getTrade(
ETH,
'0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359',
TRADE_TYPE.ETH_TO_TOKEN,
TRADE_EXACT.OUTPUT,
new BigNumber(100).multipliedBy(_10.exponentiatedBy(18))
)
expect(tradeDetails.inputAmount).toBeTruthy()
done()
})
})
import BigNumber from 'bignumber.js'
import { ethers } from 'ethers'
import { BigNumberish } from '../../types'
import { normalizeBigNumberish } from '../../utils'
import { ethers } from 'ethers'
import { normalizeBigNumberish } from '../../_utils'
interface TestCase {
input: BigNumberish
......
import BigNumber from 'bignumber.js'
import { ethers } from 'ethers'
import { _0, MAX_UINT8, MAX_UINT256 } from '../constants'
import { BigNumberish } from '../types'
import { _0, MAX_UINT8, MAX_UINT256 } from '../constants'
// check(s) that number(s) is(are) uint8(s)
function ensureUInt8(number: number): void {
......@@ -18,7 +18,7 @@ export function ensureAllUInt8(numbers: number[]): void {
// check(s) that BigNumber(s) is(are) uint256(s)
function ensureUInt256(bigNumber: BigNumber): void {
if (!bigNumber.isInteger() || bigNumber.isLessThan(_0) || bigNumber.isGreaterThan(MAX_UINT256)) {
throw Error(`Passed BigNumber '${bigNumber}' is not a valid uint256.`)
throw Error(`Passed bigNumber '${bigNumber}' is not a valid uint256.`)
}
}
......@@ -35,22 +35,22 @@ export function ensureBoundedInteger(number: number, bounds: number | number[]):
}
}
export function normalizeAddress(address: string): string {
try {
return ethers.utils.getAddress(address.toLowerCase())
} catch {
throw Error(`Passed address '${address}' is not valid.`)
}
}
export function normalizeBigNumberish(bigNumberish: BigNumberish): BigNumber {
try {
const bigNumber = BigNumber.isBigNumber(bigNumberish) ? bigNumberish : new BigNumber(bigNumberish.toString())
if (!bigNumber.isFinite()) {
throw Error(`Passed BigNumberish '${bigNumberish}' of type '${typeof bigNumberish}' is not finite.`)
throw Error
}
return bigNumber
} catch (error) {
throw Error(`Passed BigNumberish '${bigNumberish}' of type '${typeof bigNumberish}' is invalid. Error: '${error}'.`)
throw Error(`Passed bigNumberish '${bigNumberish}' of type '${typeof bigNumberish}' is invalid. Error: '${error}'.`)
}
}
export function normalizeAddress(address: string): string {
try {
return ethers.utils.getAddress(address.toLowerCase())
} catch {
throw Error(`Passed address '${address}' is not valid.`)
}
}
import BigNumber from 'bignumber.js'
import { TokenAmount, TokenReservesOptional } from './types'
import { _0, _1, _10, _997, _1000, TRADE_TYPE } from './constants'
import { ensureAllUInt8, ensureAllUInt256, normalizeBigNumberish } from './utils'
export function getInputPrice(inputAmount: BigNumber, inputReserve: BigNumber, outputReserve: BigNumber): BigNumber {
ensureAllUInt256([inputAmount, inputReserve, outputReserve])
if (inputReserve.isLessThanOrEqualTo(_0) || outputReserve.isLessThanOrEqualTo(_0)) {
throw Error(`Both inputReserve '${inputReserve}' and outputReserve '${outputReserve}' must be non-zero.`)
}
const inputAmountWithFee: BigNumber = inputAmount.multipliedBy(_997)
const numerator: BigNumber = inputAmountWithFee.multipliedBy(outputReserve)
const denominator: BigNumber = inputReserve.multipliedBy(_1000).plus(inputAmountWithFee)
const outputAmount = numerator.dividedToIntegerBy(denominator)
ensureAllUInt256([inputAmountWithFee, numerator, denominator, outputAmount])
return outputAmount
}
export function getOutputPrice(outputAmount: BigNumber, inputReserve: BigNumber, outputReserve: BigNumber): BigNumber {
ensureAllUInt256([outputAmount, inputReserve, outputReserve])
if (inputReserve.isLessThanOrEqualTo(_0) || outputReserve.isLessThanOrEqualTo(_0)) {
throw Error(`Both inputReserve '${inputReserve}' and outputReserve '${outputReserve}' must be non-zero.`)
}
const numerator: BigNumber = inputReserve.multipliedBy(outputAmount).multipliedBy(_1000)
const denominator: BigNumber = outputReserve.minus(outputAmount).multipliedBy(_997)
const inputAmount: BigNumber = numerator.dividedToIntegerBy(denominator).plus(_1)
ensureAllUInt256([numerator, denominator, inputAmount])
return inputAmount
}
// returns [numerator, decimal scalar, denominator]
function getRawMarketRate(
tokenReserves: TokenReservesOptional,
tradeType: TRADE_TYPE,
invert: boolean
): [BigNumber, BigNumber, BigNumber] {
if (tokenReserves === null) {
throw Error('outputTokenReserves must be non-null.')
} else {
const numerator: TokenAmount =
tradeType === TRADE_TYPE.ETH_TO_TOKEN ? tokenReserves.tokenReserve : tokenReserves.ethReserve
const denominator: TokenAmount =
tradeType === TRADE_TYPE.ETH_TO_TOKEN ? tokenReserves.ethReserve : tokenReserves.tokenReserve
const numeratorAmount: BigNumber = normalizeBigNumberish(numerator.amount)
const denominatorAmount: BigNumber = normalizeBigNumberish(denominator.amount)
ensureAllUInt256([numeratorAmount, denominatorAmount])
const numeratorDecimals: number = numerator.token.decimals
const denominatorDecimals: number = denominator.token.decimals
ensureAllUInt8([numeratorDecimals, denominatorDecimals])
if (!invert) {
const decimalScalar: BigNumber = _10.exponentiatedBy(denominatorDecimals - numeratorDecimals)
return [numeratorAmount, decimalScalar, denominatorAmount]
} else {
const decimalScalar: BigNumber = _10.exponentiatedBy(numeratorDecimals - denominatorDecimals)
return [denominatorAmount, decimalScalar, numeratorAmount]
}
}
}
function getRawMarketRateOneSided(
tokenReserves: TokenReservesOptional,
tradeType: TRADE_TYPE,
invert: boolean
): BigNumber {
const [numerator, decimalScalar, denominator]: [BigNumber, BigNumber, BigNumber] = getRawMarketRate(
tokenReserves,
tradeType,
invert
)
return numerator.multipliedBy(decimalScalar).dividedBy(denominator)
}
// rounds output rates to 18 decimal places
export function getMarketRate(
inputTokenReserves: TokenReservesOptional,
outputTokenReserves: TokenReservesOptional,
tradeType: TRADE_TYPE,
invert: boolean = false
): BigNumber {
if (tradeType === TRADE_TYPE.TOKEN_TO_TOKEN) {
if (inputTokenReserves === null || outputTokenReserves === null) {
throw Error('Both inputTokenReserves and outputTokenReserves must be non-null.')
} else {
const [inputNumerator, inputDecimalScalar, inputDenominator]: [
BigNumber,
BigNumber,
BigNumber
] = getRawMarketRate(inputTokenReserves, TRADE_TYPE.TOKEN_TO_ETH, invert)
const [outputNumerator, outputDecimalScalar, outputDenominator]: [
BigNumber,
BigNumber,
BigNumber
] = getRawMarketRate(outputTokenReserves, TRADE_TYPE.ETH_TO_TOKEN, invert)
return inputNumerator
.multipliedBy(inputDecimalScalar)
.multipliedBy(outputNumerator)
.multipliedBy(outputDecimalScalar)
.dividedBy(inputDenominator.multipliedBy(outputDenominator))
}
} else {
return getRawMarketRateOneSided(
tradeType === TRADE_TYPE.ETH_TO_TOKEN ? outputTokenReserves : inputTokenReserves,
tradeType,
invert
)
}
}
import BigNumber from 'bignumber.js'
import {
TokenAmount,
TokenAmountNormalized,
TokenReserves,
areTokenReserves,
areETHReserves,
OptionalReserves,
TokenReservesNormalized,
_ParsedOptionalReserves,
_DecimalRate,
_AnyRate
} from '../types'
import { _1, _10, _10000, TRADE_TYPE, ETH_TOKEN } from '../constants'
import { ensureAllUInt8, ensureAllUInt256, normalizeBigNumberish } from '../_utils'
function normalizeTokenAmount(tokenAmount: TokenAmount): TokenAmountNormalized {
ensureAllUInt8([tokenAmount.token.decimals])
const normalizedAmount: BigNumber = normalizeBigNumberish(tokenAmount.amount)
ensureAllUInt256([normalizedAmount])
return {
token: { ...tokenAmount.token },
amount: normalizedAmount
}
}
function normalizeTokenReserves(tokenReserves: TokenReserves): TokenReservesNormalized {
ensureAllUInt8([tokenReserves.token.decimals])
if (tokenReserves.exchange) {
ensureAllUInt8([tokenReserves.exchange.decimals])
}
return {
token: { ...tokenReserves.token },
...(tokenReserves.exchange ? { ...tokenReserves.exchange } : {}),
ethReserve: normalizeTokenAmount(tokenReserves.ethReserve),
tokenReserve: normalizeTokenAmount(tokenReserves.tokenReserve)
}
}
function ensureTradeTypesMatch(computedTradeType: TRADE_TYPE, passedTradeType?: TRADE_TYPE): never | void {
if (passedTradeType && passedTradeType !== computedTradeType) {
throw Error(`passedTradeType '${passedTradeType}' does not match computedTradeType '${computedTradeType}'.`)
}
}
export function parseOptionalReserves(
optionalReservesInput: OptionalReserves,
optionalReservesOutput: OptionalReserves,
passedTradeType?: TRADE_TYPE
): _ParsedOptionalReserves {
if (areTokenReserves(optionalReservesInput) && areTokenReserves(optionalReservesOutput)) {
const computedTradeType: TRADE_TYPE = TRADE_TYPE.TOKEN_TO_TOKEN
ensureTradeTypesMatch(computedTradeType, passedTradeType)
return {
tradeType: computedTradeType,
inputReserves: normalizeTokenReserves(optionalReservesInput),
outputReserves: normalizeTokenReserves(optionalReservesOutput)
}
} else if (areTokenReserves(optionalReservesInput) && !areTokenReserves(optionalReservesOutput)) {
const computedTradeType: TRADE_TYPE = TRADE_TYPE.TOKEN_TO_ETH
ensureTradeTypesMatch(computedTradeType, passedTradeType)
return {
tradeType: computedTradeType,
inputReserves: normalizeTokenReserves(optionalReservesInput),
outputReserves: {
token: ETH_TOKEN(areETHReserves(optionalReservesOutput) ? optionalReservesOutput.token.chainId : undefined)
}
}
} else if (!areTokenReserves(optionalReservesInput) && areTokenReserves(optionalReservesOutput)) {
const computedTradeType: TRADE_TYPE = TRADE_TYPE.ETH_TO_TOKEN
ensureTradeTypesMatch(computedTradeType, passedTradeType)
return {
tradeType: computedTradeType,
inputReserves: {
token: ETH_TOKEN(areETHReserves(optionalReservesInput) ? optionalReservesInput.token.chainId : undefined)
},
outputReserves: normalizeTokenReserves(optionalReservesOutput)
}
} else {
throw Error('optionalReservesInput, optionalReservesOutput, or both must be defined.')
}
}
export function calculateDecimalRate(
numerator: TokenAmountNormalized,
denominator: TokenAmountNormalized,
keepAsDecimal: boolean = false
): _AnyRate {
const largerScalar: BigNumber = _10.exponentiatedBy(
new BigNumber(Math.abs(numerator.token.decimals - denominator.token.decimals))
)
// since exponentiating with negative numbers rounds, we have to manually calculate the smaller of the scalars
const smallerScalar: BigNumber = largerScalar.isEqualTo(_1)
? _1
: new BigNumber(`0.${'0'.repeat(largerScalar.toFixed().length - 2)}1`)
const invertedIsLarger: boolean = numerator.token.decimals - denominator.token.decimals > 0
const decimalRate: _DecimalRate = {
numerator: numerator.amount,
denominator: denominator.amount,
decimalScalar: invertedIsLarger ? smallerScalar : largerScalar,
decimalScalarInverted: invertedIsLarger ? largerScalar : smallerScalar
}
return keepAsDecimal
? decimalRate
: {
rate: decimalRate.numerator.multipliedBy(decimalRate.decimalScalar).dividedBy(decimalRate.denominator),
rateInverted: decimalRate.denominator
.multipliedBy(decimalRate.decimalScalarInverted)
.dividedBy(decimalRate.numerator)
}
}
// slippage in basis points, to 18 decimals
export function calculateSlippage(baseRate: BigNumber, newRate: BigNumber): BigNumber {
const difference: BigNumber = baseRate.minus(newRate).absoluteValue()
return difference.multipliedBy(_10000).dividedBy(baseRate)
}
export * from './market'
export * from './trade'
import BigNumber from 'bignumber.js'
import {
TokenAmountNormalized,
OptionalReserves,
TokenReservesNormalized,
NormalizedReserves,
areTokenReservesNormalized,
Rate,
MarketDetails,
_ParsedOptionalReserves,
_DecimalRate,
_AnyRate
} from '../types'
import { TRADE_TYPE } from '../constants'
import { parseOptionalReserves, calculateDecimalRate } from './_utils'
// calculates the market rate for ETH_TO_TOKEN or TOKEN_TO_ETH trades
function getMarketRate(tradeType: TRADE_TYPE, reserves: NormalizedReserves, keepAsDecimal?: boolean): _AnyRate {
if (!areTokenReservesNormalized(reserves)) {
throw Error
}
const numerator: TokenAmountNormalized =
tradeType === TRADE_TYPE.ETH_TO_TOKEN ? reserves.tokenReserve : reserves.ethReserve
const denominator: TokenAmountNormalized =
tradeType === TRADE_TYPE.ETH_TO_TOKEN ? reserves.ethReserve : reserves.tokenReserve
return calculateDecimalRate(numerator, denominator, keepAsDecimal)
}
// note: rounds rates to 18 decimal places
export function getMarketDetails(
tradeType: TRADE_TYPE,
optionalReservesInput: OptionalReserves,
optionalReservesOutput: OptionalReserves
): MarketDetails {
const { inputReserves, outputReserves }: _ParsedOptionalReserves = parseOptionalReserves(
optionalReservesInput,
optionalReservesOutput,
tradeType
)
if (tradeType === TRADE_TYPE.TOKEN_TO_TOKEN) {
const {
numerator: numeratorInput,
denominator: denominatorInput,
decimalScalar: decimalScalarInput,
decimalScalarInverted: decimalScalarInvertedInput
}: _DecimalRate = getMarketRate(TRADE_TYPE.TOKEN_TO_ETH, inputReserves, true) as _DecimalRate
const {
numerator: numeratorOutput,
denominator: denominatorOutput,
decimalScalar: decimalScalarOutput,
decimalScalarInverted: decimalScalarInvertedOutput
}: _DecimalRate = getMarketRate(TRADE_TYPE.ETH_TO_TOKEN, outputReserves, true) as _DecimalRate
const marketRate: BigNumber = numeratorInput
.multipliedBy(decimalScalarInput)
.multipliedBy(numeratorOutput)
.multipliedBy(decimalScalarOutput)
.dividedBy(denominatorInput.multipliedBy(denominatorOutput))
const marketRateInverted: BigNumber = denominatorInput
.multipliedBy(decimalScalarInvertedInput)
.multipliedBy(denominatorOutput)
.multipliedBy(decimalScalarInvertedOutput)
.dividedBy(numeratorInput.multipliedBy(numeratorOutput))
return {
tradeType,
inputReserves,
outputReserves,
marketRate: { rate: marketRate, rateInverted: marketRateInverted }
}
} else {
const reserves: TokenReservesNormalized = (tradeType === TRADE_TYPE.ETH_TO_TOKEN
? outputReserves
: inputReserves) as TokenReservesNormalized
return {
tradeType,
inputReserves,
outputReserves,
marketRate: getMarketRate(tradeType, reserves) as Rate
}
}
}
import BigNumber from 'bignumber.js'
import cloneDeepWith from 'lodash.clonedeepwith'
import {
BigNumberish,
Rate,
MarketDetails,
TradeDetails,
TokenAmountNormalized,
areTokenReservesNormalized,
NormalizedReserves,
_PartialTradeDetails
} from '../types'
import { _0, _1, _997, _1000, TRADE_TYPE, TRADE_EXACT } from '../constants'
import { ensureAllUInt256, normalizeBigNumberish } from '../_utils'
import { calculateDecimalRate, calculateSlippage } from './_utils'
import { getMarketDetails } from './market'
function getInputPrice(inputAmount: BigNumber, inputReserve: BigNumber, outputReserve: BigNumber): BigNumber {
ensureAllUInt256([inputAmount, inputReserve, outputReserve])
if (inputReserve.isLessThanOrEqualTo(_0) || outputReserve.isLessThanOrEqualTo(_0)) {
throw Error(`Both inputReserve '${inputReserve}' and outputReserve '${outputReserve}' must be non-zero.`)
}
const inputAmountWithFee: BigNumber = inputAmount.multipliedBy(_997)
const numerator: BigNumber = inputAmountWithFee.multipliedBy(outputReserve)
const denominator: BigNumber = inputReserve.multipliedBy(_1000).plus(inputAmountWithFee)
const outputAmount = numerator.dividedToIntegerBy(denominator)
ensureAllUInt256([inputAmountWithFee, numerator, denominator, outputAmount])
return outputAmount
}
function getOutputPrice(outputAmount: BigNumber, inputReserve: BigNumber, outputReserve: BigNumber): BigNumber {
ensureAllUInt256([outputAmount, inputReserve, outputReserve])
if (inputReserve.isLessThanOrEqualTo(_0) || outputReserve.isLessThanOrEqualTo(_0)) {
throw Error(`Both inputReserve '${inputReserve}' and outputReserve '${outputReserve}' must be non-zero.`)
}
const numerator: BigNumber = inputReserve.multipliedBy(outputAmount).multipliedBy(_1000)
const denominator: BigNumber = outputReserve.minus(outputAmount).multipliedBy(_997)
const inputAmount: BigNumber = numerator.dividedToIntegerBy(denominator).plus(_1)
ensureAllUInt256([numerator, denominator, inputAmount])
return inputAmount
}
function _getTradeTransput(
tradeType: TRADE_TYPE,
tradeExact: TRADE_EXACT,
tradeAmount: BigNumber,
reserves: NormalizedReserves
): BigNumber {
if (!areTokenReservesNormalized(reserves)) {
throw Error
}
const inputReserve: BigNumber =
tradeType === TRADE_TYPE.ETH_TO_TOKEN ? reserves.ethReserve.amount : reserves.tokenReserve.amount
const outputReserve: BigNumber =
tradeType === TRADE_TYPE.ETH_TO_TOKEN ? reserves.tokenReserve.amount : reserves.ethReserve.amount
const calculatedAmount: BigNumber =
tradeExact === TRADE_EXACT.INPUT
? getInputPrice(tradeAmount, inputReserve, outputReserve)
: getOutputPrice(tradeAmount, inputReserve, outputReserve)
return calculatedAmount
}
function customizer(value: BigNumber): BigNumber | undefined {
if (BigNumber.isBigNumber(value)) {
return new BigNumber(value)
}
return
}
// gets the corresponding input/output amount for the passed output/input amount
function getTradeTransput(
tradeType: TRADE_TYPE,
tradeExact: TRADE_EXACT,
tradeAmount: BigNumber,
inputReserves: NormalizedReserves,
outputReserves: NormalizedReserves
): _PartialTradeDetails {
const inputReservesPost: NormalizedReserves = cloneDeepWith(inputReserves, customizer)
const outputReservesPost: NormalizedReserves = cloneDeepWith(outputReserves, customizer)
if (tradeType === TRADE_TYPE.TOKEN_TO_TOKEN) {
if (!areTokenReservesNormalized(inputReservesPost) || !areTokenReservesNormalized(outputReservesPost)) {
throw Error
}
if (tradeExact === TRADE_EXACT.INPUT) {
const intermediateTransput: BigNumber = _getTradeTransput(
TRADE_TYPE.TOKEN_TO_ETH,
TRADE_EXACT.INPUT,
tradeAmount,
inputReserves
)
const finalTransput: BigNumber = _getTradeTransput(
TRADE_TYPE.ETH_TO_TOKEN,
TRADE_EXACT.INPUT,
intermediateTransput,
outputReserves
)
inputReservesPost.ethReserve.amount = inputReservesPost.ethReserve.amount.minus(intermediateTransput)
inputReservesPost.tokenReserve.amount = inputReservesPost.tokenReserve.amount.plus(tradeAmount)
outputReservesPost.ethReserve.amount = outputReservesPost.ethReserve.amount.plus(intermediateTransput)
outputReservesPost.tokenReserve.amount = outputReservesPost.tokenReserve.amount.minus(finalTransput)
return {
transput: finalTransput,
inputReservesPost,
outputReservesPost
}
} else {
const intermediateTransput: BigNumber = _getTradeTransput(
TRADE_TYPE.ETH_TO_TOKEN,
TRADE_EXACT.OUTPUT,
tradeAmount,
outputReserves
)
const finalTransput: BigNumber = _getTradeTransput(
TRADE_TYPE.TOKEN_TO_ETH,
TRADE_EXACT.OUTPUT,
intermediateTransput,
inputReserves
)
inputReservesPost.ethReserve.amount = inputReservesPost.ethReserve.amount.minus(intermediateTransput)
inputReservesPost.tokenReserve.amount = inputReservesPost.tokenReserve.amount.plus(finalTransput)
outputReservesPost.ethReserve.amount = outputReservesPost.ethReserve.amount.plus(intermediateTransput)
outputReservesPost.tokenReserve.amount = outputReservesPost.tokenReserve.amount.minus(tradeAmount)
return {
transput: finalTransput,
inputReservesPost,
outputReservesPost
}
}
} else {
const reserves: NormalizedReserves = tradeType === TRADE_TYPE.ETH_TO_TOKEN ? outputReserves : inputReserves
const finalTransput: BigNumber = _getTradeTransput(tradeType, tradeExact, tradeAmount, reserves)
if (tradeType === TRADE_TYPE.ETH_TO_TOKEN) {
if (!areTokenReservesNormalized(outputReservesPost)) {
throw Error
}
outputReservesPost.ethReserve.amount = outputReservesPost.ethReserve.amount.plus(
tradeExact === TRADE_EXACT.INPUT ? tradeAmount : finalTransput
)
outputReservesPost.tokenReserve.amount = outputReservesPost.tokenReserve.amount.minus(
tradeExact === TRADE_EXACT.INPUT ? finalTransput : tradeAmount
)
} else {
if (!areTokenReservesNormalized(inputReservesPost)) {
throw Error
}
inputReservesPost.ethReserve.amount = inputReservesPost.ethReserve.amount.minus(
tradeExact === TRADE_EXACT.INPUT ? finalTransput : tradeAmount
)
inputReservesPost.tokenReserve.amount = inputReservesPost.tokenReserve.amount.plus(
tradeExact === TRADE_EXACT.INPUT ? tradeAmount : finalTransput
)
}
return {
transput: finalTransput,
inputReservesPost,
outputReservesPost
}
}
}
export function getTradeDetails(
tradeExact: TRADE_EXACT,
_tradeAmount: BigNumberish,
marketDetails: MarketDetails
): TradeDetails {
// get other input/output amount
const tradeAmount: BigNumber = normalizeBigNumberish(_tradeAmount)
const { transput, inputReservesPost, outputReservesPost }: _PartialTradeDetails = getTradeTransput(
marketDetails.tradeType,
tradeExact,
tradeAmount,
marketDetails.inputReserves,
marketDetails.outputReserves
)
// get input and output amounts
const inputAmount: TokenAmountNormalized = {
token: marketDetails.inputReserves.token,
amount: tradeExact === TRADE_EXACT.INPUT ? tradeAmount : transput
}
const outputAmount: TokenAmountNormalized = {
token: marketDetails.outputReserves.token,
amount: tradeExact === TRADE_EXACT.INPUT ? transput : tradeAmount
}
const marketDetailsPost: MarketDetails = getMarketDetails(
marketDetails.tradeType,
inputReservesPost,
outputReservesPost
)
const executionRate: Rate = calculateDecimalRate(outputAmount, inputAmount) as Rate
const marketRateSlippage: BigNumber = calculateSlippage(
marketDetails.marketRate.rate,
marketDetailsPost.marketRate.rate
)
const executionRateSlippage: BigNumber = calculateSlippage(marketDetails.marketRate.rate, executionRate.rate)
return {
marketDetailsPre: marketDetails,
marketDetailsPost,
tradeType: marketDetails.tradeType,
tradeExact,
inputAmount,
outputAmount,
executionRate,
marketRateSlippage,
executionRateSlippage
}
}
import BigNumber from 'bignumber.js'
import { Token } from '../types'
import ERC20 from './abis/ERC20.json'
import FACTORY from './abis/FACTORY.json'
......@@ -47,11 +48,17 @@ export const _1: BigNumber = new BigNumber('1')
export const _10: BigNumber = new BigNumber('10')
export const _997: BigNumber = new BigNumber('997')
export const _1000: BigNumber = new BigNumber('1000')
export const _10000: BigNumber = new BigNumber('10000')
export const MAX_UINT8: number = 2 ** 8 - 1
export const MAX_UINT256: BigNumber = new BigNumber('2').exponentiatedBy(new BigNumber('256')).minus(_1)
export const ERC20_ABI: string = JSON.stringify(ERC20)
export const FACTORY_ABI: string = JSON.stringify(FACTORY)
export function ETH_TOKEN(chainId?: number): Token {
return {
...(chainId ? { chainId } : {}),
address: ETH,
decimals: 18
}
}
export const CHAIN_ID_NAME: { [key: number]: string } = {
[SUPPORTED_CHAIN_ID.Mainnet]: 'homestead',
......@@ -59,3 +66,6 @@ export const CHAIN_ID_NAME: { [key: number]: string } = {
[SUPPORTED_CHAIN_ID.Rinkeby]: 'rinkeby',
[SUPPORTED_CHAIN_ID.Kovan]: 'kovan'
}
export const ERC20_ABI: string = JSON.stringify(ERC20)
export const FACTORY_ABI: string = JSON.stringify(FACTORY)
import { ethers } from 'ethers'
import { ETH, CHAIN_ID_NAME, ERC20_ABI, FACTORY_ADDRESS, FACTORY_ABI } from './constants'
import { ChainIdOrProvider, ChainIdAndProvider, Token, TokenReserves } from './types'
import { normalizeAddress, normalizeBigNumberish } from './utils'
import { ETH, ETH_TOKEN, CHAIN_ID_NAME, ERC20_ABI, FACTORY_ADDRESS, FACTORY_ABI } from '../constants'
import { ChainIdOrProvider, isChainId, _ChainIdAndProvider, Token, TokenReservesNormalized } from '../types'
import { normalizeAddress, normalizeBigNumberish } from '../_utils'
// abstraction to get contracts
// get contract object with address, ABI, and provider
function _getContract(address: string, ABI: string, provider: ethers.providers.BaseProvider): ethers.Contract {
return new ethers.Contract(normalizeAddress(address), ABI, provider)
}
// abstraction to get the chain id + provider
async function _getChainIdAndProvider(chainIdOrProvider: ChainIdOrProvider): Promise<ChainIdAndProvider> {
// get chain id and provider with either a chain id or a provider
async function _getChainIdAndProvider(chainIdOrProvider: ChainIdOrProvider): Promise<_ChainIdAndProvider> {
// if a chainId is provided, get a default provider for it
if (typeof chainIdOrProvider === 'number') {
if (isChainId(chainIdOrProvider)) {
return {
chainId: chainIdOrProvider,
provider: ethers.getDefaultProvider(CHAIN_ID_NAME[chainIdOrProvider])
......@@ -20,7 +20,7 @@ async function _getChainIdAndProvider(chainIdOrProvider: ChainIdOrProvider): Pro
}
// if a provider is provided, fetch the chainId from it
else {
const { chainId }: { chainId: number } = await chainIdOrProvider.getNetwork()
const { chainId }: ethers.utils.Network = await chainIdOrProvider.getNetwork()
return {
chainId,
provider: chainIdOrProvider
......@@ -28,20 +28,16 @@ async function _getChainIdAndProvider(chainIdOrProvider: ChainIdOrProvider): Pro
}
}
// abstraction to get token
async function _getToken(tokenAddress: string, chainIdAndProvider: ChainIdAndProvider): Promise<Token> {
// get token data from an address and chain id/provider
async function _getToken(tokenAddress: string, chainIdAndProvider: _ChainIdAndProvider): Promise<Token> {
if (tokenAddress === ETH) {
return {
chainId: chainIdAndProvider.chainId,
address: ETH,
decimals: 18
}
return ETH_TOKEN(chainIdAndProvider.chainId)
} else {
const ERC20Contract: ethers.Contract = _getContract(tokenAddress, ERC20_ABI, chainIdAndProvider.provider)
const decimals: number = await ERC20Contract.decimals()
return {
chainId: chainIdAndProvider.chainId,
address: tokenAddress,
address: ERC20Contract.address,
decimals
}
}
......@@ -51,8 +47,8 @@ async function _getToken(tokenAddress: string, chainIdAndProvider: ChainIdAndPro
export async function getTokenReserves(
tokenAddress: string,
chainIdOrProvider: ChainIdOrProvider = 1
): Promise<TokenReserves> {
const chainIdAndProvider: ChainIdAndProvider = await _getChainIdAndProvider(chainIdOrProvider)
): Promise<TokenReservesNormalized> {
const chainIdAndProvider: _ChainIdAndProvider = await _getChainIdAndProvider(chainIdOrProvider)
// fetch tokens
const ethTokenPromise: Promise<Token> = _getToken(ETH, chainIdAndProvider)
......
import BigNumber from 'bignumber.js'
import { MAX_DECIMAL_PLACES, ROUNDING_MODE, FIXED_UNDERFLOW_BEHAVIOR, _0, _10 } from './constants'
import { BigNumberish, FlexibleFormat, FormatSignificantOptions, FormatFixedOptions } from './types'
import { normalizeBigNumberish, ensureBoundedInteger, ensureAllUInt256, ensureAllUInt8 } from './utils'
import { BigNumberish, FlexibleFormat, isFormat, FormatSignificantOptions, FormatFixedOptions } from '../types'
import { _0, _10, MAX_DECIMAL_PLACES, ROUNDING_MODE, FIXED_UNDERFLOW_BEHAVIOR } from '../constants'
import { normalizeBigNumberish, ensureBoundedInteger, ensureAllUInt256, ensureAllUInt8 } from '../_utils'
function _format(
bigNumber: BigNumber,
......@@ -10,13 +10,9 @@ function _format(
roundingMode: BigNumber.RoundingMode = ROUNDING_MODE,
format: FlexibleFormat
): string {
return typeof format === 'boolean' && format === false
? bigNumber.toFixed(decimalPlaces, roundingMode)
: bigNumber.toFormat(
decimalPlaces,
roundingMode,
typeof format === 'boolean' && format === true ? undefined : format
)
return isFormat(format) || format
? bigNumber.toFormat(decimalPlaces, roundingMode, isFormat(format) ? format : undefined)
: bigNumber.toFixed(decimalPlaces, roundingMode)
}
// bignumberish is converted to significantDigits, then cast back as a bignumber and formatted, dropping trailing 0s
......
......@@ -13,3 +13,5 @@ export {
export * from './data'
export * from './computation'
export * from './format'
export * from './orchestration'
import { BigNumberish, ChainIdOrProvider, TokenReservesNormalized, MarketDetails, TradeDetails } from '../types'
import { TRADE_TYPE, TRADE_EXACT, ETH } from '../constants'
import { getTokenReserves } from '../data'
import { getMarketDetails, getTradeDetails } from '../computation'
export async function getTrade(
inputTokenAddress: string,
outputTokenAddress: string,
tradeType: TRADE_TYPE,
tradeExact: TRADE_EXACT,
tradeAmount: BigNumberish,
chainIdOrProvider?: ChainIdOrProvider
): Promise<TradeDetails> {
const tokenReservesInput: TokenReservesNormalized | null =
inputTokenAddress === ETH ? null : await getTokenReserves(inputTokenAddress, chainIdOrProvider)
const tokenReservesOutput: TokenReservesNormalized | null =
outputTokenAddress === ETH ? null : await getTokenReserves(outputTokenAddress, chainIdOrProvider)
const marketDetails: MarketDetails = getMarketDetails(tradeType, tokenReservesInput, tokenReservesOutput)
return getTradeDetails(tradeExact, tradeAmount, marketDetails)
}
import BigNumber from 'bignumber.js'
import { ethers } from 'ethers'
import { SUPPORTED_CHAIN_ID, TRADE_TYPE, TRADE_EXACT, FIXED_UNDERFLOW_BEHAVIOR } from './constants'
export type BigNumberish = BigNumber | ethers.utils.BigNumber | string | number
//// types for on-chain, submitted, and normalized data
export type ChainIdOrProvider = SUPPORTED_CHAIN_ID | ethers.providers.BaseProvider
// type guard for ChainIdOrProvider
export function isChainId(chainIdOrProvider: ChainIdOrProvider): chainIdOrProvider is SUPPORTED_CHAIN_ID {
const chainId: SUPPORTED_CHAIN_ID = chainIdOrProvider as SUPPORTED_CHAIN_ID
return typeof chainId === 'number'
}
export interface Token {
chainId?: SUPPORTED_CHAIN_ID
address?: string
decimals: number
}
export interface TokenAmount {
token: Token
amount: BigNumberish
}
export interface TokenAmountNormalized {
token: Token
amount: BigNumber
}
export interface TokenReserves {
token: Token
exchange?: Token
ethReserve: TokenAmount
tokenReserve: TokenAmount
}
export interface TokenReservesNormalized {
token: Token
exchange?: Token
ethReserve: TokenAmountNormalized
tokenReserve: TokenAmountNormalized
}
export interface EthReserves {
token: Token
}
// type for input data
export type OptionalReserves = TokenReserves | EthReserves | undefined | null
// type guard for OptionalReserves
export function areTokenReserves(reserves: OptionalReserves): reserves is TokenReserves {
const tokenReserves: TokenReserves = reserves as TokenReserves
return !!tokenReserves && tokenReserves.ethReserve !== undefined && tokenReserves.tokenReserve !== undefined
}
// type guard for OptionalReserves
export function areETHReserves(reserves: OptionalReserves): reserves is TokenReserves {
const tokenReserves: TokenReserves = reserves as TokenReserves
return !!tokenReserves && tokenReserves.ethReserve !== undefined && tokenReserves.tokenReserve !== undefined
}
// type for output data
export type NormalizedReserves = TokenReservesNormalized | EthReserves
// type guard for NormalizedReserves
export function areTokenReservesNormalized(reserves: NormalizedReserves): reserves is TokenReservesNormalized {
const tokenReservesNormalized: TokenReservesNormalized = reserves as TokenReservesNormalized
return tokenReservesNormalized.ethReserve !== undefined && tokenReservesNormalized.tokenReserve !== undefined
}
//// types for computed data
export interface Rate {
rate: BigNumber
rateInverted: BigNumber
}
export interface MarketDetails {
tradeType: TRADE_TYPE
inputReserves: NormalizedReserves
outputReserves: NormalizedReserves
marketRate: Rate
}
export interface TradeDetails {
marketDetailsPre: MarketDetails
marketDetailsPost: MarketDetails
tradeType: TRADE_TYPE
tradeExact: TRADE_EXACT
inputAmount: TokenAmountNormalized
outputAmount: TokenAmountNormalized
executionRate: Rate
marketRateSlippage: BigNumber
executionRateSlippage: BigNumber
}
//// types for formatting data
export type FlexibleFormat = BigNumber.Format | boolean
// type guard for FlexibleFormat
export function isFormat(flexibleFormat: FlexibleFormat): flexibleFormat is BigNumber.Format {
const format: BigNumber.Format = flexibleFormat as BigNumber.Format
return typeof format !== 'boolean'
}
export interface FormatSignificantOptions {
significantDigits: number
roundingMode: BigNumber.RoundingMode
forceIntegerSignificance: boolean
format: FlexibleFormat
}
export interface FormatFixedOptions {
decimalPlaces: number
roundingMode: BigNumber.RoundingMode
dropTrailingZeros: boolean
underflowBehavior: FIXED_UNDERFLOW_BEHAVIOR
format: FlexibleFormat
}
//// internal-only interfaces
export interface _ChainIdAndProvider {
chainId: number
provider: ethers.providers.BaseProvider
}
export interface _ParsedOptionalReserves {
tradeType: TRADE_TYPE
inputReserves: NormalizedReserves
outputReserves: NormalizedReserves
}
export interface _DecimalRate {
numerator: BigNumber
denominator: BigNumber
decimalScalar: BigNumber
decimalScalarInverted: BigNumber
}
export type _AnyRate = _DecimalRate | Rate
export interface _PartialTradeDetails {
transput: BigNumber
inputReservesPost: NormalizedReserves
outputReservesPost: NormalizedReserves
}
import BigNumber from 'bignumber.js'
import { ethers } from 'ethers'
import { SUPPORTED_CHAIN_ID, TRADE_TYPE, TRADE_EXACT, FIXED_UNDERFLOW_BEHAVIOR } from '../constants'
export type BigNumberish = BigNumber | ethers.utils.BigNumber | string | number
export type ChainIdOrProvider = SUPPORTED_CHAIN_ID | ethers.providers.BaseProvider
export interface ChainIdAndProvider {
chainId: number
provider: ethers.providers.BaseProvider
}
export interface Token {
chainId?: SUPPORTED_CHAIN_ID
address?: string
decimals: number
}
export interface TokenAmount {
token: Token
amount: BigNumberish
}
export interface TokenReserves {
token: Token
exchange?: Token
ethReserve: TokenAmount
tokenReserve: TokenAmount
}
export type TokenReservesOptional = TokenReserves | null
export interface TradeDetails {
tradeType: TRADE_TYPE
tradeExact: TRADE_EXACT
inputToken: Token
outputToken: Token
tradeAmount: string
marketRate: string
marketRateInverted: string
}
// formatting options
export type FlexibleFormat = boolean | BigNumber.Format
export interface FormatSignificantOptions {
significantDigits: number
roundingMode: BigNumber.RoundingMode
forceIntegerSignificance: boolean
format: FlexibleFormat
}
export interface FormatFixedOptions {
decimalPlaces: number
roundingMode: BigNumber.RoundingMode
dropTrailingZeros: boolean
underflowBehavior: FIXED_UNDERFLOW_BEHAVIOR
format: FlexibleFormat
}
......@@ -350,6 +350,18 @@
dependencies:
"@types/jest-diff" "*"
"@types/lodash.clonedeepwith@^4.5.6":
version "4.5.6"
resolved "https://registry.yarnpkg.com/@types/lodash.clonedeepwith/-/lodash.clonedeepwith-4.5.6.tgz#a5edd6c167b70396537ce6293dd8c0383d7b75e4"
integrity sha512-lroYwAgCFPkHyxi9548a+uQMelP60AaA74CBqfn9Li27+ukUZritx+JVYJ0W54PKBaBauXzTRHEvGzYrPsPZtQ==
dependencies:
"@types/lodash" "*"
"@types/lodash@*":
version "4.14.133"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.133.tgz#430721c96da22dd1694443e68e6cec7ba1c1003d"
integrity sha512-/3JqnvPnY58GLzG3Y7fpphOhATV1DDZ/Ak3DQufjlRK5E4u+s0CfClfNFtAGBabw+jDGtRFbOZe+Z02ZMWCBNQ==
"@types/node@^10.3.2":
version "10.14.7"
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.14.7.tgz#1854f0a9aa8d2cd6818d607b3d091346c6730362"
......@@ -2507,6 +2519,11 @@ locate-path@^3.0.0:
p-locate "^3.0.0"
path-exists "^3.0.0"
lodash.clonedeepwith@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.clonedeepwith/-/lodash.clonedeepwith-4.5.0.tgz#6ee30573a03a1a60d670a62ef33c10cf1afdbdd4"
integrity sha1-buMFc6A6GmDWcKYu8zwQzxr9vdQ=
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment