Skip to content

Commit 71c988f

Browse files
authored
Add Support for Scaled UI Amount Extension to JS Library (#427)
* add helper functions for @solana/kit for scaled ui amount extensions * fmt * clean up tests as recommended in the comments * fmt
1 parent 66f31b9 commit 71c988f

File tree

2 files changed

+561
-67
lines changed

2 files changed

+561
-67
lines changed

clients/js/src/amountToUiAmount.ts

+174-67
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import {
88
import { fetchSysvarClock } from '@solana/sysvars';
99
import { fetchMint } from './generated';
1010

11+
// Constants
12+
const ONE_IN_BASIS_POINTS = 10000;
13+
const SECONDS_PER_YEAR = 60 * 60 * 24 * 365.24;
14+
1115
/**
1216
* Calculates the exponent for the interest rate formula.
1317
* @param t1 - The start time in seconds.
@@ -17,8 +21,6 @@ import { fetchMint } from './generated';
1721
* @returns The calculated exponent.
1822
*/
1923
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;
2224
const timespan = t2 - t1;
2325
if (timespan < 0) {
2426
throw new Error('Invalid timespan: end time before start time');
@@ -56,15 +58,13 @@ function calculateTotalScale({
5658
currentRate: number;
5759
}): number {
5860
// Calculate pre-update exponent
59-
// e^(preUpdateAverageRate * (lastUpdateTimestamp - initializationTimestamp) / (SECONDS_PER_YEAR * ONE_IN_BASIS_POINTS))
6061
const preUpdateExp = calculateExponentForTimesAndRate(
6162
initializationTimestamp,
6263
lastUpdateTimestamp,
6364
preUpdateAverageRate
6465
);
6566

6667
// Calculate post-update exponent
67-
// e^(currentRate * (currentTimestamp - lastUpdateTimestamp) / (SECONDS_PER_YEAR * ONE_IN_BASIS_POINTS))
6868
const postUpdateExp = calculateExponentForTimesAndRate(
6969
lastUpdateTimestamp,
7070
currentTimestamp,
@@ -74,6 +74,15 @@ function calculateTotalScale({
7474
return preUpdateExp * postUpdateExp;
7575
}
7676

77+
/**
78+
* Calculates the decimal factor for a given number of decimals
79+
* @param decimals - Number of decimals
80+
* @returns The decimal factor (e.g., 100 for 2 decimals)
81+
*/
82+
function getDecimalFactor(decimals: number): number {
83+
return Math.pow(10, decimals);
84+
}
85+
7786
/**
7887
* Retrieves the current timestamp from the Solana clock sysvar.
7988
* @param rpc - The Solana rpc object.
@@ -90,6 +99,8 @@ async function getSysvarClockTimestamp(
9099
return info.unixTimestamp;
91100
}
92101

102+
// ========== INTEREST BEARING MINT FUNCTIONS ==========
103+
93104
/**
94105
* Convert amount to UiAmount for a mint with interest bearing extension without simulating a transaction
95106
* This implements the same logic as the CPI instruction available in /token/program-2022/src/extension/interest_bearing_mint/mod.rs
@@ -119,7 +130,7 @@ async function getSysvarClockTimestamp(
119130
export function amountToUiAmountForInterestBearingMintWithoutSimulation(
120131
amount: bigint,
121132
decimals: number,
122-
currentTimestamp: number, // in seconds
133+
currentTimestamp: number,
123134
lastUpdateTimestamp: number,
124135
initializationTimestamp: number,
125136
preUpdateAverageRate: number,
@@ -132,60 +143,14 @@ export function amountToUiAmountForInterestBearingMintWithoutSimulation(
132143
preUpdateAverageRate,
133144
currentRate,
134145
});
146+
135147
// Scale the amount by the total interest factor
136148
const scaledAmount = Number(amount) * totalScale;
149+
const decimalFactor = getDecimalFactor(decimals);
137150

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
145151
return (Math.trunc(scaledAmount) / decimalFactor).toString();
146152
}
147153

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-
189154
/**
190155
* Convert an amount with interest back to the original amount without interest
191156
* This implements the same logic as the CPI instruction available in /token/program-2022/src/extension/interest_bearing_mint/mod.rs
@@ -217,14 +182,14 @@ export async function amountToUiAmountForMintWithoutSimulation(
217182
export function uiAmountToAmountForInterestBearingMintWithoutSimulation(
218183
uiAmount: string,
219184
decimals: number,
220-
currentTimestamp: number, // in seconds
185+
currentTimestamp: number,
221186
lastUpdateTimestamp: number,
222187
initializationTimestamp: number,
223188
preUpdateAverageRate: number,
224189
currentRate: number
225190
): bigint {
226191
const uiAmountNumber = parseFloat(uiAmount);
227-
const decimalsFactor = Math.pow(10, decimals);
192+
const decimalsFactor = getDecimalFactor(decimals);
228193
const uiAmountScaled = uiAmountNumber * decimalsFactor;
229194

230195
const totalScale = calculateTotalScale({
@@ -235,11 +200,123 @@ export function uiAmountToAmountForInterestBearingMintWithoutSimulation(
235200
currentRate,
236201
});
237202

238-
// Calculate original principal by dividing the UI amount (principal + interest) by the total scale
203+
// Calculate original principal by dividing the UI amount by the total scale
239204
const originalPrincipal = uiAmountScaled / totalScale;
240205
return BigInt(Math.trunc(originalPrincipal));
241206
}
242207

208+
// ========== SCALED UI AMOUNT MINT FUNCTIONS ==========
209+
210+
/**
211+
* Convert amount to UiAmount for a mint with scaled UI amount extension
212+
* @param amount Amount of tokens to be converted
213+
* @param decimals Number of decimals of the mint
214+
* @param multiplier Multiplier to scale the amount
215+
* @return Scaled UI amount as a string
216+
*/
217+
export function amountToUiAmountForScaledUiAmountMintWithoutSimulation(
218+
amount: bigint,
219+
decimals: number,
220+
multiplier: number
221+
): string {
222+
const scaledAmount = Number(amount) * multiplier;
223+
const decimalFactor = getDecimalFactor(decimals);
224+
return (Math.trunc(scaledAmount) / decimalFactor).toString();
225+
}
226+
227+
/**
228+
* Convert a UI amount back to the raw amount for a mint with a scaled UI amount extension
229+
* @param uiAmount UI Amount to be converted back to raw amount
230+
* @param decimals Number of decimals for the mint
231+
* @param multiplier Multiplier for the scaled UI amount
232+
*
233+
* @return Raw amount
234+
*/
235+
export function uiAmountToAmountForScaledUiAmountMintWithoutSimulation(
236+
uiAmount: string,
237+
decimals: number,
238+
multiplier: number
239+
): bigint {
240+
const uiAmountNumber = parseFloat(uiAmount);
241+
const decimalsFactor = getDecimalFactor(decimals);
242+
const uiAmountScaled = uiAmountNumber * decimalsFactor;
243+
const rawAmount = uiAmountScaled / multiplier;
244+
return BigInt(Math.trunc(rawAmount));
245+
}
246+
247+
// ========== MAIN ENTRY POINT FUNCTIONS ==========
248+
249+
/**
250+
* Convert amount to UiAmount for a mint without simulating a transaction
251+
* This implements the same logic as `process_amount_to_ui_amount` in
252+
* solana-labs/solana-program-library/token/program-2022/src/processor.rs
253+
* and `process_amount_to_ui_amount` in solana-labs/solana-program-library/token/program/src/processor.rs
254+
*
255+
* @param rpc Rpc to use
256+
* @param mint Mint to use for calculations
257+
* @param amount Amount of tokens to be converted to Ui Amount
258+
*
259+
* @return Ui Amount generated
260+
*/
261+
export async function amountToUiAmountForMintWithoutSimulation(
262+
rpc: Rpc<GetAccountInfoApi>,
263+
mint: Address,
264+
amount: bigint
265+
): Promise<string> {
266+
const accountInfo = await fetchMint(rpc, mint);
267+
const extensions = unwrapOption(accountInfo.data.extensions);
268+
269+
// Check for interest bearing mint extension
270+
const interestBearingMintConfigState = extensions?.find(
271+
(ext) => ext.__kind === 'InterestBearingConfig'
272+
);
273+
274+
// Check for scaled UI amount extension
275+
const scaledUiAmountConfig = extensions?.find(
276+
(ext) => ext.__kind === 'ScaledUiAmountConfig'
277+
);
278+
279+
// If no special extension, do standard conversion
280+
if (!interestBearingMintConfigState && !scaledUiAmountConfig) {
281+
const amountNumber = Number(amount);
282+
const decimalsFactor = getDecimalFactor(accountInfo.data.decimals);
283+
return (amountNumber / decimalsFactor).toString();
284+
}
285+
286+
// Get timestamp if needed for special mint types
287+
const timestamp = await getSysvarClockTimestamp(rpc);
288+
289+
// Handle interest bearing mint
290+
if (interestBearingMintConfigState) {
291+
return amountToUiAmountForInterestBearingMintWithoutSimulation(
292+
amount,
293+
accountInfo.data.decimals,
294+
Number(timestamp),
295+
Number(interestBearingMintConfigState.lastUpdateTimestamp),
296+
Number(interestBearingMintConfigState.initializationTimestamp),
297+
interestBearingMintConfigState.preUpdateAverageRate,
298+
interestBearingMintConfigState.currentRate
299+
);
300+
}
301+
302+
// At this point, we know it must be a scaled UI amount mint
303+
if (scaledUiAmountConfig) {
304+
let multiplier = scaledUiAmountConfig.multiplier;
305+
// Use new multiplier if it's effective
306+
if (timestamp >= scaledUiAmountConfig.newMultiplierEffectiveTimestamp) {
307+
multiplier = scaledUiAmountConfig.newMultiplier;
308+
}
309+
return amountToUiAmountForScaledUiAmountMintWithoutSimulation(
310+
amount,
311+
accountInfo.data.decimals,
312+
multiplier
313+
);
314+
}
315+
316+
// This should never happen due to the conditions above
317+
throw new Error('Unknown mint extension type');
318+
}
319+
243320
/**
244321
* Convert a UI amount back to the raw amount
245322
*
@@ -256,24 +333,54 @@ export async function uiAmountToAmountForMintWithoutSimulation(
256333
): Promise<bigint> {
257334
const accountInfo = await fetchMint(rpc, mint);
258335
const extensions = unwrapOption(accountInfo.data.extensions);
336+
337+
// Check for interest bearing mint extension
259338
const interestBearingMintConfigState = extensions?.find(
260339
(ext) => ext.__kind === 'InterestBearingConfig'
261340
);
262-
if (!interestBearingMintConfigState) {
341+
342+
// Check for scaled UI amount extension
343+
const scaledUiAmountConfig = extensions?.find(
344+
(ext) => ext.__kind === 'ScaledUiAmountConfig'
345+
);
346+
347+
// If no special extension, do standard conversion
348+
if (!interestBearingMintConfigState && !scaledUiAmountConfig) {
263349
const uiAmountScaled =
264-
parseFloat(uiAmount) * Math.pow(10, accountInfo.data.decimals);
350+
parseFloat(uiAmount) * getDecimalFactor(accountInfo.data.decimals);
265351
return BigInt(Math.trunc(uiAmountScaled));
266352
}
267353

354+
// Get timestamp if needed for special mint types
268355
const timestamp = await getSysvarClockTimestamp(rpc);
269356

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-
);
357+
// Handle interest bearing mint
358+
if (interestBearingMintConfigState) {
359+
return uiAmountToAmountForInterestBearingMintWithoutSimulation(
360+
uiAmount,
361+
accountInfo.data.decimals,
362+
Number(timestamp),
363+
Number(interestBearingMintConfigState.lastUpdateTimestamp),
364+
Number(interestBearingMintConfigState.initializationTimestamp),
365+
interestBearingMintConfigState.preUpdateAverageRate,
366+
interestBearingMintConfigState.currentRate
367+
);
368+
}
369+
370+
// At this point, we know it must be a scaled UI amount mint
371+
if (scaledUiAmountConfig) {
372+
let multiplier = scaledUiAmountConfig.multiplier;
373+
// Use new multiplier if it's effective
374+
if (timestamp >= scaledUiAmountConfig.newMultiplierEffectiveTimestamp) {
375+
multiplier = scaledUiAmountConfig.newMultiplier;
376+
}
377+
return uiAmountToAmountForScaledUiAmountMintWithoutSimulation(
378+
uiAmount,
379+
accountInfo.data.decimals,
380+
multiplier
381+
);
382+
}
383+
384+
// This should never happen due to the conditions above
385+
throw new Error('Unknown mint extension type');
279386
}

0 commit comments

Comments
 (0)