solana-labs / solana-web3.js

Solana JavaScript SDK
https://solana-labs.github.io/solana-web3.js
MIT License
2.19k stars 869 forks source link

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

Open steveluscher opened 8 months ago

steveluscher commented 8 months ago

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

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 commented 8 months ago

Bullish on a string formatting API that looks something like:

function formatLamportsAsSol(formatter: Intl.NumberFormat, lamports: Lamports): string {
    return formatter.format(`${lamports}E-9`);
}

function formatSolAsLamports(formatter: Intl.NumberFormat, solDecimalString: string): string {
    return formatter.format(`${solDecimalString}E9`);
}

Nicely does:

console.log(formatSolAsLamports(formatter, '2.123456789'));
> "2123456789"

^ these are my half-baked thoughts.