Cairo教程:组件间交互与Staking实现
本文是Cairo教程系列第3部分,深入讲解组件间的交互。
==================== 开始 Markdown 内容 =====================
精通 Cairo
组件 3
在组件第 1 部分中,我们学习了如何在合约内创建和使用组件,并展示了组件的行为类似于 Solidity 中的抽象合约。在组件第 2 部分中,我们学习了使用 OpenZeppelin 的预构建组件创建代币合约。
到目前为止,我们在合约级别使用组件,合约导入组件并调用其函数。但如果想要构建一个使用其他组件功能的组件呢?
例如,考虑一个可以在多个合约中复用的质押组件。当用户质押或解除质押时,组件需要将代币转入和转出他们的账户。它还需要确保只有合约所有者可以更新奖励率。这两者都需要调用其他组件。组件间交互使得质押组件可以调用两个独立的组件来处理这些需求。
下图显示了质押合约集成了三个组件:质押组件处理质押逻辑,并依赖于 ERC20 组件(用于代币转账)和 Ownable 组件(确保只有合约所有者可以更新奖励率):

学完本章,你将学习如何:
- 从其他组件直接调用组件
- 管理合约内的组件依赖关系
- 指定合约 ABI 中要暴露哪些组件函数
- 在合约部署期间初始化组件
组件如何调用其他组件
一个组件应专注于一个职责领域,例如代币管理、访问控制或质押逻辑。当组件需要使用其他组件的功能时,在组件的实现签名中声明这些依赖关系。声明后,组件可以调用这些依赖项的函数。例如,质押组件可以调用 ERC20 组件的转账函数和 Ownable 组件的所有权检查函数,而无需合约来协调这些交互。
以下是用户质押 100 个代币时组件间交互流程的外观:

在本教程中,我们将把 ERC20 组件和质押组件嵌入到同一个合约中。这种嵌入式方法主要用于演示组件间的交互。在生产环境中,质押合约通常接受一个外部代币地址,并使用合约调度器与那个独立的代币合约进行交互。我们将在文章末尾解释外部代币方法及其差异。
构建质押组件
我们将构建一个依赖于 OpenZeppelin 的 ERC20 组件和 Ownable 组件的质押组件,以实际了解组件间交互的工作原理,然后将其集成到质押合约中。
我们的质押组件将提供以下功能:
- 质押代币:用户可以通过质押代币来赚取奖励。当用户质押时,代币会从他们的余额转移到合约中。
- 解除质押代币:用户可以随时解除质押(无锁仓期)。解除质押时,质押的代币连同累积的奖励一起返还给用户。
- 设置奖励率:只有合约所有者可以更新奖励率,奖励率决定了每个质押代币随时间可获得多少奖励代币。
项目设置
创建一个新的 Scarb 项目并进入其目录:
scarb new component_component
cd component_component
添加组件依赖
要使用 OpenZeppelin 的 ERC20 和 Ownable 组件,请在 Scarb.toml 文件的 [dependencies] 下添加 OpenZeppelin 依赖:

质押接口
以下接口定义了质押组件将实现的特定于质押的函数:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IStaking<TContractState> {
// 用户函数
fn stake(ref self: TContractState, amount: u256);
fn unstake(ref self: TContractState, amount: u256);
fn claim_rewards(ref self: TContractState);
// 视图函数
fn get_staked_balance(self: @TContractState, user: ContractAddress) -> u256;
fn get_total_staked(self: @TContractState) -> u256;
fn calculate_rewards(self: @TContractState, user: ContractAddress) -> u256;
fn get_reward_rate(self: @TContractState) -> u256;
// 管理函数
fn set_reward_rate(ref self: TContractState, rate: u256);
}
将接口添加到你的 src/lib.cairo 文件中,然后在其下方添加以下组件结构:
#[starknet::component]
pub mod StakingComponent {
// 组件实现将放在这里
}
存储设置
每个组件都定义自己的存储结构来跟踪其状态。对于 StakingComponent,我们需要跟踪每个用户质押了多少、他们的奖励上次计算的时间、合约中的总质押量、用户累积的奖励以及奖励率。让我们在 StakingComponent 内部定义存储结构:
#[starknet::component]
pub mod StakingComponent {
use starknet::ContractAddress;
use starknet::storage::Map;
#[storage]
pub struct Storage {
staked_balances: Map<ContractAddress, u256>,
total_staked: u256,
reward_rate: u256,
last_update_time: Map<ContractAddress, u64>,
accumulated_rewards: Map<ContractAddress, u256>,
}
}
以下是每个存储字段的含义:
staked_balances:一个映射,跟踪每个用户质押了多少代币。键是用户地址,值是他们质押的数量。total_staked:合约中所有用户质押的总代币量。reward_rate:每个质押代币每秒获得的奖励代币数量(缩放 1,000,000 倍)。可以由合约所有者更新。last_update_time:从用户地址到他们上次奖励更新时间戳的映射。accumulated_rewards:一个映射,跟踪每个用户已累积但尚未领取的奖励总额。
关于组件存储和变量命名的说明
回想一下组件第 1 部分,#[substorage(v0)] 属性允许使用该组件的合约访问该组件的状态。
当集成多个组件时,如果两个组件定义了同名的存储变量,Cairo 编译器会发出关于潜在冲突的警告:
warn: The path `component_a.variable_name` collides with existing path `component_b.variable_name`.
你可以使用 #[allow(starknet::colliding_storage_paths)] 来抑制此警告,但这并不能防止冲突;它只是静默警告。两个变量将指向相同的存储位置。
这就是为什么 OpenZeppelin 在其组件中为存储变量添加前缀(ERC20_total_supply、Ownable_owner 等)。前缀确保即使同时使用多个组件,它们的存储变量也具有唯一名称,不会冲突。
所以在构建组件时,使用不太可能与其他组件的存储变量冲突的描述性前缀或名称。
事件声明
为了跟踪用户何时质押和解除质押代币,将以下事件定义添加到 StakingComponent:
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Staked: Staked,
Unstaked: Unstaked
}
#[derive(Drop, starknet::Event)]
pub struct Staked {
pub user: ContractAddress,
pub amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Unstaked {
pub user: ContractAddress,
pub amount: u256,
}
Staked 在用户质押代币时记录用户地址和数量。Unstaked 在用户解除质押时执行相同操作。
实现质押接口
有了状态变量和事件,现在我们可以实现之前定义的 IStaking 接口。我们将从创建空函数存根开始,以便代码可以编译,然后逐个实现每个函数。
将以下实现块添加到 StakingComponent:
#[embeddable_as(StakingImpl)]
impl StakingImplImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
> of super::IStaking<ComponentState<TContractState>> {
fn stake(ref self: ComponentState<TContractState>, amount: u256) {
// 实现将放在这里
}
fn unstake(ref self: ComponentState<TContractState>, amount: u256) {
// 实现将放在这里
}
fn claim_rewards(ref self: ComponentState<TContractState>) {
// 实现将放在这里
}
fn get_staked_balance(
self: @ComponentState<TContractState>, user: ContractAddress
) -> u256 {
0 // 占位符
}
fn get_total_staked(self: @ComponentState<TContractState>) -> u256 {
0 // 占位符
}
fn calculate_rewards(
self: @ComponentState<TContractState>, user: ContractAddress
) -> u256 {
0 // 占位符
}
fn set_reward_rate(ref self: ComponentState<TContractState>, rate: u256) {
// 实现将放在这里
}
fn get_reward_rate(self: @ComponentState<TContractState>) -> u256 {
0 // 占位符
}
}
#[embeddable_as(StakingImpl)] 属性告诉 Cairo 此实现应可用于嵌入到合约中。
声明组件依赖
如前所述,StakingComponent 必须在其实现签名中声明 ERC20Component 和 OwnableComponent 作为依赖,才能直接调用它们的函数。
将 ERC20Component、OwnableComponent 和 starknet 导入添加到 StakingComponent 模块:
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::token::erc20::ERC20Component;
use starknet::{get_caller_address, get_contract_address};
接下来,在实现签名中将这些组件声明为依赖项:
#[embeddable_as(StakingImpl)]
impl StakingImplImpl
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,//ADD THIS LINE
impl Ownable: OwnableComponent::HasComponent<TContractState>,//ADD THIS LINE
> of super::IStaking<ComponentState<TContractState>> {
// 函数放在这里
}
impl ERC20: ERC20Component::HasComponent<TContractState> 和 impl Ownable: OwnableComponent::HasComponent<TContractState> 行告诉 Cairo,任何使用 StakingComponent 的合约也必须包含这些组件。
以下是在此之前完整的 StakingComponent 代码:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IStaking<TContractState> {
// 用户函数
fn stake(ref self: TContractState, amount: u256);
fn unstake(ref self: TContractState, amount: u256);
fn claim_rewards(ref self: TContractState);
// 视图函数
fn get_staked_balance(self: @TContractState, user: ContractAddress) -> u256;
fn get_total_staked(self: @TContractState) -> u256;
fn calculate_rewards(self: @TContractState, user: ContractAddress) -> u256;
fn get_reward_rate(self: @TContractState) -> u256;
// 管理函数
fn set_reward_rate(ref self: TContractState, rate: u256);
}
#[starknet::component]
pub mod StakingComponent {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::token::erc20::ERC20Component;
use starknet::storage::{
Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess,
StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address, get_contract_address};
#[storage]
pub struct Storage {
staked_balances: Map<ContractAddress, u256>,
total_staked: u256,
reward_rate: u256,
last_update_time: Map<ContractAddress, u64>,
accumulated_rewards: Map<ContractAddress, u256>,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Staked: Staked,
Unstaked: Unstaked,
}
#[derive(Drop, starknet::Event)]
pub struct Staked {
pub user: ContractAddress,
pub amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Unstaked {
pub user: ContractAddress,
pub amount: u256,
}
#[embeddable_as(StakingImpl)]
impl StakingImplImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of super::IStaking<ComponentState<TContractState>> {
fn stake(
ref self: ComponentState<TContractState>, amount: u256,
) { // 实现将放在这里
}
fn unstake(
ref self: ComponentState<TContractState>, amount: u256,
) { // 实现将放在这里
}
fn claim_rewards(ref self: ComponentState<TContractState>) {// 实现将放在这里
}
fn get_staked_balance(
self: @ComponentState<TContractState>, user: ContractAddress,
) -> u256 {
0 // 占位符
}
fn get_total_staked(self: @ComponentState<TContractState>) -> u256 {
0 // 占位符
}
fn calculate_rewards(self: @ComponentState<TContractState>, user: ContractAddress) -> u256 {
0 // 占位符
}
fn set_reward_rate(
ref self: ComponentState<TContractState>, rate: u256,
) { // 实现将放在这里
}
fn get_reward_rate(self: @ComponentState<TContractState>) -> u256 {
0 // 占位符
}
}
}
实现 stake 函数
stake 函数将代币从用户转移到合约,更新他们的质押余额,并记录用于奖励计算的时间戳:
fn stake(ref self: ComponentState<TContractState>, amount: u256) {
assert(amount > 0, 'Amount must be greater than 0');
let caller = get_caller_address();
let contract_address = get_contract_address();
// 将代币从调用者转移到合约
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
erc20._transfer(caller, contract_address, amount);
// 更新质押余额
let current_stake = self.staked_balances.read(caller);
self.staked_balances.write(caller, current_stake + amount);
let total = self.total_staked.read();
self.total_staked.write(total + amount);
self.emit(Staked { user: caller, amount });
}
该函数首先验证质押金额大于零,然后检索调用者地址和合约地址。它将代币从调用者转移到合约,更新调用者的质押余额和总质押量,并发出 Staked 事件。
组件间交互发生的地方
注意以下这几行:
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
erc20._transfer(caller, contract_address, amount);
这就是组件间交互发生的地方。get_dep_component_mut! 宏获取对 ERC20Component 的可变引用,允许我们调用其 _transfer 函数将代币从用户移动到合约。让我们分解其工作原理:
- 宏:
get_dep_component_mut!给我们一个对依赖组件的可变引用。这使我们能够调用其内部函数。 - 参数:
ref self引用组件的状态ERC20是我们在实现签名中声明的依赖名称
- 为什么需要这个宏:在
StakingComponent内部,我们不能直接调用self.erc20._transfer(...),因为每个组件的存储都在合约内保持独立。get_dep_component_mut!宏为我们获取对ERC20Component的引用,以便我们可以调用其函数。
你可能想知道为什么使用 _transfer 而不是传统的 transfer_from 函数。如引言所述,本教程使用嵌入式代币架构,其中代币和质押逻辑是同一合约的一部分。这影响了我们使用哪种转账方法。我们将在文章后面更详细地解释这一点,并与质押外部代币进行比较。
转账之后,我们通过读取用户的当前质押量来更新用户的质押余额,添加新金额,然后写回。我们还更新总质押量,并发出 Staked 事件来记录此操作。
如果你此时尝试编译代码,你会注意到 _transfer 会报错。当你悬停在其上时,你会看到:
Method `_transfer` not found on type `openzeppelin_token::erc20::erc20::ERC20Comp
onent::ComponentState::<TContractState>`. Did you import the correct trait and impl?
此错误是因为 _transfer 是 ERC20Component 的内部函数,而我们还没有导入实现它的 trait。要解决此问题,请在 StakingComponent 模块顶部添加以下导入:
use openzeppelin::token::erc20::ERC20Component::InternalTrait as ERC20InternalTrait;
此导入使我们能够访问 ERC20Component 的内部函数,如 _transfer 和 _mint。如果你再次编译,会遇到另一个错误:
Trait has no implementation in context: openzeppelin_token::erc20::erc20::ERC20Co
mponent::InternalTrait::<TContractState, ImplVarId(34881), ImplVarId(34882)> ….
发生这种情况是因为 ERC20Component 需要实现其 ERC20HooksTrait。该 trait 定义了可以在代币转账之前和之后运行的 hooks。由于我们的质押合约不需要自定义 hooks,我们将使用 OpenZeppelin 提供的空实现。
更新 ERC20 导入,包含 ERC20HooksEmptyImpl:
use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
现在代码应该可以成功编译,_transfer 函数将按预期工作。
然而,stake 函数的实现还不完整。在更新用户的质押之前,我们需要先计算他们累积的奖励。让我们创建内部辅助函数来处理奖励计算。
用于奖励计算的内部辅助函数
组件可以拥有仅在组件内部或使用它的合约中可访问的内部函数,就像 ERC20Component 中的 _transfer 一样。这些函数不属于公共接口,不会出现在合约的 ABI 中。
我们使用 #[generate_trait] 属性来定义它们,该属性会自动生成一个 trait 来容纳内部函数。在主要实现块下方添加以下内部实现块,以处理奖励计算和更新:
#[generate_trait]
pub impl InternalImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of InternalTrait<TContractState> {
// 内部函数将放在这里
}
在实现奖励计算辅助函数之前,我们需要一个初始化函数来在合约部署时设置组件的初始状态,包括初始奖励率:
#[generate_trait]
pub impl InternalImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of InternalTrait<TContractState> {
// 下方是新添加的 //
fn initializer(ref self: ComponentState<TContractState>, initial_reward_rate: u256) {
self.reward_rate.write(initial_reward_rate);
}
// 其他内部函数将放在这里
}
理解组件初始化函数
有时组件需要只运行一次的初始化逻辑。在 Solidity 中,这可以通过构造函数实现。虽然 Cairo 合约也支持构造函数,但组件不支持。
相反,组件使用初始化函数:在合约部署时处理设置的常规函数。**框架不强制执行单次执行,因此约定只从合约的构造函数中调用初始化函数。**由于构造函数仅在部署期间运行一次,这确保了初始化函数也只被调用一次。
在这种情况下,来自 OpenZeppelin 的 Ownable 和 ERC20 组件,以及我们自定义的 StakingComponent,都提供了名为 initializer 的初始化函数。因为它是一个常规函数,其名称可以是任意的。名称 initializer 是一种约定。
当我们稍后在文章中构建 StakingContract 时,将从合约的构造函数中调用这些初始化函数。忘记调用组件的初始化函数将使其状态未初始化,从而破坏合约逻辑或造成安全漏洞。
OpenZeppelin 中 Ownable 组件初始化函数的片段如下所示:

Ownable 初始化函数设置初始所有者地址,因此我们的构造函数必须将所有者地址作为参数。
计算待领取奖励
有了初始化函数,我们可以实现奖励计算辅助函数。_calculate_pending_rewards 函数根据用户的质押量和自上次更新以来经过的时间计算用户赚取了多少奖励:
fn _calculate_pending_rewards(
self: @ComponentState<TContractState>, user: ContractAddress,
) -> u256 {
let staked = self.staked_balances.read(user);
if staked == 0 {
return self.accumulated_rewards.read(user);
}
let last_update = self.last_update_time.read(user);
let current_time = get_block_timestamp();
if last_update == 0 {
return 0;
}
let time_elapsed = current_time - last_update;
let reward_rate = self.reward_rate.read();
// 计算新奖励:staked_amount * reward_rate * time_elapsed
let new_rewards = (staked * reward_rate * time_elapsed.into()) / 1000000;
let accumulated = self.accumulated_rewards.read(user);
accumulated + new_rewards
}
该函数首先检查用户是否有任何质押代币。如果没有,则返回他们之前质押的累积奖励。
然后它检索用户奖励上次更新的时间以及当前区块时间戳。如果用户从未质押过(他们的 last_update_time 为 0),函数返回 0,因为还没有奖励需要计算。
函数计算自上次更新以来经过的时间,并检索当前奖励率。奖励计算公式为:staked_amount * reward_rate * time_elapsed / 1000000。我们除以 1,000,000,因为 Cairo 没有浮点数。奖励率缩放 1,000,000 倍,以整数形式表示分数值。
函数然后将之前累积的奖励添加到新计算出的奖励中,并返回总数。
我们需要在模块顶部与现有导入一起导入 get_block_timestamp:
use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address};
现在让我们实现 update_rewards 函数,它使用 _calculate_pending_rewards 来计算用户的任何待领取奖励,更新他们的累积奖励和上次更新时间戳:
fn update_rewards(ref self: ComponentState<TContractState>, user: ContractAddress) {
let pending = self._calculate_pending_rewards(user);
self.accumulated_rewards.write(user, pending);
self.last_update_time.write(user, get_block_timestamp());
}
实现了 update_rewards 后,我们可以返回并完成 stake 函数,通过在更改用户质押之前添加奖励更新。
完成 stake() 函数
更新 stake 函数,包含 update_rewards 调用:
fn stake(ref self: ComponentState<TContractState>, amount: u256) {
assert(amount > 0, 'Amount must be greater than 0');
let caller = get_caller_address();
let contract_address = get_contract_address();
// 在更改质押之前更新奖励
self.update_rewards(caller); // 添加此行
// 将代币从调用者转移到合约
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
erc20._transfer(caller, contract_address, amount);
// 更新质押余额
let current_stake = self.staked_balances.read(caller);
self.staked_balances.write(caller, current_stake + amount);
let total = self.total_staked.read();
self.total_staked.write(total + amount);
self.emit(Staked { user: caller, amount });
}
添加的是 self.update_rewards(caller),在我们更改用户质押余额之前调用。这可确保奖励是基于用户之前的质押量计算的,然后再添加新代币。如果没有这一行,用户将失去他们之前质押所赚取的奖励。
以下是在此之前的完整 StakingComponent 代码:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IStaking<TContractState> {
// 用户函数
fn stake(ref self: TContractState, amount: u256);
fn unstake(ref self: TContractState, amount: u256);
fn claim_rewards(ref self: TContractState);
// 视图函数
fn get_staked_balance(self: @TContractState, user: ContractAddress) -> u256;
fn get_total_staked(self: @TContractState) -> u256;
fn calculate_rewards(self: @TContractState, user: ContractAddress) -> u256;
fn get_reward_rate(self: @TContractState) -> u256;
// 管理函数
fn set_reward_rate(ref self: TContractState, rate: u256);
}
#[starknet::component]
pub mod StakingComponent {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::token::erc20::ERC20Component::InternalTrait as ERC20InternalTrait;
use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use starknet::storage::{
Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess,
StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address};
#[storage]
pub struct Storage {
staked_balances: Map<ContractAddress, u256>,
total_staked: u256,
reward_rate: u256,
last_update_time: Map<ContractAddress, u64>,
accumulated_rewards: Map<ContractAddress, u256>,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Staked: Staked,
Unstaked: Unstaked,
}
#[derive(Drop, starknet::Event)]
pub struct Staked {
pub user: ContractAddress,
pub amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Unstaked {
pub user: ContractAddress,
pub amount: u256,
}
#[embeddable_as(StakingImpl)]
impl StakingImplImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of super::IStaking<ComponentState<TContractState>> {
/// @notice 将代币质押到合约中并更新奖励
/// @param amount 要质押的代币数量
fn stake(ref self: ComponentState<TContractState>, amount: u256) {
assert(amount > 0, 'Amount must be greater than 0');
let caller = get_caller_address();
let contract_address = get_contract_address();
// 在更改质押之前更新奖励
self.update_rewards(caller);
// 这将从调用者转移到合约,无需批准
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
erc20._transfer(caller, contract_address, amount);
// 更新质押余额
let current_stake = self.staked_balances.read(caller);
self.staked_balances.write(caller, current_stake + amount);
let total = self.total_staked.read();
self.total_staked.write(total + amount);
self.emit(Staked { user: caller, amount });
}
fn unstake(
ref self: ComponentState<TContractState>, amount: u256,
) { // 实现将放在这里
}
fn claim_rewards(ref self: ComponentState<TContractState>) { // 实现将放在这里
}
fn get_staked_balance(
self: @ComponentState<TContractState>, user: ContractAddress,
) -> u256 {
0 // 占位符
}
fn get_total_staked(self: @ComponentState<TContractState>) -> u256 {
0 // 占位符
}
fn calculate_rewards(self: @ComponentState<TContractState>, user: ContractAddress) -> u256 {
0 // 占位符
}
fn set_reward_rate(
ref self: ComponentState<TContractState>, rate: u256,
) { // 实现将放在这里
}
fn get_reward_rate(self: @ComponentState<TContractState>) -> u256 {
0 // 占位符
}
}
#[generate_trait]
pub impl InternalImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of InternalTrait<TContractState> {
// 下方是新添加的 //
fn initializer(ref self: ComponentState<TContractState>, initial_reward_rate: u256) {
self.reward_rate.write(initial_reward_rate);
}
fn _calculate_pending_rewards(
self: @ComponentState<TContractState>, user: ContractAddress,
) -> u256 {
let staked = self.staked_balances.read(user);
if staked == 0 {
return self.accumulated_rewards.read(user);
}
let last_update = self.last_update_time.read(user);
let current_time = get_block_timestamp();
if last_update == 0 {
return 0;
}
let time_elapsed = current_time - last_update;
let reward_rate = self.reward_rate.read();
// 计算新奖励:staked_amount * reward_rate * time_elapsed
let new_rewards = (staked * reward_rate * time_elapsed.into()) / 1000000;
let accumulated = self.accumulated_rewards.read(user);
accumulated + new_rewards
}
fn update_rewards(ref self: ComponentState<TContractState>, user: ContractAddress) {
let pending = self._calculate_pending_rewards(user);
self.accumulated_rewards.write(user, pending);
self.last_update_time.write(user, get_block_timestamp());
}
}
}
实现 unstake 函数
unstake 函数允许用户从合约中提取他们的质押代币。让我们实现它:
fn unstake(ref self: ComponentState<TContractState>, amount: u256) {
let caller = get_caller_address();
let current_stake = self.staked_balances.read(caller);
assert(amount > 0, 'Amount must be greater than 0');
assert(current_stake >= amount, 'Insufficient staked balance');
// 在更改质押之前更新奖励
self.update_rewards(caller);
// 更新质押余额
self.staked_balances.write(caller, current_stake - amount);
let total = self.total_staked.read();
self.total_staked.write(total - amount);
// 使用 ERC20Component 将代币转回用户
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
let contract_address = get_contract_address();
erc20._transfer(contract_address, caller, amount);
self.emit(Unstaked { user: caller, amount });
}
该函数首先检索调用者地址和其当前质押余额。然后验证金额并确认用户有足够的质押代币。
与 stake 函数一样,我们在修改用户质押余额之前调用 self.update_rewards(caller)。这确保奖励是根据其提取前的质押量计算的。
更新奖励后,函数减少用户的质押余额和总质押量。然后使用 ERC20Component 的 _transfer 函数将代币从合约转回用户,并发出 Unstaked 事件。
实现 claim_rewards 函数
claim_rewards 函数允许用户领取他们累积的奖励,同时将他们的代币保留在合约中质押:
fn claim_rewards(ref self: ComponentState<TContractState>) {
let caller = get_caller_address();
self.update_rewards(caller);
let rewards = self.accumulated_rewards.read(caller);
assert(rewards > 0, 'No rewards to claim');
// 重置累积奖励
self.accumulated_rewards.write(caller, 0);
// 将奖励代币转账到用户
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
let contract_address = get_contract_address();
erc20._transfer(contract_address, caller, rewards);
}
claim_rewards 函数首先更新用户的奖励,以确保所有待领取奖励都已计算。然后它读取用户的总累积奖励。
函数验证用户有奖励可领取,将他们的累积奖励重置为零,并使用 _transfer 将奖励代币转账到用户。
实现 set_reward_rate 函数
set_reward_rate 函数允许合约所有者更新奖励率:
fn set_reward_rate(ref self: ComponentState<TContractState>, rate: u256) {
// 使用 OwnableComponent 检查所有权
let ownable = get_dep_component!(@self, Ownable);
ownable.assert_only_owner();
self.reward_rate.write(rate);
}
这个函数也展示了另一个组件间交互。我们使用 get_dep_component!(@self, Ownable) 来访问 OwnableComponent 并调用其 assert_only_owner 函数。如果调用者不是合约所有者,此函数将 panic,从而防止未经授权的用户更改奖励率。
注意,这里我们使用 get_dep_component!(没有 _mut),而不是与 ERC20Component 一起使用的 get_dep_component_mut!。区别在于:
get_dep_component_mut!提供可变引用——当你需要修改组件的状态时使用(如转账代币)get_dep_component!提供不可变引用——当你只需要读取数据或调用不修改状态的函数时使用(如检查所有权)
由于 assert_only_owner 只读取所有者地址,不修改 OwnableComponent 的状态,我们使用不可变版本。
如果你现在编译代码,会出现错误:
Method 'assert_only_owner' not found on type '@openzeppelin_access::ownable::owna
ble::OwnableComponent::ComponentState::<TContractState>'. Consider importing one
of the following traits: 'OwnableComponent::InternalTrait'
这意味着我们需要在模块顶部导入 OwnableComponent 的内部 trait:
use openzeppelin::access::ownable::OwnableComponent::InternalTrait as OwnableInternalTrait;
所有权检查通过后,函数更新存储中的奖励率。
实现视图函数
我们需要实现四个视图函数:
get_staked_balance返回特定用户质押了多少代币,get_total_staked返回所有用户质押的总量,calculate_rewards显示用户可领取的奖励数量,以及get_reward_rate返回当前奖励率。
fn get_staked_balance(
self: @ComponentState<TContractState>, user: ContractAddress
) -> u256 {
self.staked_balances.read(user)
}
fn get_total_staked(self: @ComponentState<TContractState>) -> u256 {
self.total_staked.read()
}
fn calculate_rewards(
self: @ComponentState<TContractState>, user: ContractAddress
) -> u256 {
self._calculate_pending_rewards(user)
}
fn get_reward_rate(self: @ComponentState<TContractState>) -> u256 {
self.reward_rate.read()
}
这些实现从存储中读取并返回请求的值。calculate_rewards 函数使用内部 _calculate_pending_rewards 函数来计算奖励。
以下是完整的 StakingComponent 代码:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IStaking<TContractState> {
// 用户函数
fn stake(ref self: TContractState, amount: u256);
fn unstake(ref self: TContractState, amount: u256);
fn claim_rewards(ref self: TContractState);
// 视图函数
fn get_staked_balance(self: @TContractState, user: ContractAddress) -> u256;
fn get_total_staked(self: @TContractState) -> u256;
fn calculate_rewards(self: @TContractState, user: ContractAddress) -> u256;
fn get_reward_rate(self: @TContractState) -> u256;
// 管理函数
fn set_reward_rate(ref self: TContractState, rate: u256);
}
#[starknet::component]
pub mod StakingComponent {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::access::ownable::OwnableComponent::InternalTrait as OwnableInternalTrait;
use openzeppelin::token::erc20::ERC20Component::InternalTrait as ERC20InternalTrait;
use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use starknet::storage::{
Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess,
StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address};
#[storage]
pub struct Storage {
staked_balances: Map<ContractAddress, u256>,
total_staked: u256,
reward_rate: u256,
last_update_time: Map<ContractAddress, u64>,
accumulated_rewards: Map<ContractAddress, u256>,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Staked: Staked,
Unstaked: Unstaked
}
#[derive(Drop, starknet::Event)]
pub struct Staked {
pub user: ContractAddress,
pub amount: u256,
}
#[derive(Drop, starknet::Event)]
pub struct Unstaked {
pub user: ContractAddress,
pub amount: u256,
}
#[embeddable_as(StakingImpl)]
impl StakingImplImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of super::IStaking<ComponentState<TContractState>> {
/// @notice 将代币质押到合约中并更新奖励
/// @param amount 要质押的代币数量
fn stake(ref self: ComponentState<TContractState>, amount: u256) {
assert(amount > 0, 'Amount must be greater than 0');
let caller = get_caller_address();
let contract_address = get_contract_address();
// 在更改质押之前更新奖励
self.update_rewards(caller);
// 这将从调用者转移到合约,无需批准
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
erc20._transfer(caller, contract_address, amount);
// 更新质押余额
let current_stake = self.staked_balances.read(caller);
self.staked_balances.write(caller, current_stake + amount);
let total = self.total_staked.read();
self.total_staked.write(total + amount);
self.emit(Staked { user: caller, amount });
}
/// @notice 解除质押代币并将其转回用户
/// @param amount 要解除质押的代币数量
fn unstake(ref self: ComponentState<TContractState>, amount: u256) {
let caller = get_caller_address();
let current_stake = self.staked_balances.read(caller);
assert(amount > 0, 'Amount must be greater than 0');
assert(current_stake >= amount, 'Insufficient staked balance');
// 在更改质押之前更新奖励
self.update_rewards(caller);
// 更新质押余额
self.staked_balances.write(caller, current_stake - amount);
let total = self.total_staked.read();
self.total_staked.write(total - amount);
// 使用 ERC20Component 将代币转回用户
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
let contract_address = get_contract_address();
erc20._transfer(contract_address, caller, amount);
self.emit(Unstaked { user: caller, amount });
}
/// @notice 为调用者领取累积的奖励
fn claim_rewards(ref self: ComponentState<TContractState>) {
let caller = get_caller_address();
self.update_rewards(caller);
let rewards = self.accumulated_rewards.read(caller);
assert(rewards > 0, 'No rewards to claim');
// 重置累积奖励
self.accumulated_rewards.write(caller, 0);
// 将奖励代币转账到用户
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
let contract_address = get_contract_address();
erc20._transfer(contract_address, caller, rewards);
}
/// @notice 返回特定用户的质押余额
/// @param user 用户地址
/// @return 用户质押的代币数量
fn get_staked_balance(
self: @ComponentState<TContractState>, user: ContractAddress,
) -> u256 {
self.staked_balances.read(user)
}
/// @notice 返回合约中质押的代币总量
/// @return 总质押量
fn get_total_staked(self: @ComponentState<TContractState>) -> u256 {
self.total_staked.read()
}
/// @notice 计算用户的待领取奖励
/// @param user 用户地址
/// @return 待领取奖励数量
fn calculate_rewards(self: @ComponentState<TContractState>, user: ContractAddress) -> u256 {
self._calculate_pending_rewards(user)
}
/// @notice 设置奖励率(仅限所有者调用)
/// @param rate 新的每秒奖励率
fn set_reward_rate(ref self: ComponentState<TContractState>, rate: u256) {
// 使用 OwnableComponent 检查所有权
let ownable = get_dep_component!(@self, Ownable);
ownable.assert_only_owner();
self.reward_rate.write(rate);
}
/// @notice 返回当前奖励率
/// @return 每秒奖励率
fn get_reward_rate(self: @ComponentState<TContractState>) -> u256 {
self.reward_rate.read()
}
}
#[generate_trait]
pub impl InternalImpl<
TContractState,
+HasComponent<TContractState>,
+Drop<TContractState>,
impl ERC20: ERC20Component::HasComponent<TContractState>,
impl Ownable: OwnableComponent::HasComponent<TContractState>,
> of InternalTrait<TContractState> {
// 使用初始奖励率初始化质押组件
fn initializer(ref self: ComponentState<TContractState>, initial_reward_rate: u256) {
self.reward_rate.write(initial_reward_rate);
}
// 更新用户的累积奖励
fn update_rewards(ref self: ComponentState<TContractState>, user: ContractAddress) {
let pending = self._calculate_pending_rewards(user);
self.accumulated_rewards.write(user, pending);
self.last_update_time.write(user, get_block_timestamp());
}
// 根据质押量和经过时间计算待领取奖励
fn _calculate_pending_rewards(
self: @ComponentState<TContractState>, user: ContractAddress,
) -> u256 {
let staked = self.staked_balances.read(user);
if staked == 0 {
return self.accumulated_rewards.read(user);
}
let last_update = self.last_update_time.read(user);
let current_time = get_block_timestamp();
if last_update == 0 {
return 0;
}
let time_elapsed = current_time - last_update;
let reward_rate = self.reward_rate.read();
// 计算新奖励:staked_amount * reward_rate * time_elapsed
let new_rewards = (staked * reward_rate * time_elapsed.into()) / 1000000;
let accumulated = self.accumulated_rewards.read(user);
accumulated + new_rewards
}
}
}
我们现在已经完成了 StakingComponent,其中包含了我们需要的所有质押逻辑。该组件处理质押和解除质押代币、计算和领取奖励以及管理奖励率。
然而,组件本身无法部署。我们需要创建一个合约,将我们的 StakingComponent 以及 ERC20Component 和 OwnableComponent 集成在一起。这个合约将作为用户交互的可部署智能合约。
构建质押合约
StakingContract 将:
- 包含我们刚刚构建的
StakingComponent - 包含
ERC20Component(用于质押代币) - 包含
OwnableComponent(用于访问控制) - 使用必要参数初始化所有三个组件
- 暴露我们希望用户能够调用的函数
让我们构建将所有这些组件结合在一起的 StakingContract。
导入依赖
注意: 在本教程中,我们将 StakingComponent 和 StakingContract 都构建在同一个 lib.cairo 文件中。在更大的项目中,你可以将它们组织到不同的文件中(例如 staking_component.cairo 和 staking_contract.cairo),但将所有内容放在一个文件中更易于理解。
首先,我们需要导入我们将使用的所有组件:
#[starknet::contract]
mod StakingContract {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use starknet::{ContractAddress, get_contract_address};
use super::StakingComponent;
}
我们从 OpenZeppelin 导入 OwnableComponent 和 ERC20Component,以及我们刚刚创建的 StakingComponent。
声明组件
接下来,声明我们的合约将使用的三个组件:
// 组件声明
component!(path: StakingComponent, storage: staking, event: StakingEvent);
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
每个 component! 宏声明一个组件并指定:
- 组件路径(使用哪个组件)
- 存储名称(该组件的存储将保存在哪里)
- 事件名称(该组件的事件叫什么)
配置 ERC20Component
ERC20Component 要求我们实现其 ImmutableConfig trait。该 trait 配置在编译时固定而非存储在合约存储中的值。ERC20 的 DECIMALS 值在部署后永远不会改变,因此非常适合这种模式。
// ERC20 Mixin 配置
impl ERC20ImmutableConfig of ERC20Component::ImmutableConfig {
const DECIMALS: u8 = 18;
}
这将代币设置为使用 18 位小数,这是大多数代币的标准。
暴露组件函数
现在我们需要决定每个组件中的哪些函数应该是公开可访问的。我们使用 #[abi(embed_v0)] 属性来暴露实现:
// 公开暴露质押函数
#[abi(embed_v0)]
impl StakingImpl = StakingComponent::StakingImpl<ContractState>;
// 公开暴露 ERC20 函数
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
// 公开暴露 Ownable 函数
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
这些行使得所有质押函数、ERC-20 函数(如 transfer、balance_of)和所有权函数(如 transfer_ownership)在合约的公共接口(ABI)中可用。
我们还需要使内部实现可用,以便组件可以互相调用对方的内部函数:
// 内部实现
impl StakingInternalImpl = StakingComponent::InternalImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
存储结构
每个组件都需要自己的存储空间。我们为所有三个组件声明存储:
#[storage]
struct Storage {
#[substorage(v0)]
staking: StakingComponent::Storage,
#[substorage(v0)]
erc20: ERC20Component::Storage,
#[substorage(v0)]
ownable: OwnableComponent::Storage,
}
#[substorage(v0)] 属性告诉 Cairo 每个字段包含一个组件的存储结构。
事件
类似地,我们需要聚合来自所有组件的事件:
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
#[flat]
StakingEvent: StakingComponent::Event,
#[flat]
ERC20Event: ERC20Component::Event,
#[flat]
OwnableEvent: OwnableComponent::Event,
}
构造函数
构造函数在部署合约时初始化所有三个组件:
#[constructor]
fn constructor(
ref self: ContractState,
owner: ContractAddress,
token_name: ByteArray,
token_symbol: ByteArray,
initial_reward_rate: u256,
initial_supply: u256,
) {
// 初始化所有权
self.ownable.initializer(owner);
// 初始化 ERC20 代币
self.erc20.initializer(token_name, token_symbol);
// 将初始供应量铸造到合约用于奖励
self.erc20.mint(get_contract_address(), initial_supply);
// 使用奖励率初始化质押
self.staking.initializer(initial_reward_rate);
}
构造函数接受所有三个组件所需的参数:所有者地址、代币名称和符号、初始奖励率以及初始代币供应量。它初始化每个组件,并将初始供应量铸造到合约本身。这确保了合约在用户质押和领取时有足够的代币用于支付奖励。
添加 mint 函数
我们添加一个额外的函数,允许所有者铸造代币:
#[generate_trait]
#[abi(per_item)]
impl ExternalImpl of ExternalTrait {
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.ownable.assert_only_owner();
self.erc20.mint(recipient, amount);
}
}
此函数检查调用者是所有者,然后将代币铸造到指定的接收者。
以下是完整的 StakingContract:
#[starknet::contract]
mod StakingContract {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use starknet::{ContractAddress, get_contract_address};
use super::StakingComponent;
// 组件声明
component!(path: StakingComponent, storage: staking, event: StakingEvent);
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
// ERC20 Mixin 配置
impl ERC20ImmutableConfig of ERC20Component::ImmutableConfig {
const DECIMALS: u8 = 18;
}
// 公开暴露质押函数
#[abi(embed_v0)]
impl StakingImpl = StakingComponent::StakingImpl<ContractState>;
// 公开暴露 ERC20 函数
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
// 公开暴露 Ownable 函数
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
// 内部实现
impl StakingInternalImpl = StakingComponent::InternalImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
staking: StakingComponent::Storage,
#[substorage(v0)]
erc20: ERC20Component::Storage,
#[substorage(v0)]
ownable: OwnableComponent::Storage,
}
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
#[flat]
StakingEvent: StakingComponent::Event,
#[flat]
ERC20Event: ERC20Component::Event,
#[flat]
OwnableEvent: OwnableComponent::Event,
}
#[constructor]
fn constructor(
ref self: ContractState,
owner: ContractAddress,
token_name: ByteArray,
token_symbol: ByteArray,
initial_reward_rate: u256,
initial_supply: u256,
) {
// 初始化所有权
self.ownable.initializer(owner);
// 初始化 ERC20 代币
self.erc20.initializer(token_name, token_symbol);
// 将初始供应量铸造到合约用于奖励
self.erc20.mint(get_contract_address(), initial_supply);
// 使用奖励率初始化质押
self.staking.initializer(initial_reward_rate);
}
#[generate_trait]
#[abi(per_item)]
impl ExternalImpl of ExternalTrait {
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.ownable.assert_only_owner();
self.erc20.mint(recipient, amount);
}
}
}
为什么使用 ERC20Component 的 _transfer 而不是 transfer_from
虽然 transfer_from 是用于已批准代币转账的标准 ERC-20 函数,但在同一合约内的组件间调用中它无法正常工作。
在一个典型的质押合约中,质押合约和代币是独立的合约。当用户质押 STRK 代币时:
- 用户批准质押合约:
token.approve(staking_contract, amount) - 质押合约调用:
token.transfer_from(user, staking_contract, amount) - 在代币合约内部,
transfer_from调用get_caller_address(),返回质押合约的地址 - 代币检查:
allowances[user][staking_contract] >= amount - 如果已批准,代币转账
这之所以有效,是因为代币合约内部的 get_caller_address() 返回质押合约(外部调用者),这与批准匹配。
组件间调用中会发生什么
在我们的嵌入式架构中,StakingComponent 和 ERC20Component 是同一合约的一部分。当 StakingComponent 调用 ERC20Component 时,这是一个内部调用,而不是外部调用。
需要注意的重要行为是,即使从组件内部调用,get_caller_address() 也会保持原始外部调用者的地址。当用户调用 stake() 时:
- 外部调用进入合约:
get_caller_address()返回用户 - StakingComponent.stake() 执行:
get_caller_address()返回用户 - ERC20Component.transfer_from() 执行:
get_caller_address()仍然返回用户
因此,transfer_from 检查的是 allowances[user][user] 而不是 allowances[user][contract],这将不起作用。
组件调用是内部调度,而不是外部合约调用,因此调用链中没有中间合约地址。
在组件调用中使用 _transfer
_transfer 内部函数绕过批准机制,直接转账代币:
fn stake(ref self: ComponentState<TContractState>, amount: u256) {
let mut erc20 = get_dep_component_mut!(ref self, ERC20);
erc20._transfer(caller, contract_address, amount);
// 无需批准检查
}
正如前面提到的,这之所以有效,是因为我们的合约本身就是代币;同一个合约控制着代币余额和转账(ERC20Component)以及质押逻辑(StakingComponent)。_transfer 函数完全绕过了批准机制,因此用户无需在质押前调用 approve()。
质押外部代币
在一个典型的质押合约中,你会质押外部代币,而不是使用嵌入的 ERC20Component。使用这种标准模式,你将在 stake() 函数中使用外部代币的调度器(IERC20Dispatcher { contract_address: token_address }),在该外部合约上调用 transfer_from,并且批准机制将正常工作,因为这是一个真正的外部调用。
transfer_from 适用于外部的合约到合约调用,而不适用于同一合约内的组件到组件调用。当组件在单个合约内交互时,使用不依赖 get_caller_address() 进行授权检查的内部函数(如 _transfer)。即使它们实现了相同的接口,外部合约交互和内部组件交互之间的执行上下文和调用者语义是不同的。
结论
组件间交互使我们能够构建依赖于其他组件的可重用逻辑。通过显式声明依赖关系,一个组件可以直接调用另一个组件的函数,而不是通过合约路由调用。
在本教程中,我们构建了一个 StakingComponent,它声明了对 ERC20Component 和 OwnableComponent 的依赖。这种声明和调用依赖组件的模式是构建模块化、可组合智能合约的基础。
如前所述,我们在本教程中使用的嵌入式代币方法主要用于演示目的;生产环境中的质押合约通常使用外部代币模式。
当每个组件处理一个特定关注点时,组件效果最好。当逻辑需要与多个组件交互时,显式声明这些依赖关系以保持代码的组织性和可重用性。
==================== 结束 Markdown 内容 =====================
- 原文链接: rareskills.io/post/cairo...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~