Skip to content

Commit

Permalink
Add pnum (#1935)
Browse files Browse the repository at this point in the history
* Add pnum

* Add changeset

* Refactor + add test cases

* Remove eslint comment in test file

* Refactor 2 arg to be number or options

* Rename 2nd arg for pnum

* Rename 2nd arg in jsdoc as well
  • Loading branch information
JasonMHasperhoven authored Dec 5, 2024
1 parent b2c408e commit 712e7b1
Show file tree
Hide file tree
Showing 4 changed files with 282 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/modern-taxis-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/types': minor
---

Add pnum
139 changes: 139 additions & 0 deletions packages/types/src/pnum.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
126 changes: 126 additions & 0 deletions packages/types/src/pnum.ts
Original file line number Diff line number Diff line change
@@ -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 };
14 changes: 12 additions & 2 deletions packages/types/src/round.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand All @@ -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);
}

0 comments on commit 712e7b1

Please sign in to comment.