本文介绍了如何使用 Anchor 框架创建一个具有计息功能的 Token-2022 mint,通过 PDA 进行权限控制,并实现利率更新。文章详细阐述了创建、初始化、铸造以及更新利率的完整生命周期,并使用 LiteSVM 模拟时间推移,验证计息的准确性,最后提供了一个构建简易质押奖励程序的自学练习。
利息计算扩展增加了一种功能,允许 token mint 随着时间的推移累积利息。 之前,我们介绍了这个扩展,并解释了余额如何在不改变链上原始账户余额的情况下,以虚拟方式增长。 我们当时的重点是该扩展在概念上是如何运作的,以及 Solana 的客户端函数是如何计算应计利息的。
在本文中,我们将把这些知识付诸实践。 我们将使用 Anchor 构建一个管理系统,该系统在 PDA (程序派生地址) 权限下,以编程方式创建计息 token mint,从而确保只有该程序才能控制它。 该系统还将允许通过指定的利率权限更新利率。
我们将构建的程序将演示计息 token 的完整生命周期:初始化、铸造、利息累积和利率变更。 我们还将使用 LiteSVM 的时间旅行功能来随着时间的推移测试利息累积。
在本文结束时,你将对计息 token 在实践中是如何运作的有一个扎实的理解。
我们将从创建一个新的 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
,以确认你的项目已正确设置。
该项目将分为两个阶段:
1. Anchor Rust 程序
Anchor Rust 程序将处理三个核心操作:
所有这些操作都将在 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
扩展的 token mint,并设置利率权限。mint_tokens
: 使用 PDA 作为 mint 权限,将 token 铸造到用户的账户。update_rate
: 更新 mint 的年利率,仅限于利率权限。2. TypeScript 测试
TypeScript 测试将验证程序是否可以:
现在我们了解了项目结构,让我们来实现链上程序本身。
我们先导入所需的 Anchor 和 Token-2022 依赖项,并在 program/interest-bearing/src/lib.rs
文件中声明程序 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 的重新导出。 混合使用两者可能会导致版本不匹配和运行时冲突。
最后,运行 anchor keys sync
以确保 declare_id!
宏中的程序 ID 与 Anchor.toml
中定义的密钥对匹配。
我们已经准备好所有依赖项,现在让我们设置工作流程来创建和初始化计息 token mint。
我们现在创建 create_interest_bearing_mint
函数:
pub fn create_interest_bearing_mint(...) -> Result<()> { ... }
该函数执行四个步骤来设置一个新的启用 InterestBearingConfig
扩展的 Token-2022 mint。 这些步骤是:
InterestBearingConfig
账户大小InterestBearingConfig
扩展initialize_mint2
函数第 1 步:计算所需的账户大小
在 Solana 中创建账户时,你需要指定账户的大小并相应地支付租金。
我们将使用我们之前导入的 ExtensionType
中的 try_calculate_account_len
函数来自动计算保存基本 mint 数据和扩展数据所需的账户大小。 这确保账户被分配足够的空间用于 InterestBearingConfig
扩展。
let mint_size = ExtensionType::try_calculate_account_len::<Mint>(&[\
ExtensionType::InterestBearingConfig,\
])?;
在第一部分中,我们讨论了如何手动计算扩展数据,但我们将在此处使用 try_calculate_account_len
。 使用 try_calculate_account_len
是标准做法,它允许我们一次性计算 Mint 账户和扩展数据的准确大小。
第 2 步:创建和资助 mint 账户
现在我们有了计算准确大小的机制,我们将使用系统程序手动创建 mint 账户,并为其提供免租金的 lamports ( 当一个账户持有相对于其大小而言足够多的 lamports 时,它将变为“免租金”,并且永远不会被收取租金或删除)。
Anchor 不会自动执行此步骤,因为 Token-2022 mint 需要自定义大小以适应扩展。 账户上的 #[account(init)]
属性假定一个固定大小(对于标准 SPL Token mint 有效),但 Token-2022 mint 则根据它们包含的扩展而有所不同。 为了正确处理这个问题,你必须自己计算所需的空间并手动创建账户。
下面的代码使用精确的空间和 lamports 创建 mint 账户,使其免于租金。
Rent::get()?.minimum_balance(mint_size)
基于账户的大小计算使账户免于租金所需的最小 lamports。system_program::create_account
然后分配并资助该账户,并将所有权分配给 Token-2022 程序 ( token_program.key()
)。这确保了在任何 Token-2022 指令初始化它之前,mint 账户被正确调整大小、免于租金并归正确的程序所有。
// 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(),
)?;
我们将在本文的后面定义完整的 CreateInterestBearingMint
账户结构,其中 &ctx
指的是上面的代码。
第 3 步:初始化 InterestBearingConfig
扩展
接下来,我们通过设置利率权限和初始利率(以基点为单位)来初始化 InterestBearingConfig
****扩展。
此步骤必须在 ****初始化基本 mint 之前进行,因为必须首先设置扩展——否则,mint 的布局将与预期的账户大小不匹配,并且 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 初始化基本 mint 本身。 这设置了 mint 的小数位数,将 PDA 分配为 mint 和冻结权限,并最终确定了 Token-2022 mint 的配置。
由于程序无法保存私钥,因此 PDA 充当 mint 的权限。 每当程序需要代表此 PDA 签名时(例如,在铸造新 token 时),它必须使用相同的种子和跳转组合 ( [b"mint-authority", &[bump]]
) 重新派生 PDA。
Anchor 通过 ctx.bumps
公开此跳转。
跳转是在 PDA 派生期间添加的单字节值 (0–255)。 它确保生成的地址无法从任何私钥生成。 它还必须包含在 PDA 签名验证期间的签名者种子中; 否则,验证将失败。
我们还将 mint 权限和 冻结权限都设置为 PDA,以确保只有程序的逻辑才能铸造或冻结 token。
// 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>
(Anchor 对 AccountInfo
的包装器,它会自动验证账户以确保它是一个已初始化的 token mint)。
我们在此处使用 UncheckedAccount
,因为我们需要使用扩展空间创建 mint,并且 Anchor 在初始化完成后才能将其验证为 Mint
。
该结构使用其种子和跳转定义了 mint_authority
PDA。 完成后,程序逻辑可以铸造或冻结 token,但没有外部密钥对可以。
该结构还定义了我们使用的其他账户; 我们添加了注释来指定它们。
#[derive(Accounts)]
pub struct CreateInterestBearingMint<'info> {
/// CHECK: This account is created manually as a Token-2022 mint with extensions.
// CHECK:此账户是使用扩展手动创建为 Token-2022 mint 的。
#[account(mut)]
pub payer: Signer<'info>,
/// CHECK: PDA account used as mint and freeze authority
// CHECK:PDA 账户用作 mint 和冻结权限
#[account(\
seeds = [b"mint-authority"],\
bump\
)]
pub mint_authority: UncheckedAccount<'info>,
/// Raw mint account to be created with extension space
// 要使用扩展空间创建的原始 mint 账户
/// CHECK: We trust the token program to validate this is a proper mint account.
// CHECK:我们相信 token 程序会验证这是一个正确的 mint 账户。
#[account(mut, signer)]
pub mint: UncheckedAccount<'info>,
/// Token-2022 program
// Token-2022 程序
pub token_program: Program<'info, Token2022>,
pub system_program: Program<'info, System>,
/// Signer that will control interest rate updates
// 将控制利率更新的签名者
pub rate_authority: Signer<'info>,
}
我们到目前为止讨论的创建 token mint 的完整代码如下所示:
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);
// msg!("创建计息 mint @ {} bps", rate_bps);
// 1) Compute mint size including extension header + InterestBearingConfig
// 1) 计算 mint 大小,包括扩展标头 + InterestBearingConfig
let mint_size = ExtensionType::try_calculate_account_len::<PodMint>(&[\
ExtensionType::InterestBearingConfig,\
])?;
// 2) Create the mint account with correct space and rent
// 2) 使用正确的空间和租金创建 mint 账户
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
// 3) 在基本 mint 初始化之前初始化计息扩展
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)
// 4) 初始化基本 mint(小数位数、权限)
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.
// CHECK:此账户是使用扩展手动创建为 Token-2022 mint 的。
#[account(mut)]
pub payer: Signer<'info>,
/// CHECK: PDA account used as mint and freeze authority
// CHECK:PDA 账户用作 mint 和冻结权限
#[account(\
seeds = [b"mint-authority"],\
bump\
)]
pub mint_authority: UncheckedAccount<'info>,
/// Raw mint account to be created with extension space
// 要使用扩展空间创建的原始 mint 账户
/// CHECK: We trust the token program to validate this is a proper mint account.
// CHECK:我们相信 token 程序会验证这是一个正确的 mint 账户。
// #[account(mut)]
#[account(mut, signer)]
pub mint: UncheckedAccount<'info>,
/// Token-2022 program
// Token-2022 程序
pub token_program: Program<'info, Token2022>,
pub system_program: Program<'info, System>,
/// Signer that will control interest rate updates
// 将控制利率更新的签名者
pub rate_authority: Signer<'info>,
}
}
现在我们已经创建了 token mint 并初始化了扩展,让我们继续实现 mint_tokens
函数。
mint_tokens
函数使用 PDA 作为 mint 权限,将 token 铸造到用户的账户。
以下是 mint_tokens
函数的功能:
mint_to
CPI。 它通过 CpiContext::new_with_signer
传递签名者种子,运行时将 PDA 识别为授权签名者,并将指定数量的 token 铸造到接收者的 token 账户。pub fn mint_tokens(ctx: Context<MintTokens>, amount: u64) -> Result<()> {
// Fetch the bump for the PDA so we can recreate the same signer seeds
// 获取 PDA 的跳转,以便我们可以重新创建相同的签名者种子
let bump = ctx.bumps.mint_authority;
let signer_seeds: &[&[&[u8]]] = &[&[b"mint-authority", &[bump]]];
// Call into the Token-2022 program to mint tokens
// 调用 Token-2022 程序以铸造 token
// `CpiContext::new_with_signer` lets us pass the PDA seeds so the runtime
// 可以让我们传递 PDA 种子,以便运行时
// can treat the PDA as if it signed the instruction
// 可以将 PDA 视为已签署该指令
mint_to(
CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
MintTo {
// Mint account whose supply will increase
// mint 账户的供应量将增加
mint: ctx.accounts.mint.to_account_info(),
// Recipient’s token account that will receive the minted tokens
// 接收者的 token 账户将收到铸造的 token
to: ctx.accounts.to_token_account.to_account_info(),
// PDA that acts as mint authority
// 用作 mint 权限的 PDA
authority: ctx.accounts.mint_authority.to_account_info(),
},
signer_seeds,
),
amount, // Number of tokens to mint
// 要铸造的 token 数量
)?;
Ok(())
}
下面是定义我们在上面的 mint_tokens
函数中使用的所有账户的结构。
它列出了在 PDA 权限下铸造新 token 所需的所有账户,确保使用正确的 mint、接收者和 Token-2022 程序。
pub struct MintTokens<'info> {
/// CHECK: PDA authority must match the seed used during mint init
// CHECK:PDA 权限必须与 mint 初始化期间使用的种子匹配
#[account(\
seeds = [b"mint-authority"],\
bump\
)]
/// CHECK: This is the mint authority PDA we created during mint init.
// CHECK:这是我们在 mint 初始化期间创建的 mint 权限 PDA。
pub mint_authority: UncheckedAccount<'info>,
/// Use token_interface to bind this Mint to Token2022 program
// 使用 token_interface 将此 Mint 绑定到 Token2022 程序
/// CHECK: We trust the token program to validate this is a proper mint account.
// CHECK:我们相信 token 程序会验证这是一个正确的 mint 账户。
#[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>,
}
我们已经了解了如何创建 token mint、初始化扩展和铸造 token。 下一步是更新利率。
下面的 update_rate
函数确保只有配置的 rate_authority
才能更新 mint 的年利率。 它通过调用 Token-2022 CPI interest_bearing_mint_update_rate
来实现这一点。
该函数还使用 InterestBearingMintUpdateRate
结构来指定 CPI 调用所需的账户(mint、token 程序和利率权限),然后在更新存储在 mint 的扩展数据中的利率之前,验证签名者是否与内部配置的权限匹配。
pub fn update_rate(ctx: Context<UpdateRate>, new_rate_bps: i16) -> Result<()> {
msg!("Update interest rate -> {} bps", new_rate_bps);
// msg!("更新利率 -> {} bps", new_rate_bps);
// Call into the Token-2022 program to update interest rate on the mint
// 调用 Token-2022 程序以更新 mint 上的利率
// The CPI will check that the provided rate_authority signer matches the
// CPI 将检查提供的 rate_authority 签名者是否与
// authority configured in the mint's extension data
// mint 的扩展数据中配置的权限匹配
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)
// 以基点为单位的新利率 (1% = 100 bps)
)?;
Ok(())
}
下面的上下文结构定义了更新利率所需的账户。 rate_authority
必须签署交易,并且 Token-2022 程序确保它与 mint 的扩展中设置的权限匹配。
#[derive(Accounts)]
pub struct UpdateRate<'info> {
/// CHECK: This is the mint account we’re updating. We rely on Token-2022
// CHECK:这是我们正在更新的 mint 账户。 我们依靠 Token-2022
/// program logic to validate its data, so Anchor does not need to enforce checks here.
// 程序逻辑来验证其数据,因此 Anchor 无需在此处强制执行检查。
#[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)
首先,通过在你的终端上运行 anchor build
来构建程序。
我们需要测试不同的时间线,才能真正演示一段时间内的收益累积。 这在测试中可能很棘手,我们将使用 LiteSVM——一个轻量级的 Solana 虚拟机,用于模拟时间进展并验证利息累积,而无需在实时集群上运行。
安装 LiteSVM 和 Solana SPL token 库依赖项,我们将使用它与 token 交互:
yarn add anchor-litesvm @solana/spl-token
将你的 interest_bearing.ts
文件的内容替换为下面的代码。
此测试与我们的程序交互,以演示计息 Token 扩展如何随着时间的推移积累价值。
该测试遵循以下步骤:
初始化计息 mint: 创建一个新的 token mint,配置为 3% 的年利率,并分配一个以后可以更新此利率的权限。 还记录初始化时间戳,以进行精确的利息跟踪。
将 token 铸造给接收者: 将 1000 个 token 铸造到接收者的关联 token 账户中。 测试确认链上 mint 配置和 token 余额在初始化时是正确的。
模拟多个周期的复利:
使用 LiteSVM 的虚拟时钟来快进时间并演示复利增长:
每个周期计算:
将两个结果进行比较,以确认虚拟增长与连续复利的数学期望相匹配。
确认 token 余额与预期的指数曲线一致增长,即使在多次利率变更之后也是如此。 该测试还打印中间结果,以显示复利在每个阶段是如何演变的。
此代码包含注释,解释了每个块的功能:
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
// 每年的秒数 = 365.24 * 24 * 60 * 60 // 365.24 天
const ONE_IN_BASIS_POINTS = 10000;
// 基数点中的一个 = 10000
/**
* Calculate the exponential factor for continuous compounding
* 计算连续复利的指数因子
* This mirrors the SPL Token implementation exactly.
* 这完全镜像了 SPL Token 的实现。
* We are copying it here because it's not exported from the SPL token library.
* 我们复制它是因为它没有从 SPL token 库中导出。
*
* Formula: e^((rate * timespan) / (SECONDS_PER_YEAR * 10000))
* 公式:e^((rate * timespan) / (SECONDS_PER_YEAR * 10000))
*
* @param t1 - Start time in seconds
* @param t1 - 以秒为单位的开始时间
* @param t2 - End time in seconds
* @param t2 - 以秒为单位的结束时间
* @param rateBps - Interest rate in basis points
* @param rateBps - 以基点为单位的利率
*/
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
// 设置一个轻量级的 Solana VM 用于测试
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%)
// 以基点为单位的利率(1 个基点 = 0.01%)
const RATE_1_BPS = 300; // 3.00% annual rate
// RATE_1_BPS = 300 // 3.00% 年利率
const RATE_2_BPS = 500; // 5.00% annual rate
// RATE_2_BPS = 500 // 5.00% 年利率
const RATE_3_BPS = 700; // 7.00% annual rate
// RATE_3_BPS = 700 // 7.00% 年利率
// More precise year definition (accounts for leap years)
// 更精确的年份定义(考虑闰年)
const SECONDS_PER_YEAR = 365.24 * 24 * 60 * 60; // ~31,556,736 seconds
// 每年的秒数 = 365.24 * 24 * 60 * 60 // ~31,556,736 秒
// Token configuration
// Token 配置
const DECIMALS = 9;
const INITIAL_BALANCE = 1000; // Start with 1000 tokens (UI amount)
// DECIMALS = 9
// 初始余额 = 1000 // 从 1000 个 token 开始(UI 金额)
// Starting point for our virtual clock (Jan 1, 2024)
// 我们虚拟时钟的起点(2024 年 1 月 1 日)
const INITIAL_TIMESTAMP = 1704067200n;
// INITIAL_TIMESTAMP = 1704067200n
/**
* Get UI amount for interest-bearing tokens
* 获取计息 token 的 UI 金额
* This implements the exact same logic as amountToUiAmountForInterestBearingMintWithoutSimulation
* 这实现了与 amountToUiAmountForInterestBearingMintWithoutSimulation 完全相同的逻辑
* from the SPL Token library, adapted for LiteSVM
* 来自 SPL Token 库,适用于 LiteSVM
*
* The calculation happens in two phases:
* 计算分两个阶段进行:
* 1. Pre-update: Interest from initialization to last rate update
* 1. 预更新:从初始化到上次利率更新的利息
* 2. Post-update: Interest from last rate update to current time
* 2. 后更新:从上次利率更新到现在时间的利息
*
* Total scale = e^(r1*t1) * e^(r2*t2)
* 总比例 = e^(r1*t1) * e^(r2*t2)
*/
const getInterestBearingUiAmount = async (
rawAmount: bigint
): Promise<number> => {
// Fetch mint configuration
// 拉取 mint 配置
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```markdown
/**
* 测试 1:创建计息的 mint
*/
it("创建一个计息的 mint", async () => {
// 调用我们的程序来初始化 mint,起始利率为 3%
await program.methods
.createInterestBearingMint(RATE_1_BPS, DECIMALS)
.accounts({
payer: provider.wallet.publicKey, // 谁为交易付费
mint: mint.publicKey, // 我们正在创建的新 mint
rateAuthority: rateAuthority.publicKey, // 谁可以更新利率
})
.signers([rateAuthority, mint])
.rpc();
// 验证 mint 是否已使用正确的配置创建
const mintInfo = await getMint(
provider.connection,
mint.publicKey,
"confirmed",
TOKEN_2022_PROGRAM_ID
);
const interestConfig = await getInterestBearingMintConfigState(mintInfo);
console.log("计息配置:", {
rateAuthority: interestConfig?.rateAuthority?.toBase58(),
currentRate: interestConfig?.currentRate,
initializationTimestamp: interestConfig?.initializationTimestamp,
lastUpdateTimestamp: interestConfig?.lastUpdateTimestamp,
});
// 确保记录了初始化时间戳(对于利息计算非常重要)
assert.ok(
interestConfig?.initializationTimestamp !== 0,
"初始化时间戳不应为 0"
);
});
/**
* 测试 2:将初始 token mint 给接收者
*/
it("将 token mint 给接收者", async () => {
recipientAta = getAssociatedTokenAddressSync(
mint.publicKey,
recipient.publicKey,
false,
TOKEN_2022_PROGRAM_ID
);
// 创建 ATA(它还不存在)
const createAtaTx = new Transaction().add(
createAssociatedTokenAccountInstruction(
provider.wallet.publicKey,
recipientAta,
recipient.publicKey,
mint.publicKey,
TOKEN_2022_PROGRAM_ID
)
);
await provider.sendAndConfirm(createAtaTx, []);
// 将初始余额的 token mint 给接收者
// 将 UI 金额 (1000) 转换为原始金额 (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();
// 验证 mint 的金额是否正确
const tokenAccount = await getAccount(
provider.connection,
recipientAta,
"confirmed",
TOKEN_2022_PROGRAM_ID
);
// 对于计息 token,我们需要使用 SPL Token 方法来获取 UI 金额
const balance = await getInterestBearingUiAmount(tokenAccount.amount);
assert.strictEqual(
balance,
INITIAL_BALANCE,
`初始余额应为 ${INITIAL_BALANCE}`
);
console.log(`初始余额: ${balance} tokens`);
});
/**
* 测试 3:演示 15 个月的复利
*
* 时间线:
* 1. 以 3% 的利率开始的 1000 个token
* 2. 等待 3 个月 → 余额以 3% 的利率增长
* 3. 将利率更改为 5%
* 4. 再等待 9 个月 → 余额以 5% 的利率增长(总共 12 个月)
* 5. 将利率更改为 7%
* 6. 再等待 3 个月 → 余额以 7% 的利率增长(总共 15 个月)
*/
it("演示复利增长: 3 个月、12 个月、15 个月", async () => {
console.log("\n=== 开始计息累积测试 ===");
console.log(`起始余额: ${INITIAL_BALANCE} tokens\n`);
// ==================================
// 期间 1:前 3 个月,年利率 3%
// ==================================
console.log(`\n--- 期间 1:3 个月 @ ${RATE_1_BPS / 100}% ---`);
// 将时间快进 3 个月(0.25 年)
const clock1 = svm.getClock();
clock1.unixTimestamp += BigInt(Math.floor(SECONDS_PER_YEAR * 0.25));
svm.setClock(clock1);
// 检查接收者的 token 余额
const tokenAccount1 = await getAccount(
provider.connection,
recipientAta,
"confirmed",
TOKEN_2022_PROGRAM_ID
);
// 使用官方的 SPL Token 方法来获取应用利息后的 UI 金额
const balanceAfter3Months = await getInterestBearingUiAmount(
tokenAccount1.amount
);
// 使用连续复利公式计算我们的预期值
const expectedBalance1 = calculateExpectedBalance(
INITIAL_BALANCE,
RATE_1_BPS,
0.25
);
console.log(`3 个月后的余额: ${balanceAfter3Months.toFixed(6)}`);
console.log(
`预期余额 (A = P e^{r t}): ${expectedBalance1.toFixed(6)}`
);
console.log(
`赚取的利息: ${(balanceAfter3Months - INITIAL_BALANCE).toFixed(6)}`
);
// 验证计算是否正确(在 0.01 个 token 容差范围内)
assert.ok(
Math.abs(balanceAfter3Months - expectedBalance1) < 0.01,
"3 个月后的余额不正确"
);
// ===============================================
// 期间 2:将利率更改为 5%,然后提前 9 个月
// ===============================================
// 将利率更新为 5%
await program.methods
.updateRate(RATE_2_BPS)
.accounts({
mint: mint.publicKey,
rateAuthority: rateAuthority.publicKey,
})
.signers([rateAuthority])
.rpc();
console.log(
`\n--- 期间 2:在前 3 个月后,9 个月 @ ${
RATE_2_BPS / 100
}%(总计 = 12 个月)---`
);
// 将时间快进 9 个月(从开始算起总共 12 个月)
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
);
// 使用 SPL Token 的官方方法获取 UI 金额
const balanceAfter12Months = await getInterestBearingUiAmount(
tokenAccount2.amount
);
// 预期值:(3 个月后的余额) * e^(0.05 * 0.75)
const expectedBalance2 = calculateExpectedBalance(
balanceAfter3Months,
RATE_2_BPS,
0.75
);
console.log(`12 个月后的余额: ${balanceAfter12Months.toFixed(6)}`);
console.log(
`预期余额 (A2 = A1 * e^{r2 * 0.75}): ${expectedBalance2.toFixed(
6
)}`
);
console.log(
`赚取的总利息: ${(
balanceAfter12Months - INITIAL_BALANCE
).toFixed(6)}`
);
assert.ok(
Math.abs(balanceAfter12Months - expectedBalance2) < 0.01,
"12 个月后的余额不正确"
);
// ==============================================
// 期间 3:将利率更改为 7%,然后提前最后 3 个月
// ==============================================
// 将利率更新为 7%
await program.methods
.updateRate(RATE_3_BPS)
.accounts({
mint: mint.publicKey,
rateAuthority: rateAuthority.publicKey,
})
.signers([rateAuthority])
.rpc();
console.log(
`\n--- 期间 3:额外 3 个月 @ ${
RATE_3_BPS / 100
}%(总计 = 15 个月)---`
);
// 将时间快进最后 3 个月(从开始算起总共 15 个月)
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
);
// 使用 SPL Token 的官方方法获取最终的 UI 金额
const balanceAfter15Months = await getInterestBearingUiAmount(
tokenAccount3.amount
);
// 预期值:(12 个月后的余额) * e^(0.07 * 0.25)
const expectedBalance3 = calculateExpectedBalance(
balanceAfter12Months,
RATE_3_BPS,
0.25
);
console.log(`15 个月后的余额: ${balanceAfter15Months.toFixed(6)}`);
console.log(
`预期余额 (A3 = A2 * e^{r3 * 0.25}): ${expectedBalance3.toFixed(
6
)}`
);
console.log(
`赚取的总利息: ${(
balanceAfter15Months - INITIAL_BALANCE
).toFixed(6)}`
);
console.log(
`15 个月内的有效回报: ${(
(balanceAfter15Months / INITIAL_BALANCE - 1) *
100
).toFixed(6)}%`
);
// 最终验证(对于累积舍入,容差略大)
assert.ok(
Math.abs(balanceAfter15Months - expectedBalance3) < 0.02,
"15 个月后的最终余额不正确"
);
});
});
使用命令 anchor test
运行测试。测试输出应如下所示:
从上面的屏幕截图中,你会注意到我们的利息累积工作正常,并且与前面讨论的连续复利计算一致。
到目前为止,我们已经完成了计息扩展的完整生命周期。这使我们能够更深入地了解计息扩展如何工作,而不仅仅是概念。并为你提供了一个具体的起点,用于试验扩展并将其集成到实际程序中。
用户将常规 token(如 USDC)存入 staking 池,并收到计息“收据 token”,这些 token 的价值会随着时间的推移自动增长,从而无需复杂的奖励领取机制。
本文是 Solana 教程系列 的一部分。
- 原文链接: rareskills.io/post/token...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!