|
| 1 | +import { |
| 2 | + type GetAccountInfoApi, |
| 3 | + type Rpc, |
| 4 | + Address, |
| 5 | + UnixTimestamp, |
| 6 | + unwrapOption, |
| 7 | +} from '@solana/web3.js'; |
| 8 | +import { fetchSysvarClock } from '@solana/sysvars'; |
| 9 | +import { fetchMint } from './generated'; |
| 10 | + |
| 11 | +/** |
| 12 | + * Calculates the exponent for the interest rate formula. |
| 13 | + * @param t1 - The start time in seconds. |
| 14 | + * @param t2 - The end time in seconds. |
| 15 | + * @param r - The interest rate in basis points. |
| 16 | + * |
| 17 | + * @returns The calculated exponent. |
| 18 | + */ |
| 19 | +function calculateExponentForTimesAndRate(t1: number, t2: number, r: number) { |
| 20 | + const ONE_IN_BASIS_POINTS = 10000; |
| 21 | + const SECONDS_PER_YEAR = 60 * 60 * 24 * 365.24; |
| 22 | + const timespan = t2 - t1; |
| 23 | + if (timespan < 0) { |
| 24 | + throw new Error('Invalid timespan: end time before start time'); |
| 25 | + } |
| 26 | + |
| 27 | + const numerator = r * timespan; |
| 28 | + const exponent = numerator / (SECONDS_PER_YEAR * ONE_IN_BASIS_POINTS); |
| 29 | + return Math.exp(exponent); |
| 30 | +} |
| 31 | + |
| 32 | +/** |
| 33 | + * Calculates the total scale factor for an interest bearing token by combining two exponential functions: |
| 34 | + * One for the period between initialization and last update using the pre-update average rate, |
| 35 | + * and another for the period between last update and current time using the current rate. |
| 36 | + * |
| 37 | + * @param currentTimestamp Current timestamp in seconds |
| 38 | + * @param lastUpdateTimestamp Last time the interest rate was updated in seconds |
| 39 | + * @param initializationTimestamp Time the interest bearing extension was initialized in seconds |
| 40 | + * @param preUpdateAverageRate Interest rate in basis points before last update |
| 41 | + * @param currentRate Current interest rate in basis points |
| 42 | + * |
| 43 | + * @returns The total scale factor as a product of the two exponential functions |
| 44 | + */ |
| 45 | +function calculateTotalScale({ |
| 46 | + currentTimestamp, |
| 47 | + lastUpdateTimestamp, |
| 48 | + initializationTimestamp, |
| 49 | + preUpdateAverageRate, |
| 50 | + currentRate, |
| 51 | +}: { |
| 52 | + currentTimestamp: number; |
| 53 | + lastUpdateTimestamp: number; |
| 54 | + initializationTimestamp: number; |
| 55 | + preUpdateAverageRate: number; |
| 56 | + currentRate: number; |
| 57 | +}): number { |
| 58 | + // Calculate pre-update exponent |
| 59 | + // e^(preUpdateAverageRate * (lastUpdateTimestamp - initializationTimestamp) / (SECONDS_PER_YEAR * ONE_IN_BASIS_POINTS)) |
| 60 | + const preUpdateExp = calculateExponentForTimesAndRate( |
| 61 | + initializationTimestamp, |
| 62 | + lastUpdateTimestamp, |
| 63 | + preUpdateAverageRate |
| 64 | + ); |
| 65 | + |
| 66 | + // Calculate post-update exponent |
| 67 | + // e^(currentRate * (currentTimestamp - lastUpdateTimestamp) / (SECONDS_PER_YEAR * ONE_IN_BASIS_POINTS)) |
| 68 | + const postUpdateExp = calculateExponentForTimesAndRate( |
| 69 | + lastUpdateTimestamp, |
| 70 | + currentTimestamp, |
| 71 | + currentRate |
| 72 | + ); |
| 73 | + |
| 74 | + return preUpdateExp * postUpdateExp; |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * Retrieves the current timestamp from the Solana clock sysvar. |
| 79 | + * @param rpc - The Solana rpc object. |
| 80 | + * @returns A promise that resolves to the current timestamp in seconds. |
| 81 | + * @throws An error if the sysvar clock cannot be fetched or parsed. |
| 82 | + */ |
| 83 | +async function getSysvarClockTimestamp( |
| 84 | + rpc: Rpc<GetAccountInfoApi> |
| 85 | +): Promise<UnixTimestamp> { |
| 86 | + const info = await fetchSysvarClock(rpc); |
| 87 | + if (!info) { |
| 88 | + throw new Error('Failed to fetch sysvar clock'); |
| 89 | + } |
| 90 | + return info.unixTimestamp; |
| 91 | +} |
| 92 | + |
| 93 | +/** |
| 94 | + * Convert amount to UiAmount for a mint with interest bearing extension without simulating a transaction |
| 95 | + * This implements the same logic as the CPI instruction available in /token/program-2022/src/extension/interest_bearing_mint/mod.rs |
| 96 | + * In general to calculate compounding interest over a period of time, the formula is: |
| 97 | + * A = P * e^(r * t) where |
| 98 | + * A = final amount after interest |
| 99 | + * P = principal amount (initial investment) |
| 100 | + * r = annual interest rate (as a decimal, e.g., 5% = 0.05) |
| 101 | + * t = time in years |
| 102 | + * e = mathematical constant (~2.718) |
| 103 | + * |
| 104 | + * In this case, we are calculating the total scale factor for the interest bearing extension which is the product of two exponential functions: |
| 105 | + * totalScale = e^(r1 * t1) * e^(r2 * t2) |
| 106 | + * where r1 and r2 are the interest rates before and after the last update, and t1 and t2 are the times in years between |
| 107 | + * the initialization timestamp and the last update timestamp, and between the last update timestamp and the current timestamp. |
| 108 | + * |
| 109 | + * @param amount Amount of tokens to be converted |
| 110 | + * @param decimals Number of decimals of the mint |
| 111 | + * @param currentTimestamp Current timestamp in seconds |
| 112 | + * @param lastUpdateTimestamp Last time the interest rate was updated in seconds |
| 113 | + * @param initializationTimestamp Time the interest bearing extension was initialized in seconds |
| 114 | + * @param preUpdateAverageRate Interest rate in basis points (1 basis point = 0.01%) before last update |
| 115 | + * @param currentRate Current interest rate in basis points |
| 116 | + * |
| 117 | + * @return Amount scaled by accrued interest as a string with appropriate decimal places |
| 118 | + */ |
| 119 | +export function amountToUiAmountForInterestBearingMintWithoutSimulation( |
| 120 | + amount: bigint, |
| 121 | + decimals: number, |
| 122 | + currentTimestamp: number, // in seconds |
| 123 | + lastUpdateTimestamp: number, |
| 124 | + initializationTimestamp: number, |
| 125 | + preUpdateAverageRate: number, |
| 126 | + currentRate: number |
| 127 | +): string { |
| 128 | + const totalScale = calculateTotalScale({ |
| 129 | + currentTimestamp, |
| 130 | + lastUpdateTimestamp, |
| 131 | + initializationTimestamp, |
| 132 | + preUpdateAverageRate, |
| 133 | + currentRate, |
| 134 | + }); |
| 135 | + // Scale the amount by the total interest factor |
| 136 | + const scaledAmount = Number(amount) * totalScale; |
| 137 | + |
| 138 | + // Calculate the decimal factor (e.g. 100 for 2 decimals) |
| 139 | + const decimalFactor = Math.pow(10, decimals); |
| 140 | + |
| 141 | + // Convert to UI amount by: |
| 142 | + // 1. Truncating to remove any remaining decimals |
| 143 | + // 2. Dividing by decimal factor to get final UI amount |
| 144 | + // 3. Converting to string |
| 145 | + return (Math.trunc(scaledAmount) / decimalFactor).toString(); |
| 146 | +} |
| 147 | + |
| 148 | +/** |
| 149 | + * Convert amount to UiAmount for a mint without simulating a transaction |
| 150 | + * This implements the same logic as `process_amount_to_ui_amount` in |
| 151 | + * solana-labs/solana-program-library/token/program-2022/src/processor.rs |
| 152 | + * and `process_amount_to_ui_amount` in solana-labs/solana-program-library/token/program/src/processor.rs |
| 153 | + * |
| 154 | + * @param rpc Rpc to use |
| 155 | + * @param mint Mint to use for calculations |
| 156 | + * @param amount Amount of tokens to be converted to Ui Amount |
| 157 | + * |
| 158 | + * @return Ui Amount generated |
| 159 | + */ |
| 160 | +export async function amountToUiAmountForMintWithoutSimulation( |
| 161 | + rpc: Rpc<GetAccountInfoApi>, |
| 162 | + mint: Address, |
| 163 | + amount: bigint |
| 164 | +): Promise<string> { |
| 165 | + const accountInfo = await fetchMint(rpc, mint); |
| 166 | + const extensions = unwrapOption(accountInfo.data.extensions); |
| 167 | + const interestBearingMintConfigState = extensions?.find( |
| 168 | + (ext) => ext.__kind === 'InterestBearingConfig' |
| 169 | + ); |
| 170 | + if (!interestBearingMintConfigState) { |
| 171 | + const amountNumber = Number(amount); |
| 172 | + const decimalsFactor = Math.pow(10, accountInfo.data.decimals); |
| 173 | + return (amountNumber / decimalsFactor).toString(); |
| 174 | + } |
| 175 | + |
| 176 | + const timestamp = await getSysvarClockTimestamp(rpc); |
| 177 | + |
| 178 | + return amountToUiAmountForInterestBearingMintWithoutSimulation( |
| 179 | + amount, |
| 180 | + accountInfo.data.decimals, |
| 181 | + Number(timestamp), |
| 182 | + Number(interestBearingMintConfigState.lastUpdateTimestamp), |
| 183 | + Number(interestBearingMintConfigState.initializationTimestamp), |
| 184 | + interestBearingMintConfigState.preUpdateAverageRate, |
| 185 | + interestBearingMintConfigState.currentRate |
| 186 | + ); |
| 187 | +} |
| 188 | + |
| 189 | +/** |
| 190 | + * Convert an amount with interest back to the original amount without interest |
| 191 | + * This implements the same logic as the CPI instruction available in /token/program-2022/src/extension/interest_bearing_mint/mod.rs |
| 192 | + * |
| 193 | + * @param uiAmount UI Amount (principal plus continuously compounding interest) to be converted back to original principal |
| 194 | + * @param decimals Number of decimals for the mint |
| 195 | + * @param currentTimestamp Current timestamp in seconds |
| 196 | + * @param lastUpdateTimestamp Last time the interest rate was updated in seconds |
| 197 | + * @param initializationTimestamp Time the interest bearing extension was initialized in seconds |
| 198 | + * @param preUpdateAverageRate Interest rate in basis points (hundredths of a percent) before the last update |
| 199 | + * @param currentRate Current interest rate in basis points |
| 200 | + * |
| 201 | + * In general to calculate the principal from the UI amount, the formula is: |
| 202 | + * P = A / (e^(r * t)) where |
| 203 | + * P = principal |
| 204 | + * A = UI amount |
| 205 | + * r = annual interest rate (as a decimal, e.g., 5% = 0.05) |
| 206 | + * t = time in years |
| 207 | + * |
| 208 | + * 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: |
| 209 | + * totalScale = e^(r1 * t1) * e^(r2 * t2) |
| 210 | + * 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, |
| 211 | + * and t2 is the time in years between the last update timestamp and the current timestamp. |
| 212 | + * then to calculate the principal, we divide the UI amount by the total scale factor: |
| 213 | + * P = A / totalScale |
| 214 | + * |
| 215 | + * @return Original amount (principal) without interest |
| 216 | + */ |
| 217 | +export function uiAmountToAmountForInterestBearingMintWithoutSimulation( |
| 218 | + uiAmount: string, |
| 219 | + decimals: number, |
| 220 | + currentTimestamp: number, // in seconds |
| 221 | + lastUpdateTimestamp: number, |
| 222 | + initializationTimestamp: number, |
| 223 | + preUpdateAverageRate: number, |
| 224 | + currentRate: number |
| 225 | +): bigint { |
| 226 | + const uiAmountNumber = parseFloat(uiAmount); |
| 227 | + const decimalsFactor = Math.pow(10, decimals); |
| 228 | + const uiAmountScaled = uiAmountNumber * decimalsFactor; |
| 229 | + |
| 230 | + const totalScale = calculateTotalScale({ |
| 231 | + currentTimestamp, |
| 232 | + lastUpdateTimestamp, |
| 233 | + initializationTimestamp, |
| 234 | + preUpdateAverageRate, |
| 235 | + currentRate, |
| 236 | + }); |
| 237 | + |
| 238 | + // Calculate original principal by dividing the UI amount (principal + interest) by the total scale |
| 239 | + const originalPrincipal = uiAmountScaled / totalScale; |
| 240 | + return BigInt(Math.trunc(originalPrincipal)); |
| 241 | +} |
| 242 | + |
| 243 | +/** |
| 244 | + * Convert a UI amount back to the raw amount |
| 245 | + * |
| 246 | + * @param rpc Rpc to use |
| 247 | + * @param mint Mint to use for calculations |
| 248 | + * @param uiAmount UI Amount to be converted back to raw amount |
| 249 | + * |
| 250 | + * @return Raw amount |
| 251 | + */ |
| 252 | +export async function uiAmountToAmountForMintWithoutSimulation( |
| 253 | + rpc: Rpc<GetAccountInfoApi>, |
| 254 | + mint: Address, |
| 255 | + uiAmount: string |
| 256 | +): Promise<bigint> { |
| 257 | + const accountInfo = await fetchMint(rpc, mint); |
| 258 | + const extensions = unwrapOption(accountInfo.data.extensions); |
| 259 | + const interestBearingMintConfigState = extensions?.find( |
| 260 | + (ext) => ext.__kind === 'InterestBearingConfig' |
| 261 | + ); |
| 262 | + if (!interestBearingMintConfigState) { |
| 263 | + const uiAmountScaled = |
| 264 | + parseFloat(uiAmount) * Math.pow(10, accountInfo.data.decimals); |
| 265 | + return BigInt(Math.trunc(uiAmountScaled)); |
| 266 | + } |
| 267 | + |
| 268 | + const timestamp = await getSysvarClockTimestamp(rpc); |
| 269 | + |
| 270 | + return uiAmountToAmountForInterestBearingMintWithoutSimulation( |
| 271 | + uiAmount, |
| 272 | + accountInfo.data.decimals, |
| 273 | + Number(timestamp), |
| 274 | + Number(interestBearingMintConfigState.lastUpdateTimestamp), |
| 275 | + Number(interestBearingMintConfigState.initializationTimestamp), |
| 276 | + interestBearingMintConfigState.preUpdateAverageRate, |
| 277 | + interestBearingMintConfigState.currentRate |
| 278 | + ); |
| 279 | +} |
0 commit comments