Skip to content

Commit 769acd1

Browse files
authored
client-js: add JS helpers for interest bearing tokens' UIAmounts (#53)
* WIP: amount to ui amount helpers and tests * clean up helper functions * remove ava verbose mode * prettier fix * address pr comments * pnpm format fix * re-run formatting
1 parent 9c7d771 commit 769acd1

File tree

5 files changed

+580
-1
lines changed

5 files changed

+580
-1
lines changed

clients/js/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
},
4343
"homepage": "https://github.com/solana-program/token-2022#readme",
4444
"peerDependencies": {
45-
"@solana/web3.js": "^2.0.0"
45+
"@solana/web3.js": "^2.0.0",
46+
"@solana/sysvars": "^2.0.0"
4647
},
4748
"devDependencies": {
4849
"@ava/typescript": "^4.1.0",

clients/js/pnpm-lock.yaml

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

clients/js/src/amountToUiAmount.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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+
}

clients/js/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './generated';
22

3+
export * from './amountToUiAmount';
34
export * from './getInitializeInstructionsForExtensions';
45
export * from './getTokenSize';
56
export * from './getMintSize';

0 commit comments

Comments
 (0)