Token-2022 生息扩展允许代币的 mint 自动为其所有 token account 累积利息。它使用在 mint 链上配置中定义的年化利率。
然而,这种利息只是一种计算视图:实际的代币余额在链上永远不会改变。相反,钱包和应用程序会对每个用户的链上余额应用连续复利公式,以计算其包含应计利息的净余额。开发者可以将计算出的利息与链上余额合并为一个单独的值,或者将它们分开显示。
该扩展提供了用于累积利息的会计机制;它依赖于独立的 DeFi 应用程序来为这些利息提供经济支持。
在本文中,我们将剖析生息扩展的架构,解释计算背后的数学原理,介绍钱包的兼容性,并通过 Anchor 演示一个实用的实现示例。
生息扩展架构
正如我们在 Token-2022 文章中讨论的那样,代币扩展是添加到 mint 或 token account 的模块化功能。mint 帐户的基础布局大小为 82 字节,token account 的基础布局大小为 165 字节。扩展数据附加在这些基础大小之后,因此在创建 mint 或 token account 之前,你必须分配等于基础大小加上任何启用的扩展大小的空间。
为生息扩展分配的空间存储了扩展的数据,包括一个可以更新利率的权限账户字段(authority account)。如果权限账户字段全为零,则将其视为 None,这意味着利率保持不可变。在实践中,可以将该权限设置为一个 DeFi 应用程序,然后由该程序设置利率以反映应用内的实际经济活动。
除了权限字段外,下面的 Rust 结构体(我们直接从扩展的源代码中提取)定义了完整的生息扩展数据:
- 初始化时间戳 (
initialization_timestamp),它作为所有利息计算的起始时间 - 从初始化到最后一次利率更新期间的平均利率 (
pre_update_average_rate)。 - 上次更改利率的时间戳 (
last_update_timestamp),用于计算应计利息 - 自上次更新时间戳以来应用的当前利率 (
current_rate)。
/// Annual interest rate, expressed as basis points
pub type BasisPoints = PodI16;
const ONE_IN_BASIS_POINTS: f64 = 10_000.;
const SECONDS_PER_YEAR: f64 = 60. * 60. * 24. * 365.24;
pub struct InterestBearingConfig {
/// Authority that can set the interest rate and authority
pub rate_authority: OptionalNonZeroPubkey,
/// Timestamp of initialization, from which to base interest calculations
pub initialization_timestamp: UnixTimestamp,
/// Average rate from initialization until the last time the rate was updated
pub pre_update_average_rate: BasisPoints,
/// Timestamp of the last update, used to calculate the total amount accrued
pub last_update_timestamp: UnixTimestamp,
/// Current rate, since the last update
pub current_rate: BasisPoints,
}
生息扩展的 Type-Length-Value (TLV) 布局
所有 Token-2022 扩展都遵循 Type-Length-Value (TLV) 格式,这使得程序能够轻松读取并跳过存储在帐户中的不同扩展数据。
InterestBearingConfig TLV 条目的编码如下:
- T (
type):0x0A(InterestBearingConfig的类型标识符) - L (
length):0x34(u8,十进制值为 52) - V (
value): 按顺序拼接的序列化字段:rate_authority(32 字节)initialization_timestamp(8 字节)pre_update_average_rate(2 字节)last_update_timestamp(8 字节)current_rate(2 字节)
字段 pre_update_average_rate 和 current_rate 并不是作为浮点数存储的。相反,它们以 基点 (basis points) 的形式存储。
1 基点 (basis point) = 1/100 (0.01%)
因此,为了表示 2.50% 的年利率,你需要在 current_rate 字段中存储整数 250(因为 250 个基点是 250*1/100=2.5%)。要将百分比转换为基点,只需除以 0.01(或者等效地,乘以 100)。在这个例子中,2.5 / 0.01=250。
在 InterestBearingConfig 扩展的 TLV 布局中,V 部分是所有扩展字段按顺序拼接的序列化值,这在我们之前的 Token-2022 文章中已有解释。
例如,假设该扩展包含以下值:
rate_authority:7xKXtg2CW87d9LN6HBUtjQVSiJ9MCrgdGubbyiTZRjrwb(32 字节)initialization_timestamp:1672531200(2023 年 1 月 1 日;8 字节)pre_update_average_rate:500(5.00% 的基点;2 字节)last_update_timestamp:1704067200(2024 年 1 月 1 日;8 字节)current_rate:500(5.00% 的基点;2 字节)
当我们将上述字段拼接为一个连续的字节序列时,TLV 的 V(十六进制)部分为:
0x689536DF68C2FB0A61A08DEDC9797145C969328A05D68A2A8C06E15A3AB6BD5200CDB06300000000F4018000926500000000F401
完整的 TLV 条目是 T(0x0A) | L(0x34) | V(...),并紧接在 Mint 账户数据之后追加。

生息扩展的初始化
生息扩展在一次操作中启用并初始化,该操作既保留了空间,又写入了扩展的 TLV。正如我们之前在 Token-2022 文章中讨论的那样,
其他扩展通常需要两个步骤:
- 启用所需的扩展并为该扩展保留空间
- 以及一个单独的初始化指令来对其进行配置。
利息计算模型
假设你在银行有一个储蓄账户。当你以 3% 的年利率存入 $1,000 时,你的账户对账单并不会显示银行每天都在为你铸造新的美元。相反,银行的系统会计算如果是复利的话,你的余额会增长多少,并在你每次登录时向你显示更新后的数字。
生息扩展使你的 mint 账户以类似的方式工作。你在链上的余额(相当于你在银行的余额)永远保持在 $1,000 不变。但是你的钱包(就像网上银行应用程序一样)会使用复利公式,在六个月后显示 ****$1,015,或者在一年后显示 $1,030。
如果银行后来将你的利率从 ****3% 提高到 5% ****,未来的利息复利会更快,但你前一年 3% 的收益已经被“锁定”,最终余额将正确反映该时间段内 3% 的增长率。
生息扩展使用连续复利公式 () 来计算利息,并确保无论利率是否变化,你的应计收益都是准确的(我们稍后将详细探讨该公式)。
该公式被硬编码在 Token-2022 程序中,分为两种变体:在最近一次利率更新 before(之前)和 after(之后)。
before 变体捕获早期利率下的增长(以防权限更新利率),而 after 变体则捕获当前利率下的增长。它们共同产生一个连续的、时间加权的复利因子,从而确保余额在多次利率变化中保持准确。
接下来,让我们通过一个示例来展示这些操作是如何在数学上计算的。
生息扩展如何使用连续复利公式
首先,这里是公式中各个变量的描述:
- A = 最终金额(本金 + 利息)
- P = 本金(初始金额)
- e = 欧拉数(约等于 2.71828…)
- r = 年利率(以小数表示)
- t = 以年为单位的时间(Token-2022 内部以秒为单位处理)
其中 SECONDS_PER_YEAR = 60 × 60 × 24 × 365.24
以利率未发生变化的情况为例:
- 你存入 1000 个代币 (P = 1000)
- 利率为 5% (r = 0.05)
- 1 年后 (t = 1)。
- 显示的余额计算如下:
我们再来展示一下利率发生变化时的行为
现在假设利率从 3% 开始,但在前 3 个月后增加到 5%。Token-2022 通过将计算分为几个阶段来处理这种情况。
注意: 在我们的数学模型中,我们将三个月表示为 0.25 (计算方式为 3 ÷ 12 = 0.25)
-
更新前的增长:
让我们先计算经过的时间 (t)。
如果我们将公式中的 t 替换为上面计算出的 0.25 年,我们将得到以下结果:
-
更新后的增长:
新利率 = 5% (r₂ = 0.05)
剩余经过的时间 = 9 个月,计算为 9/12 (t₂ = 0.75)
从目前为止的计算中,你会注意到该利息代表了一年的收益。在前三个月(更新前的增长期),用户以 3% 的利率赚取了 7.53 个代币,使其总数达到 1,007.53 个代币。当剩余九个月(更新后的增长期)的利率升至 5% 时,他们额外赚取了 38.5 个代币,最终余额达到 1,046.03 个代币。
处理多次利率更新
我们已经了解了这种计算方法在两个时间段内是如何运作的,但生息扩展可以在代币的生命周期内多次更新利率。每次更新都确保应计收益与所有过去和未来的利率变化保持一致。
当设置新利率时,程序会按如下方式更新 InterestBearingConfig 中的字段:
- 它将
pre_update_average_rate重新计算为所有先前利率(包括刚刚被替换的利率)的时间加权平均值。 - 它将
last_update_timestamp推进到当前的区块时间。 - 它将
current_rate设置为新的利率值(例如,700个基点代表 7%)。
在数学上,我们可以使用以下公式重新计算所有先前利率的新的时间加权平均值 (pre_update_average_rate):
其中:
- r₁ →
pre_update_average_rate(先前的平均利率) - t₁ →
last_update_timestamp - initialization_timestamp(所有先前利率下的经过时间) - r₂ →
current_rate(更新前的最新利率) - t₂ →
current_timestamp - last_update_timestamp(当前利率下的经过时间)
时间加权平均值计算示例
假设我们的初始条件为:
initialization_timestamp = 0pre_update_average_rate = 300(3%)last_update_timestamp = 7889184秒 (约 3 个月。InterestBearingConfig扩展存储的是绝对 Unix 时间戳,但在本例中,我们使用以秒为单位的 3 个月经过时间来说明利息增长,因为利息仅取决于时间的流逝,而与具体的时间戳值无关)current_rate = 500(5%)current_timestamp = 31556736(≈ 1 年)new_rate = 700(7%)
那么:
更新后:
pre_update_average_rate = 450(4.50%)last_update_timestamp = 31556736current_rate = 700(7%)
公式的代码演示
更新前的增长期和更新后的增长期 ****在扩展的源代码中被实现为 pre_update_exp 和 post_update_exp 函数。定义这两个函数的部分如下所示。
pre_update_exp 和 post_update_exp 函数直接实现了连续复利公式 (),具体而言,是最近一次利率更新前后两个不同时间段的利息增长因子 ()。
pre_update_exp计算代币初始化到最后一次利率更新之间这段时间的复利增长。- 它将该期间的平均利率 (
pre_update_average_rate) 乘以以秒为单位的经过时间。
- 它将分子 (numerator) 除以下列两项:
- 一年中的秒数 (
SECONDS_PER_YEAR),用于将时间从秒转换为年,以及 - 常量
ONE_IN_BASIS_POINTS(等于 10,000),用于将利率从基点转换为小数。
- 一年中的秒数 (
- 最后,它计算
exponent.exp(),即该时间段的连续增长因子(欧拉数加上指数的幂)。这在数学上表示为 。
- 它将该期间的平均利率 (
post_update_exp执行相同的计算,但使用当前利率 (current_rate) 和自最近一次更新以来的经过时间 (post_update_timespan)。
以下是 Token-2022 代码库中的 pre_update_exp 和 post_update_exp 函数:
pub struct InterestBearingConfig {
/// Authority that can set the interest rate and authority
pub rate_authority: OptionalNonZeroPubkey,
/// Timestamp of initialization, from which to base interest calculations
pub initialization_timestamp: UnixTimestamp,
/// Average rate from initialization until the last time it was updated
pub pre_update_average_rate: BasisPoints,
/// Timestamp of the last update, used to calculate the total amount accrued
pub last_update_timestamp: UnixTimestamp,
/// Current rate, since the last update
pub current_rate: BasisPoints,
}
fn pre_update_exp(&self) -> Option<f64> {
let numerator = (i16::from(self.pre_update_average_rate) as i128)
.checked_mul(self.pre_update_timespan()? as i128)? as f64;
let exponent = numerator / SECONDS_PER_YEAR / ONE_IN_BASIS_POINTS;
Some(exponent.exp())
}
fn post_update_exp(&self, unix_timestamp: i64) -> Option<f64> {
let numerator = (i16::from(self.current_rate) as i128)
.checked_mul(self.post_update_timespan(unix_timestamp)? as i128)? as f64;
let exponent = numerator / SECONDS_PER_YEAR / ONE_IN_BASIS_POINTS;
Some(exponent.exp())
}
fn post_update_timespan(&self, unix_timestamp: i64) -> Option<i64> {
unix_timestamp.checked_sub(self.last_update_timestamp.into())
}
为了计算精确的复利(无论利率是否发生变化),生息扩展使用了 total_scale 函数。
它将 pre_update_exp 和 post_update_exp 的结果相乘,这两个结果分别代表了上一次利率更新前后的增长因子。
该乘积给出了两个时间段内的总指数增长因子。
最后,total_scale 函数将结果除以 10^decimals,以将该值缩放为代币的标准精度。例如,SOL 代币有 9 位小数,因此 pre_update_exp 和 post_update_exp 的结果将除以 10^9。
total_scale 的结果值是应用于链上余额以准确显示连续复利的 缩放因子 (scaling factor)。
fn total_scale(&self, decimals: u8, unix_timestamp: i64) -> Option<f64> {
Some(
self.pre_update_exp()? * self.post_update_exp(unix_timestamp)?
/ 10_f64.powi(decimals as i32),
)
}
换言之,生息扩展中每次利息计算的结果值都是本金与 total_scale 的乘积。
生息扩展如何在内部应用该公式
以下是在内部实现这种计算的三个重要字段:
pre_update_average_rate: 存储直到最后一次利率更新的累计增长因子(公式中的指数)。在我们的例子中,3 个月后,此项捕获的值为0.03 × 0.25 = 0.0075。last_update_timestamp: 标记上一次利率更新发生的准确时间。在示例中,这是第 3 个月标记处的时间戳。current_rate: 当前生效的利率。在示例中,3 个月后该利率从0.03切换为0.05。
当钱包或程序查询余额时,生息扩展将公式重构为:
这等同于我们前面提到的 total_scale 函数中的内容:
fn total_scale(&self, decimals: u8, unix_timestamp: i64) -> Option<f64> {
Some(
self.pre_update_exp()? * self.post_update_exp(unix_timestamp)?
/ 10_f64.powi(decimals as i32),
)
}
UI 显示余额转换函数
生息扩展暴露了两个函数,钱包和应用程序使用这些函数在链下一致地显示余额:
- 将原始金额转换为 UI 金额的函数 (
amount_to_ui_amount),它首先计算应计利息(代币金额 * 总缩放比例),然后将结果格式化为具有给定小数精度的字符串,并去除不必要的零。
/// Convert a raw amount to its UI representation using the given decimals
/// field. Excess zeroes or unneeded decimal point are trimmed.
pub fn amount_to_ui_amount(
&self,
amount: u64,
decimals: u8,
unix_timestamp: i64,
) -> Option<String> {
let scaled_amount_with_interest =
(amount as f64) * self.total_scale(decimals, unix_timestamp)?;
let ui_amount = format!("{scaled_amount_with_interest:.*}", decimals as usize);
Some(trim_ui_amount_string(ui_amount, decimals))
}
- 以及
try_ui_amount_into_amount,它将 UI 余额(包含计算出的利息的余额)转换回内部使用的原始金额(不包含利息)。以下是生息扩展源代码中的原始实现。
/// Try to convert a UI representation of a token amount to its raw amount
/// using the given decimals field
pub fn try_ui_amount_into_amount(
&self,
ui_amount: &str,
decimals: u8,
unix_timestamp: i64,
) -> Result<u64, ProgramError> {
let scaled_amount = ui_amount
.parse::<f64>()
.map_err(|_| ProgramError::InvalidArgument)?;
let amount = scaled_amount
/ self
.total_scale(decimals, unix_timestamp)
.ok_or(ProgramError::InvalidArgument)?;
if amount > (u64::MAX as f64) || amount < (u64::MIN as f64) || amount.is_nan() {
Err(ProgramError::InvalidArgument)
} else {
// this is important, if you round earlier, you'll get wrong "inf"
// answers
Ok(amount.round() as u64)
}
}
上述函数 (amount_to_ui_amount 和 try_ui_amount_into_amount) 并非在链上执行。它们是在 Rust SDK 中实现的客户端辅助函数 (client-side helpers)(在 TypeScript SDK 中也有对应的实现)。
钱包兼容性
Token-2022 扩展目前在 Solana 生态系统中的钱包中尚未得到广泛支持。由于生息代币的链上余额永远不会改变,钱包必须在 mint 上检测到生息扩展,并在显示用户的余额之前应用复利公式。如果没有这个逻辑,钱包将始终显示原始本金金额,而忽略累积的增长。
这种差异意味着两个钱包可能会显示同一账户的不同结果:一个只显示存储在链上的固定金额,另一个则显示从扩展字段派生出的连续复利余额。在钱包更新其账户渲染逻辑以包含 Token-2022 扩展之前,依赖于生息代币的应用程序通常需要自行计算并显示余额。
结论
在本文中,我们讨论了生息代币扩展的工作原理,以及它如何引入一种直接在代币 mint 级别表示收益的方法,而无需链上余额更新或定期的分发交易。
我们还讨论了所有利息累积是如何通过链下的确定性公式进行的,这使得系统保持高效,同时仍能为用户提供余额不断增长的体验。
余额显示背后的数学原理确保了应计利息的正确复利计算,钱包的集成也可以依赖提供的函数来保持余额的一致性。
目前各大钱包和浏览器对此的支持仍然有限,因此,采用此功能的应用程序现阶段必须承担起正确显示余额的责任。
本文是 Solana 教程系列 的一部分。