-
Notifications
You must be signed in to change notification settings - Fork 2.3k
token-js: add JS helpers for interest bearing tokens' UIAmounts #7541
Changes from 11 commits
ab09a86
39c40e7
0ef4859
83cf986
35df63b
12a380c
2e7c15d
d0ea366
1cb2f80
bd426ed
807ae7a
1a914b3
463eefb
c16944f
35ce02f
24a8d37
613701f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -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 { getMint, unpackMint } from '../state/mint.js'; | ||||||
import { getInterestBearingMintConfigState } from '../extensions/interestBearingMint/state.js'; | ||||||
|
||||||
/** | ||||||
* Amount as a string using mint-prescribed decimals | ||||||
|
@@ -28,3 +30,237 @@ 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. | ||||||
*/ | ||||||
const 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. | ||||||
*/ | ||||||
const getSysvarClockTimestamp = async (connection: Connection): Promise<number> => { | ||||||
const info = await connection.getParsedAccountInfo(new PublicKey('SysvarC1ock11111111111111111111111111111111')); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: same here, let's avoid the arrow function |
||||||
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, Number(currentTimestamp), currentRate) | ||||||
|
||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit:
Suggested change
|
||||||
// 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 | ||||||
* @param programId SPL Token program account (default: TOKEN_PROGRAM_ID) | ||||||
* | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: this param doesn't exist
Suggested change
|
||||||
* @return Ui Amount generated | ||||||
*/ | ||||||
export async function amountToUiAmountForMintWithoutSimulation( | ||||||
connection: Connection, | ||||||
mint: PublicKey, | ||||||
amount: bigint, | ||||||
): Promise<string> { | ||||||
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, | ||||||
interestBearingMintConfigState.lastUpdateTimestamp, | ||||||
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 (principle plus continuously compounding interest) to be converted back to original principle | ||||||
* @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 principle from the UI amount, the formula is: | ||||||
* P = A / (e^(r * t)) where | ||||||
* P = principle | ||||||
* 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 principle 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 principle, we divide the UI amount by the total scale factor: | ||||||
* P = A / totalScale | ||||||
* | ||||||
* @return Original amount (principle) 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 principle by dividing the UI amount (principle + interest) by the total scale | ||||||
const originalPrinciple = uiAmountScaled / totalScale; | ||||||
return BigInt(Math.floor(originalPrinciple)); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: If we want to use the term, it's |
||||||
} | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: is there a reason to use |
||||||
|
||||||
/** | ||||||
* 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 | ||||||
* @param programId SPL Token program account (default: TOKEN_PROGRAM_ID) | ||||||
* | ||||||
* | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: since the param was removed
Suggested change
|
||||||
* @return Raw amount | ||||||
*/ | ||||||
export async function uiAmountToAmountForMintWithoutSimulation( | ||||||
connection: Connection, | ||||||
mint: PublicKey, | ||||||
uiAmount: string, | ||||||
): Promise<bigint> { | ||||||
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 = await getMint(connection, mint, 'confirmed', programId); | ||||||
|
||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This causes a second network call to fetch the account again, so let's do |
||||||
const interestBearingMintConfigState = getInterestBearingMintConfigState(mintInfo); | ||||||
if (!interestBearingMintConfigState) { | ||||||
const uiAmountScaled = parseFloat(uiAmount) * Math.pow(10, mintInfo.decimals); | ||||||
return BigInt(Math.floor(uiAmountScaled)); | ||||||
} | ||||||
|
||||||
const timestamp = await getSysvarClockTimestamp(connection); | ||||||
|
||||||
return uiAmountToAmountWithoutSimulation( | ||||||
uiAmount, | ||||||
mintInfo.decimals, | ||||||
timestamp, | ||||||
interestBearingMintConfigState.lastUpdateTimestamp, | ||||||
interestBearingMintConfigState.initializationTimestamp, | ||||||
interestBearingMintConfigState.preUpdateAverageRate, | ||||||
interestBearingMintConfigState.currentRate | ||||||
); | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,9 +6,9 @@ import { ExtensionType, getExtensionData } from '../extensionType.js'; | |
|
||
export interface InterestBearingMintConfigState { | ||
rateAuthority: PublicKey; | ||
initializationTimestamp: bigint; | ||
initializationTimestamp: number; | ||
preUpdateAverageRate: number; | ||
lastUpdateTimestamp: bigint; | ||
lastUpdateTimestamp: number; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. these were changed because the buffer encoder does not work with bigints. numbers should be fine for timestamps and this doesn't seem to impact any other current usage There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, what do you mean about the buffer encoder not working? We use bigints in a lot of the extensions and they seem to work. I'd prefer to leave this as is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Specifically if you use a bigint it has an issue when trying to encode this state due to this function that's in the buffer encoding library:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe to fix this we'd have to fix the buffer encoding library. I took a look through all other state type definitions I could find and didn't see any other bigints |
||
currentRate: number; | ||
} | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: we don't typically use arrow functions in this package -- can this be updated to a
function(...)
?