@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
87 lines (78 loc) • 3.45 kB
text/typescript
import { InvalidValueError } from "./errors"
/**
* Decimal places a `dec` value is scaled by. Fixed by the protocol, so any two decimals are
* directly comparable — and range-indexable — as plain integers.
*/
export const DECIMAL_SCALE = 18
/** 10 ** {@link DECIMAL_SCALE} — the scaling factor between a `dec` and its integer units. */
export const DECIMAL_UNIT = 10n ** BigInt(DECIMAL_SCALE)
/** The widest scaled value an `int256` holds. */
const INT256_MAX = 2n ** 255n - 1n
/** The narrowest scaled value an `int256` holds. */
const INT256_MIN = -(2n ** 255n)
/**
* A decimal literal: an optional sign, at least one integer digit, and an optional fraction of at
* most {@link DECIMAL_SCALE} digits. Exponents are not part of the grammar — `1e18` is a parse
* error rather than a silent reinterpretation.
*/
const DECIMAL_RE = /^([+-]?)(\d+)(?:\.(\d+))?$/
/**
* Parses a decimal literal into its scaled integer units.
*
* Excess fractional precision is an **error, never a rounding**: the protocol stores exactly 18
* decimal places, and silently dropping the 19th digit would make a stored value differ from the
* one that was written.
*
* @param input - The decimal literal, e.g. `"3.5"` or `"-0.000001"`.
* @returns The value scaled by 10 ** 18.
* @throws {InvalidValueError} If the literal is malformed, too precise, or out of `int256` range.
*/
export function decimalToUnits(input: string): bigint {
const match = DECIMAL_RE.exec(input)
if (!match) {
throw new InvalidValueError(
"dec",
input,
"not a decimal literal",
'Write it as sign, digits, and an optional "." with up to 18 fractional digits — e.g. ' +
'dec("3.5") or dec("-0.25"). Exponents such as "1e18" are not accepted.',
)
}
const [, sign, whole, fraction = ""] = match
if (fraction.length > DECIMAL_SCALE) {
throw new InvalidValueError(
"dec",
input,
`${fraction.length} fractional digits exceeds the ${DECIMAL_SCALE} the protocol stores`,
"Round it yourself before storing — a dec is never rounded for you, so that what you " +
"write is exactly what is stored.",
)
}
const digits = BigInt(whole + fraction.padEnd(DECIMAL_SCALE, "0"))
const units = sign === "-" ? -digits : digits
if (units > INT256_MAX || units < INT256_MIN) {
throw new InvalidValueError("dec", input, "exceeds the int256 range a dec is stored in")
}
return units
}
/**
* Renders scaled integer units back as a canonical decimal string: no leading zeros, no trailing
* fractional zeros, no `-0`. Parsing the result yields the same units, so this is the inverse of
* {@link decimalToUnits} on every value it accepts.
*
* @param units - The value scaled by 10 ** 18.
* @returns The canonical decimal string, e.g. `"3.5"`.
* @throws {InvalidValueError} If the units are outside the `int256` range.
*/
export function unitsToDecimal(units: bigint): string {
if (units > INT256_MAX || units < INT256_MIN) {
throw new InvalidValueError("dec", units, "exceeds the int256 range a dec is stored in")
}
const negative = units < 0n
const magnitude = negative ? -units : units
const whole = magnitude / DECIMAL_UNIT
const fraction = (magnitude % DECIMAL_UNIT).toString().padStart(DECIMAL_SCALE, "0")
const trimmed = fraction.replace(/0+$/, "")
const sign = negative ? "-" : ""
return trimmed ? `${sign}${whole}.${trimmed}` : `${sign}${whole}`
}