From 7ba684eba4dafb9ea184e17039bc69d604eae2ed Mon Sep 17 00:00:00 2001 From: Pluto Han Date: Fri, 24 Jul 2026 16:21:13 +0400 Subject: [PATCH 1/2] js,js-legacy: Match on-chain semantics in amountToUiAmount helpers The conversion helpers in both clients diverged from the program: plain-mint paths lost precision above 2^53 and accepted malformed or out-of-range input, extension paths truncated at base-unit precision so multipliers below one rendered nonzero amounts as "0", and the interest-bearing ui-amount path truncated where the program rounds. Plain-mint conversions now use exact bigint string arithmetic ported from the program. Extension paths scale with the total multiplier like the program, format with an exact round-half-even formatter, validate input, range-check against u64 and saturate like the program's cast. --- .../js-legacy/src/actions/amountToUiAmount.ts | 166 ++++++++++++++---- .../test/unit/interestBearing.test.ts | 53 +++++- .../js-legacy/test/unit/scaledAmount.test.ts | 20 ++- clients/js/src/amountToUiAmount.ts | 162 ++++++++++++++--- .../amountToUiAmount.test.ts | 42 ++++- .../amountToUiAmount.test.ts | 39 +++- 6 files changed, 411 insertions(+), 71 deletions(-) diff --git a/clients/js-legacy/src/actions/amountToUiAmount.ts b/clients/js-legacy/src/actions/amountToUiAmount.ts index 1c720144f..28104ddcf 100644 --- a/clients/js-legacy/src/actions/amountToUiAmount.ts +++ b/clients/js-legacy/src/actions/amountToUiAmount.ts @@ -81,19 +81,123 @@ function getDecimalFactor(decimals: number): number { return Math.pow(10, decimals); } +const U64_MAX = 18446744073709551615n; + +// 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 >> 52n) & 0x7ffn); + const fractionBits = bits & 0xfffffffffffffn; + const mantissa = biasedExponent === 0 ? fractionBits : fractionBits | (1n << 52n); + const exponent = (biasedExponent === 0 ? -1074 : biasedExponent - 1075) + 0; + // |value| * 10^decimals as the exact fraction numerator/denominator + let numerator = mantissa * 10n ** BigInt(decimals); + let denominator = 1n; + if (exponent >= 0) { + numerator <<= BigInt(exponent); + } else { + denominator = 1n << BigInt(-exponent); + } + let quotient = numerator / denominator; + const doubledRemainder = (numerator % denominator) * 2n; + if (doubledRemainder > denominator || (doubledRemainder === denominator && (quotient & 1n) === 1n)) { + quotient += 1n; + } + const sign = value < 0 && quotient > 0n ? '-' : ''; + 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; } /** @@ -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); } /** @@ -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); } /** @@ -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 @@ -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( @@ -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'); } /** @@ -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'); } /** @@ -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); diff --git a/clients/js-legacy/test/unit/interestBearing.test.ts b/clients/js-legacy/test/unit/interestBearing.test.ts index c3bce6897..0ea1be859 100644 --- a/clients/js-legacy/test/unit/interestBearing.test.ts +++ b/clients/js-legacy/test/unit/interestBearing.test.ts @@ -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' }, ]; @@ -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) { @@ -229,7 +229,7 @@ describe('amountToUiAmountForMintWithoutSimulation', () => { mint, BigInt('18446744073709551615'), ); - expect(result).to.equal('20386805083448.098'); + expect(result).to.equal('20386805083448.097656'); }); }); @@ -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 () => { @@ -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); + } }); }); diff --git a/clients/js-legacy/test/unit/scaledAmount.test.ts b/clients/js-legacy/test/unit/scaledAmount.test.ts index 9dbdaa947..aefe99888 100644 --- a/clients/js-legacy/test/unit/scaledAmount.test.ts +++ b/clients/js-legacy/test/unit/scaledAmount.test.ts @@ -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(); + }); +}); diff --git a/clients/js/src/amountToUiAmount.ts b/clients/js/src/amountToUiAmount.ts index e31933104..d28b10396 100644 --- a/clients/js/src/amountToUiAmount.ts +++ b/clients/js/src/amountToUiAmount.ts @@ -74,6 +74,125 @@ function getDecimalFactor(decimals: number): number { return Math.pow(10, decimals); } +const U64_MAX = 18446744073709551615n; + +// 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; + +/** + * 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 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 >> 52n) & 0x7ffn); + const fractionBits = bits & 0xfffffffffffffn; + const mantissa = biasedExponent === 0 ? fractionBits : fractionBits | (1n << 52n); + const exponent = (biasedExponent === 0 ? -1074 : biasedExponent - 1075) + 0; + // |value| * 10^decimals as the exact fraction numerator/denominator + let numerator = mantissa * 10n ** BigInt(decimals); + let denominator = 1n; + if (exponent >= 0) { + numerator <<= BigInt(exponent); + } else { + denominator = 1n << BigInt(-exponent); + } + let quotient = numerator / denominator; + const doubledRemainder = (numerator % denominator) * 2n; + if (doubledRemainder > denominator || (doubledRemainder === denominator && (quotient & 1n) === 1n)) { + quotient += 1n; + } + const sign = value < 0 && quotient > 0n ? '-' : ''; + 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; +} + /** * Retrieves the current timestamp from the Solana clock sysvar. * @param rpc - The Solana rpc object. @@ -133,11 +252,10 @@ export function amountToUiAmountForInterestBearingMintWithoutSimulation( currentRate, }); - // Scale the amount by the total interest factor - const scaledAmount = Number(amount) * totalScale; - const decimalFactor = getDecimalFactor(decimals); - - 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); } /** @@ -177,9 +295,7 @@ export function uiAmountToAmountForInterestBearingMintWithoutSimulation( preUpdateAverageRate: number, currentRate: number, ): bigint { - const uiAmountNumber = parseFloat(uiAmount); - const decimalsFactor = getDecimalFactor(decimals); - const uiAmountScaled = uiAmountNumber * decimalsFactor; + const uiAmountNumber = parseUiAmountF64(uiAmount); const totalScale = calculateTotalScale({ currentTimestamp, @@ -189,9 +305,10 @@ export function uiAmountToAmountForInterestBearingMintWithoutSimulation( currentRate, }); - // Calculate original principal by dividing the UI amount 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'); } // ========== SCALED UI AMOUNT MINT FUNCTIONS ========== @@ -208,9 +325,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); } /** @@ -226,11 +344,10 @@ export function uiAmountToAmountForScaledUiAmountMintWithoutSimulation( decimals: number, multiplier: number, ): bigint { - const uiAmountNumber = parseFloat(uiAmount); - const decimalsFactor = getDecimalFactor(decimals); - const uiAmountScaled = uiAmountNumber * decimalsFactor; - 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'); } // ========== MAIN ENTRY POINT FUNCTIONS ========== @@ -263,9 +380,7 @@ export async function amountToUiAmountForMintWithoutSimulation( // If no special extension, do standard conversion if (!interestBearingMintConfigState && !scaledUiAmountConfig) { - const amountNumber = Number(amount); - const decimalsFactor = getDecimalFactor(accountInfo.data.decimals); - return (amountNumber / decimalsFactor).toString(); + return amountToUiAmountStringTrimmed(amount, accountInfo.data.decimals); } // Get timestamp if needed for special mint types @@ -323,8 +438,7 @@ export async function uiAmountToAmountForMintWithoutSimulation( // If no special extension, do standard conversion if (!interestBearingMintConfigState && !scaledUiAmountConfig) { - const uiAmountScaled = parseFloat(uiAmount) * getDecimalFactor(accountInfo.data.decimals); - return BigInt(Math.trunc(uiAmountScaled)); + return uiAmountStringToAmount(uiAmount, accountInfo.data.decimals); } // Get timestamp if needed for special mint types diff --git a/clients/js/test/extensions/interestBearingMint/amountToUiAmount.test.ts b/clients/js/test/extensions/interestBearingMint/amountToUiAmount.test.ts index f25cf406f..aa44520e9 100644 --- a/clients/js/test/extensions/interestBearingMint/amountToUiAmount.test.ts +++ b/clients/js/test/extensions/interestBearingMint/amountToUiAmount.test.ts @@ -119,7 +119,7 @@ test('should return the correct UiAmount when interest bearing config is not pre { 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' }, ]; @@ -137,8 +137,8 @@ test('should return the correct UiAmount for constant 5% rate', async () => { 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) { @@ -185,7 +185,7 @@ test('should handle huge values correctly', async () => { }); const result = await amountToUiAmountForMintWithoutSimulation(rpc, mint, BigInt('18446744073709551615')); - expect(result).toBe('20386805083448.098'); + expect(result).toBe('20386805083448.097656'); }); test('should return the correct amount for constant 5% rate', async () => { @@ -226,7 +226,7 @@ test('should return the correct amount for constant -5% rate', async () => { }); const result = await uiAmountToAmountForMintWithoutSimulation(rpc, mint, '0.951229424500714'); - expect(result).toBe(9999999999n); // calculation truncates to avoid floating point precision issues in transfers + expect(result).toBe(10000000000n); // the program rounds on this path }); test('should return the correct amount for netting out rates', async () => { @@ -249,5 +249,35 @@ test('should handle huge values correctly for amount to ui amount', async () => }); const result = await uiAmountToAmountForMintWithoutSimulation(rpc, mint, '20386805083448100000'); - expect(result).toBe(18446744073709551616n); + expect(result).toBe(18446744073709551615n); +}); + +test('should convert plain mint amounts exactly across the u64 range', async () => { + const rpc = getMockRpc({ + [clock]: createMockClockAccountInfo(0), + [mint]: createMockMintAccountInfo(9, false), + }); + const result = await amountToUiAmountForMintWithoutSimulation(rpc, mint, 18446744073709551615n); + expect(result).toBe('18446744073.709551615'); + const roundTrip = await uiAmountToAmountForMintWithoutSimulation(rpc, mint, '18446744073.709551615'); + expect(roundTrip).toBe(18446744073709551615n); +}); + +test('should not lose precision above 2^53 for plain mints', async () => { + const rpc = getMockRpc({ + [clock]: createMockClockAccountInfo(0), + [mint]: createMockMintAccountInfo(0, false), + }); + const result = await amountToUiAmountForMintWithoutSimulation(rpc, mint, 9007199254740993n); + expect(result).toBe('9007199254740993'); +}); + +test('should reject malformed or out-of-range plain mint ui amounts', async () => { + const rpc = getMockRpc({ + [clock]: createMockClockAccountInfo(0), + [mint]: createMockMintAccountInfo(0, false), + }); + for (const badUiAmount of ['1e30', 'abc', '1.5oops', '18446744073709551616']) { + await expect(uiAmountToAmountForMintWithoutSimulation(rpc, mint, badUiAmount)).rejects.toThrow(); + } }); diff --git a/clients/js/test/extensions/scaledUiAmountMint/amountToUiAmount.test.ts b/clients/js/test/extensions/scaledUiAmountMint/amountToUiAmount.test.ts index c2db28534..fdcc14158 100644 --- a/clients/js/test/extensions/scaledUiAmountMint/amountToUiAmount.test.ts +++ b/clients/js/test/extensions/scaledUiAmountMint/amountToUiAmount.test.ts @@ -124,7 +124,7 @@ test('should return the correct UiAmount when scaled ui amount config is not pre { 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' }, ]; @@ -328,7 +328,7 @@ test('should handle huge values correctly', async () => { }); const result = await amountToUiAmountForMintWithoutSimulation(rpc, mint, BigInt('18446744073709551615')); - expect(result).toBe('36893488147419.1'); + expect(result).toBe('36893488147419.101562'); }); test('should handle huge values correctly for amount to ui amount', async () => { @@ -340,3 +340,38 @@ test('should handle huge values correctly for amount to ui amount', async () => const result = await uiAmountToAmountForMintWithoutSimulation(rpc, mint, '1844674407370955.16'); expect(result).toBe(922337203685477n); }); + +test('should show sub-unit amounts for multipliers below one', async () => { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(0), + [mint]: createMockMintAccountInfo(6, true, { multiplier: 0.001 }), + }); + expect(await amountToUiAmountForMintWithoutSimulation(rpc, mint, 500n)).toBe('0.000001'); + expect(await amountToUiAmountForMintWithoutSimulation(rpc, mint, 1500n)).toBe('0.000002'); +}); + +test('should show sub-unit amounts for a 0.5 multiplier', async () => { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(0), + [mint]: createMockMintAccountInfo(2, true, { multiplier: 0.5 }), + }); + expect(await amountToUiAmountForMintWithoutSimulation(rpc, mint, 1n)).toBe('0.01'); +}); + +test('should reject malformed ui amounts', async () => { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(0), + [mint]: createMockMintAccountInfo(2, true, { multiplier: 0.5 }), + }); + for (const badUiAmount of ['abc', '1.5oops', '', '0x10']) { + await expect(uiAmountToAmountForMintWithoutSimulation(rpc, mint, badUiAmount)).rejects.toThrow(); + } +}); + +test('should reject converted amounts above the u64 range', async () => { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(0), + [mint]: createMockMintAccountInfo(0, true, { multiplier: 0.1 }), + }); + await expect(uiAmountToAmountForMintWithoutSimulation(rpc, mint, '18446744073709551615')).rejects.toThrow(); +}); From 114f187c63f13b7201b7675c1ca362d403c68972 Mon Sep 17 00:00:00 2001 From: Pluto Han Date: Fri, 24 Jul 2026 17:23:40 +0400 Subject: [PATCH 2/2] js-legacy: Avoid bigint literals for the ES2016 cjs build The cjs tsconfig targets ES2016 where bigint literals are a syntax error. Use the BigInt constructor instead; behavior is unchanged. --- .../js-legacy/src/actions/amountToUiAmount.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/clients/js-legacy/src/actions/amountToUiAmount.ts b/clients/js-legacy/src/actions/amountToUiAmount.ts index 28104ddcf..fd17ddede 100644 --- a/clients/js-legacy/src/actions/amountToUiAmount.ts +++ b/clients/js-legacy/src/actions/amountToUiAmount.ts @@ -81,7 +81,7 @@ function getDecimalFactor(decimals: number): number { return Math.pow(10, decimals); } -const U64_MAX = 18446744073709551615n; +const U64_MAX = BigInt('18446744073709551615'); // Matches Rust's f64 grammar: optional sign, digits with optional fraction, // optional exponent, or inf/infinity/nan. @@ -125,24 +125,24 @@ function formatUiAmountString(value: number, decimals: number): string { const view = new DataView(new ArrayBuffer(8)); view.setFloat64(0, Math.abs(value)); const bits = view.getBigUint64(0); - const biasedExponent = Number((bits >> 52n) & 0x7ffn); - const fractionBits = bits & 0xfffffffffffffn; - const mantissa = biasedExponent === 0 ? fractionBits : fractionBits | (1n << 52n); + 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 * 10n ** BigInt(decimals); - let denominator = 1n; + let numerator = mantissa * BigInt(10) ** BigInt(decimals); + let denominator = BigInt(1); if (exponent >= 0) { numerator <<= BigInt(exponent); } else { - denominator = 1n << BigInt(-exponent); + denominator = BigInt(1) << BigInt(-exponent); } let quotient = numerator / denominator; - const doubledRemainder = (numerator % denominator) * 2n; - if (doubledRemainder > denominator || (doubledRemainder === denominator && (quotient & 1n) === 1n)) { - quotient += 1n; + const doubledRemainder = (numerator % denominator) * BigInt(2); + if (doubledRemainder > denominator || (doubledRemainder === denominator && (quotient & BigInt(1)) === BigInt(1))) { + quotient += BigInt(1); } - const sign = value < 0 && quotient > 0n ? '-' : ''; + const sign = value < 0 && quotient > BigInt(0) ? '-' : ''; if (decimals === 0) { return sign + quotient.toString(); }