diff --git a/clients/js/src/amountToUiAmount.ts b/clients/js/src/amountToUiAmount.ts index e5652b27..6d22070b 100644 --- a/clients/js/src/amountToUiAmount.ts +++ b/clients/js/src/amountToUiAmount.ts @@ -8,6 +8,10 @@ import { import { fetchSysvarClock } from '@solana/sysvars'; import { fetchMint } from './generated'; +// Constants +const ONE_IN_BASIS_POINTS = 10000; +const SECONDS_PER_YEAR = 60 * 60 * 24 * 365.24; + /** * Calculates the exponent for the interest rate formula. * @param t1 - The start time in seconds. @@ -17,8 +21,6 @@ import { fetchMint } from './generated'; * @returns The calculated exponent. */ function calculateExponentForTimesAndRate(t1: number, t2: number, r: number) { - const ONE_IN_BASIS_POINTS = 10000; - const SECONDS_PER_YEAR = 60 * 60 * 24 * 365.24; const timespan = t2 - t1; if (timespan < 0) { throw new Error('Invalid timespan: end time before start time'); @@ -56,7 +58,6 @@ function calculateTotalScale({ currentRate: number; }): number { // Calculate pre-update exponent - // e^(preUpdateAverageRate * (lastUpdateTimestamp - initializationTimestamp) / (SECONDS_PER_YEAR * ONE_IN_BASIS_POINTS)) const preUpdateExp = calculateExponentForTimesAndRate( initializationTimestamp, lastUpdateTimestamp, @@ -64,7 +65,6 @@ function calculateTotalScale({ ); // Calculate post-update exponent - // e^(currentRate * (currentTimestamp - lastUpdateTimestamp) / (SECONDS_PER_YEAR * ONE_IN_BASIS_POINTS)) const postUpdateExp = calculateExponentForTimesAndRate( lastUpdateTimestamp, currentTimestamp, @@ -74,6 +74,15 @@ function calculateTotalScale({ return preUpdateExp * postUpdateExp; } +/** + * Calculates the decimal factor for a given number of decimals + * @param decimals - Number of decimals + * @returns The decimal factor (e.g., 100 for 2 decimals) + */ +function getDecimalFactor(decimals: number): number { + return Math.pow(10, decimals); +} + /** * Retrieves the current timestamp from the Solana clock sysvar. * @param rpc - The Solana rpc object. @@ -90,6 +99,8 @@ async function getSysvarClockTimestamp( return info.unixTimestamp; } +// ========== INTEREST BEARING MINT FUNCTIONS ========== + /** * Convert amount to UiAmount for a mint with interest bearing extension without simulating a transaction * This implements the same logic as the CPI instruction available in /token/program-2022/src/extension/interest_bearing_mint/mod.rs @@ -119,7 +130,7 @@ async function getSysvarClockTimestamp( export function amountToUiAmountForInterestBearingMintWithoutSimulation( amount: bigint, decimals: number, - currentTimestamp: number, // in seconds + currentTimestamp: number, lastUpdateTimestamp: number, initializationTimestamp: number, preUpdateAverageRate: number, @@ -132,60 +143,14 @@ export function amountToUiAmountForInterestBearingMintWithoutSimulation( preUpdateAverageRate, currentRate, }); + // Scale the amount by the total interest factor const scaledAmount = Number(amount) * totalScale; + const decimalFactor = getDecimalFactor(decimals); - // Calculate the decimal factor (e.g. 100 for 2 decimals) - const decimalFactor = Math.pow(10, decimals); - - // Convert to UI amount by: - // 1. Truncating to remove any remaining decimals - // 2. Dividing by decimal factor to get final UI amount - // 3. Converting to string return (Math.trunc(scaledAmount) / decimalFactor).toString(); } -/** - * Convert amount to UiAmount for a mint without simulating a transaction - * This implements the same logic as `process_amount_to_ui_amount` in - * solana-labs/solana-program-library/token/program-2022/src/processor.rs - * and `process_amount_to_ui_amount` in solana-labs/solana-program-library/token/program/src/processor.rs - * - * @param rpc Rpc to use - * @param mint Mint to use for calculations - * @param amount Amount of tokens to be converted to Ui Amount - * - * @return Ui Amount generated - */ -export async function amountToUiAmountForMintWithoutSimulation( - rpc: Rpc, - mint: Address, - amount: bigint -): Promise { - const accountInfo = await fetchMint(rpc, mint); - const extensions = unwrapOption(accountInfo.data.extensions); - const interestBearingMintConfigState = extensions?.find( - (ext) => ext.__kind === 'InterestBearingConfig' - ); - if (!interestBearingMintConfigState) { - const amountNumber = Number(amount); - const decimalsFactor = Math.pow(10, accountInfo.data.decimals); - return (amountNumber / decimalsFactor).toString(); - } - - const timestamp = await getSysvarClockTimestamp(rpc); - - return amountToUiAmountForInterestBearingMintWithoutSimulation( - amount, - accountInfo.data.decimals, - Number(timestamp), - Number(interestBearingMintConfigState.lastUpdateTimestamp), - Number(interestBearingMintConfigState.initializationTimestamp), - interestBearingMintConfigState.preUpdateAverageRate, - interestBearingMintConfigState.currentRate - ); -} - /** * Convert an amount with interest back to the original amount without interest * This implements the same logic as the CPI instruction available in /token/program-2022/src/extension/interest_bearing_mint/mod.rs @@ -217,14 +182,14 @@ export async function amountToUiAmountForMintWithoutSimulation( export function uiAmountToAmountForInterestBearingMintWithoutSimulation( uiAmount: string, decimals: number, - currentTimestamp: number, // in seconds + currentTimestamp: number, lastUpdateTimestamp: number, initializationTimestamp: number, preUpdateAverageRate: number, currentRate: number ): bigint { const uiAmountNumber = parseFloat(uiAmount); - const decimalsFactor = Math.pow(10, decimals); + const decimalsFactor = getDecimalFactor(decimals); const uiAmountScaled = uiAmountNumber * decimalsFactor; const totalScale = calculateTotalScale({ @@ -235,11 +200,123 @@ export function uiAmountToAmountForInterestBearingMintWithoutSimulation( currentRate, }); - // Calculate original principal by dividing the UI amount (principal + interest) by the total scale + // Calculate original principal by dividing the UI amount by the total scale const originalPrincipal = uiAmountScaled / totalScale; return BigInt(Math.trunc(originalPrincipal)); } +// ========== SCALED UI AMOUNT MINT FUNCTIONS ========== + +/** + * Convert amount to UiAmount for a mint with scaled UI amount extension + * @param amount Amount of tokens to be converted + * @param decimals Number of decimals of the mint + * @param multiplier Multiplier to scale the amount + * @return Scaled UI amount as a string + */ +export function amountToUiAmountForScaledUiAmountMintWithoutSimulation( + amount: bigint, + decimals: number, + multiplier: number +): string { + const scaledAmount = Number(amount) * multiplier; + const decimalFactor = getDecimalFactor(decimals); + return (Math.trunc(scaledAmount) / decimalFactor).toString(); +} + +/** + * Convert a UI amount back to the raw amount for a mint with a scaled UI amount extension + * @param uiAmount UI Amount to be converted back to raw amount + * @param decimals Number of decimals for the mint + * @param multiplier Multiplier for the scaled UI amount + * + * @return Raw amount + */ +export function uiAmountToAmountForScaledUiAmountMintWithoutSimulation( + uiAmount: string, + 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)); +} + +// ========== MAIN ENTRY POINT FUNCTIONS ========== + +/** + * Convert amount to UiAmount for a mint without simulating a transaction + * This implements the same logic as `process_amount_to_ui_amount` in + * solana-labs/solana-program-library/token/program-2022/src/processor.rs + * and `process_amount_to_ui_amount` in solana-labs/solana-program-library/token/program/src/processor.rs + * + * @param rpc Rpc to use + * @param mint Mint to use for calculations + * @param amount Amount of tokens to be converted to Ui Amount + * + * @return Ui Amount generated + */ +export async function amountToUiAmountForMintWithoutSimulation( + rpc: Rpc, + mint: Address, + amount: bigint +): Promise { + const accountInfo = await fetchMint(rpc, mint); + const extensions = unwrapOption(accountInfo.data.extensions); + + // Check for interest bearing mint extension + const interestBearingMintConfigState = extensions?.find( + (ext) => ext.__kind === 'InterestBearingConfig' + ); + + // Check for scaled UI amount extension + const scaledUiAmountConfig = extensions?.find( + (ext) => ext.__kind === 'ScaledUiAmountConfig' + ); + + // If no special extension, do standard conversion + if (!interestBearingMintConfigState && !scaledUiAmountConfig) { + const amountNumber = Number(amount); + const decimalsFactor = getDecimalFactor(accountInfo.data.decimals); + return (amountNumber / decimalsFactor).toString(); + } + + // Get timestamp if needed for special mint types + const timestamp = await getSysvarClockTimestamp(rpc); + + // Handle interest bearing mint + if (interestBearingMintConfigState) { + return amountToUiAmountForInterestBearingMintWithoutSimulation( + amount, + accountInfo.data.decimals, + Number(timestamp), + Number(interestBearingMintConfigState.lastUpdateTimestamp), + Number(interestBearingMintConfigState.initializationTimestamp), + interestBearingMintConfigState.preUpdateAverageRate, + interestBearingMintConfigState.currentRate + ); + } + + // At this point, we know it must be a scaled UI amount mint + if (scaledUiAmountConfig) { + let multiplier = scaledUiAmountConfig.multiplier; + // Use new multiplier if it's effective + if (timestamp >= scaledUiAmountConfig.newMultiplierEffectiveTimestamp) { + multiplier = scaledUiAmountConfig.newMultiplier; + } + return amountToUiAmountForScaledUiAmountMintWithoutSimulation( + amount, + accountInfo.data.decimals, + multiplier + ); + } + + // This should never happen due to the conditions above + throw new Error('Unknown mint extension type'); +} + /** * Convert a UI amount back to the raw amount * @@ -256,24 +333,54 @@ export async function uiAmountToAmountForMintWithoutSimulation( ): Promise { const accountInfo = await fetchMint(rpc, mint); const extensions = unwrapOption(accountInfo.data.extensions); + + // Check for interest bearing mint extension const interestBearingMintConfigState = extensions?.find( (ext) => ext.__kind === 'InterestBearingConfig' ); - if (!interestBearingMintConfigState) { + + // Check for scaled UI amount extension + const scaledUiAmountConfig = extensions?.find( + (ext) => ext.__kind === 'ScaledUiAmountConfig' + ); + + // If no special extension, do standard conversion + if (!interestBearingMintConfigState && !scaledUiAmountConfig) { const uiAmountScaled = - parseFloat(uiAmount) * Math.pow(10, accountInfo.data.decimals); + parseFloat(uiAmount) * getDecimalFactor(accountInfo.data.decimals); return BigInt(Math.trunc(uiAmountScaled)); } + // Get timestamp if needed for special mint types const timestamp = await getSysvarClockTimestamp(rpc); - return uiAmountToAmountForInterestBearingMintWithoutSimulation( - uiAmount, - accountInfo.data.decimals, - Number(timestamp), - Number(interestBearingMintConfigState.lastUpdateTimestamp), - Number(interestBearingMintConfigState.initializationTimestamp), - interestBearingMintConfigState.preUpdateAverageRate, - interestBearingMintConfigState.currentRate - ); + // Handle interest bearing mint + if (interestBearingMintConfigState) { + return uiAmountToAmountForInterestBearingMintWithoutSimulation( + uiAmount, + accountInfo.data.decimals, + Number(timestamp), + Number(interestBearingMintConfigState.lastUpdateTimestamp), + Number(interestBearingMintConfigState.initializationTimestamp), + interestBearingMintConfigState.preUpdateAverageRate, + interestBearingMintConfigState.currentRate + ); + } + + // At this point, we know it must be a scaled UI amount mint + if (scaledUiAmountConfig) { + let multiplier = scaledUiAmountConfig.multiplier; + // Use new multiplier if it's effective + if (timestamp >= scaledUiAmountConfig.newMultiplierEffectiveTimestamp) { + multiplier = scaledUiAmountConfig.newMultiplier; + } + return uiAmountToAmountForScaledUiAmountMintWithoutSimulation( + uiAmount, + accountInfo.data.decimals, + multiplier + ); + } + + // This should never happen due to the conditions above + throw new Error('Unknown mint extension type'); } diff --git a/clients/js/test/extensions/scaledUiAmountMint/amountToUiAmount.test.ts b/clients/js/test/extensions/scaledUiAmountMint/amountToUiAmount.test.ts new file mode 100644 index 00000000..26374c08 --- /dev/null +++ b/clients/js/test/extensions/scaledUiAmountMint/amountToUiAmount.test.ts @@ -0,0 +1,387 @@ +import test from 'ava'; +import type { + GetAccountInfoApi, + Lamports, + Rpc, + Base64EncodedBytes, + Commitment, + UnixTimestamp, + ReadonlyUint8Array, +} from '@solana/kit'; +import { address, Address, getBase64Decoder } from '@solana/kit'; +import { getSysvarClockEncoder, SYSVAR_CLOCK_ADDRESS } from '@solana/sysvars'; +import { + amountToUiAmountForMintWithoutSimulation, + uiAmountToAmountForMintWithoutSimulation, + TOKEN_2022_PROGRAM_ADDRESS, + getMintEncoder, +} from '../../../src'; + +const ONE_YEAR_IN_SECONDS = 31556736; +const CLOCK = SYSVAR_CLOCK_ADDRESS; +const mint = address('So11111111111111111111111111111111111111112'); + +// Helper functions +type AccountInfo = Readonly<{ + executable: boolean; + lamports: Lamports; + owner: Address; + rentEpoch: bigint; + data: ReadonlyUint8Array; +}>; + +function getMockRpc( + accounts: Record +): Rpc { + const getAccountInfo = ( + address: Address, + _config?: { commitment?: Commitment } + ) => { + const account = accounts[address]; + if (!account) { + throw new Error(`Account not found for address: ${address}`); + } + if (!(account.data instanceof Uint8Array)) { + throw new Error( + `Account data is not a Uint8Array for address: ${address}` + ); + } + return { + send: async () => ({ + context: { slot: 0n }, + value: account + ? { + executable: account.executable, + lamports: account.lamports, + owner: account.owner, + rentEpoch: account.rentEpoch, + data: [getBase64Decoder().decode(account.data), 'base64'] as [ + Base64EncodedBytes, + 'base64', + ], + } + : null, + }), + }; + }; + return { getAccountInfo } as unknown as Rpc; +} + +function populateMockAccount(data: ReadonlyUint8Array) { + return { + executable: false, + lamports: 1000000n as Lamports, + owner: TOKEN_2022_PROGRAM_ADDRESS, + rentEpoch: 0n, + data, + }; +} + +function createMockMintAccountInfo( + decimals = 2, + hasScaledUiAmountConfig = false, + config: { + multiplier?: number; + newMultiplier?: number; + newMultiplierEffectiveTimestamp?: number; + } = {} +) { + const defaultAddress = address('11111111111111111111111111111111'); + const mintEncoder = getMintEncoder(); + + const data = mintEncoder.encode({ + mintAuthority: defaultAddress, + supply: BigInt(1000000), + decimals: decimals, + isInitialized: true, + freezeAuthority: defaultAddress, + extensions: hasScaledUiAmountConfig + ? [ + { + __kind: 'ScaledUiAmountConfig', + authority: defaultAddress, + multiplier: config.multiplier || 2, + newMultiplierEffectiveTimestamp: + config.newMultiplierEffectiveTimestamp || ONE_YEAR_IN_SECONDS * 3, + newMultiplier: config.newMultiplier || 2, + }, + ] + : [], + }); + return populateMockAccount(data); +} + +function createMockClockAccountInfo(unixTimestamp: number) { + const clockEncoder = getSysvarClockEncoder(); + const data = clockEncoder.encode({ + epoch: 0n, + epochStartTimestamp: 0n as UnixTimestamp, + leaderScheduleEpoch: 0n, + slot: 0n, + unixTimestamp: BigInt(unixTimestamp) as UnixTimestamp, + }); + return populateMockAccount(data); +} + +// GROUP 1: Basic functionality - standard mint without scaled UI extension +test('should return the correct UiAmount when scaled ui amount config is not present', async (t) => { + const testCases = [ + { 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(1000000000), expected: '0.1' }, + ]; + + for (const { decimals, amount, expected } of testCases) { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(decimals, false), + }); + const result = await amountToUiAmountForMintWithoutSimulation( + rpc, + mint, + amount + ); + t.is(result, expected); + } +}); + +// GROUP 2: Basic integer multiplier tests +test('should return the correct UiAmount with scale factor of 2', async (t) => { + const testCases = [ + { decimals: 0, amount: BigInt(100), expected: '200' }, + { decimals: 1, amount: BigInt(100), expected: '20' }, + { decimals: 10, amount: BigInt(10000000000), expected: '2' }, + ]; + + for (const { decimals, amount, expected } of testCases) { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(decimals, true, { multiplier: 2 }), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + rpc, + mint, + amount + ); + t.is(result, expected); + } +}); + +test('should return the correct amount for different scale factors', async (t) => { + const testCases = [ + { + decimals: 0, + multiplier: 2, + uiAmount: '2', + expected: 1n, + }, + { + decimals: 10, + multiplier: 3, + uiAmount: '3', + expected: 10000000000n, + }, + ]; + + for (const { decimals, multiplier, uiAmount, expected } of testCases) { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(decimals, true, { multiplier }), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation( + rpc, + mint, + uiAmount + ); + t.is(result, expected); + } +}); + +// GROUP 3: Decimal multiplier tests +test('should handle decimal multipliers correctly', async (t) => { + const testCases = [ + { + decimals: 2, + multiplier: 0.5, + amount: BigInt(100), + expected: '0.5', + }, + { + decimals: 2, + multiplier: 1.5, + amount: BigInt(100), + expected: '1.5', + }, + { + decimals: 3, + multiplier: 0.001, + amount: BigInt(1000), + expected: '0.001', + }, + ]; + + for (const { decimals, multiplier, amount, expected } of testCases) { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(decimals, true, { multiplier }), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + rpc, + mint, + amount + ); + t.is(result, expected); + } +}); + +test('should convert UI amounts with decimal multipliers correctly', async (t) => { + const testCases = [ + { + multiplier: 0.5, + uiAmount: '1', + expected: 200n, // 1 * 100(for 2 decimals) / 0.5 + }, + { + multiplier: 1.5, + uiAmount: '3', + expected: 200n, // 3 * 100(for 2 decimals) / 1.5 + }, + ]; + + for (const { multiplier, uiAmount, expected } of testCases) { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(2, true, { multiplier }), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation( + rpc, + mint, + uiAmount + ); + t.is(result, expected); + } +}); + +// GROUP 4: Tests for handling new effective multipliers +test('should use new multiplier when timestamp is after effective timestamp', async (t) => { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(2, true, { + multiplier: 2, + newMultiplier: 3, + newMultiplierEffectiveTimestamp: ONE_YEAR_IN_SECONDS, + }), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + rpc, + mint, + BigInt(100) + ); + t.is(result, '3'); +}); + +test('should use current multiplier when timestamp is before effective timestamp', async (t) => { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS / 2), + [mint]: createMockMintAccountInfo(2, true, { + multiplier: 2, + newMultiplier: 3, + newMultiplierEffectiveTimestamp: ONE_YEAR_IN_SECONDS, + }), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + rpc, + mint, + BigInt(100) + ); + t.is(result, '2'); +}); + +test('should use new multiplier for amount to ui conversion when timestamp is after effective timestamp', async (t) => { + // Mock clock to a time after the effective timestamp + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(2, true, { + multiplier: 2, + newMultiplier: 4, + newMultiplierEffectiveTimestamp: ONE_YEAR_IN_SECONDS, + }), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation(rpc, mint, '2'); + t.is(result, 50n); // 2 * 100(for 2 decimals) / 4 +}); + +test('should use current multiplier for amount to ui conversion when timestamp is before effective timestamp', async (t) => { + // Mock clock to a time before the effective timestamp + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS / 2), + [mint]: createMockMintAccountInfo(2, true, { + multiplier: 2, + newMultiplier: 4, + newMultiplierEffectiveTimestamp: ONE_YEAR_IN_SECONDS, + }), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation(rpc, mint, '2'); + t.is(result, 100n); // 2 * 100(for 2 decimals) / 2 +}); + +// GROUP 5: Edge cases and large number handling +test('should handle decimal places correctly', async (t) => { + const testCases = [ + { decimals: 1, uiAmount: '0.2', expected: 1n }, + { decimals: 10, uiAmount: '0.0000000002', expected: 1n }, + { decimals: 10, uiAmount: '10.0000000002', expected: 50000000001n }, + ]; + + for (const { decimals, uiAmount, expected } of testCases) { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(decimals, true, { multiplier: 2 }), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation( + rpc, + mint, + uiAmount + ); + t.is(result, expected); + } +}); + +test('should handle huge values correctly', async (t) => { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(6, true, { multiplier: 2 }), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + rpc, + mint, + BigInt('18446744073709551615') + ); + t.is(result, '36893488147419.1'); +}); + +test('should handle huge values correctly for amount to ui amount', async (t) => { + const rpc = getMockRpc({ + [CLOCK]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(0, true, { multiplier: 2 }), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation( + rpc, + mint, + '1844674407370955.16' + ); + t.is(result, 922337203685477n); +});