diff --git a/token/js/src/actions/amountToUiAmount.ts b/token/js/src/actions/amountToUiAmount.ts index 667454ab8a8..d6a7a204ff5 100644 --- a/token/js/src/actions/amountToUiAmount.ts +++ b/token/js/src/actions/amountToUiAmount.ts @@ -1,7 +1,9 @@ -import type { Connection, PublicKey, Signer, TransactionError } from '@solana/web3.js'; -import { Transaction } from '@solana/web3.js'; -import { TOKEN_PROGRAM_ID } from '../constants.js'; +import type { Connection, Signer, TransactionError } from '@solana/web3.js'; +import { PublicKey, Transaction } from '@solana/web3.js'; +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'; import { createAmountToUiAmountInstruction } from '../instructions/amountToUiAmount.js'; +import { unpackMint } from '../state/mint.js'; +import { getInterestBearingMintConfigState } from '../extensions/interestBearingMint/state.js'; /** * Amount as a string using mint-prescribed decimals @@ -28,3 +30,242 @@ export async function amountToUiAmount( } return err; } + +/** + * 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; + const numerator = r * timespan; + const exponent = numerator / (SECONDS_PER_YEAR * ONE_IN_BASIS_POINTS); + return Math.exp(exponent); +} + +/** + * Retrieves the current timestamp from the Solana clock sysvar. + * @param connection - The Solana connection 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(connection: Connection): Promise { + const info = await connection.getParsedAccountInfo(new PublicKey('SysvarC1ock11111111111111111111111111111111')); + if (!info) { + throw new Error('Failed to fetch sysvar clock'); + } + if (typeof info.value === 'object' && info.value && 'data' in info.value && 'parsed' in info.value.data) { + return info.value.data.parsed.info.unixTimestamp; + } + throw new Error('Failed to parse sysvar clock'); +} + +/** + * 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 amountToUiAmountWithoutSimulation( + amount: bigint, + decimals: number, + currentTimestamp: number, // in seconds + lastUpdateTimestamp: number, + initializationTimestamp: number, + preUpdateAverageRate: number, + currentRate: number, +): string { + // 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); + + // Calculate total scale + 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 = 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 /token/program-2022/src/processor.rs + * and `process_amount_to_ui_amount` in /token/program/src/processor.rs + * + * @param connection Connection 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( + connection: Connection, + mint: PublicKey, + amount: bigint, +): Promise { + const accountInfo = await connection.getAccountInfo(mint); + const programId = accountInfo?.owner; + if (programId !== TOKEN_PROGRAM_ID && programId !== TOKEN_2022_PROGRAM_ID) { + throw new Error('Invalid program ID'); + } + + const mintInfo = unpackMint(mint, accountInfo, programId); + + const interestBearingMintConfigState = getInterestBearingMintConfigState(mintInfo); + if (!interestBearingMintConfigState) { + const amountNumber = Number(amount); + const decimalsFactor = Math.pow(10, mintInfo.decimals); + return (amountNumber / decimalsFactor).toString(); + } + + const timestamp = await getSysvarClockTimestamp(connection); + + return amountToUiAmountWithoutSimulation( + amount, + mintInfo.decimals, + 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 uiAmountToAmountWithoutSimulation( + 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; + + // Calculate pre-update exponent + const preUpdateExp = calculateExponentForTimesAndRate( + initializationTimestamp, + lastUpdateTimestamp, + preUpdateAverageRate, + ); + + // Calculate post-update exponent + const postUpdateExp = calculateExponentForTimesAndRate(lastUpdateTimestamp, currentTimestamp, currentRate); + + // 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)); +} + +/** + * Convert a UI amount back to the raw amount + * + * @param connection Connection 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( + connection: Connection, + mint: PublicKey, + uiAmount: string, +): Promise { + const accountInfo = await connection.getAccountInfo(mint); + const programId = accountInfo?.owner; + if (programId !== TOKEN_PROGRAM_ID && programId !== TOKEN_2022_PROGRAM_ID) { + throw new Error('Invalid program ID'); + } + + const mintInfo = unpackMint(mint, accountInfo, programId); + const interestBearingMintConfigState = getInterestBearingMintConfigState(mintInfo); + if (!interestBearingMintConfigState) { + const uiAmountScaled = parseFloat(uiAmount) * Math.pow(10, mintInfo.decimals); + return BigInt(Math.trunc(uiAmountScaled)); + } + + const timestamp = await getSysvarClockTimestamp(connection); + + return uiAmountToAmountWithoutSimulation( + uiAmount, + mintInfo.decimals, + timestamp, + Number(interestBearingMintConfigState.lastUpdateTimestamp), + Number(interestBearingMintConfigState.initializationTimestamp), + interestBearingMintConfigState.preUpdateAverageRate, + interestBearingMintConfigState.currentRate, + ); +} diff --git a/token/js/test/unit/interestBearing.test.ts b/token/js/test/unit/interestBearing.test.ts new file mode 100644 index 00000000000..c3bce68970d --- /dev/null +++ b/token/js/test/unit/interestBearing.test.ts @@ -0,0 +1,327 @@ +import { expect } from 'chai'; +import type { Connection } from '@solana/web3.js'; +import { PublicKey } from '@solana/web3.js'; +import { + amountToUiAmountForMintWithoutSimulation, + uiAmountToAmountForMintWithoutSimulation, +} from '../../src/actions/amountToUiAmount'; +import { AccountLayout, InterestBearingMintConfigStateLayout, TOKEN_2022_PROGRAM_ID } from '../../src'; +import { MintLayout } from '../../src/state/mint'; +import { ExtensionType } from '../../src/extensions/extensionType'; +import { AccountType } from '../../src/extensions/accountType'; + +const ONE_YEAR_IN_SECONDS = 31556736; + +// Mock connection class +class MockConnection { + private mockAccountInfo: any; + private mockClock: { + epoch: number; + epochStartTimestamp: number; + leaderScheduleEpoch: number; + slot: number; + unixTimestamp: number; + }; + + constructor() { + this.mockAccountInfo = null; + this.mockClock = { + epoch: 0, + epochStartTimestamp: 0, + leaderScheduleEpoch: 0, + slot: 0, + unixTimestamp: ONE_YEAR_IN_SECONDS, + }; + } + + getAccountInfo = async (address: PublicKey) => { + return this.getParsedAccountInfo(address); + }; + + // used to get the clock timestamp + getParsedAccountInfo = async (address: PublicKey) => { + if (address.toString() === 'SysvarC1ock11111111111111111111111111111111') { + return { + value: { + data: { + parsed: { + info: this.mockClock, + }, + }, + }, + }; + } + return this.mockAccountInfo; + }; + + setClockTimestamp(timestamp: number) { + this.mockClock = { + ...this.mockClock, + unixTimestamp: timestamp, + }; + } + + resetClock() { + this.mockClock = { + ...this.mockClock, + unixTimestamp: ONE_YEAR_IN_SECONDS, + }; + } + + setAccountInfo(info: any) { + this.mockAccountInfo = info; + } +} + +function createMockMintData( + decimals = 2, + hasInterestBearingConfig = false, + config: { preUpdateAverageRate?: number; currentRate?: number } = {}, +) { + const mintData = Buffer.alloc(MintLayout.span); + MintLayout.encode( + { + mintAuthorityOption: 1, + mintAuthority: new PublicKey(new Uint8Array(32).fill(1)), + supply: BigInt(1000000), + decimals: decimals, + isInitialized: true, + freezeAuthorityOption: 1, + freezeAuthority: new PublicKey(new Uint8Array(32).fill(1)), + }, + mintData, + ); + + const baseData = Buffer.alloc(AccountLayout.span + 1); + mintData.copy(baseData, 0); + baseData[AccountLayout.span] = AccountType.Mint; + + if (!hasInterestBearingConfig) { + return baseData; + } + + // write extension data using the InterestBearingMintConfigStateLayout + const extensionData = Buffer.alloc(InterestBearingMintConfigStateLayout.span); + const rateAuthority = new Uint8Array(32).fill(1); // rate authority + Buffer.from(rateAuthority).copy(extensionData, 0); + extensionData.writeBigUInt64LE(BigInt(0), 32); // initialization timestamp + extensionData.writeInt16LE(config.preUpdateAverageRate || 500, 40); // pre-update average rate + extensionData.writeBigUInt64LE(BigInt(ONE_YEAR_IN_SECONDS), 42); // last update timestamp + extensionData.writeInt16LE(config.currentRate || 500, 50); // current rate + + const TYPE_SIZE = 2; + const LENGTH_SIZE = 2; + const tlvBuffer = Buffer.alloc(TYPE_SIZE + LENGTH_SIZE + extensionData.length); + tlvBuffer.writeUInt16LE(ExtensionType.InterestBearingConfig, 0); + tlvBuffer.writeUInt16LE(extensionData.length, TYPE_SIZE); + extensionData.copy(tlvBuffer, TYPE_SIZE + LENGTH_SIZE); + + const fullData = Buffer.alloc(baseData.length + tlvBuffer.length); + baseData.copy(fullData, 0); + tlvBuffer.copy(fullData, baseData.length); + + return fullData; +} + +describe('amountToUiAmountForMintWithoutSimulation', () => { + let connection: MockConnection; + const mint = new PublicKey('So11111111111111111111111111111111111111112'); + + beforeEach(() => { + connection = new MockConnection() as unknown as MockConnection; + }); + + afterEach(() => { + connection.resetClock(); + }); + + it('should return the correct UiAmount when interest bearing config is not present', async () => { + 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) { + connection.setAccountInfo({ + owner: TOKEN_2022_PROGRAM_ID, + lamports: 1000000, + data: createMockMintData(decimals, false), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + connection as unknown as Connection, + mint, + amount, + ); + expect(result).to.equal(expected); + } + }); + + // continuous compounding interest of 5% for 1 year for 1 token = 1.0512710963760240397 + it('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' }, + ]; + + for (const { decimals, amount, expected } of testCases) { + connection.setAccountInfo({ + owner: TOKEN_2022_PROGRAM_ID, + lamports: 1000000, + data: createMockMintData(decimals, true), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + connection as unknown as Connection, + mint, + amount, + ); + expect(result).to.equal(expected); + } + }); + + it('should return the correct UiAmount for constant -5% rate', async () => { + connection.setAccountInfo({ + owner: TOKEN_2022_PROGRAM_ID, + lamports: 1000000, + data: createMockMintData(10, true, { preUpdateAverageRate: -500, currentRate: -500 }), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + connection as unknown as Connection, + mint, + BigInt(10000000000), + ); + expect(result).to.equal('0.9512294245'); + }); + + it('should return the correct UiAmount for netting out rates', async () => { + connection.setClockTimestamp(ONE_YEAR_IN_SECONDS * 2); + connection.setAccountInfo({ + owner: TOKEN_2022_PROGRAM_ID, + lamports: 1000000, + data: createMockMintData(10, true, { preUpdateAverageRate: -500, currentRate: 500 }), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + connection as unknown as Connection, + mint, + BigInt(10000000000), + ); + expect(result).to.equal('1'); + }); + + it('should handle huge values correctly', async () => { + connection.setClockTimestamp(ONE_YEAR_IN_SECONDS * 2); + connection.setAccountInfo({ + owner: TOKEN_2022_PROGRAM_ID, + lamports: 1000000, + data: createMockMintData(6, true), + }); + + const result = await amountToUiAmountForMintWithoutSimulation( + connection as unknown as Connection, + mint, + BigInt('18446744073709551615'), + ); + expect(result).to.equal('20386805083448.098'); + }); +}); + +describe('amountToUiAmountForMintWithoutSimulation', () => { + let connection: MockConnection; + const mint = new PublicKey('So11111111111111111111111111111111111111112'); + + beforeEach(() => { + connection = new MockConnection() as unknown as MockConnection; + }); + + afterEach(() => { + connection.resetClock(); + }); + it('should return the correct amount for constant 5% rate', async () => { + connection.setAccountInfo({ + owner: TOKEN_2022_PROGRAM_ID, + lamports: 1000000, + data: createMockMintData(0, true), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation( + connection as unknown as Connection, + mint, + '1.0512710963760241', + ); + expect(result).to.equal(1n); + }); + + it('should handle decimal places correctly', async () => { + 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) { + connection.setAccountInfo({ + owner: TOKEN_2022_PROGRAM_ID, + lamports: 1000000, + data: createMockMintData(decimals, true), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation( + connection as unknown as Connection, + mint, + uiAmount, + ); + expect(result).to.equal(expected); + } + }); + + it('should return the correct amount for constant -5% rate', async () => { + connection.setAccountInfo({ + owner: TOKEN_2022_PROGRAM_ID, + lamports: 1000000, + data: createMockMintData(10, true, { preUpdateAverageRate: -500, currentRate: -500 }), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation( + connection as unknown as Connection, + mint, + '0.951229424500714', + ); + expect(result).to.equal(9999999999n); // calculation truncates to avoid floating point precision issues in transfers + }); + + it('should return the correct amount for netting out rates', async () => { + connection.setClockTimestamp(ONE_YEAR_IN_SECONDS * 2); + connection.setAccountInfo({ + owner: TOKEN_2022_PROGRAM_ID, + lamports: 1000000, + data: createMockMintData(10, true, { preUpdateAverageRate: -500, currentRate: 500 }), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation(connection as unknown as Connection, mint, '1'); + expect(result).to.equal(10000000000n); + }); + + it('should handle huge values correctly', async () => { + connection.setClockTimestamp(ONE_YEAR_IN_SECONDS * 2); + connection.setAccountInfo({ + owner: TOKEN_2022_PROGRAM_ID, + lamports: 1000000, + data: createMockMintData(0, true), + }); + + const result = await uiAmountToAmountForMintWithoutSimulation( + connection as unknown as Connection, + mint, + '20386805083448100000', + ); + expect(result).to.equal(18446744073709551616n); + }); +});