solana-web3.js icon indicating copy to clipboard operation
solana-web3.js copied to clipboard

[experimental] A library to make it easy to do math and work with amounts

Open steveluscher opened this issue 1 year ago • 1 comments
trafficstars

The new web3.js leans into JavaScript BigInts and opaque types for amounts like Lamports. While BigInts can prevent truncation errors (see #1116) they are in some ways more difficult to do math on. While the Lamports type gives developers a strong and typesafe signal that a given input or output is in lamports rather than SOL, it offers no way to convert between the two or to prepare one for UI display in the other.

The goal of this issue is to develop an API that allows developers to do both without introducing rounding or precision errors.

Spitballing an API

Values as basis points

Starting with values expressed as an opaque type having basis points and a decimal:

type Value<
  TDecimals extends bigint,
  TBasisPoints extends bigint = bigint
> = [basisPoints: TBasisPoints, decimal: TDecimals];
type LamportsValue = Value<0n>;
type SolValue = Value<9n>;

And coercion functions:

function lamportsValue(putativeLamportsValue: unknown): putativeValue is LamportsValue {
  assertIsValue(putativeValue);  // Array.isArray(v) && v.length === 2 && v.every(vv => typeof vv === 'bigint')
  if (putativeValue[1] !== 0n) {
    throw new SolanaError(...);
  }
  return putativeValue as LamportsValue;
}
function solValue(putativeSolValue: unknown): putativeValue is SolValue {
  assertIsValue(putativeValue);  // Array.isArray(v) && v.length === 2 && v.every(vv => typeof vv === 'bigint')
  if (putativeValue[1] !== 9n) {
    throw new SolanaError(...);
  }
  return putativeValue as SolValue;
}

[!IMPORTANT] Decimals must be 0n or greater.

Math

TODO

Formatting

[!TIP] Apparently we can achieve UI display using Intl.NumberFormat v3 by leaning on scientific notation. Check it out.

const formatter = new Intl.NumberFormat("en-US", {
  currency: "USD",
  minimumFractionDigits: 2,
  roundingMode: 'halfExpand',
  style: "currency",
});
const basisPoints = 100115000n;
const decimals = 6;
formatter.format(`${basisPoints}E-${decimals}`); // -> $100.12

See also

  • https://github.com/tc39/proposal-intl-numberformat-v3#interpret-strings-as-decimals-ecma-402-334
  • https://caniuse.com/mdn-javascript_builtins_intl_numberformat_format_number_parameter-string_decimal

Convert values to decimal strings for formatting.

function valueToDecimalString<TDecimals extends bigint, TBasisPoints extends bigint = bigint>(
    value: Value<TDecimals, TBasisPoints>,
): `${TBasisPoints}E-${TDecimals}` {
    return `${value[0]}E-${value[1]}`;
}

Let callers supply a formatter.

function getFormatter(locale) {
    return new Intl.NumberFormat(locale, {
        maximumFractionDigits: 2,
        roundingMode: 'halfExpand',
        notation: 'compact',
        compactDisplay: 'short',
    });
}
const decimalString = valueToDecimalString([12345671234555321n, 9n]);
`${getFormatter('ja-JP').format(decimalString)} SOL`;
> '1234.57万 SOL'
`${getFormatter('en-US').format(decimalString)} SOL`;
> '12.35M SOL'

Maybe, just maybe, we can make a passthrough:

function format(value: Value<bigint>, ...formatArgs: ConstructorParameters<typeof Intl.NumberFormat>): string {
    // Don't love this architecture though, since it means recreating the `NumberFormat` object on every pass.
    const formatter = new Intl.NumberFormat(...formatArgs);
    const decimalString = valueToDecimalString(value);
    return formatter.format(decimalString);
}

steveluscher avatar Feb 15 '24 22:02 steveluscher