Skip to content

Commit

Permalink
feat(cr): number formatter
Browse files Browse the repository at this point in the history
  • Loading branch information
jurajpiar committed Dec 4, 2024
1 parent beaa8e7 commit 2fad8a8
Show file tree
Hide file tree
Showing 3 changed files with 264 additions and 2 deletions.
4 changes: 2 additions & 2 deletions src/app/collective-rewards/rewards/utils/formatMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { formatCurrency, toFixed } from '@/lib/utils'
import { formatUnits } from 'viem'
import { formatCurrency } from '@/app/collective-rewards/utils/formatter'

export const formatMetrics = (amount: number, price: number, symbol: string, currency: string) => ({
amount: `${toFixed(amount)} ${symbol}`,
amount: `${formatCurrency(amount, symbol)} ${symbol}`,
fiatAmount: `= ${currency} ${formatCurrency(amount * price, currency)}`,
})

Expand Down
108 changes: 108 additions & 0 deletions src/app/collective-rewards/utils/formatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect, test } from 'vitest'
import { formatNumber, RoundingMode } from './formatter'

describe('formatNumber', () => {
it('should return a string', () => {
expect(formatNumber(1n)).toBeTypeOf('string')
})

it('should contain "." for decimals > 0', () => {
const wholePart = '1234567'
const decimalPart = '111222333444555666'
const value = `${wholePart}${decimalPart}`
const output = formatNumber(value, {
decimals: 1,
})
expect(output.indexOf('.') > 0).toBeTruthy()
})

it('should contain no "." for decimals == 0', () => {
const wholePart = '1234567'
const decimalPart = '111222333444555666'
const value = `${wholePart}${decimalPart}`
const output = formatNumber(value, {
decimals: 0,
})
expect(output.indexOf('.') == -1).toBeTruthy()
})

it('should separate thousands with given separator', () => {
const value = '1234567111222333444555666'
const output = formatNumber(value, {
thousandsSeparator: '∂',
decimals: 12,
})

expect(output).to.eq('1∂234∂567∂111∂222.333∂444∂555∂666')
})

test.each(
Array(19)
.fill(0)
.map((_, i) => i),
)('should have number of decimals as defined in options', expectedDecimals => {
const value = '1234567111222333444555666'
const output = formatNumber(value, {
decimals: expectedDecimals,
})

const [_, actualDecimalPart] = output.split('.')

expect(actualDecimalPart?.length ?? 0, `when options.decimals == ${expectedDecimals}`).to.eq(
expectedDecimals,
)
})

const roundingModes: RoundingMode[] = ['ceil', 'floor', 'round']
test.each(
Array(19 * roundingModes.length)
.fill(0)
.map((_, i) => [i % 19, roundingModes[i % roundingModes.length]]),
)(
'should contain number of decimals according to the decimalPlaces rounding options',
(decimalPlaces, mode) => {
const wholePart = '1234567'
const decimalPart = '000111222333444555'
const value = `${wholePart}${decimalPart}`
const expectedNumberOfDecimals = decimalPlaces
const output = formatNumber(value, {
round: {
decimalPlaces,
mode,
},
})
const [_, actualDecimalPart] = output.split('.')

const actualNumberOfDecimals = actualDecimalPart?.length ?? 0

expect(
actualNumberOfDecimals,
`when decimalPlaces == ${decimalPlaces} and rounding mode == ${mode}`,
).to.eq(expectedNumberOfDecimals)
},
)

// TODO: test w/ carryovers eg 39999.999999
it('should carryover correctly when ceiled', () => {
const wholePart = '1234567'
const decimalPart = '999999999999999999'
const value = `${wholePart}${decimalPart}`
const output = formatNumber(value, {
round: {
decimalPlaces: 0,
mode: 'ceil',
},
})

expect(output).to.eq('1234568')
})

// TODO: test w/ less decimals
// TODO: test defaults
// TODO: test mismatched options
// TODO: test disappearance of .
// TODO: test w/o decimals
// TODO: test w/ 0.000001
// TODO: test w/ unknown rounding mode
// TODO: test that the least significant digit is not rounded
})
154 changes: 154 additions & 0 deletions src/app/collective-rewards/utils/formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { RBTC, RIF } from '@/lib/constants'
import { formatUnits, getAddress, InvalidAddressError } from 'viem'
export type RoundingMode = 'floor' | 'ceil' | 'round'
type RoundingOptions = {
mode: RoundingMode
decimalPlaces: number
}
type NumberFormatOptions = {
decimals: number
thousandsSeparator: string
round: Partial<RoundingOptions>
}

export const DEFAULT_NUMBER_FORMAT_OPTIONS: NumberFormatOptions = {
decimals: 18,
round: {
mode: 'round',
decimalPlaces: 18,
},
thousandsSeparator: '',
}

type FormatNumber = (value: bigint | string | number, options?: Partial<NumberFormatOptions>) => string
const normaliseValue: FormatNumber = (value, options) => {
const valueAsBigInt = BigInt(value)
const { decimals } = options as NumberFormatOptions
const { decimalPlaces, mode } = options?.round as RoundingOptions
const inUnits = formatUnits(valueAsBigInt, decimals)

if (decimals === decimalPlaces) {
return inUnits
}

const fn = roundingModes[mode]

return fn(inUnits, decimalPlaces)
}

export const formatNumber: FormatNumber = (value, options) => {
const mergedOptions: NumberFormatOptions = {
...DEFAULT_NUMBER_FORMAT_OPTIONS,
...(options ?? {}),
round: {
...DEFAULT_NUMBER_FORMAT_OPTIONS.round,
...(options?.round ?? {}),
},
}
const normalisedValue = normaliseValue(value, mergedOptions)

const { thousandsSeparator } = mergedOptions
const [wholePart, decimalPart] = normalisedValue.split('.')
const wholePartWSeparator = wholePart
.split('')
.reduceRight(
(acc: string, digit: string, index: number, arr) =>
`${digit}${index === arr.length - 1 || index % 3 ? '' : thousandsSeparator}${acc}`,
'',
)

const decimalPartWSeparator = decimalPart
? decimalPart
.split('')
.reduce(
(acc: string, digit: string, index: number) =>
`${acc}${!index || index % 3 ? '' : thousandsSeparator}${digit}`,
'',
)
: ''

return `${wholePartWSeparator}${decimalPartWSeparator ? '.' : ''}${decimalPartWSeparator}`
}

const floor = (value: string, toDecimals: number) => {
const indexOfDecimalPoint = value.indexOf('.')

return value.slice(0, indexOfDecimalPoint + 1 + toDecimals)
}

const ceil = (value: string, toDecimals: number) => {
const [wholePart, decimalPart] = value.split('.')
if (decimalPart.length < toDecimals) {
return value
}

const croppedDecimals = decimalPart.slice(0, toDecimals)
const retainingPrefix = '10'
const ceiledPrefixedDecimals = (BigInt(retainingPrefix + croppedDecimals) + 1n).toString()
const [_, carryover, ...ceiledDecimals] = ceiledPrefixedDecimals

return `${BigInt(wholePart) + BigInt(carryover)}${toDecimals ? '.' : ''}${ceiledDecimals.join('')}`
}

const round = (value: string, toDecimals: number) => {
const leastSignificantDigit = value.indexOf('.')
if (Number(leastSignificantDigit) >= 5) {
return ceil(value, toDecimals)
}

return floor(value, toDecimals)
}

type RoundingFunction = (value: string, toDecimals: number) => string
type RoundingModes = Record<RoundingOptions['mode'], RoundingFunction>
export const roundingModes: RoundingModes = { floor, ceil, round }

export const formatRIF: FormatNumber = (value, options) => {
return formatNumber(value, {
decimals: 18,
round: {
decimalPlaces: 0,
mode: 'floor',
...options?.round,
},
thousandsSeparator: ',',
...options,
})
}

export const formatRBTC: FormatNumber = (value, options) => {
return formatNumber(value, {
decimals: 18,
round: {
decimalPlaces: 5,
mode: 'floor',
...options?.round,
},
thousandsSeparator: ',',
...options,
})
}

type FormatCurrency = (
value: string | number | bigint,
symbol: string,
options?: NumberFormatOptions,
) => string
export const formatCurrency: FormatCurrency = (value, symbol, options) => {
const valueAsString = value.toString()
if (symbol.toLowerCase().endsWith('rif')) {
// TODO: I'm not happy with such weak checks. It would be better to map tokens onto addresses in configuration and read the map
return formatRIF(valueAsString, options)
}
if (symbol.toLowerCase().endsWith('rbtc')) {
return formatRBTC(valueAsString, options)
}

return formatNumber(valueAsString, {
round: {
decimalPlaces: 3,
...options?.round,
},
...options,
})
}

0 comments on commit 2fad8a8

Please sign in to comment.