Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/sdk-coin-sol/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ export {
resolvePermissionlessThaw,
resolveTransferHookAccounts,
} from './token2022Resolve';
export { rawToUiAmountString, uiAmountToRaw } from './scaledUiAmount';
140 changes: 140 additions & 0 deletions modules/sdk-coin-sol/src/lib/scaledUiAmount.ts
Original file line number Diff line number Diff line change
@@ -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
}
112 changes: 112 additions & 0 deletions modules/sdk-coin-sol/test/unit/scaledUiAmount.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
});
});
});
Loading