计息扩展(interest-bearing extension)为代币铸币厂(token mint)添加了随着时间推移累积利息的功能。在之前关于 Token-2022 的讨论中,我们介绍了计息扩展,并解释了余额如何在不改变链上账户原始余额的情况下实现虚拟增长。我们之前的重点是该扩展在概念上是如何工作的,以及 Solana 的客户端函数是如何计算应计利息的。
在本文中,我们将把这些知识付诸实践。我们将使用 Anchor 构建一个管理系统,通过编程方式在一个 PDA(程序派生地址) 权限下创建一个计息代币铸币厂,这能确保只有程序才能控制它。该系统还允许通过指定的利率权限(rate authority)来更新利率。
我们将要构建的程序将展示计息代币的完整生命周期:初始化、铸币、利息累积和利率变更。 我们还将使用 LiteSVM 进行时间穿越(time traveling),以测试随着时间推移产生的利息累积。
在本文结束时,你将对计息代币在实践中的运作方式有扎实的理解。
项目初始化
我们首先创建一个新的 Anchor 项目。运行以下命令来初始化项目:
anchor init interest-bearing && cd interest-bearing
现在,更新你的 program/src/Cargo.toml 文件,包含 anchor-spl 依赖项并启用 idl-build。idl-build 特性使 Anchor 能够为 CPI(跨程序调用)调用生成 IDL 定义,我们稍后在编写测试以调用程序函数时会用到它。
[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
你现在可以成功运行 anchor build 来确认你的项目已正确设置。
项目结构
该项目将分为两个阶段:
- Anchor Rust 程序
- TypeScript 测试
1. Anchor Rust 程序
Anchor Rust 程序将处理三个核心操作:
- 创建并初始化一个新的计息铸币厂,并将 PDA 设置为铸币权限(mint authority)
- 铸造计息代币
- 通过利率权限更新利率
这些操作中的每一个都将作为链上函数入口点在 Anchor 中实现。
#[program]
pub mod interest_bearing {
pub fn create_interest_bearing_mint(...) -> Result<()> { ... }
pub fn mint_tokens(...) -> Result<()> { ... }
pub fn update_rate(...) -> Result<()> { ... }
}
我们将在 programs/interest-bearing/src/lib.rs 文件中定义这些函数:
create_interest_bearing_mint:创建一个启用了InterestBearingConfig扩展的代币铸币厂,并设置利率权限。mint_tokens:使用 PDA 作为铸币权限,将代币铸造到用户的账户中。update_rate:更新铸币厂的年利率,仅限利率权限执行此操作。
2. TypeScript 测试
TypeScript 测试将验证程序能够:
- 创建计息铸币厂
- 在 PDA 权限下铸造代币
- 通过利率权限更新利率
- 准确显示虚拟利息累积
实现 Anchor Rust 程序
现在我们了解了项目结构,接下来让我们实现链上程序本身。
我们首先在 program/interest-bearing/src/lib.rs 文件中导入所需的 Anchor 和 Token-2022 依赖项,并声明程序 ID:
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");
注意,我们没有直接安装 spl-token-2022——而是使用了 Anchor 的重新导出(re-export)。混合使用两者可能会导致版本不匹配和运行时冲突。
最后,运行 anchor keys sync 以确保 declare_id! 宏中的程序 ID 与你的 Anchor.toml 中定义的密钥对相匹配。
我们已经准备好了所有的依赖项,现在,让我们设置创建和初始化计息代币铸币厂的工作流程。
i. 创建并初始化计息代币铸币厂
我们现在将创建 create_interest_bearing_mint 函数:
pub fn create_interest_bearing_mint(...) -> Result<()> { ... }
该函数执行四个步骤,以设置一个启用了 InterestBearingConfig 扩展的新 Token-2022 铸币厂。步骤如下:
- 步骤 1:计算
InterestBearingConfig账户大小 - 步骤 2:创建铸币账户并为其注资 lamports 用于租金
- 步骤 3:初始化
InterestBearingConfig扩展 - 步骤 4:运行标准的
initialize_mint2函数
步骤 1:计算所需的账户大小
在 Solana 中创建账户时,你需要指定账户的大小并支付相应的租金。
我们将使用之前导入的 ExtensionType 中的 try_calculate_account_len 函数,自动计算容纳基础铸币数据和扩展数据所需的账户大小。这确保了该账户被分配了足够的空间来存储 InterestBearingConfig 扩展。
let mint_size = ExtensionType::try_calculate_account_len::<Mint>(&[
ExtensionType::InterestBearingConfig,
])?;
在第一部分中,我们讨论了如何手动计算扩展数据,但这里我们将使用 try_calculate_account_len。使用 try_calculate_account_len 是标准做法,它允许我们一次性准确计算出 Mint 账户和扩展数据的大小。
步骤 2:创建并注资铸币账户
现在我们有了计算准确大小的机制,我们将使用系统程序(system program)手动创建铸币账户,并为其提供免租金的 lamports(当一个账户相对于其大小持有足够的 lamports 时,它就会变成“免租金(rent-exempt)”状态,并且永远不会被收取租金或被删除)。
Anchor 不会自动执行此步骤,因为 Token-2022 铸币厂需要为扩展进行自定义调整大小。账户上的 #[account(init)] 属性假定大小是固定的(这适用于标准 SPL Token 铸币厂),但 Token-2022 铸币厂的大小取决于它们包含的扩展。要正确处理这个问题,你必须自己计算所需的空间并手动创建账户。
下面的代码创建了具有确切空间和免租金所需 lamports 的铸币账户。
Rent::get()?.minimum_balance(mint_size)根据账户大小计算使其免租金所需的最小 lamports。- 然后
system_program::create_account分配并为账户注资,将所有权分配给 Token-2022 程序(token_program.key())。 - CPI 上下文指定 lamports 来自
payer(支付方),并且正在创建的新账户是mint(铸币厂)。
这确保了在任何 Token-2022 指令对其进行初始化之前,铸币账户的尺寸大小合适、免租金且归属于正确的程序。
// 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(),
)?;
我们将在本文后面定义上述代码中 &ctx 所引用的完整 CreateInterestBearingMint 账户结构体。
步骤 3:初始化 InterestBearingConfig 扩展
接下来,我们通过设置利率权限和初始利率(以基点为单位)来初始化 InterestBearingConfig 扩展。
这一步骤必须在初始化基础铸币厂之前发生,因为必须首先设置扩展——否则,铸币厂的布局将与预期的账户大小不匹配,从而导致 initialize_mint2 失败。
// 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,
)?;
我们在这里使用了 Some(ctx.accounts.rate_authority.key()),因为利率权限是可选的。正如第一部分所提到的,如果没有提供利率权限,该字段将被填充为零,使得利率变得不可更改。
步骤 4:运行标准的 initialize_mint2 函数
最后,下面的代码使用标准的 initialize_mint2 CPI 初始化基础铸币厂本身。这会设置铸币厂的小数位数,将 PDA 指定为铸币和冻结权限(mint and freeze authority),并最终确定 Token-2022 铸币厂配置。
因为程序不能持有私钥,所以 PDA 充当铸币厂的权限。每当程序需要代表这个 PDA 签名时(例如,在铸造新代币时),它必须使用相同的种子(seed)和 bump 组合([b"mint-authority", &[bump]])来重新派生 PDA。
Anchor 通过 ctx.bumps 暴露了这个 bump。
bump 是在 PDA 派生期间添加的单字节值(0-255)。它确保生成的地址不能从任何私钥生成。在 PDA 签名验证期间,它也必须包含在签名者种子中;否则,验证将失败。
我们还将铸币权限和冻结权限都设置为该 PDA,以确保只有程序的逻辑才能铸造或冻结代币。
// 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()),
)?;
CreateInterestBearingMint 账户上下文
下面是定义我们到目前为止使用的 CreateInterestBearingMint 函数的账户上下文的结构体。
请注意,mint 被声明为 UncheckedAccount,而不是 InterfaceAccount<Mint>(这是一个对 AccountInfo 的 Anchor 包装器,会自动验证账户以确保它是一个已初始化的代币铸币厂)。
我们在这里使用 UncheckedAccount,因为我们需要创建带有扩展空间的铸币厂,而在初始化之前,Anchor 无法将其作为 Mint 进行验证。
该结构体定义了带有其种子和 bump 的 mint_authority PDA。完成后,程序逻辑就可以铸造或冻结代币,但任何外部密钥对都不能这样做。
该结构体还定义了我们用过的其他账户;我们添加了注释以对它们进行说明。
#[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>,
}
我们到目前为止讨论的创建代币铸币厂的完整代码如下所示:
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>,
}
}
现在我们已经创建了代币铸币厂并初始化了扩展,让我们继续实现 mint_tokens 函数。
ii. 创建铸造代币函数
mint_tokens 函数使用 PDA 作为铸币权限,将代币铸造到用户账户中。
以下是 mint_tokens 函数执行的操作:
- 它首先获取 PDA 的 bump,并重建验证所需的签名者种子。
- 然后,它调用 Token-2022 程序的
mint_toCPI。它通过CpiContext::new_with_signer传递签名者种子,运行时(runtime)将识别 PDA 为授权签名者,并将指定数量的代币铸造到接收者的代币账户中。
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(())
}
下面是定义上述 mint_tokens 函数中使用的所有账户的结构体。
它列出了在 PDA 权限下铸造新代币所需的所有账户,确保使用了正确的铸币厂、接收者和 Token-2022 程序。
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. 更新利率
我们已经看到了如何创建代币铸币厂、初始化扩展以及铸造代币。下一步是更新利率。
下面的 update_rate 函数确保只有已配置的 rate_authority 才能更新铸币厂的年利率。它是通过调用 Token-2022 CPI 的 interest_bearing_mint_update_rate 来实现这一点的。
该函数还使用 InterestBearingMintUpdateRate 结构体来指定 CPI 调用需要哪些账户(铸币厂、代币程序和利率权限),然后在更新存储在铸币厂扩展数据中的利率之前,会在内部验证签名者是否与配置的权限相匹配。
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(())
}
下面的上下文结构体定义了更新利率所需的账户。rate_authority 必须签署交易,而 Token-2022 程序会确保其与铸币厂扩展中设置的权限相匹配。
#[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>,
}
完整实现
你可以从下面的代码库克隆完整的实现以探索完整代码:
git clone [https://github.com/ezesundayeze/interest-bearing-mint](https://github.com/ezesundayeze/interest-bearing-mint/blob/main/programs/interest-bearing/src/lib.rs)
TypeScript 测试
首先,在终端上运行 anchor build 来构建程序。
我们需要测试不同的时间线,才能真正展示一段时间内的收益累积。在测试中做到这一点可能很棘手,我们将使用 LiteSVM——一个轻量级的 Solana 虚拟机来模拟时间的流逝,并在不运行真实集群的情况下验证利息的累积。
安装 LiteSVM 和我们将用于与代币交互的 Solana SPL token 库依赖项:
yarn add anchor-litesvm @solana/spl-token
将你的 interest_bearing.ts 文件内容替换为下面的代码。
该测试与我们的程序交互,以展示计息代币扩展如何随时间产生复利价值。
该测试遵循以下步骤:
- 初始化计息铸币厂: 创建一个新的代币铸币厂,配置 3% 的年利率,并分配一个之后可以更新此利率的权限。初始化时间戳也会被记录下来,以进行精确的利息追踪。
- 向接收者铸造代币: 向接收者的关联代币账户铸造 1000 个代币。测试将确认链上铸币厂配置和代币余额在初始化时是正确的。
- 模拟多个周期的复利:
使用 LiteSVM 的虚拟时钟快进时间并展示复利增长:- 周期 1: 3 个月,年利率 3%
- 周期 2: 再过 9 个月,年利率 5%(在第 12 个月更新利率后)
- 周期 3: 再过 3 个月,年利率 7%(最后一个周期,即第 15 个月)
每个周期计算: - 使用连续复利公式 预期的余额
- SPL Token 辅助函数计算的虚拟余额,反映了累积利息。
对比这两个结果,以确认虚拟增长与连续复利的数学预期相匹配。
- 验证 15 个月内的复利增长:
确认即使在多次利率变更后,代币余额的增长仍符合预期的指数曲线。该测试还会打印中间结果,以展示在每个阶段复利是如何演变的。
这段代码包含了注释,解释了每个代码块的作用:
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"
);
});
});
使用命令 anchor test 运行测试。测试输出应如下所示:

从上述截图可以看出,你会注意到我们的利息累积工作正常,并与我们之前讨论的连续复利计算相吻合。
结论
到目前为止,我们已经走过了计息扩展的完整生命周期。这使我们能够超越概念,更深入地了解计息扩展的工作原理。这也为你进行扩展实验并将其整合到实际程序中提供了一个具体的起点。
自主学习练习
构建一个简单的质押奖励程序
用户将普通代币(如 USDC)存入质押池(staking pool),并获得计息的“收据代币(receipt tokens)”,这些代币会随着时间推移自动增加价值,从而消除了复杂奖励认领机制的需要。
当你成功构建出原型时,请在 X(推特)上标记 @rareskills_io!
本文是 Solana 教程系列的一部分。