diff --git a/clients/js/package.json b/clients/js/package.json index 87395df65..e7cd7042a 100644 --- a/clients/js/package.json +++ b/clients/js/package.json @@ -42,7 +42,8 @@ }, "homepage": "https://github.com/solana-program/token-2022#readme", "peerDependencies": { - "@solana/web3.js": "^2.0.0" + "@solana/web3.js": "^2.0.0", + "@solana/sysvars": "^2.0.0" }, "devDependencies": { "@ava/typescript": "^4.1.0", diff --git a/clients/js/pnpm-lock.yaml b/clients/js/pnpm-lock.yaml index 3437e9a1d..221d1d690 100644 --- a/clients/js/pnpm-lock.yaml +++ b/clients/js/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@solana/sysvars': + specifier: ^2.0.0 + version: 2.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.5.3) devDependencies: '@ava/typescript': specifier: ^4.1.0 diff --git a/clients/js/src/amountToUiAmount.ts b/clients/js/src/amountToUiAmount.ts new file mode 100644 index 000000000..a8664d4b7 --- /dev/null +++ b/clients/js/src/amountToUiAmount.ts @@ -0,0 +1,279 @@ +import { + type GetAccountInfoApi, + type Rpc, + Address, + UnixTimestamp, + unwrapOption, +} from '@solana/web3.js'; +import { fetchSysvarClock } from '@solana/sysvars'; +import { fetchMint } from './generated'; + +/** + * Calculates the exponent for the interest rate formula. + * @param t1 - The start time in seconds. + * @param t2 - The end time in seconds. + * @param r - The interest rate in basis points. + * + * @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'); + } + + const numerator = r * timespan; + const exponent = numerator / (SECONDS_PER_YEAR * ONE_IN_BASIS_POINTS); + return Math.exp(exponent); +} + +/** + * Calculates the total scale factor for an interest bearing token by combining two exponential functions: + * One for the period between initialization and last update using the pre-update average rate, + * and another for the period between last update and current time using the current rate. + * + * @param currentTimestamp Current timestamp in seconds + * @param lastUpdateTimestamp Last time the interest rate was updated in seconds + * @param initializationTimestamp Time the interest bearing extension was initialized in seconds + * @param preUpdateAverageRate Interest rate in basis points before last update + * @param currentRate Current interest rate in basis points + * + * @returns The total scale factor as a product of the two exponential functions + */ +function calculateTotalScale({ + currentTimestamp, + lastUpdateTimestamp, + initializationTimestamp, + preUpdateAverageRate, + currentRate, +}: { + currentTimestamp: number; + lastUpdateTimestamp: number; + initializationTimestamp: number; + preUpdateAverageRate: number; + currentRate: number; +}): number { + // Calculate pre-update exponent + // e^(preUpdateAverageRate * (lastUpdateTimestamp - initializationTimestamp) / (SECONDS_PER_YEAR * ONE_IN_BASIS_POINTS)) + const preUpdateExp = calculateExponentForTimesAndRate( + initializationTimestamp, + lastUpdateTimestamp, + preUpdateAverageRate + ); + + // Calculate post-update exponent + // e^(currentRate * (currentTimestamp - lastUpdateTimestamp) / (SECONDS_PER_YEAR * ONE_IN_BASIS_POINTS)) + const postUpdateExp = calculateExponentForTimesAndRate( + lastUpdateTimestamp, + currentTimestamp, + currentRate + ); + + return preUpdateExp * postUpdateExp; +} + +/** + * Retrieves the current timestamp from the Solana clock sysvar. + * @param rpc - The Solana rpc object. + * @returns A promise that resolves to the current timestamp in seconds. + * @throws An error if the sysvar clock cannot be fetched or parsed. + */ +async function getSysvarClockTimestamp( + rpc: Rpc +): Promise { + const info = await fetchSysvarClock(rpc); + if (!info) { + throw new Error('Failed to fetch sysvar clock'); + } + return info.unixTimestamp; +} + +/** + * 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 + * In general to calculate compounding interest over a period of time, the formula is: + * A = P * e^(r * t) where + * A = final amount after interest + * P = principal amount (initial investment) + * r = annual interest rate (as a decimal, e.g., 5% = 0.05) + * t = time in years + * e = mathematical constant (~2.718) + * + * In this case, we are calculating the total scale factor for the interest bearing extension which is the product of two exponential functions: + * totalScale = e^(r1 * t1) * e^(r2 * t2) + * where r1 and r2 are the interest rates before and after the last update, and t1 and t2 are the times in years between + * the initialization timestamp and the last update timestamp, and between the last update timestamp and the current timestamp. + * + * @param amount Amount of tokens to be converted + * @param decimals Number of decimals of the mint + * @param currentTimestamp Current timestamp in seconds + * @param lastUpdateTimestamp Last time the interest rate was updated in seconds + * @param initializationTimestamp Time the interest bearing extension was initialized in seconds + * @param preUpdateAverageRate Interest rate in basis points (1 basis point = 0.01%) before last update + * @param currentRate Current interest rate in basis points + * + * @return Amount scaled by accrued interest as a string with appropriate decimal places + */ +export function amountToUiAmountForInterestBearingMintWithoutSimulation( + amount: bigint, + decimals: number, + currentTimestamp: number, // in seconds + lastUpdateTimestamp: number, + initializationTimestamp: number, + preUpdateAverageRate: number, + currentRate: number +): string { + const totalScale = calculateTotalScale({ + currentTimestamp, + lastUpdateTimestamp, + initializationTimestamp, + preUpdateAverageRate, + currentRate, + }); + // 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 = 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 + * + * @param uiAmount UI Amount (principal plus continuously compounding interest) to be converted back to original principal + * @param decimals Number of decimals for the mint + * @param currentTimestamp Current timestamp in seconds + * @param lastUpdateTimestamp Last time the interest rate was updated in seconds + * @param initializationTimestamp Time the interest bearing extension was initialized in seconds + * @param preUpdateAverageRate Interest rate in basis points (hundredths of a percent) before the last update + * @param currentRate Current interest rate in basis points + * + * In general to calculate the principal from the UI amount, the formula is: + * P = A / (e^(r * t)) where + * P = principal + * A = UI amount + * r = annual interest rate (as a decimal, e.g., 5% = 0.05) + * t = time in years + * + * In this case, we are calculating the principal by dividing the UI amount by the total scale factor which is the product of two exponential functions: + * totalScale = e^(r1 * t1) * e^(r2 * t2) + * where r1 is the pre-update average rate, r2 is the current rate, t1 is the time in years between the initialization timestamp and the last update timestamp, + * and t2 is the time in years between the last update timestamp and the current timestamp. + * then to calculate the principal, we divide the UI amount by the total scale factor: + * P = A / totalScale + * + * @return Original amount (principal) without interest + */ +export function uiAmountToAmountForInterestBearingMintWithoutSimulation( + uiAmount: string, + decimals: number, + currentTimestamp: number, // in seconds + lastUpdateTimestamp: number, + initializationTimestamp: number, + preUpdateAverageRate: number, + currentRate: number +): bigint { + const uiAmountNumber = parseFloat(uiAmount); + const decimalsFactor = Math.pow(10, decimals); + const uiAmountScaled = uiAmountNumber * decimalsFactor; + + const totalScale = calculateTotalScale({ + currentTimestamp, + lastUpdateTimestamp, + initializationTimestamp, + preUpdateAverageRate, + currentRate, + }); + + // Calculate original principal by dividing the UI amount (principal + interest) by the total scale + const originalPrincipal = uiAmountScaled / totalScale; + return BigInt(Math.trunc(originalPrincipal)); +} + +/** + * Convert a UI amount back to the raw amount + * + * @param rpc Rpc to use + * @param mint Mint to use for calculations + * @param uiAmount UI Amount to be converted back to raw amount + * + * @return Raw amount + */ +export async function uiAmountToAmountForMintWithoutSimulation( + rpc: Rpc, + mint: Address, + uiAmount: string +): Promise { + const accountInfo = await fetchMint(rpc, mint); + const extensions = unwrapOption(accountInfo.data.extensions); + const interestBearingMintConfigState = extensions?.find( + (ext) => ext.__kind === 'InterestBearingConfig' + ); + if (!interestBearingMintConfigState) { + const uiAmountScaled = + parseFloat(uiAmount) * Math.pow(10, accountInfo.data.decimals); + return BigInt(Math.trunc(uiAmountScaled)); + } + + const timestamp = await getSysvarClockTimestamp(rpc); + + return uiAmountToAmountForInterestBearingMintWithoutSimulation( + uiAmount, + accountInfo.data.decimals, + Number(timestamp), + Number(interestBearingMintConfigState.lastUpdateTimestamp), + Number(interestBearingMintConfigState.initializationTimestamp), + interestBearingMintConfigState.preUpdateAverageRate, + interestBearingMintConfigState.currentRate + ); +} diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index 026d5f32d..58f554768 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -1,5 +1,6 @@ export * from './generated'; +export * from './amountToUiAmount'; export * from './getInitializeInstructionsForExtensions'; export * from './getTokenSize'; export * from './getMintSize'; diff --git a/clients/js/test/extensions/interestBearingMint/amountToUiAmount.test.ts b/clients/js/test/extensions/interestBearingMint/amountToUiAmount.test.ts new file mode 100644 index 000000000..b21c88598 --- /dev/null +++ b/clients/js/test/extensions/interestBearingMint/amountToUiAmount.test.ts @@ -0,0 +1,294 @@ +import test from 'ava'; +import type { + GetAccountInfoApi, + Lamports, + Rpc, + Base64EncodedBytes, + Commitment, + UnixTimestamp, + ReadonlyUint8Array, +} from '@solana/web3.js'; +import { address, Address, getBase64Decoder } from '@solana/web3.js'; +import { getSysvarClockEncoder, SYSVAR_CLOCK_ADDRESS } from '@solana/sysvars'; +import { + amountToUiAmountForMintWithoutSimulation, + uiAmountToAmountForMintWithoutSimulation, + TOKEN_2022_PROGRAM_ADDRESS, + getMintEncoder, +} from '../../../src'; + +const ONE_YEAR_IN_SECONDS = 31556736; + +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, + hasInterestBearingConfig = false, + config: { preUpdateAverageRate?: number; currentRate?: number } = {} +) { + const defaultAddress = address('11111111111111111111111111111111'); + const mintEncoder = getMintEncoder(); + + const data = mintEncoder.encode({ + mintAuthority: defaultAddress, + supply: BigInt(1000000), + decimals: decimals, + isInitialized: true, + freezeAuthority: defaultAddress, + extensions: hasInterestBearingConfig + ? [ + { + __kind: 'InterestBearingConfig', + rateAuthority: defaultAddress, + initializationTimestamp: BigInt(0), + preUpdateAverageRate: config.preUpdateAverageRate || 500, + lastUpdateTimestamp: BigInt(ONE_YEAR_IN_SECONDS), + currentRate: config.currentRate || 500, + }, + ] + : [], + }); + return populateMockAccount(data); +} + +const createMockClockAccountInfo = (unixTimestamp: number) => { + const clockEncoder = getSysvarClockEncoder(); + const data = clockEncoder.encode({ + epoch: 0n, + epochStartTimestamp: BigInt(0) as UnixTimestamp, + leaderScheduleEpoch: 0n, + slot: 0n, + unixTimestamp: BigInt(unixTimestamp) as UnixTimestamp, + }); + return populateMockAccount(data); +}; + +const mint = address('So11111111111111111111111111111111111111112'); +const clock = SYSVAR_CLOCK_ADDRESS; + +test('should return the correct UiAmount when interest bearing 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), + [mint]: createMockMintAccountInfo(decimals, false), + }); + const result = await amountToUiAmountForMintWithoutSimulation( + rpc, + mint, + amount + ); + t.is(result, expected); + } +}); + +test('should return the correct UiAmount for constant 5% rate', async (t) => { + 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' }, + ]; + + for (const { decimals, amount, expected } of testCases) { + const rpc = getMockRpc({ + [clock]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS), + [mint]: createMockMintAccountInfo(decimals, true), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + rpc, + mint, + amount + ); + t.is(result, expected); + } +}); + +test('should return the correct UiAmount for constant -5% rate', async (t) => { + const rpc = getMockRpc({ + [clock]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS), + [mint]: createMockMintAccountInfo(10, true, { + preUpdateAverageRate: -500, + currentRate: -500, + }), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + rpc, + mint, + BigInt(10000000000) + ); + t.is(result, '0.9512294245'); +}); + +test('should return the correct UiAmount for netting out rates', async (t) => { + const rpc = getMockRpc({ + [clock]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(10, true, { + preUpdateAverageRate: -500, + currentRate: 500, + }), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + rpc, + mint, + BigInt(10000000000) + ); + t.is(result, '1'); +}); + +test('should handle huge values correctly', async (t) => { + const rpc = getMockRpc({ + [clock]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(6, true), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + rpc, + mint, + BigInt('18446744073709551615') + ); + t.is(result, '20386805083448.098'); +}); + +test('should return the correct amount for constant 5% rate', async (t) => { + const rpc = getMockRpc({ + [clock]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS), + [mint]: createMockMintAccountInfo(0, true), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation( + rpc, + mint, + '1.0512710963760241' + ); + t.is(result, 1n); +}); + +test('should handle decimal places correctly', async (t) => { + const testCases = [ + { decimals: 1, uiAmount: '0.10512710963760241', expected: 1n }, + { decimals: 10, uiAmount: '0.00000000010512710963760242', expected: 1n }, + { decimals: 10, uiAmount: '1.0512710963760241', expected: 10000000000n }, + ]; + + for (const { decimals, uiAmount, expected } of testCases) { + const rpc = getMockRpc({ + [clock]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS), + [mint]: createMockMintAccountInfo(decimals, true), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation( + rpc, + mint, + uiAmount + ); + t.is(result, expected); + } +}); + +test('should return the correct amount for constant -5% rate', async (t) => { + const rpc = getMockRpc({ + [clock]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS), + [mint]: createMockMintAccountInfo(10, true, { + preUpdateAverageRate: -500, + currentRate: -500, + }), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation( + rpc, + mint, + '0.951229424500714' + ); + t.is(result, 9999999999n); // calculation truncates to avoid floating point precision issues in transfers +}); + +test('should return the correct amount for netting out rates', async (t) => { + const rpc = getMockRpc({ + [clock]: createMockClockAccountInfo(ONE_YEAR_IN_SECONDS * 2), + [mint]: createMockMintAccountInfo(10, true, { + preUpdateAverageRate: -500, + currentRate: 500, + }), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation(rpc, mint, '1'); + t.is(result, 10000000000n); +}); + +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), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation( + rpc, + mint, + '20386805083448100000' + ); + t.is(result, 18446744073709551616n); +});