Skip to content
This repository has been archived by the owner on Apr 25, 2024. It is now read-only.

Commit

Permalink
change how we represent currency amounts (#3)
Browse files Browse the repository at this point in the history
* change how we represent currency amounts

* fix unit tests

* some unit tests for the printing methods

* more tests

* add 14.x

* address comments

* fix failing unit test

* numerator
  • Loading branch information
moodysalem authored May 10, 2021
1 parent 42b315a commit fd37337
Show file tree
Hide file tree
Showing 9 changed files with 179 additions and 44 deletions.
6 changes: 5 additions & 1 deletion .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ name: Unit Tests

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
tests:
name: Unit tests
strategy:
matrix:
node: ['10.x', '12.x']
node: ['10.x', '12.x', '14.x']

runs-on: ubuntu-latest

Expand Down
97 changes: 92 additions & 5 deletions src/entities/fractions/currencyAmount.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,115 @@
import JSBI from 'jsbi'
import { ChainId, MaxUint256 } from '../../constants'
import { ETHER } from '../ether'
import { Token } from '../token'
import CurrencyAmount from './currencyAmount'
import Percent from './percent'

describe('CurrencyAmount', () => {
const ADDRESS_ONE = '0x0000000000000000000000000000000000000001'

describe('constructor', () => {
it('works', () => {
const token = new Token(ChainId.MAINNET, ADDRESS_ONE, 18)
const amount = new CurrencyAmount(token, 100)
expect(amount.raw).toEqual(JSBI.BigInt(100))
const amount = CurrencyAmount.fromRawAmount(token, 100)
expect(amount.quotient).toEqual(JSBI.BigInt(100))
})
})

describe('#quotient', () => {
it('returns the amount after multiplication', () => {
const token = new Token(ChainId.MAINNET, ADDRESS_ONE, 18)
const amount = CurrencyAmount.fromRawAmount(token, 100).multiply(new Percent(15, 100))
expect(amount.quotient).toEqual(JSBI.BigInt(15))
})
})

describe('#ether', () => {
it('produces ether amount', () => {
const amount = CurrencyAmount.ether(100)
expect(amount.raw).toEqual(JSBI.BigInt(100))
expect(amount.quotient).toEqual(JSBI.BigInt(100))
expect(amount.currency).toEqual(ETHER)
})
})

it('token amount can be max uint256', () => {
const amount = new CurrencyAmount(new Token(ChainId.MAINNET, ADDRESS_ONE, 18), MaxUint256)
expect(amount.raw).toEqual(MaxUint256)
const amount = CurrencyAmount.fromRawAmount(new Token(ChainId.MAINNET, ADDRESS_ONE, 18), MaxUint256)
expect(amount.quotient).toEqual(MaxUint256)
})
it('token amount cannot exceed max uint256', () => {
expect(() =>
CurrencyAmount.fromRawAmount(new Token(ChainId.MAINNET, ADDRESS_ONE, 18), JSBI.add(MaxUint256, JSBI.BigInt(1)))
).toThrow('AMOUNT')
})
it('token amount quotient cannot exceed max uint256', () => {
expect(() =>
CurrencyAmount.fromFractionalAmount(
new Token(ChainId.MAINNET, ADDRESS_ONE, 18),
JSBI.add(JSBI.multiply(MaxUint256, JSBI.BigInt(2)), JSBI.BigInt(2)),
JSBI.BigInt(2)
)
).toThrow('AMOUNT')
})
it('token amount numerator can be gt. uint256 if denominator is gt. 1', () => {
const amount = CurrencyAmount.fromFractionalAmount(
new Token(ChainId.MAINNET, ADDRESS_ONE, 18),
JSBI.add(MaxUint256, JSBI.BigInt(2)),
2
)
expect(amount.numerator).toEqual(JSBI.add(JSBI.BigInt(2), MaxUint256))
})

describe('#toFixed', () => {
it('throws for decimals > currency.decimals', () => {
const token = new Token(ChainId.MAINNET, ADDRESS_ONE, 0)
const amount = CurrencyAmount.fromRawAmount(token, 1000)
expect(() => amount.toFixed(3)).toThrow('DECIMALS')
})
it('is correct for 0 decimals', () => {
const token = new Token(ChainId.MAINNET, ADDRESS_ONE, 0)
const amount = CurrencyAmount.fromRawAmount(token, 123456)
expect(amount.toFixed(0)).toEqual('123456')
})
it('is correct for 18 decimals', () => {
const token = new Token(ChainId.MAINNET, ADDRESS_ONE, 18)
const amount = CurrencyAmount.fromRawAmount(token, 1e15)
expect(amount.toFixed(9)).toEqual('0.001000000')
})
})

describe('#toSignificant', () => {
it('does not throw for sig figs > currency.decimals', () => {
const token = new Token(ChainId.MAINNET, ADDRESS_ONE, 0)
const amount = CurrencyAmount.fromRawAmount(token, 1000)
expect(amount.toSignificant(3)).toEqual('1000')
})
it('is correct for 0 decimals', () => {
const token = new Token(ChainId.MAINNET, ADDRESS_ONE, 0)
const amount = CurrencyAmount.fromRawAmount(token, 123456)
expect(amount.toSignificant(4)).toEqual('123400')
})
it('is correct for 18 decimals', () => {
const token = new Token(ChainId.MAINNET, ADDRESS_ONE, 18)
const amount = CurrencyAmount.fromRawAmount(token, 1e15)
expect(amount.toSignificant(9)).toEqual('0.001')
})
})

describe('#toExact', () => {
it('does not throw for sig figs > currency.decimals', () => {
const token = new Token(ChainId.MAINNET, ADDRESS_ONE, 0)
const amount = CurrencyAmount.fromRawAmount(token, 1000)
expect(amount.toExact()).toEqual('1000')
})
it('is correct for 0 decimals', () => {
const token = new Token(ChainId.MAINNET, ADDRESS_ONE, 0)
const amount = CurrencyAmount.fromRawAmount(token, 123456)
expect(amount.toExact()).toEqual('123456')
})
it('is correct for 18 decimals', () => {
const token = new Token(ChainId.MAINNET, ADDRESS_ONE, 18)
const amount = CurrencyAmount.fromRawAmount(token, 123e13)
expect(amount.toExact()).toEqual('0.00123')
})
})
})
70 changes: 50 additions & 20 deletions src/entities/fractions/currencyAmount.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import JSBI from 'jsbi'
import { currencyEquals } from '../../utils/currencyEquals'
import { Currency } from '../currency'
import { Ether, ETHER } from '../ether'
import { Ether } from '../ether'
import invariant from 'tiny-invariant'
import _Big from 'big.js'
import toFormat from 'toformat'
Expand All @@ -13,44 +13,74 @@ const Big = toFormat(_Big)

export default class CurrencyAmount<T extends Currency> extends Fraction {
public readonly currency: T
public readonly decimalScale: JSBI

/**
* Helper that calls the constructor with the ETHER currency
* @param amount ether amount in wei
* Returns a new currency amount instance from the
* @param currency the currency in the amount
* @param rawAmount the raw token or ether amount
*/
public static ether(amount: BigintIsh): CurrencyAmount<Ether> {
return new CurrencyAmount(ETHER, amount)
public static fromRawAmount<T extends Currency>(currency: T, rawAmount: BigintIsh): CurrencyAmount<T> {
return new CurrencyAmount(currency, rawAmount)
}

// amount _must_ be raw, i.e. in the native representation
public constructor(currency: T, amount: BigintIsh) {
const parsedAmount = JSBI.BigInt(amount)
invariant(JSBI.lessThanOrEqual(parsedAmount, MaxUint256), 'AMOUNT')
/**
* Construct a currency amount with a denominator that is not equal to 1
* @param currency the currency
* @param numerator the numerator of the fractional token amount
* @param denominator the denominator of the fractional token amount
*/
public static fromFractionalAmount<T extends Currency>(
currency: T,
numerator: BigintIsh,
denominator: BigintIsh
): CurrencyAmount<T> {
return new CurrencyAmount(currency, numerator, denominator)
}

super(parsedAmount, JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(currency.decimals)))
this.currency = currency
/**
* Helper that calls the constructor with the ETHER currency
* @param rawAmount ether amount in wei
*/
public static ether(rawAmount: BigintIsh): CurrencyAmount<Ether> {
return CurrencyAmount.fromRawAmount(Ether.ETHER, rawAmount)
}

public get raw(): JSBI {
return this.numerator
protected constructor(currency: T, numerator: BigintIsh, denominator?: BigintIsh) {
super(numerator, denominator)
invariant(JSBI.lessThanOrEqual(this.quotient, MaxUint256), 'AMOUNT')
this.currency = currency
this.decimalScale = JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(currency.decimals))
}

public add(other: CurrencyAmount<T>): CurrencyAmount<T> {
invariant(currencyEquals(this.currency, other.currency), 'TOKEN')
return new CurrencyAmount(this.currency, JSBI.add(this.raw, other.raw))
invariant(currencyEquals(this.currency, other.currency), 'CURRENCY')
const added = super.add(other)
return CurrencyAmount.fromFractionalAmount(this.currency, added.numerator, added.denominator)
}

public subtract(other: CurrencyAmount<T>): CurrencyAmount<T> {
invariant(currencyEquals(this.currency, other.currency), 'TOKEN')
return new CurrencyAmount(this.currency, JSBI.subtract(this.raw, other.raw))
invariant(currencyEquals(this.currency, other.currency), 'CURRENCY')
const subtracted = super.subtract(other)
return CurrencyAmount.fromFractionalAmount(this.currency, subtracted.numerator, subtracted.denominator)
}

public multiply(other: Fraction | BigintIsh): CurrencyAmount<T> {
const multiplied = super.multiply(other)
return CurrencyAmount.fromFractionalAmount(this.currency, multiplied.numerator, multiplied.denominator)
}

public divide(other: Fraction | BigintIsh): CurrencyAmount<T> {
const divided = super.divide(other)
return CurrencyAmount.fromFractionalAmount(this.currency, divided.numerator, divided.denominator)
}

public toSignificant(
significantDigits: number = 6,
format?: object,
rounding: Rounding = Rounding.ROUND_DOWN
): string {
return super.toSignificant(significantDigits, format, rounding)
return super.divide(this.decimalScale).toSignificant(significantDigits, format, rounding)
}

public toFixed(
Expand All @@ -59,11 +89,11 @@ export default class CurrencyAmount<T extends Currency> extends Fraction {
rounding: Rounding = Rounding.ROUND_DOWN
): string {
invariant(decimalPlaces <= this.currency.decimals, 'DECIMALS')
return super.toFixed(decimalPlaces, format, rounding)
return super.divide(this.decimalScale).toFixed(decimalPlaces, format, rounding)
}

public toExact(format: object = { groupSeparator: '' }): string {
Big.DP = this.currency.decimals
return new Big(this.numerator.toString()).div(this.denominator.toString()).toFormat(format)
return new Big(this.quotient.toString()).div(this.decimalScale.toString()).toFormat(format)
}
}
2 changes: 1 addition & 1 deletion src/entities/fractions/price.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('Price', () => {
describe('#quote', () => {
it('returns correct value', () => {
const price = new Price(t0, t1, 1, 5)
expect(price.quote(new CurrencyAmount(t0, 10))).toEqual(new CurrencyAmount(t1, 50))
expect(price.quote(CurrencyAmount.fromRawAmount(t0, 10))).toEqual(CurrencyAmount.fromRawAmount(t1, 50))
})
})
})
12 changes: 6 additions & 6 deletions src/entities/fractions/price.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,6 @@ export default class Price<TBase extends Currency, TQuote extends Currency> exte
)
}

public get raw(): Fraction {
return new Fraction(this.numerator, this.denominator)
}

public get adjusted(): Fraction {
return super.multiply(this.scalar)
}
Expand All @@ -42,10 +38,14 @@ export default class Price<TBase extends Currency, TQuote extends Currency> exte
return new Price(this.baseCurrency, other.quoteCurrency, fraction.denominator, fraction.numerator)
}

// quotes with floor division
/**
* Return the amount of quote currency corresponding to a given amount of the base currency
* @param currencyAmount the amount of base currency to quote against the price
*/
public quote(currencyAmount: CurrencyAmount<TBase>): CurrencyAmount<TQuote> {
invariant(currencyEquals(currencyAmount.currency, this.baseCurrency), 'TOKEN')
return new CurrencyAmount(this.quoteCurrency, super.multiply(currencyAmount.raw).quotient)
const result = super.multiply(currencyAmount)
return CurrencyAmount.fromFractionalAmount(this.quoteCurrency, result.numerator, result.denominator)
}

public toSignificant(significantDigits: number = 6, format?: object, rounding?: Rounding): string {
Expand Down
14 changes: 11 additions & 3 deletions src/utils/computePriceImpact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,25 @@ describe('#computePriceImpact', () => {

it('is correct for zero', () => {
expect(
computePriceImpact(new Price(ETHER, t0, 10, 100), new CurrencyAmount(ETHER, 10), new CurrencyAmount(t0, 100))
computePriceImpact(new Price(ETHER, t0, 10, 100), CurrencyAmount.ether(10), CurrencyAmount.fromRawAmount(t0, 100))
).toEqual(new Percent(0, 10000))
})
it('is correct for half output', () => {
expect(
computePriceImpact(new Price(t0, t1, 10, 100), new CurrencyAmount(t0, 10), new CurrencyAmount(t0, 50))
computePriceImpact(
new Price(t0, t1, 10, 100),
CurrencyAmount.fromRawAmount(t0, 10),
CurrencyAmount.fromRawAmount(t1, 50)
)
).toEqual(new Percent(5000, 10000))
})
it('is negative for more output', () => {
expect(
computePriceImpact(new Price(t0, t1, 10, 100), new CurrencyAmount(t0, 10), new CurrencyAmount(t0, 200))
computePriceImpact(
new Price(t0, t1, 10, 100),
CurrencyAmount.fromRawAmount(t0, 10),
CurrencyAmount.fromRawAmount(t1, 200)
)
).toEqual(new Percent(-10000, 10000))
})
})
4 changes: 2 additions & 2 deletions src/utils/computePriceImpact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export function computePriceImpact<TBase extends Currency, TQuote extends Curren
inputAmount: CurrencyAmount<TBase>,
outputAmount: CurrencyAmount<TQuote>
): Percent {
const exactQuote = midPrice.raw.multiply(inputAmount.raw)
const quotedOutputAmount = midPrice.quote(inputAmount)
// calculate price impact := (exactQuote - outputAmount) / exactQuote
const priceImpact = exactQuote.subtract(outputAmount.raw).divide(exactQuote)
const priceImpact = quotedOutputAmount.subtract(outputAmount).divide(quotedOutputAmount)
return new Percent(priceImpact.numerator, priceImpact.denominator)
}
12 changes: 7 additions & 5 deletions src/utils/wrappedCurrencyAmount.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { ChainId } from '../constants'
import { CurrencyAmount, ETHER, Token, WETH9 } from '../entities'
import { CurrencyAmount, Token, WETH9 } from '../entities'
import { wrappedCurrencyAmount } from './wrappedCurrencyAmount'

describe('#wrappedCurrencyAmount', () => {
const token = new Token(ChainId.MAINNET, '0x0000000000000000000000000000000000000001', 18)

it('wraps ether', () => {
expect(wrappedCurrencyAmount(new CurrencyAmount(ETHER, 10), ChainId.RINKEBY)).toEqual(
new CurrencyAmount(WETH9[ChainId.RINKEBY], 10)
expect(wrappedCurrencyAmount(CurrencyAmount.ether(10), ChainId.RINKEBY)).toEqual(
CurrencyAmount.fromRawAmount(WETH9[ChainId.RINKEBY], 10)
)
})
it('does nothing to tokens', () => {
expect(wrappedCurrencyAmount(new CurrencyAmount(token, 10), ChainId.MAINNET)).toEqual(new CurrencyAmount(token, 10))
expect(wrappedCurrencyAmount(CurrencyAmount.fromRawAmount(token, 10), ChainId.MAINNET)).toEqual(
CurrencyAmount.fromRawAmount(token, 10)
)
})
it('throws if different network ', () => {
expect(() => wrappedCurrencyAmount(new CurrencyAmount(token, 10), ChainId.RINKEBY)).toThrow('CHAIN_ID')
expect(() => wrappedCurrencyAmount(CurrencyAmount.fromRawAmount(token, 10), ChainId.RINKEBY)).toThrow('CHAIN_ID')
})
})
6 changes: 5 additions & 1 deletion src/utils/wrappedCurrencyAmount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,9 @@ export function wrappedCurrencyAmount(
currencyAmount: CurrencyAmount<Currency>,
chainId: ChainId
): CurrencyAmount<Token> {
return new CurrencyAmount(wrappedCurrency(currencyAmount.currency, chainId), currencyAmount.raw)
return CurrencyAmount.fromFractionalAmount(
wrappedCurrency(currencyAmount.currency, chainId),
currencyAmount.numerator,
currencyAmount.denominator
)
}

0 comments on commit fd37337

Please sign in to comment.