Solana 60 天课程

2025年02月27日更新 77 人订阅
原价: ¥ 36 限时优惠
专栏简介 开始 Solana - 安装与故障排除 Solana 和 Rust 中的算术与基本类型 Solana Anchor 程序 IDL Solana中的Require、Revert和自定义错误 Solana程序是可升级的,并且没有构造函数 Solidity开发者的Rust基础 Rust不寻常的语法 Rust 函数式过程宏 Rust 结构体与属性式和自定义派生宏 Rust 和 Solana 中的可见性与“继承” Solana时钟及其他“区块”变量 Solana 系统变量详解 Solana 日志、“事件”与交易历史 Tx.origin、msg.sender 和 onlyOwner 在 Solana 中:识别调用者 Solana 计算单元与交易费用介绍 在 Solana 和 Anchor 中初始化账户 Solana 计数器教程:在账户中读写数据 使用 Solana web3 js 和 Anchor 读取账户数据 在Solana中创建“映射”和“嵌套映射” Solana中的存储成本、最大存储容量和账户调整 在 Solana 中读取账户余额的 Anchor 方法:address(account).balance 功能修饰符(view、pure、payable)和回退函数在 Solana 中不存在的原因 在 Solana 上实现 SOL 转账及构建支付分配器 使用不同签名者修改账户 PDA(程序派生地址)与 Solana 中的密钥对账户 理解 Solana 中的账户所有权:从PDA中转移SOL Anchor 中的 Init if needed 与重初始化攻击 Solana 中的多重调用:批量交易与交易大小限制 Solana 中的所有者与权限 在Solana中删除和关闭账户与程序 在 Anchor 中:不同类型的账户 在链上读取另一个锚点程序账户数据 在 Anchor 中的跨程序调用(CPI) SPL Token 的运作方式 使用 Anchor 和 Web3.js 转移 SPL Token Solana 教程 - 如何实现 Token 出售 基础银行教程 Metaplex Token 元数据工作原理 使用Metaplex实施代币元数据 使用 LiteSVM 进行时间旅行测试 Solana Token-2022 标准规范 生息代币第一部分 计息代币第二部分

计息代币第二部分

本文介绍了如何使用 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-buildidl-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 程序
  2. 以及 TypeScript 测试

1. Anchor Rust 程序

Anchor Rust 程序将处理三个核心操作:

  • 创建和初始化一个新的计息 mint,并将 PDA 设置为 mint 权限
  • 铸造计息 token
  • 通过利率权限更新利率。

所有这些操作都将在 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 文件中定义这些函数:

  1. create_interest_bearing_mint: 创建一个启用了 InterestBearingConfig 扩展的 token mint,并设置利率权限。
  2. mint_tokens: 使用 PDA 作为 mint 权限,将 token 铸造到用户的账户。
  3. update_rate: 更新 mint 的年利率,仅限于利率权限。

2. TypeScript 测试

TypeScript 测试将验证程序是否可以:

  • 创建一个计息 mint
  • 在 PDA 权限下铸造 token
  • 通过利率权限更新利率
  • 准确显示虚拟利息累积。

实现 Anchor Rust 程序

现在我们了解了项目结构,让我们来实现链上程序本身。

我们先导入所需的 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。

i. 创建和初始化计息 token mint

我们现在创建 create_interest_bearing_mint 函数:

pub fn create_interest_bearing_mint(...) -> Result<()> { ... }

该函数执行四个步骤来设置一个新的启用 InterestBearingConfig 扩展的 Token-2022 mint。 这些步骤是:

  • 第 1 步:计算 InterestBearingConfig 账户大小
  • 第 2 步:创建 mint 账户并为其提供用于租金的 lamports
  • 第 3 步:初始化 InterestBearingConfig 扩展
  • 第 4 步:运行标准 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())。
  • CPI 上下文指定 lamports 来自付款人,并且正在创建的新账户是 mint。

这确保了在任何 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 函数。

ii. 创建 Mint token 函数

mint_tokens 函数使用 PDA 作为 mint 权限,将 token 铸造到用户的账户。

以下是 mint_tokens 函数的功能:

  • 它首先检索 PDA 的跳转并重建验证所需的签名者种子。
  • 然后,它调用 Token-2022 程序的 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>,
}

iii. 更新利率

我们已经了解了如何创建 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)

TypeScript 测试

首先,通过在你的终端上运行 anchor build 来构建程序。

我们需要测试不同的时间线,才能真正演示一段时间内的收益累积。 这在测试中可能很棘手,我们将使用 LiteSVM——一个轻量级的 Solana 虚拟机,用于模拟时间进展并验证利息累积,而无需在实时集群上运行。

安装 LiteSVM 和 Solana SPL token 库依赖项,我们将使用它与 token 交互:

yarn add anchor-litesvm @solana/spl-token

将你的 interest_bearing.ts 文件的内容替换为下面的代码。

此测试与我们的程序交互,以演示计息 Token 扩展如何随着时间的推移积累价值。

该测试遵循以下步骤:

  1. 初始化计息 mint: 创建一个新的 token mint,配置为 3% 的年利率,并分配一个以后可以更新此利率的权限。 还记录初始化时间戳,以进行精确的利息跟踪。

  2. 将 token 铸造给接收者: 将 1000 个 token 铸造到接收者的关联 token 账户中。 测试确认链上 mint 配置和 token 余额在初始化时是正确的。

  3. 模拟多个周期的复利:

使用 LiteSVM 的虚拟时钟来快进时间并演示复利增长:

  • 周期 1: 3 个月,年利率为 3%
  • 周期 2: 9 个月以上,年利率为 5%(利率更新后,在第 12 个月)
  • 周期 3: 3 个月以上,年利率为 7%(最后一个周期,第 15 个月)

每个周期计算:

  • 使用连续复利公式 A=P×ert 计算的预期余额
  • 由 SPL Token 助手计算的虚拟余额,反映了应计利息。

将两个结果进行比较,以确认虚拟增长与连续复利的数学期望相匹配。

  1. 验证 15 个月内的复利增长:

确认 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 运行测试。测试输出应如下所示:

命令行终端的屏幕截图,显示了成功的利息累积测试。测试以 1000 个 token 的余额开始,并模拟三个期间:1) 3 个月,利息为 3%;2) 另外 9 个月,利息为 5%;3) 最后 3 个月,利息为 7%。每个步骤都显示了新的余额、使用公式 A=Pert 计算的预期余额以及赚取的总利息,从而确认了逻辑正确。

从上面的屏幕截图中,你会注意到我们的利息累积工作正常,并且与前面讨论的连续复利计算一致。

结论

到目前为止,我们已经完成了计息扩展的完整生命周期。这使我们能够更深入地了解计息扩展如何工作,而不仅仅是概念。并为你提供了一个具体的起点,用于试验扩展并将其集成到实际程序中。

自学练习

构建一个简单的 Staking 奖励程序

用户将常规 token(如 USDC)存入 staking 池,并收到计息“收据 token”,这些 token 的价值会随着时间的推移自动增长,从而无需复杂的奖励领取机制。

本文是 Solana 教程系列 的一部分。

  • 原文链接: rareskills.io/post/token...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论