La extensión de devengo de intereses añade la capacidad para que un mint de token acumule intereses con el tiempo. En nuestra discusión anterior sobre Token-2022, introdujimos la extensión de devengo de intereses y explicamos cómo los balances crecen virtualmente sin cambiar el balance bruto de la cuenta en la cadena (on-chain). Nuestro enfoque fue en cómo funciona conceptualmente la extensión y cómo las funciones del cliente de Solana calculan los intereses devengados.
En este artículo, pondremos ese conocimiento en práctica. Construiremos un sistema de gestión usando Anchor que crea un mint de token con devengo de intereses programáticamente bajo una autoridad PDA (program-derived address), lo que asegura que solo el programa pueda controlarlo. El sistema también permitirá actualizaciones de tasas a través de una autoridad de tasas designada.
El programa que construiremos demostrará el ciclo de vida completo de un token con devengo de intereses: inicialización, acuñación (minting), acumulación de intereses y cambios de tasa. También probaremos la acumulación de intereses a medida que pasa el tiempo usando viajes en el tiempo con LiteSVM.
Al final de este artículo, tendrás una sólida comprensión de cómo funciona el token con devengo de intereses en la práctica.
Inicialización del proyecto
Comenzaremos creando un nuevo proyecto en Anchor. Ejecuta el siguiente comando para inicializar el proyecto:
anchor init interest-bearing && cd interest-bearing
Ahora, actualiza tu archivo program/src/Cargo.toml para incluir la dependencia anchor-spl y habilitar idl-build. La característica idl-build hace que Anchor genere definiciones IDL para llamadas CPI (Cross-Program Invocation), las cuales usaremos más adelante al escribir pruebas para llamar a las funciones del programa.
[package]
name = "interest-bearing"
version = "0.1.0"
description = "Created with Anchor"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
name = "interest_bearing"
[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] # We added this
[dependencies]
anchor-lang = { version = "0.31.1", features = ["init-if-needed"] } # Include this
anchor-spl = { version = "0.31.1", features = ["idl-build"] } # include this
Ahora puedes ejecutar anchor build de forma exitosa para confirmar que tu proyecto está configurado correctamente.
Estructura del proyecto
El proyecto constará de dos fases:
- El programa de Rust en Anchor
- Y las pruebas en TypeScript
1. El programa de Rust en Anchor
El programa de Rust en Anchor manejará tres acciones principales:
- Crear e inicializar un nuevo mint con devengo de intereses, y configurar un PDA como autoridad del mint
- Acuñar (mint) tokens con devengo de intereses
- Actualizar la tasa de interés a través de la autoridad de tasas.
Cada una de estas acciones se implementará en Anchor como puntos de entrada de funciones on-chain.
#[program]
pub mod interest_bearing {
pub fn create_interest_bearing_mint(...) -> Result<()> { ... }
pub fn mint_tokens(...) -> Result<()> { ... }
pub fn update_rate(...) -> Result<()> { ... }
}
Definiremos las funciones en el archivo programs/interest-bearing/src/lib.rs:
create_interest_bearing_mint: Crea un mint de token con la extensiónInterestBearingConfighabilitada y establece la autoridad de tasas.mint_tokens: Acuña tokens a la cuenta de un usuario utilizando un PDA como la autoridad del mint.update_rate: Actualiza la tasa de interés anual del mint, restringida a la autoridad de tasas.
2. Las pruebas en TypeScript
Las pruebas en TypeScript verificarán que el programa puede:
- crear un mint con devengo de intereses
- acuñar tokens bajo la autoridad de un PDA
- actualizar la tasa de interés a través de la autoridad de tasas
- mostrar con precisión la acumulación virtual de intereses.
Implementando el programa de Rust en Anchor
Ahora que entendemos la estructura del proyecto, implementemos el programa on-chain en sí.
Comenzamos importando las dependencias requeridas de Anchor y Token-2022 y declarando el ID del programa en el archivo program/interest-bearing/src/lib.rs:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{self, CreateAccount};
use anchor_spl::{
token_2022::{
initialize_mint2,
spl_token_2022::{
extension::{ExtensionType},
pod::PodMint,
},
InitializeMint2, Token2022,
},
token_interface::{Mint, TokenAccount, mint_to, MintTo},
token_2022_extensions::interest_bearing_mint::{
interest_bearing_mint_initialize,
interest_bearing_mint_update_rate,
InterestBearingMintInitialize,
InterestBearingMintUpdateRate,
},
};
declare_id!("5fRtYEKp81rfRRqcYxp9XMnucgvtt6JxQ5GDDQy2TMtx");
Nota que no instalamos spl-token-2022 directamente—en su lugar, estamos usando la reexportación de Anchor. Mezclar ambos puede llevar a discrepancias de versiones y conflictos en tiempo de ejecución.
Finalmente, ejecuta anchor keys sync para asegurarte de que el ID de tu programa en la macro declare_id! coincida con el keypair definido en tu Anchor.toml.
Tenemos todas las dependencias listas, ahora, configuremos el flujo de trabajo para crear e inicializar mints de tokens con devengo de intereses.
i. Crear e inicializar mints de token con devengo de intereses
Ahora crearemos la función create_interest_bearing_mint:
pub fn create_interest_bearing_mint(...) -> Result<()> { ... }
La función realiza cuatro pasos para configurar un nuevo mint Token-2022 con la extensión InterestBearingConfig habilitada. Los pasos son:
- Paso 1: Calcular el tamaño de cuenta requerido para
InterestBearingConfig - Paso 2: Crear la cuenta del mint y fondearla con lamports para el alquiler
- Paso 3: Inicializar la extensión
InterestBearingConfig - Paso 4: Ejecutar la función estándar
initialize_mint2
Paso 1: Calcular el tamaño de cuenta requerido
Al crear una cuenta en Solana, necesitas especificar el tamaño de la cuenta y pagar el alquiler correspondiente.
Usaremos la función try_calculate_account_len del ExtensionType que importamos anteriormente para calcular el tamaño de cuenta requerido para albergar tanto los datos base del mint como los datos de la extensión automáticamente. Esto asegura que la cuenta se asigne con suficiente espacio para la extensión InterestBearingConfig.
let mint_size = ExtensionType::try_calculate_account_len::<Mint>(&[
ExtensionType::InterestBearingConfig,
])?;
En la parte uno, discutimos cómo calcular manualmente los datos de la extensión, pero aquí usaremos try_calculate_account_len. Usar try_calculate_account_len es una práctica estándar y nos permite calcular el tamaño exacto de la cuenta del Mint y los datos de la extensión de una sola vez.
Paso 2: Crear y fondear la cuenta del mint
Ahora que tenemos el mecanismo para calcular el tamaño exacto, crearemos la cuenta del mint manualmente usando el programa del sistema (system program), fondeándola con lamports exentos de alquiler (cuando una cuenta posee suficientes lamports en relación a su tamaño, se vuelve “exenta de alquiler” y nunca se le cobrará alquiler ni será eliminada).
Anchor no realiza este paso automáticamente porque los mints de Token-2022 requieren un tamaño personalizado para las extensiones. El atributo #[account(init)] en las cuentas asume un tamaño fijo (válido para los mints estándar de SPL Token), pero los mints de Token-2022 varían dependiendo de las extensiones que incluyan. Para manejar esto correctamente, debes calcular el espacio requerido tú mismo y crear la cuenta manualmente.
El código a continuación crea la cuenta del mint con el espacio exacto y los lamports necesarios para la exención de alquiler.
Rent::get()?.minimum_balance(mint_size)calcula los lamports mínimos requeridos para hacer que la cuenta sea exenta de alquiler basándose en su tamaño.system_program::create_accountluego asigna y fondea la cuenta, asignando la propiedad al programa Token-2022 (token_program.key()).- El contexto CPI especifica que los lamports provienen del pagador (payer), y la nueva cuenta que se está creando es el mint.
Esto asegura que la cuenta del mint esté dimensionada adecuadamente, exenta de alquiler y poseída por el programa correcto antes de que cualquier instrucción de Token-2022 la inicialice.
// 2) Create the mint account with correct space and rent
let lamports = Rent::get()?.minimum_balance(mint_size);
system_program::create_account(
CpiContext::new(
ctx.accounts.system_program.to_account_info(),
CreateAccount {
from: ctx.accounts.payer.to_account_info(),
to: ctx.accounts.mint.to_account_info(),
},
),
lamports,
mint_size as u64,
&ctx.accounts.token_program.key(),
)?;
Definiremos el struct de cuentas completo CreateInterestBearingMint al que se refiere &ctx en el código anterior más adelante en este artículo.
Paso 3: Inicializar la extensión InterestBearingConfig
A continuación, inicializamos la extensión InterestBearingConfig estableciendo la autoridad de tasas y la tasa de interés inicial (en puntos básicos).
Este paso debe ocurrir antes de inicializar el mint base, ya que las extensiones deben configurarse primero; de lo contrario, el diseño del mint no coincidirá con el tamaño de cuenta esperado y initialize_mint2 fallará.
// 3) Initialize the interest-bearing extension BEFORE base mint init
interest_bearing_mint_initialize(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
InterestBearingMintInitialize {
token_program_id: ctx.accounts.token_program.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
},
),
Some(ctx.accounts.rate_authority.key()),
rate_bps,
)?;
Usamos Some(ctx.accounts.rate_authority.key()) aquí porque la autoridad de tasas es opcional. Como mencionamos en la parte uno, si no se proporciona una autoridad de tasas, el campo se rellenará con ceros, haciendo que la tasa sea inmutable.
Paso 4: Ejecutar la función estándar initialize_mint2
Finalmente, el código a continuación inicializa el mint base en sí usando el CPI estándar initialize_mint2. Esto establece los decimales del mint, asigna el PDA como autoridad de acuñación y de congelación (freeze authority), y finaliza la configuración del mint Token-2022.
Debido a que los programas no pueden poseer claves privadas, el PDA actúa como la autoridad del mint. Siempre que el programa necesite firmar en nombre de este PDA (por ejemplo, al acuñar nuevos tokens), debe derivar nuevamente el PDA usando la misma combinación de semilla y bump ([b"mint-authority", &[bump]]).
Anchor expone este bump a través de ctx.bumps.
El bump es un valor de un solo byte (0–255) añadido durante la derivación del PDA. Asegura que la dirección resultante no pueda ser generada a partir de ninguna clave privada. También debe ser incluido en las semillas de firmante (signer seeds) durante la verificación de firmas del PDA; de lo contrario, la verificación fallará.
También establecemos tanto la autoridad del mint como la autoridad de congelación al PDA para asegurar que solo la lógica del programa pueda acuñar o congelar tokens.
// 4) Initialize base mint (decimals, authorities)
let mint_auth_bump = ctx.bumps.mint_authority;
let signer_seeds: &[&[&[u8]]] = &[&[b"mint-authority", &[mint_auth_bump]]];
initialize_mint2(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
InitializeMint2 {
mint: ctx.accounts.mint.to_account_info(),
},
signer_seeds,
),
decimals,
&ctx.accounts.mint_authority.key(),
Some(&ctx.accounts.mint_authority.key()),
)?;
Contexto de cuentas CreateInterestBearingMint
A continuación se muestra el struct que define el contexto de cuentas para la función CreateInterestBearingMint que hemos usado hasta ahora.
Nota que el mint se declara como una UncheckedAccount en lugar de una InterfaceAccount<Mint> (un wrapper de Anchor alrededor de AccountInfo que valida automáticamente una cuenta para asegurar que sea un mint de token inicializado).
Usamos UncheckedAccount aquí porque necesitamos crear el mint con el espacio para la extensión, y Anchor no puede validarlo como un Mint hasta después de la inicialización.
El struct define el PDA de mint_authority con su semilla y bump. Una vez hecho esto, la lógica del programa puede acuñar o congelar tokens, pero ningún keypair externo puede hacerlo.
El struct también define otras cuentas que hemos usado; hemos añadido comentarios especificándolas.
#[derive(Accounts)]
pub struct CreateInterestBearingMint<'info> {
/// CHECK: This account is created manually as a Token-2022 mint with extensions.
#[account(mut)]
pub payer: Signer<'info>,
/// CHECK: PDA account used as mint and freeze authority
#[account(
seeds = [b"mint-authority"],
bump
)]
pub mint_authority: UncheckedAccount<'info>,
/// Raw mint account to be created with extension space
/// CHECK: We trust the token program to validate this is a proper mint account.
#[account(mut, signer)]
pub mint: UncheckedAccount<'info>,
/// Token-2022 program
pub token_program: Program<'info, Token2022>,
pub system_program: Program<'info, System>,
/// Signer that will control interest rate updates
pub rate_authority: Signer<'info>,
}
El código completo para crear el mint de token que hemos discutido hasta ahora se muestra a continuación:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{self, CreateAccount};
use anchor_spl::{
token_2022::{
initialize_mint2,
spl_token_2022::{
extension::{ExtensionType},
pod::PodMint,
},
InitializeMint2, Token2022,
},
token_interface::{Mint, TokenAccount, mint_to, MintTo},
token_2022_extensions::interest_bearing_mint::{
interest_bearing_mint_initialize,
interest_bearing_mint_update_rate,
InterestBearingMintInitialize,
InterestBearingMintUpdateRate,
},
};
declare_id!("5fRtYEKp81rfRRqcYxp9XMnucgvtt6JxQ5GDDQy2TMtx");
#[program]
pub mod interest_bearing {
use super::*;
pub fn create_interest_bearing_mint(
ctx: Context<CreateInterestBearingMint>,
rate_bps: i16,
decimals: u8,
) -> Result<()> {
msg!("Create interest-bearing mint @ {} bps", rate_bps);
// 1) Compute mint size including extension header + InterestBearingConfig
let mint_size = ExtensionType::try_calculate_account_len::<PodMint>(&[
ExtensionType::InterestBearingConfig,
])?;
// 2) Create the mint account with correct space and rent
let lamports = Rent::get()?.minimum_balance(mint_size);
system_program::create_account(
CpiContext::new(
ctx.accounts.system_program.to_account_info(),
CreateAccount {
from: ctx.accounts.payer.to_account_info(),
to: ctx.accounts.mint.to_account_info(),
},
),
lamports,
mint_size as u64,
&ctx.accounts.token_program.key(),
)?;
// 3) Initialize the interest-bearing extension BEFORE base mint init
interest_bearing_mint_initialize(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
InterestBearingMintInitialize {
token_program_id: ctx.accounts.token_program.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
},
),
Some(ctx.accounts.rate_authority.key()),
rate_bps,
)?;
// 4) Initialize base mint (decimals, authorities)
let mint_auth_bump = ctx.bumps.mint_authority;
let signer_seeds: &[&[&[u8]]] = &[&[b"mint-authority", &[mint_auth_bump]]];
initialize_mint2(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
InitializeMint2 {
mint: ctx.accounts.mint.to_account_info(),
},
signer_seeds,
),
decimals,
&ctx.accounts.mint_authority.key(),
Some(&ctx.accounts.mint_authority.key()),
)?;
Ok(())
}
#[derive(Accounts)]
pub struct CreateInterestBearingMint<'info> {
/// CHECK: This account is created manually as a Token-2022 mint with extensions.
#[account(mut)]
pub payer: Signer<'info>,
/// CHECK: PDA account used as mint and freeze authority
#[account(
seeds = [b"mint-authority"],
bump
)]
pub mint_authority: UncheckedAccount<'info>,
/// Raw mint account to be created with extension space
/// CHECK: We trust the token program to validate this is a proper mint account.
// #[account(mut)]
#[account(mut, signer)]
pub mint: UncheckedAccount<'info>,
/// Token-2022 program
pub token_program: Program<'info, Token2022>,
pub system_program: Program<'info, System>,
/// Signer that will control interest rate updates
pub rate_authority: Signer<'info>,
}
}
Ahora que hemos creado los mints del token e inicializado la extensión, procedamos a implementar la función mint_tokens.
ii. Creando la función Mint tokens
La función mint_tokens acuña tokens a la cuenta de un usuario, utilizando el PDA como la autoridad del mint.
Esto es lo que hace la función mint_tokens:
- Primero recupera el bump del PDA y reconstruye las semillas de firmante necesarias para la verificación.
- Luego, llama al CPI
mint_todel programa Token-2022. Pasa las semillas de firmante a través deCpiContext::new_with_signer, el runtime reconoce al PDA como el firmante autorizado y acuña el número especificado de tokens a la cuenta de tokens del destinatario.
pub fn mint_tokens(ctx: Context<MintTokens>, amount: u64) -> Result<()> {
// Fetch the bump for the PDA so we can recreate the same signer seeds
let bump = ctx.bumps.mint_authority;
let signer_seeds: &[&[&[u8]]] = &[&[b"mint-authority", &[bump]]];
// Call into the Token-2022 program to mint tokens
// `CpiContext::new_with_signer` lets us pass the PDA seeds so the runtime
// can treat the PDA as if it signed the instruction
mint_to(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
MintTo {
// Mint account whose supply will increase
mint: ctx.accounts.mint.to_account_info(),
// Recipient’s token account that will receive the minted tokens
to: ctx.accounts.to_token_account.to_account_info(),
// PDA that acts as mint authority
authority: ctx.accounts.mint_authority.to_account_info(),
},
signer_seeds,
),
amount, // Number of tokens to mint
)?;
Ok(())
}
A continuación se encuentra el struct que define todas las cuentas que usamos en la función mint_tokens de arriba.
Enumera todas las cuentas necesarias para acuñar nuevos tokens bajo la autoridad del PDA, asegurando que se utilicen el mint, el destinatario y el programa Token-2022 correctos.
pub struct MintTokens<'info> {
/// CHECK: PDA authority must match the seed used during mint init
#[account(
seeds = [b"mint-authority"],
bump
)]
/// CHECK: This is the mint authority PDA we created during mint init.
pub mint_authority: UncheckedAccount<'info>,
/// Use token_interface to bind this Mint to Token2022 program
/// CHECK: We trust the token program to validate this is a proper mint account.
#[account(mut, mint::token_program = token_program)]
pub mint: InterfaceAccount<'info, Mint>,
#[account(mut, token::mint = mint, token::authority = recipient)]
pub to_token_account: InterfaceAccount<'info, TokenAccount>,
pub recipient: Signer<'info>,
pub token_program: Program<'info, Token2022>,
}
iii. Actualizando la tasa de interés
Hemos visto cómo crear el mint del token, inicializar la extensión y acuñar tokens. El siguiente paso es actualizar la tasa de interés.
La función update_rate a continuación asegura que solo la rate_authority configurada pueda actualizar la tasa de interés anual del mint. Lo hace llamando al CPI de Token-2022 interest_bearing_mint_update_rate.
La función también utiliza el struct InterestBearingMintUpdateRate para especificar qué cuentas (mint, programa del token y autoridad de tasas) son requeridas para la llamada CPI, y luego verifica que el firmante coincida con la autoridad configurada internamente antes de actualizar la tasa almacenada en los datos de la extensión del mint.
pub fn update_rate(ctx: Context<UpdateRate>, new_rate_bps: i16) -> Result<()> {
msg!("Update interest rate -> {} bps", new_rate_bps);
// Call into the Token-2022 program to update interest rate on the mint
// The CPI will check that the provided rate_authority signer matches the
// authority configured in the mint's extension data
interest_bearing_mint_update_rate(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
InterestBearingMintUpdateRate {
token_program_id: ctx.accounts.token_program.to_account_info(),
mint: ctx.accounts.mint.to_account_info(),
rate_authority: ctx.accounts.rate_authority.to_account_info(),
},
),
new_rate_bps, // new interest rate in basis points (1% = 100 bps)
)?;
Ok(())
}
El struct de contexto a continuación define las cuentas requeridas para actualizar la tasa. La rate_authority debe firmar la transacción, y el programa Token-2022 asegura que coincida con la autoridad establecida en la extensión del mint.
#[derive(Accounts)]
pub struct UpdateRate<'info> {
/// CHECK: This is the mint account we’re updating. We rely on Token-2022
/// program logic to validate its data, so Anchor does not need to enforce checks here.
#[account(mut, mint::token_program = token_program)]
pub mint: InterfaceAccount<'info, Mint>,
/// Must sign and match the extension’s configured rate authority
pub rate_authority: Signer<'info>,
pub token_program: Program<'info, Token2022>,
}
Implementación completa
Puedes clonar la implementación completa desde el siguiente repositorio para explorar el código completo:
git clone [https://github.com/ezesundayeze/interest-bearing-mint](https://github.com/ezesundayeze/interest-bearing-mint/blob/main/programs/interest-bearing/src/lib.rs)
Las pruebas en TypeScript
Primero, construye el programa ejecutando anchor build en tu terminal.
Necesitaremos probar diferentes líneas de tiempo para demostrar verdaderamente la acumulación de rendimientos a lo largo de un período de tiempo. Esto puede ser complicado de hacer en las pruebas, por lo que usaremos LiteSVM—una máquina virtual ligera de Solana para simular el progreso del tiempo y verificar la acumulación de intereses sin tener que ejecutarse en un clúster en vivo.
Instala LiteSVM y la dependencia de la biblioteca SPL token de Solana que usaremos para interactuar con el token:
yarn add anchor-litesvm @solana/spl-token
Reemplaza el contenido de tu archivo interest_bearing.ts con el código a continuación.
Esta prueba interactúa con nuestro programa para demostrar cómo la extensión del Token con Devengo de Intereses incrementa su valor mediante el interés compuesto con el tiempo.
La prueba sigue estos pasos:
-
Inicializar un mint con devengo de intereses: Crea un nuevo mint de token configurado con una tasa de interés anual del 3% y asigna una autoridad que posteriormente puede actualizar esta tasa. El timestamp de inicialización también se registra para un seguimiento preciso de los intereses.
-
Acuñar tokens a un destinatario: Acuña 1000 tokens en la cuenta de tokens asociada del destinatario. La prueba confirma que la configuración del mint on-chain y los balances de los tokens sean correctos al momento de la inicialización.
-
Simular el interés compuesto durante múltiples períodos:
Utiliza el reloj virtual de LiteSVM para avanzar el tiempo y demostrar el crecimiento compuesto:
- Período 1: 3 meses a una tasa anual del 3%
- Período 2: 9 meses más a una tasa anual del 5% (después de la actualización de la tasa, en el mes 12)
- Período 3: 3 meses más a una tasa anual del 7% (período final, en el mes 15)
Cada período calcula:
- El balance esperado utilizando la fórmula de interés compuesto continuo
- El balance virtual calculado por los ayudantes (helpers) de SPL Token, que reflejan los intereses devengados.
Ambos resultados se comparan para confirmar que el crecimiento virtual coincide con la expectativa matemática del interés compuesto continuo.
-
Validar el crecimiento compuesto sobre 15 meses:
Confirma que el balance del token crece en línea con la curva exponencial esperada, incluso después de múltiples cambios de tasa. La prueba también imprime resultados intermedios para mostrar cómo evoluciona el interés compuesto en cada etapa.
Este código contiene comentarios que explican lo que hace cada bloque:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { InterestBearing } from "../target/types/interest_bearing";
import {
TOKEN_2022_PROGRAM_ID,
createAssociatedTokenAccountInstruction,
getAssociatedTokenAddressSync,
getAccount,
getMint,
getInterestBearingMintConfigState,
} from "@solana/spl-token";
import {
PublicKey,
Keypair,
Transaction,
LAMPORTS_PER_SOL,
} from "@solana/web3.js";
import { fromWorkspace, LiteSVMProvider } from "anchor-litesvm";
import assert from "assert";
// Constants for interest calculations (must be at module level)
const SECONDS_PER_YEAR = 365.24 * 24 * 60 * 60; // 365.24 days
const ONE_IN_BASIS_POINTS = 10000;
/**
* Calculate the exponential factor for continuous compounding
* This mirrors the SPL Token implementation exactly.
* We are copying it here because it's not exported from the SPL token library.
*
* Formula: e^((rate * timespan) / (SECONDS_PER_YEAR * 10000))
*
* @param t1 - Start time in seconds
* @param t2 - End time in seconds
* @param rateBps - Interest rate in basis points
*/
const calculateExponentForTimesAndRate = (
t1: number,
t2: number,
rateBps: number
): number => {
const timespan = t2 - t1;
const numerator = rateBps * timespan;
const exponent = numerator / (SECONDS_PER_YEAR * ONE_IN_BASIS_POINTS);
return Math.exp(exponent);
};
describe("interest-bearing", () => {
// Set up a lightweight Solana VM for testing
const svm = fromWorkspace("./").withBuiltins().withSysvars();
const provider = new LiteSVMProvider(svm);
anchor.setProvider(provider);
// Get reference to our compiled program
const program = anchor.workspace.InterestBearing as Program<InterestBearing>;
// Key accounts we'll use throughout the tests
let mint: Keypair;
let rateAuthority: Keypair;
let recipient: Keypair;
let recipientAta: PublicKey;
// Interest rates in basis points (1 basis point = 0.01%)
const RATE_1_BPS = 300; // 3.00% annual rate
const RATE_2_BPS = 500; // 5.00% annual rate
const RATE_3_BPS = 700; // 7.00% annual rate
// More precise year definition (accounts for leap years)
const SECONDS_PER_YEAR = 365.24 * 24 * 60 * 60; // ~31,556,736 seconds
// Token configuration
const DECIMALS = 9;
const INITIAL_BALANCE = 1000; // Start with 1000 tokens (UI amount)
// Starting point for our virtual clock (Jan 1, 2024)
const INITIAL_TIMESTAMP = 1704067200n;
/**
* Get UI amount for interest-bearing tokens
* This implements the exact same logic as amountToUiAmountForInterestBearingMintWithoutSimulation
* from the SPL Token library, adapted for LiteSVM
*
* The calculation happens in two phases:
* 1. Pre-update: Interest from initialization to last rate update
* 2. Post-update: Interest from last rate update to current time
*
* Total scale = e^(r1*t1) * e^(r2*t2)
*/
const getInterestBearingUiAmount = async (
rawAmount: bigint
): Promise<number> => {
// Fetch mint configuration
const mintInfo = await getMint(
provider.connection,
mint.publicKey,
"confirmed",
TOKEN_2022_PROGRAM_ID
);
const interestConfig = getInterestBearingMintConfigState(mintInfo);
if (!interestConfig) {
throw new Error("Interest config not found");
}
// Get current timestamp from LiteSVM clock
const currentTimestamp = Number(svm.getClock().unixTimestamp);
const lastUpdateTimestamp = Number(interestConfig.lastUpdateTimestamp);
const initializationTimestamp = Number(
interestConfig.initializationTimestamp
);
// Calculate pre-update exponent (initialization to last update)
const preUpdateExp = calculateExponentForTimesAndRate(
initializationTimestamp,
lastUpdateTimestamp,
interestConfig.preUpdateAverageRate
);
// Calculate post-update exponent (last update to current time)
const postUpdateExp = calculateExponentForTimesAndRate(
lastUpdateTimestamp,
currentTimestamp,
interestConfig.currentRate
);
// Total scale factor is the product of both exponentials
const totalScale = preUpdateExp * postUpdateExp;
// Apply the scale to the raw amount
const scaledAmount = Number(rawAmount) * totalScale;
// Convert to UI amount by dividing by decimal factor
const decimalFactor = Math.pow(10, DECIMALS);
const uiAmount = Math.trunc(scaledAmount) / decimalFactor;
return uiAmount;
};
/**
* Manually calculate expected balance with continuous compounding
* This serves as our "test oracle" to verify the SPL Token calculations are correct
*
* Formula: A_final = A_start * e^(rate * time_in_years)
*/
const calculateExpectedBalance = (
startBalance: number,
rateBps: number,
timeInYears: number
): number => {
const rateDecimal = rateBps / 10000;
return startBalance * Math.exp(rateDecimal * timeInYears);
};
before(async () => {
// Set our virtual clock to Jan 1, 2024 (for a consistent starting point)
const clock = svm.getClock();
clock.unixTimestamp = INITIAL_TIMESTAMP;
svm.setClock(clock);
console.log("Initial clock set to:", INITIAL_TIMESTAMP.toString());
// Generate fresh keypairs for this test run
mint = Keypair.generate();
rateAuthority = Keypair.generate();
recipient = Keypair.generate();
// Give accounts some SOL to pay for transactions
svm.airdrop(provider.wallet.publicKey, BigInt(5 * LAMPORTS_PER_SOL));
svm.airdrop(recipient.publicKey, BigInt(5 * LAMPORTS_PER_SOL));
});
/**
* Test 1: Create the interest-bearing mint
*/
it("creates an interest bearing mint", async () => {
// Call our program to initialize the mint with starting rate of 3%
await program.methods
.createInterestBearingMint(RATE_1_BPS, DECIMALS)
.accounts({
payer: provider.wallet.publicKey, // Who pays for the transaction
mint: mint.publicKey, // The new mint we're creating
rateAuthority: rateAuthority.publicKey, // Who can update interest rates
})
.signers([rateAuthority, mint])
.rpc();
// Verify the mint was created with correct configuration
const mintInfo = await getMint(
provider.connection,
mint.publicKey,
"confirmed",
TOKEN_2022_PROGRAM_ID
);
const interestConfig = await getInterestBearingMintConfigState(mintInfo);
console.log("Interest-bearing config:", {
rateAuthority: interestConfig?.rateAuthority?.toBase58(),
currentRate: interestConfig?.currentRate,
initializationTimestamp: interestConfig?.initializationTimestamp,
lastUpdateTimestamp: interestConfig?.lastUpdateTimestamp,
});
// Ensure initialization timestamp was recorded (important for interest calculations)
assert.ok(
interestConfig?.initializationTimestamp !== 0,
"Initialization timestamp should not be 0"
);
});
/**
* Test 2: Mint initial tokens to recipient
*/
it("mints tokens to a recipient", async () => {
recipientAta = getAssociatedTokenAddressSync(
mint.publicKey,
recipient.publicKey,
false,
TOKEN_2022_PROGRAM_ID
);
// Create the ATA (it doesn't exist yet)
const createAtaTx = new Transaction().add(
createAssociatedTokenAccountInstruction(
provider.wallet.publicKey,
recipientAta,
recipient.publicKey,
mint.publicKey,
TOKEN_2022_PROGRAM_ID
)
);
await provider.sendAndConfirm(createAtaTx, []);
// Mint the initial balance of tokens to the recipient
// Convert UI amount (1000) to raw amount (1000 * 10^9)
await program.methods
.mintTokens(new anchor.BN(INITIAL_BALANCE * 10 ** DECIMALS))
.accounts({
mint: mint.publicKey,
toTokenAccount: recipientAta,
recipient: recipient.publicKey,
})
.signers([recipient])
.rpc();
// Verify the correct amount was minted
const tokenAccount = await getAccount(
provider.connection,
recipientAta,
"confirmed",
TOKEN_2022_PROGRAM_ID
);
// For interest-bearing tokens, we need to use the SPL Token method to get UI amount
const balance = await getInterestBearingUiAmount(tokenAccount.amount);
assert.strictEqual(
balance,
INITIAL_BALANCE,
`Initial balance should be ${INITIAL_BALANCE}`
);
console.log(`Initial balance: ${balance} tokens`);
});
/**
* Test 3: demonstrate compound interest over 15 months
*
* Timeline:
* 1. Start with 1000 tokens at 3% rate
* 2. Wait 3 months → balance grows with 3% rate
* 3. Change rate to 5%
* 4. Wait 9 more months → balance grows with 5% rate (12 months total)
* 5. Change rate to 7%
* 6. Wait 3 more months → balance grows with 7% rate (15 months total)
*/
it("demonstrates compounded interest growth: 3 months, 12 months, 15 months", async () => {
console.log("\n=== Starting Interest Accrual Test ===");
console.log(`Starting balance: ${INITIAL_BALANCE} tokens\n`);
// ==================================
// PERIOD 1: First 3 months at 3% annual rate
// ==================================
console.log(`\n--- Period 1: 3 Months @ ${RATE_1_BPS / 100}% ---`);
// Fast-forward time by 3 months (0.25 years)
const clock1 = svm.getClock();
clock1.unixTimestamp += BigInt(Math.floor(SECONDS_PER_YEAR * 0.25));
svm.setClock(clock1);
// Check the recipient's token balance
const tokenAccount1 = await getAccount(
provider.connection,
recipientAta,
"confirmed",
TOKEN_2022_PROGRAM_ID
);
// Use the official SPL Token method to get UI amount with interest applied
const balanceAfter3Months = await getInterestBearingUiAmount(
tokenAccount1.amount
);
// Calculate what we expect using the continuous compounding formula
const expectedBalance1 = calculateExpectedBalance(
INITIAL_BALANCE,
RATE_1_BPS,
0.25
);
console.log(`Balance after 3 months: ${balanceAfter3Months.toFixed(6)}`);
console.log(
`Expected balance (A = P e^{r t}): ${expectedBalance1.toFixed(6)}`
);
console.log(
`Interest earned: ${(balanceAfter3Months - INITIAL_BALANCE).toFixed(6)}`
);
// Verify the calculation is correct (within 0.01 token tolerance)
assert.ok(
Math.abs(balanceAfter3Months - expectedBalance1) < 0.01,
"Balance after 3 months is incorrect"
);
// ===============================================
// PERIOD 2: Change rate to 5%, then advance 9 more months
// ===============================================
// Update the interest rate to 5%
await program.methods
.updateRate(RATE_2_BPS)
.accounts({
mint: mint.publicKey,
rateAuthority: rateAuthority.publicKey,
})
.signers([rateAuthority])
.rpc();
console.log(
`\n--- Period 2: 9 Months @ ${
RATE_2_BPS / 100
}% after initial 3 months (total = 12 months) ---`
);
// Fast-forward time by 9 more months (total of 12 months from start)
const clock2 = svm.getClock();
clock2.unixTimestamp += BigInt(Math.floor(SECONDS_PER_YEAR * 0.75));
svm.setClock(clock2);
const tokenAccount2 = await getAccount(
provider.connection,
recipientAta,
"confirmed",
TOKEN_2022_PROGRAM_ID
);
// Get UI amount using SPL Token's official method
const balanceAfter12Months = await getInterestBearingUiAmount(
tokenAccount2.amount
);
// Expected: (balance after 3 months) * e^(0.05 * 0.75)
const expectedBalance2 = calculateExpectedBalance(
balanceAfter3Months,
RATE_2_BPS,
0.75
);
console.log(`Balance after 12 months: ${balanceAfter12Months.toFixed(6)}`);
console.log(
`Expected balance (A2 = A1 * e^{r2 * 0.75}): ${expectedBalance2.toFixed(
6
)}`
);
console.log(
`Total interest earned: ${(
balanceAfter12Months - INITIAL_BALANCE
).toFixed(6)}`
);
assert.ok(
Math.abs(balanceAfter12Months - expectedBalance2) < 0.01,
"Balance after 12 months is incorrect"
);
// ==============================================
// PERIOD 3: Change rate to 7%, then advance final 3 months
// ==============================================
// Update the interest rate to 7%
await program.methods
.updateRate(RATE_3_BPS)
.accounts({
mint: mint.publicKey,
rateAuthority: rateAuthority.publicKey,
})
.signers([rateAuthority])
.rpc();
console.log(
`\n--- Period 3: extra 3 Months @ ${
RATE_3_BPS / 100
}% (total = 15 months) ---`
);
// Fast-forward time by 3 final months (total of 15 months from start)
const clock3 = svm.getClock();
clock3.unixTimestamp += BigInt(Math.floor(SECONDS_PER_YEAR * 0.25));
svm.setClock(clock3);
const tokenAccount3 = await getAccount(
provider.connection,
recipientAta,
"confirmed",
TOKEN_2022_PROGRAM_ID
);
// Get final UI amount using SPL Token's official method
const balanceAfter15Months = await getInterestBearingUiAmount(
tokenAccount3.amount
);
// Expected: (balance after 12 months) * e^(0.07 * 0.25)
const expectedBalance3 = calculateExpectedBalance(
balanceAfter12Months,
RATE_3_BPS,
0.25
);
console.log(`Balance after 15 months: ${balanceAfter15Months.toFixed(6)}`);
console.log(
`Expected balance (A3 = A2 * e^{r3 * 0.25}): ${expectedBalance3.toFixed(
6
)}`
);
console.log(
`Total interest earned: ${(
balanceAfter15Months - INITIAL_BALANCE
).toFixed(6)}`
);
console.log(
`Effective return over 15 months: ${(
(balanceAfter15Months / INITIAL_BALANCE - 1) *
100
).toFixed(6)}%`
);
// Final verification (slightly larger tolerance for accumulated rounding)
assert.ok(
Math.abs(balanceAfter15Months - expectedBalance3) < 0.02,
"Final balance after 15 months is incorrect"
);
});
});
Ejecuta las pruebas con el comando anchor test. La salida de las pruebas debería verse así:

De la captura de pantalla anterior, notarás que nuestra acumulación de intereses funciona correctamente y se alinea con el cálculo de interés compuesto continuo que discutimos antes.
Conclusión
Hasta ahora hemos recorrido el ciclo de vida completo de una extensión de devengo de intereses. Esto nos ha permitido comprender más profundamente cómo funciona la extensión de intereses más allá de los conceptos. Y te proporciona un punto de partida concreto para experimentar con extensiones e integrarlas en programas reales.
Ejercicio de autoestudio
Construye un Programa Simple de Recompensas por Staking
Los usuarios depositan tokens regulares (como USDC) en un pool de staking y reciben “tokens de recibo” con devengo de intereses que aumentan automáticamente su valor con el tiempo, eliminando la necesidad de mecanismos complejos para reclamar recompensas.
¡Etiqueta a @rareskills_io en X cuando hayas construido exitosamente tu prototipo!
Este artículo es parte de una serie de tutoriales sobre Solana.