diff --git a/.changeset/modern-taxis-repair.md b/.changeset/modern-taxis-repair.md new file mode 100644 index 0000000000..ed01d2e62b --- /dev/null +++ b/.changeset/modern-taxis-repair.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/types': minor +--- + +Add pnum diff --git a/packages/types/src/pnum.test.ts b/packages/types/src/pnum.test.ts new file mode 100644 index 0000000000..71a901bef2 --- /dev/null +++ b/packages/types/src/pnum.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { pnum } from './pnum.js'; +import { BigNumber } from 'bignumber.js'; +import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb'; + +describe('pnum', () => { + it('should correctly parse and convert a number with decimals', () => { + const result = pnum(123.456, { exponent: 3 }); + + expect(result.toNumber()).toBe(123.456); + expect(result.toString()).toBe('123.456'); + expect(result.toBigInt()).toBe(123456n); + expect(result.toBigNumber()).toStrictEqual(new BigNumber('123.456')); + expect(result.toLoHi().lo).toBe(123456n); + expect(result.toAmount()).toStrictEqual( + new Amount({ + lo: 123456n, + hi: 0n, + }), + ); + }); + + it('should correctly parse and convert a string with decimals', () => { + const result = pnum('123456789.01230000', { exponent: 6 }); + + expect(result.toNumber()).toBe(123456789.0123); + expect(result.toRoundedNumber(2)).toBe(123456789.01); + expect(result.toString()).toBe('123456789.0123'); + expect(result.toRoundedString()).toBe('123456789.012300'); + expect(result.toFormattedString()).toBe('123,456,789.012300'); + expect(result.toFormattedString({ trailingZeros: false })).toBe('123,456,789.0123'); + expect(result.toBigInt()).toBe(123456789012300n); + expect(result.toBigNumber()).toStrictEqual(new BigNumber('123456789.0123')); + expect(result.toLoHi().lo).toBe(123456789012300n); + expect(result.toAmount()).toStrictEqual( + new Amount({ + lo: 123456789012300n, + hi: 0n, + }), + ); + }); + + it('should correctly parse and convert a bigint', () => { + const result = pnum(9123456789n, { exponent: 6 }); + + expect(result.toNumber()).toBe(9123.456789); + expect(result.toRoundedNumber(5)).toBe(9123.45679); + expect(result.toString()).toBe('9123.456789'); + expect(result.toRoundedString()).toBe('9123.456789'); + expect(result.toFormattedString()).toBe('9,123.456789'); + expect(result.toBigInt()).toBe(9123456789n); + expect(result.toBigNumber()).toStrictEqual(new BigNumber('9123.456789')); + expect(result.toLoHi().lo).toBe(9123456789n); + expect(result.toAmount()).toStrictEqual( + new Amount({ + lo: 9123456789n, + hi: 0n, + }), + ); + }); + + it('should correctly parse and convert a LoHi', () => { + const result = pnum({ lo: 99999n, hi: 99999n }, { exponent: 6 }); + + expect(result.toNumber()).toBe(1844655960626881500); + expect(result.toRoundedNumber(5)).toBe(1844655960626881500); + expect(result.toString()).toBe('1844655960626881452.148383'); + expect(result.toRoundedString()).toBe('1844655960626881452.148383'); + expect(result.toFormattedString()).toBe('1,844,655,960,626,881,452.148383'); + expect(result.toBigInt()).toBe(1844655960626881452148383n); + expect(result.toBigNumber()).toStrictEqual(new BigNumber('1844655960626881452.148383')); + expect(result.toLoHi().lo).toBe(99999n); + expect(result.toLoHi().hi).toBe(99999n); + expect(result.toAmount()).toStrictEqual( + new Amount({ + lo: 99999n, + hi: 99999n, + }), + ); + }); + + it('should correctly parse and convert an Amount', () => { + const result = pnum( + new Amount({ + lo: 9123456789n, + hi: 0n, + }), + { exponent: 6 }, + ); + + expect(result.toNumber()).toBe(9123.456789); + expect(result.toRoundedNumber(5)).toBe(9123.45679); + expect(result.toString()).toBe('9123.456789'); + expect(result.toRoundedString()).toBe('9123.456789'); + expect(result.toFormattedString()).toBe('9,123.456789'); + expect(result.toBigInt()).toBe(9123456789n); + expect(result.toBigNumber()).toStrictEqual(new BigNumber('9123.456789')); + expect(result.toLoHi().lo).toBe(9123456789n); + expect(result.toAmount()).toStrictEqual( + new Amount({ + lo: 9123456789n, + hi: 0n, + }), + ); + }); + + it('should correctly parse and convert undefined', () => { + const result = pnum(); + + expect(result.toString()).toBe('0'); + expect(result.toRoundedString(2)).toBe('0.00'); + expect(result.toRoundedNumber(5)).toBe(0); + expect(result.toFormattedString()).toBe('0'); + expect(result.toNumber()).toBe(0); + expect(result.toLoHi().lo).toBe(0n); + expect(result.toBigInt()).toBe(0n); + expect(result.toBigNumber()).toStrictEqual(new BigNumber('0')); + expect(result.toAmount()).toStrictEqual( + new Amount({ + lo: 0n, + hi: 0n, + }), + ); + }); + + it('should correctly parse exponent as number or options', () => { + const result1 = pnum(123455678, 4); + const result2 = pnum(123455678n, { exponent: 4 }); + const result3 = pnum(123455678n, 4); + + expect(result1.toString()).toBe('123455678'); + expect(result1.toRoundedString()).toBe('123455678.0000'); + expect(result1.toFormattedString()).toBe('123,455,678.0000'); + + expect(result2.toFormattedString()).toBe('12,345.5678'); + expect(result2.toFormattedString()).toBe(result3.toFormattedString()); + expect(result3.toString()).toBe('12345.5678'); + }); +}); diff --git a/packages/types/src/pnum.ts b/packages/types/src/pnum.ts new file mode 100644 index 0000000000..39dddd1723 --- /dev/null +++ b/packages/types/src/pnum.ts @@ -0,0 +1,126 @@ +import { BigNumber } from 'bignumber.js'; +import { round } from '@penumbra-zone/types/round'; +import { LoHi, joinLoHi, splitLoHi } from '@penumbra-zone/types/lo-hi'; +import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb'; +import { ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { getAmount, getDisplayDenomExponentFromValueView } from '@penumbra-zone/getters/value-view'; +import { removeTrailingZeros } from '@penumbra-zone/types/shortify'; + +/** + * pnum (penumbra number) + * + * In Penumbra a number can be in the form of a base unit (bigint, LoHi, Amount, ValueView) + * or a number with decimals for display purposes (string, number) + * + * This function handles all these cases automatically internally + * - when input is a bigint, LoHi, Amount, or ValueView, it is assumed to be in base units + * - when input is a string or number, it is multiplied by 10^exponent and converted to base units + * + * Likewise for all methods, the outputs are + * - in base units for bigint, LoHi, Amount and ValueView + * - in display form with decimals for string and number + * + * @param input + * @param optionsOrExponent + */ +function pnum( + input?: string | number | LoHi | bigint | Amount | ValueView | undefined, + optionsOrExponent: { exponent?: number } | number = { exponent: 0 }, +) { + let value: BigNumber; + let exponent = + typeof optionsOrExponent === 'number' ? optionsOrExponent : (optionsOrExponent.exponent ?? 0); + + if (typeof input === 'string' || typeof input === 'number') { + value = new BigNumber(input).shiftedBy(exponent); + } else if (typeof input === 'bigint') { + value = new BigNumber(input.toString()); + } else if (input instanceof ValueView) { + const amount = getAmount(input); + value = new BigNumber(joinLoHi(amount.lo, amount.hi).toString()); + exponent = + input.valueView.case === 'knownAssetId' ? getDisplayDenomExponentFromValueView(input) : 0; + } else if ( + input instanceof Amount || + (typeof input === 'object' && + 'lo' in input && + 'hi' in input && + typeof input.lo === 'bigint' && + typeof input.hi === 'bigint') + ) { + value = new BigNumber(joinLoHi(input.lo, input.hi).toString()); + } else { + value = new BigNumber(0); + } + + return { + toNumber(): number { + const number = value.shiftedBy(-exponent).toNumber(); + if (!Number.isFinite(number)) { + throw new Error('Number exceeds JavaScript numeric limits, convert to other type instead.'); + } + return number; + }, + + toRoundedNumber(decimals = exponent): number { + const number = value.shiftedBy(-exponent).toNumber(); + if (!Number.isFinite(number)) { + throw new Error('Number exceeds JavaScript numeric limits, convert to other type instead.'); + } + return Number(round({ value: number, decimals })); + }, + + toString(): string { + return value.shiftedBy(-exponent).toString(); + }, + + toRoundedString(decimals = exponent): string { + return round({ value: value.shiftedBy(-exponent).toString(), decimals, trailingZeros: true }); + }, + + toFormattedString( + options: { + commas?: boolean; + decimals?: number; + trailingZeros?: boolean; + } = {}, + ): string { + const defaultOptions = { + commas: true, + decimals: exponent, + trailingZeros: true, + }; + + const { commas, decimals, trailingZeros } = { + ...defaultOptions, + ...options, + }; + + const number = value.shiftedBy(-exponent).toFormat(decimals, { + decimalSeparator: '.', + groupSeparator: commas ? ',' : '', + groupSize: 3, + }); + + return trailingZeros ? number : removeTrailingZeros(number); + }, + + toBigInt(): bigint { + return BigInt(value.toFixed(0)); + }, + + toBigNumber(): BigNumber { + return value.shiftedBy(-exponent); + }, + + toLoHi(): LoHi { + return splitLoHi(BigInt(value.toFixed(0))); + }, + + toAmount(): Amount { + return new Amount(splitLoHi(BigInt(value.toFixed(0)))); + }, + }; +} + +export { pnum }; diff --git a/packages/types/src/round.ts b/packages/types/src/round.ts index f80282a5af..5337f0aa3e 100644 --- a/packages/types/src/round.ts +++ b/packages/types/src/round.ts @@ -4,9 +4,10 @@ import { removeTrailingZeros } from './shortify.js'; export type RoundingMode = 'half-up' | 'up' | 'down'; export interface RoundOptions { - value: number; + value: number | string; decimals: number; roundingMode?: RoundingMode; + trailingZeros?: boolean; } const EXPONENTIAL_NOTATION_THRESHOLD = new Decimal('1e21'); @@ -34,7 +35,12 @@ const getDecimalRoundingMode = (mode: RoundingMode): Decimal.Rounding => { * - down: Rounds towards zero * - up: Rounds way from zero */ -export function round({ value, decimals, roundingMode = 'half-up' }: RoundOptions): string { +export function round({ + value, + decimals, + roundingMode = 'half-up', + trailingZeros = false, +}: RoundOptions): string { const decimalValue = new Decimal(value); // Determine if exponential notation is needed @@ -53,5 +59,9 @@ export function round({ value, decimals, roundingMode = 'half-up' }: RoundOption result = roundedDecimal.toFixed(decimals, getDecimalRoundingMode(roundingMode)); } + if (trailingZeros) { + return result; + } + return removeTrailingZeros(result); }