Skip to content
Open
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
166 changes: 134 additions & 32 deletions clients/js-legacy/src/actions/amountToUiAmount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,123 @@ function getDecimalFactor(decimals: number): number {
return Math.pow(10, decimals);
}

const U64_MAX = BigInt('18446744073709551615');

// Matches Rust's f64 grammar: optional sign, digits with optional fraction,
// optional exponent, or inf/infinity/nan.
const F64_STRING_PATTERN = /^[+-]?(?:inf(?:inity)?|nan|(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)$/i;

/**
* Convert a UI amount to an atomic amount by removing decimal scaling
* For example, converts "1.234" with 3 decimals to 1234 (atomic units)
*
* @param uiAmount UI Amount to be converted to atomic UI amount
* @param decimals Number of decimals for the mint
*
* @return Atomic UI amount
* Parses a UI amount string the way the program parses an `f64`.
* @throws An error if the string is not a valid float representation.
*/
function parseUiAmountF64(uiAmount: string): number {
if (!F64_STRING_PATTERN.test(uiAmount)) {
throw new Error(`Invalid ui amount: ${uiAmount}`);
}
if (/inf/i.test(uiAmount)) {
return uiAmount.startsWith('-') ? -Infinity : Infinity;
}
return Number(uiAmount);
}

/**
* Trims trailing zeros and a dangling decimal point, mirroring the program's
* `trim_ui_amount_string`.
*/
function trimUiAmountString(uiAmount: string, decimals: number): string {
if (decimals > 0 && uiAmount.includes('.')) {
return uiAmount.replace(/0+$/, '').replace(/\.$/, '');
}
return uiAmount;
}

/**
* Formats a scaled f64 to `decimals` digits and trims, mirroring the
* program's `format!("{scaled_amount:.*}", decimals)` followed by trimming.
* Rounds half to even on the exact binary value of the double, like Rust,
* which `toFixed` (round half up) does not.
*/
function uiAmountToAtomicUiAmount(uiAmount: string, decimals: number): number {
const uiAmountNumber = parseFloat(uiAmount);
const decimalFactor = getDecimalFactor(decimals);
return uiAmountNumber * decimalFactor;
function formatUiAmountString(value: number, decimals: number): string {
if (!Number.isFinite(value)) {
return Number.isNaN(value) ? 'NaN' : value > 0 ? 'inf' : '-inf';
}
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, Math.abs(value));
const bits = view.getBigUint64(0);
const biasedExponent = Number((bits >> BigInt(52)) & BigInt(0x7ff));
const fractionBits = bits & BigInt('0xfffffffffffff');
const mantissa = biasedExponent === 0 ? fractionBits : fractionBits | (BigInt(1) << BigInt(52));
const exponent = (biasedExponent === 0 ? -1074 : biasedExponent - 1075) + 0;
// |value| * 10^decimals as the exact fraction numerator/denominator
let numerator = mantissa * BigInt(10) ** BigInt(decimals);
let denominator = BigInt(1);
if (exponent >= 0) {
numerator <<= BigInt(exponent);
} else {
denominator = BigInt(1) << BigInt(-exponent);
}
let quotient = numerator / denominator;
const doubledRemainder = (numerator % denominator) * BigInt(2);
if (doubledRemainder > denominator || (doubledRemainder === denominator && (quotient & BigInt(1)) === BigInt(1))) {
quotient += BigInt(1);
}
const sign = value < 0 && quotient > BigInt(0) ? '-' : '';
if (decimals === 0) {
return sign + quotient.toString();
}
const digits = quotient.toString().padStart(decimals + 1, '0');
const fixed = `${sign}${digits.slice(0, -decimals)}.${digits.slice(-decimals)}`;
return trimUiAmountString(fixed, decimals);
}

/**
* Converts a raw token amount to a UI amount string exactly, mirroring the
* program's `amount_to_ui_amount_string_trimmed`. Exact for the full u64
* range, unlike float division.
*/
function amountToUiAmountStringTrimmed(amount: bigint, decimals: number): string {
if (decimals === 0) {
return amount.toString();
}
const digits = amount.toString().padStart(decimals + 1, '0');
return trimUiAmountString(`${digits.slice(0, -decimals)}.${digits.slice(-decimals)}`, decimals);
}

/**
* Converts a UI amount string to a raw token amount exactly, mirroring the
* program's `try_ui_amount_into_amount` for standard mints.
* @throws An error if the string is malformed or out of the u64 range.
*/
function uiAmountStringToAmount(uiAmount: string, decimals: number): bigint {
const parts = uiAmount.split('.');
const fraction = (parts[1] ?? '').replace(/0+$/, '');
if (parts.length > 2 || fraction.length > decimals) {
throw new Error(`Invalid ui amount: ${uiAmount}`);
}
const digits = parts[0] + fraction + '0'.repeat(decimals - fraction.length);
if (!/^\+?\d+$/.test(digits)) {
throw new Error(`Invalid ui amount: ${uiAmount}`);
}
const amount = BigInt(digits.replace('+', ''));
if (amount > U64_MAX) {
throw new Error(`Invalid ui amount: ${uiAmount}`);
}
return amount;
}

/**
* Range-checks and converts a scaled f64 back to a u64 amount, mirroring the
* program's checks and its saturating `as u64` cast. `u64::MAX as f64` rounds
* up to 2^64, so the check admits values the cast then saturates to u64::MAX.
* @throws An error if the amount is negative, NaN or above the u64 range.
*/
function f64AmountToU64(amount: number, mode: 'round' | 'trunc'): bigint {
if (Number.isNaN(amount) || amount < 0 || amount > Number(U64_MAX)) {
throw new Error(`Amount out of range: ${amount}`);
}
const integral = BigInt(mode === 'round' ? Math.round(amount) : Math.trunc(amount));
return integral > U64_MAX ? U64_MAX : integral;
}

/**
Expand Down Expand Up @@ -144,14 +248,10 @@ export function amountToUiAmountForInterestBearingMintWithoutSimulation(
// Calculate total scale factor
const totalScale = preUpdateExp * postUpdateExp;

// Scale the amount by the total interest factor
const scaledAmount = Number(amount) * totalScale;

// Calculate the decimal factor (e.g. 100 for 2 decimals)
const decimalFactor = getDecimalFactor(decimals);

// Convert to UI amount by truncating and dividing by decimal factor
return (Math.trunc(scaledAmount) / decimalFactor).toString();
// Scale by the total interest factor, which includes the decimal factor
// on-chain, then format to the mint's decimals like the program does
const scaledAmount = Number(amount) * (totalScale / getDecimalFactor(decimals));
return formatUiAmountString(scaledAmount, decimals);
}

/**
Expand All @@ -166,9 +266,10 @@ export function amountToUiAmountForScaledUiAmountMintWithoutSimulation(
decimals: number,
multiplier: number,
): string {
const scaledAmount = Number(amount) * multiplier;
const decimalFactor = getDecimalFactor(decimals);
return (Math.trunc(scaledAmount) / decimalFactor).toString();
// Scale by the total multiplier, which includes the decimal factor
// on-chain, then format to the mint's decimals like the program does
const scaledAmount = Number(amount) * (multiplier / getDecimalFactor(decimals));
return formatUiAmountString(scaledAmount, decimals);
}

/**
Expand Down Expand Up @@ -203,8 +304,7 @@ export async function amountToUiAmountForMintWithoutSimulation(

// Standard conversion for regular mints
if (!interestBearingMintConfigState && !scaledUiAmountConfig) {
const decimalFactor = getDecimalFactor(mintInfo.decimals);
return (Number(amount) / decimalFactor).toString();
return amountToUiAmountStringTrimmed(amount, mintInfo.decimals);
}

// Get timestamp only if needed for special mint types
Expand Down Expand Up @@ -260,7 +360,7 @@ export function uiAmountToAmountForInterestBearingMintWithoutSimulation(
preUpdateAverageRate: number,
currentRate: number,
): bigint {
const uiAmountScaled = uiAmountToAtomicUiAmount(uiAmount, decimals);
const uiAmountNumber = parseUiAmountF64(uiAmount);

// Calculate pre-update exponent
const preUpdateExp = calculateExponentForTimesAndRate(
Expand All @@ -275,9 +375,10 @@ export function uiAmountToAmountForInterestBearingMintWithoutSimulation(
// Calculate total scale
const totalScale = preUpdateExp * postUpdateExp;

// Calculate original principal by dividing the UI amount (principal + interest) by the total scale
const originalPrincipal = uiAmountScaled / totalScale;
return BigInt(Math.trunc(originalPrincipal));
// Divide by the total scale, which includes the decimal factor on-chain.
// The program rounds rather than truncates on this path.
const originalPrincipal = uiAmountNumber / (totalScale / getDecimalFactor(decimals));
return f64AmountToU64(originalPrincipal, 'round');
}

/**
Expand All @@ -295,9 +396,10 @@ export function uiAmountToAmountForScaledUiAmountMintWithoutSimulation(
decimals: number,
multiplier: number,
): bigint {
const uiAmountScaled = uiAmountToAtomicUiAmount(uiAmount, decimals);
const rawAmount = uiAmountScaled / multiplier;
return BigInt(Math.trunc(rawAmount));
const uiAmountNumber = parseUiAmountF64(uiAmount);
// Divide by the total multiplier, which includes the decimal factor on-chain
const rawAmount = uiAmountNumber / (multiplier / getDecimalFactor(decimals));
return f64AmountToU64(rawAmount, 'trunc');
}

/**
Expand Down Expand Up @@ -330,7 +432,7 @@ export async function uiAmountToAmountForMintWithoutSimulation(

if (!interestBearingMintConfigState && !scaledUiAmountConfig) {
// Standard conversion for regular mints
return BigInt(Math.trunc(uiAmountToAtomicUiAmount(uiAmount, mintInfo.decimals)));
return uiAmountStringToAmount(uiAmount, mintInfo.decimals);
}

const timestamp = await getSysvarClockTimestamp(connection);
Expand Down
53 changes: 47 additions & 6 deletions clients/js-legacy/test/unit/interestBearing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ describe('amountToUiAmountForMintWithoutSimulation', () => {
{ decimals: 0, amount: BigInt(100), expected: '100' },
{ decimals: 2, amount: BigInt(100), expected: '1' },
{ decimals: 9, amount: BigInt(1000000000), expected: '1' },
{ decimals: 10, amount: BigInt(1), expected: '1e-10' },
{ decimals: 10, amount: BigInt(1), expected: '0.0000000001' },
{ decimals: 10, amount: BigInt(1000000000), expected: '0.1' },
];

Expand All @@ -165,8 +165,8 @@ describe('amountToUiAmountForMintWithoutSimulation', () => {
const testCases = [
{ decimals: 0, amount: BigInt(1), expected: '1' },
{ decimals: 1, amount: BigInt(1), expected: '0.1' },
{ decimals: 10, amount: BigInt(1), expected: '1e-10' },
{ decimals: 10, amount: BigInt(10000000000), expected: '1.0512710963' },
{ decimals: 10, amount: BigInt(1), expected: '0.0000000001' },
{ decimals: 10, amount: BigInt(10000000000), expected: '1.0512710964' },
];

for (const { decimals, amount, expected } of testCases) {
Expand Down Expand Up @@ -229,7 +229,7 @@ describe('amountToUiAmountForMintWithoutSimulation', () => {
mint,
BigInt('18446744073709551615'),
);
expect(result).to.equal('20386805083448.098');
expect(result).to.equal('20386805083448.097656');
});
});

Expand Down Expand Up @@ -294,7 +294,7 @@ describe('amountToUiAmountForMintWithoutSimulation', () => {
mint,
'0.951229424500714',
);
expect(result).to.equal(9999999999n); // calculation truncates to avoid floating point precision issues in transfers
expect(result).to.equal(10000000000n); // the program rounds on this path
});

it('should return the correct amount for netting out rates', async () => {
Expand Down Expand Up @@ -322,6 +322,47 @@ describe('amountToUiAmountForMintWithoutSimulation', () => {
mint,
'20386805083448100000',
);
expect(result).to.equal(18446744073709551616n);
expect(result).to.equal(18446744073709551615n);
});
});

describe('plain mint exact conversions', () => {
let connection: MockConnection;
const mint = new PublicKey('So11111111111111111111111111111111111111112');

beforeEach(() => {
connection = new MockConnection() as unknown as MockConnection;
connection.setAccountInfo({
owner: TOKEN_2022_PROGRAM_ID,
lamports: 1000000,
data: createMockMintData(9, false),
});
});

it('converts amounts exactly across the u64 range', async () => {
const result = await amountToUiAmountForMintWithoutSimulation(
connection as unknown as Connection,
mint,
18446744073709551615n,
);
expect(result).to.equal('18446744073.709551615');
const roundTrip = await uiAmountToAmountForMintWithoutSimulation(
connection as unknown as Connection,
mint,
'18446744073.709551615',
);
expect(roundTrip).to.equal(18446744073709551615n);
});

it('rejects malformed or out-of-range ui amounts', async () => {
for (const badUiAmount of ['1e30', 'abc', '1.5oops', '99999999999.999999999']) {
let threw = false;
try {
await uiAmountToAmountForMintWithoutSimulation(connection as unknown as Connection, mint, badUiAmount);
} catch {
threw = true;
}
expect(threw, badUiAmount).to.equal(true);
}
});
});
20 changes: 19 additions & 1 deletion clients/js-legacy/test/unit/scaledAmount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,25 @@ describe('Scaled UI Amount Extension', () => {
mint,
BigInt(Number.MAX_SAFE_INTEGER),
);
expect(result).to.equal('Infinity');
expect(result).to.equal('inf');
});
});
});

describe('exact conversions matching the on-chain program', () => {
it('shows sub-unit amounts for multipliers below one', () => {
expect(amountToUiAmountForScaledUiAmountMintWithoutSimulation(500n, 6, 0.001)).to.equal('0.000001');
expect(amountToUiAmountForScaledUiAmountMintWithoutSimulation(1500n, 6, 0.001)).to.equal('0.000002');
expect(amountToUiAmountForScaledUiAmountMintWithoutSimulation(1n, 2, 0.5)).to.equal('0.01');
});

it('rejects malformed ui amounts', () => {
for (const badUiAmount of ['abc', '1.5oops', '', '0x10']) {
expect(() => uiAmountToAmountForScaledUiAmountMintWithoutSimulation(badUiAmount, 2, 0.5)).to.throw();
}
});

it('rejects converted amounts above the u64 range', () => {
expect(() => uiAmountToAmountForScaledUiAmountMintWithoutSimulation('18446744073709551615', 0, 0.1)).to.throw();
});
});
Loading
Loading