From cfc42606c6878e810728af36e6cb3232362a6c51 Mon Sep 17 00:00:00 2001 From: Abhishek Agrawal Date: Wed, 16 Sep 2026 12:12:34 +0530 Subject: [PATCH] feat(sdk-coin-sol): add scaled-UI uiAmount/raw conversion util TICKET: CSHLD-1688 --- modules/sdk-coin-sol/src/lib/index.ts | 1 + .../sdk-coin-sol/src/lib/scaledUiAmount.ts | 140 ++++++++++++++++++ .../sdk-coin-sol/test/unit/scaledUiAmount.ts | 112 ++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 modules/sdk-coin-sol/src/lib/scaledUiAmount.ts create mode 100644 modules/sdk-coin-sol/test/unit/scaledUiAmount.ts diff --git a/modules/sdk-coin-sol/src/lib/index.ts b/modules/sdk-coin-sol/src/lib/index.ts index d1b0543f73..70db21e7e3 100644 --- a/modules/sdk-coin-sol/src/lib/index.ts +++ b/modules/sdk-coin-sol/src/lib/index.ts @@ -36,3 +36,4 @@ export { resolvePermissionlessThaw, resolveTransferHookAccounts, } from './token2022Resolve'; +export { rawToUiAmountString, uiAmountToRaw } from './scaledUiAmount'; diff --git a/modules/sdk-coin-sol/src/lib/scaledUiAmount.ts b/modules/sdk-coin-sol/src/lib/scaledUiAmount.ts new file mode 100644 index 0000000000..baa62108a8 --- /dev/null +++ b/modules/sdk-coin-sol/src/lib/scaledUiAmount.ts @@ -0,0 +1,140 @@ +/** + * Copyright 2026 BitGo, Inc. All Rights Reserved. + */ + +/** + * Solana Token-2022 ScaledUiAmount conversion utilities (CSHLD-1688). + * + * A scaled-UI mint carries a `UiAmountMultiplier`. On-chain (and therefore in + * every send/build path of this SDK) amounts are raw base units; the UI + * denomination is `raw * multiplier / 10^decimals`, matching the SPL + * token-2022 ScaledUiAmount extension semantics. These utilities exist for + * display, reconciliation, and balance conversions only — they never feed + * transaction building, which stays raw-denominated. + * + * All arithmetic is exact scaled-integer (BigInt) math — floats are never used, + * so conversions are deterministic across runtimes and lose no precision: + * `rawToUiAmountString` is always exact (the divisor is a power of ten, so the + * exact expansion fits within `multiplierScale + decimals` fraction digits). + * `uiAmountToRaw` floors to an integer number of base units and is the exact + * inverse of `rawToUiAmountString` whenever the ui string is exact. + */ + +/** Significand and scale of a decimal literal: value = ±digits / 10^scale. */ +interface ParsedDecimal { + negative: boolean; + digits: bigint; + scale: number; +} + +const DECIMAL_LITERAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/; + +/** + * Parse an exact decimal string into scaled-integer parts. Rejects scientific + * notation, empty strings, NaN/Infinity, and non-numeric input. + * + * @param value The decimal literal, e.g. "0.001"; "1e-9" must be written as "0.000000001" + */ +function parseDecimal(value: string, what: string): ParsedDecimal { + if (typeof value !== 'string' || !DECIMAL_LITERAL_PATTERN.test(value)) { + throw new Error(`Invalid ${what} '${value}': expected a finite decimal string`); + } + const negative = value.startsWith('-'); + const unsigned = negative || value.startsWith('+') ? value.slice(1) : value; + const [intPart = '0', fracPart = ''] = unsigned.split('.'); + const digits = BigInt(intPart + fracPart || '0'); + return { negative, digits, scale: fracPart.length }; +} + +/** Render ±digits/10^scale as a normalized decimal string (no trailing zeros, no "-0"). */ +function toDecimalString({ negative, digits, scale }: ParsedDecimal): string { + if (digits === 0n) { + return '0'; + } + let s = digits.toString().padStart(scale + 1, '0'); + if (scale > 0) { + s = `${s.slice(0, -scale)}.${s.slice(-scale)}`; + s = s.replace(/0+$/, '').replace(/\.$/, ''); + } + return negative ? `-${s}` : s; +} + +/** + * Convert a raw base-unit amount to its scaled UI denomination. + * + * Exact by construction: the result is `raw * multiplier / 10^decimals`, whose + * exact decimal expansion never exceeds `multiplier scale + decimals` fraction + * digits, so no rounding ever occurs. + * + * @param raw Amount in raw base units (integer, >= 0) + * @param multiplier The mint's UiAmountMultiplier as a decimal string (> 0) + * @param decimals The mint's decimals (>= 0) + * @returns The exact UI amount as a decimal string + * @throws if any argument is malformed, the multiplier is not positive, or raw/decimals are negative + */ +export function rawToUiAmountString(raw: bigint, multiplier: string, decimals: number): string { + if (typeof raw !== 'bigint') { + throw new Error(`Invalid raw amount '${String(raw)}': expected a BigInt of base units`); + } + if (raw < 0n) { + throw new Error(`Invalid raw amount '${raw}': must be non-negative`); + } + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error(`Invalid decimals '${decimals}': must be a non-negative integer`); + } + const mult = parseDecimal(multiplier, 'multiplier'); + if (mult.negative || mult.digits === 0n) { + throw new Error(`Invalid multiplier '${multiplier}': must be positive`); + } + + // ui = raw * (multDigits / 10^multScale) / 10^decimals + // = (raw * multDigits) / 10^(multScale + decimals) — exact, terminating expansion + const numerator = raw * mult.digits; + const fractionDigits = mult.scale + decimals; + const denominator = 10n ** BigInt(fractionDigits); + + const quotient = numerator / denominator; + const remainder = numerator % denominator; + if (remainder === 0n) { + return toDecimalString({ negative: false, digits: quotient, scale: 0 }); + } + // The denominator IS 10^fractionDigits, so the exact fraction digits are `remainder` + // zero-padded to fractionDigits — no long division needed. + return toDecimalString({ + negative: false, + digits: quotient * 10n ** BigInt(fractionDigits) + remainder, + scale: fractionDigits, + }); +} + +/** + * Convert a scaled UI denomination to raw base units. Inverse of + * {@link rawToUiAmountString}: flooring an exact ui string reproduces the + * original raw amount. + * + * @param uiAmount The UI amount as a decimal string (>= 0) + * @param multiplier The mint's UiAmountMultiplier as a decimal string (> 0) + * @param decimals The mint's decimals (>= 0) + * @returns The raw amount in base units, floored to the integer unit + * @throws if any argument is malformed, the ui amount is negative, or the multiplier is not positive + */ +export function uiAmountToRaw(uiAmount: string, multiplier: string, decimals: number): bigint { + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error(`Invalid decimals '${decimals}': must be a non-negative integer`); + } + const ui = parseDecimal(uiAmount, 'uiAmount'); + if (ui.negative) { + throw new Error(`Invalid uiAmount '${uiAmount}': must be non-negative`); + } + const mult = parseDecimal(multiplier, 'multiplier'); + if (mult.negative || mult.digits === 0n) { + throw new Error(`Invalid multiplier '${multiplier}': must be positive`); + } + + // ui = uiDigits / 10^uiScale; multiplier = multDigits / 10^multScale + // raw = (uiDigits / 10^uiScale) * 10^decimals * (10^multScale / multDigits) + // = (uiDigits * 10^(decimals + multScale)) / (multDigits * 10^uiScale) + const numerator = ui.digits * 10n ** BigInt(decimals + mult.scale); + const denominator = mult.digits * 10n ** BigInt(ui.scale); + return numerator / denominator; // floor — raw base units are integers +} diff --git a/modules/sdk-coin-sol/test/unit/scaledUiAmount.ts b/modules/sdk-coin-sol/test/unit/scaledUiAmount.ts new file mode 100644 index 0000000000..a580ede2f7 --- /dev/null +++ b/modules/sdk-coin-sol/test/unit/scaledUiAmount.ts @@ -0,0 +1,112 @@ +import 'should'; +import { rawToUiAmountString, uiAmountToRaw } from '../../src/lib/scaledUiAmount'; + +describe('Sol ScaledUiAmount conversions', function () { + describe('rawToUiAmountString', function () { + it('converts raw to UI with multiplier 1 (identity denomination)', function () { + rawToUiAmountString(1_000_000_000n, '1', 9).should.equal('1'); + rawToUiAmountString(1n, '1', 9).should.equal('0.000000001'); + rawToUiAmountString(0n, '1', 9).should.equal('0'); + }); + + it('converts raw to UI with multiplier 0.001', function () { + // 10^9 raw * 0.001 / 10^9 = 0.001 + rawToUiAmountString(1_000_000_000n, '0.001', 9).should.equal('0.001'); + // 1 raw * 0.001 / 10^9 = 10^-12 + rawToUiAmountString(1n, '0.001', 9).should.equal('0.000000000001'); + }); + + it('converts raw to UI with multiplier 1e-9 (ticket extreme)', function () { + // 10^9 raw * 10^-9 / 10^9 = 10^-9 + rawToUiAmountString(1_000_000_000n, '0.000000001', 9).should.equal('0.000000001'); + rawToUiAmountString(1n, '0.000000001', 9).should.equal('0.000000000000000001'); + }); + + it('handles multipliers greater than 1', function () { + // 2 raw * 1.5 / 10^2 = 0.03 + rawToUiAmountString(2n, '1.5', 2).should.equal('0.03'); + // 100 raw * 12.5 / 10^2 = 12.5 + rawToUiAmountString(100n, '12.5', 2).should.equal('12.5'); + }); + + it('normalizes trailing zeros', function () { + rawToUiAmountString(500_000_000n, '1', 9).should.equal('0.5'); + rawToUiAmountString(1_230_000_000n, '1', 9).should.equal('1.23'); + }); + + it('rejects malformed inputs', function () { + (() => rawToUiAmountString(-1n, '1', 9)).should.throw(/must be non-negative/); + (() => rawToUiAmountString(1n, '0', 9)).should.throw(/must be positive/); + (() => rawToUiAmountString(1n, '-0.001', 9)).should.throw(/must be positive/); + (() => rawToUiAmountString(1n, '1e-9', 9)).should.throw(/finite decimal string/); + (() => rawToUiAmountString(1n, 'abc', 9)).should.throw(/finite decimal string/); + (() => rawToUiAmountString(1n, 'NaN', 9)).should.throw(/finite decimal string/); + (() => rawToUiAmountString(1n, '1', -1)).should.throw(/non-negative integer/); + (() => rawToUiAmountString(1n, '1', 1.5)).should.throw(/non-negative integer/); + // @ts-expect-error runtime guard against non-BigInt raw + (() => rawToUiAmountString(1000, '1', 9)).should.throw(/BigInt of base units/); + }); + }); + + describe('uiAmountToRaw', function () { + it('converts UI to raw with multiplier 1', function () { + uiAmountToRaw('1', '1', 9).should.equal(1_000_000_000n); + uiAmountToRaw('0.000000001', '1', 9).should.equal(1n); + uiAmountToRaw('0', '1', 9).should.equal(0n); + }); + + it('converts UI to raw with multiplier 0.001', function () { + uiAmountToRaw('0.001', '0.001', 9).should.equal(1_000_000_000n); + uiAmountToRaw('0.000000000001', '0.001', 9).should.equal(1n); + }); + + it('converts UI to raw with multiplier 1e-9 (ticket extreme)', function () { + uiAmountToRaw('0.000000001', '0.000000001', 9).should.equal(1_000_000_000n); + uiAmountToRaw('0.000000000000000001', '0.000000001', 9).should.equal(1n); + }); + + it('floors to integer base units', function () { + // 1.0000000005 UI * 10^9 = 1000000000.5 raw -> floors to 1000000000 + uiAmountToRaw('1.0000000005', '1', 9).should.equal(1_000_000_000n); + }); + + it('rejects malformed inputs', function () { + (() => uiAmountToRaw('-1', '1', 9)).should.throw(/must be non-negative/); + (() => uiAmountToRaw('1', '0', 9)).should.throw(/must be positive/); + (() => uiAmountToRaw('1e-9', '1', 9)).should.throw(/finite decimal string/); + (() => uiAmountToRaw('1', '1', -1)).should.throw(/non-negative integer/); + }); + }); + + describe('round-trip exactness at multiplier extremes', function () { + const multipliers = ['1', '0.001', '0.000000001', '1.5', '12.5']; + const rawAmounts = [ + 0n, + 1n, + 999n, + 1_000n, + 123_456_789n, + 1_000_000_000n, + 999_999_999_999_999_999n, + 10n ** 27n, // > u64 max supply, still exact + ]; + + for (const multiplier of multipliers) { + it(`round-trips every raw amount through UI at multiplier ${multiplier}`, function () { + for (const raw of rawAmounts) { + const ui = rawToUiAmountString(raw, multiplier, 9); + uiAmountToRaw(ui, multiplier, 9).should.equal(raw); + } + }); + } + + it('round-trips at decimals other than 9', function () { + for (const decimals of [0, 2, 6, 18]) { + for (const raw of rawAmounts) { + const ui = rawToUiAmountString(raw, '0.001', decimals); + uiAmountToRaw(ui, '0.001', decimals).should.equal(raw); + } + } + }); + }); +});