在组件第一部分中,我们学习了如何在一个文件中创建和使用组件。我们从头开始构建了一个 CounterComponent,并将其存储、事件和实现集成到了我们的合约中。
智能合约中使用的大多数组件都来自外部库。OpenZeppelin Contracts for Cairo 提供了用于所有权、访问控制、代币标准等功能的组件,这些组件可以导入到合约中,类似于 OpenZeppelin Contracts for Solidity。
在本教程中,你将学习如何导入和使用 OpenZeppelin 的组件,而不是从头开始构建所有内容;了解外部 crate 组件的导入路径;并使用 OpenZeppelin Wizard 来生成样板代码。
设置依赖项
在导入 OpenZeppelin 组件之前,我们需要在项目中将 OpenZeppelin Contracts 库添加为依赖项。Cairo 使用 Scarbs.xyz 作为其官方包注册表,类似于 JavaScript 的 npm 或 Rust 的 crates.io。
创建一个新的 scarb 项目并导航到其目录:
scarb new erc20_component
cd erc20_component
在项目目录中打开 Scarb.toml 文件,并在 [dependencies] 部分下添加以下条目:
[dependencies]
starknet = "2.13.1"
openzeppelin = "2.0.0" //ADD THIS LINE

语法 openzeppelin = "2.0.0" 会自动从 Cairo 的官方包注册表 Scarbs.xyz 获取该包。版本 “2.0.0” 指定了要使用的 OpenZeppelin Contracts 版本。我们目前使用的是 v2.0.0,这是在撰写本文时的最新稳定版本。请查看 Scarbs.xyz for OpenZeppelin 或 OpenZeppelin Contracts for Cairo releases page 以获取当前的最新版本。
运行 scarb build 下载并编译依赖项。构建成功后,依赖项就准备就绪了,你可以将 OpenZeppelin 组件导入到你的合约中。
使用 OpenZeppelin Wizard 构建 ERC20 代币
我们将使用 OpenZeppelin 组件构建一个 ERC20 代币合约。通过使用 OpenZeppelin Wizard,我们将生成合约代码,然后解释这些组件是如何导入和集成的。
使用 OpenZeppelin Wizard
OpenZeppelin Wizard 是一个基于 Web 的交互式工具,用于生成合约的样板代码。它允许我们选择所需的功能,并生成可以直接使用的完整合约代码,而不是从头开始构建。这是实现 Ownable、ERC20、ERC721 等功能的一种更快捷的方式。
我们的代币将使用以下三个组件:
- ERC20Component: 用于代币功能
- OwnableComponent: 用于访问控制
- PausableComponent: 用于暂停/恢复代币转账
既然我们了解了 OpenZeppelin Wizard 的作用,接下来让我们用它来生成一个合约。用于 Cairo 的 OpenZeppelin Wizard 可以在 OpenZeppelin 网站的 Wizard 子域名上找到。在浏览器中访问 OpenZeppelin Wizard for Cairo,并选择 ‘ERC20’ 作为合约类型。
在 ‘SETTINGS’(设置)部分,将名称更改为你想要的代币名称并更新符号。在 ‘FEATURES’(功能)部分,勾选 (☑️) Mintable 和 Pausable;Ownable 会自动被勾选。

复制右上角的代码,并将其粘贴到你项目目录中的 src/lib.cairo 文件里。生成的代码应该与以下合约类似,包含所有必需的导入、组件声明、存储结构、事件、构造函数和自定义函数(pause、unpause 和 mint):
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts for Cairo ^2.0.0
#[starknet::contract]
mod RareToken {
use openzeppelin::access::ownable::OwnableComponent;
use openzeppelin::security::pausable::PausableComponent;
use openzeppelin::token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20Component};
use starknet::ContractAddress;
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
component!(path: PausableComponent, storage: pausable, event: PausableEvent);
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
// External
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
#[abi(embed_v0)]
impl PausableImpl = PausableComponent::PausableImpl<ContractState>;
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
// Internal
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
impl PausableInternalImpl = PausableComponent::InternalImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
#[storage]
struct Storage {
#[substorage(v0)]
erc20: ERC20Component::Storage,
#[substorage(v0)]
pausable: PausableComponent::Storage,
#[substorage(v0)]
ownable: OwnableComponent::Storage,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
ERC20Event: ERC20Component::Event,
#[flat]
PausableEvent: PausableComponent::Event,
#[flat]
OwnableEvent: OwnableComponent::Event,
}
#[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
self.erc20.initializer("RareToken", "RTK");
self.ownable.initializer(owner);
}
impl ERC20HooksImpl of ERC20Component::ERC20HooksTrait<ContractState> {
fn before_update(
ref self: ERC20Component::ComponentState<ContractState>,
from: ContractAddress,
recipient: ContractAddress,
amount: u256,
) {
let contract_state = self.get_contract();
contract_state.pausable.assert_not_paused();
}
}
#[generate_trait]
#[abi(per_item)]
impl ExternalImpl of ExternalTrait {
#[external(v0)]
fn pause(ref self: ContractState) {
self.ownable.assert_only_owner();
self.pausable.pause();
}
#[external(v0)]
fn unpause(ref self: ContractState) {
self.ownable.assert_only_owner();
self.pausable.unpause();
}
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.ownable.assert_only_owner();
self.erc20.mint(recipient, amount);
}
}
}
不费吹灰之力,我们已经生成了一个具有可铸造、可暂停和访问控制功能的、功能完备的合约。
有了生成的代码,让我们来详细分析一下 OpenZeppelin 组件是如何导入并集成到合约中的。
理解生成的代码
在使用组件时,需要执行三个步骤:
- 导入组件,
- 使用
component!宏将你的合约与其链接,以及 - 嵌入组件实现,以在你的合约中暴露它们的函数
让我们看看这在我们生成的 RareToken 合约中是如何运作的。
第 1 步:导入组件
第一步是导入组件。下面代码中突出显示的导入语句将 OwnableComponent、PausableComponent 和 ERC20Component 引入到合约的作用域中,使其功能可供使用:

第 2 步:使用 component! 宏链接组件
在导入所需的组件之后,使用 component! 宏在合约中设置(链接)这些组件:

component! 宏声明了我们的合约将如何连接到每个组件。它接受三个参数:
path:组件的路径(被导入的内容)。在本例中:ERC20Component、PausableComponent和OwnableComponentstorage:合约中指向组件存储的存储变量名称。要访问组件的存储,你需要在你的合约存储中有一个引用该组件存储的变量

在上面的示例中,使用了存储名称 erc20、pausable 和 ownable。这些名称可以自定义,但它们必须与合约存储结构(storage struct)中声明的内容相匹配。
正如在组件第一部分中所讨论的,每个存储字段都带有 #[substorage(v0)] 注解,以表明它引用了组件的存储。
3. event:合约中指向组件事件的事件变体名称。
在下方的截图中,请注意顶部高亮显示的事件名称(第 11-13 行)是如何与底部高亮显示的事件变体(第 42、44、46 行)相对应的。component! 宏中的 event 参数(例如 ERC20Event)映射到合约事件枚举中的变体名称。

在本例中,使用了 ERC20Event、PausableEvent 和 OwnableEvent。就像存储名称一样,这些名称可以是任何内容,但它们必须与合约事件枚举中声明的名称相匹配。
在这里,应用到每个事件变体的 #[flat] 属性非常重要。回想一下“事件”章节中的 “Using #[flat] attribute” 部分,#[flat] 属性会改变事件选择器的计算方式。
如果没有 #[flat],组件事件将包含一个组件 ID 作为第一个键,并且来自某个组件变体的所有事件将共享同一个由外部枚举变体名称计算得出的选择器。例如,ERC20Component 中的 Transfer 和 Approval 事件都会将 starknetKeccak("ERC20Event") 作为它们的选择器,这使得仅凭选择器无法区分不同的事件类型。
使用 #[flat] 后,组件 ID 前缀将被移除,每个事件都使用其自身的结构体名称作为选择器:starknetKeccak("Transfer")、starknetKeccak("Approval")。这使得精确的事件过滤成为可能,并与外部工具和索引器所期望的标准事件结构相匹配。
第 3 步:组件实现
现在让我们看看生成代码中的组件实现。有两种类型:外部(external)和内部(internal)。外部实现可以从合约外部调用,而内部实现只能在合约内部使用。
生成的代码包含三个暴露组件功能的外部实现:
// External
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
#[abi(embed_v0)]
impl PausableImpl = PausableComponent::PausableImpl<ContractState>;
#[abi(embed_v0)]
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;
#[abi(embed_v0)] 属性使这些实现可被公开访问;它们的函数可以从合约外部被调用。让我们详细了解每个实现。
ERC20MixinImpl 将所有必需的 ERC20 功能组合到了一个包中:
ERC20Impl: 包含transfer、approve、balance_of等核心函数ERC20MetadataImpl: 包含name、symbol、decimals等元数据(Metadata)函数ERC20CamelImpl: 包含用于兼容的驼峰命名法函数版本(例如balanceOf、totalSupply)
使用 ERC20 mixin 使我们免除了分别嵌入每个实现的麻烦。
除了 ERC20 mixin 之外,合约还嵌入了其他两个外部实现:
PausableImpl提供了用于停止合约操作的pause()、用于恢复操作的unpause()以及用于检查当前暂停状态的is_paused()OwnableMixinImpl提供了用于查看当前所有者的owner()、用于将所有权转移到新地址的transfer_ownership()以及用于彻底移除所有者的renounce_ownership()
生成的代码还包含这些内部实现:
// Internal
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
impl PausableInternalImpl = PausableComponent::InternalImpl<ContractState>;
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;
请注意,上面的实现没有 #[abi(embed_v0)],那是因为它们不能从合约外部被公开调用。
构造函数
构造函数通过 ERC20 组件的 initializer 设置代币的名称和符号,并通过 Ownable 组件的 initializer 设置合约所有者。
#[constructor]
fn constructor(
ref self: ContractState,
name: ByteArray,
symbol: ByteArray,
fixed_supply: u256,
recipient: ContractAddress,
owner: ContractAddress
) {
self.erc20.initializer(name, symbol);
self.erc20.mint(recipient, fixed_supply);
self.ownable.initializer(owner);
}
每个 initializer(初始化函数)只能调用一次,部署后即锁定这些设置。
ERC20 钩子(Hooks)
钩子(Hooks)是在特定操作之前或之后自动运行的函数。ERC20 组件提供了一个 ERC20HooksTrait,允许你添加在代币转账期间运行的逻辑。
before_update 钩子
生成的代码包含一个 before_update 钩子,它在任何代币操作之前检查合约是否已暂停:
impl ERC20HooksImpl of ERC20Component::ERC20HooksTrait<ContractState> {
fn before_update(
ref self: ERC20Component::ComponentState<ContractState>,
from: ContractAddress,
recipient: ContractAddress,
amount: u256,
) {
let contract_state = self.get_contract();
contract_state.pausable.assert_not_paused();
}
}
before_update 函数在任何代币余额更改(转账、铸造或销毁)之前运行。在此实现中:
self.get_contract()检索合约状态contract_state.pausable.assert_not_paused()检查合约是否暂停- 如果已暂停,则交易将回滚;如果没有,则转账继续进行
这就是可暂停(pausable)功能的工作原理;通过在每个代币操作之前检查暂停状态,合约可以在暂停时停止所有转账。
Update 前和 Update 后钩子
如果不在生成的代码中实现 before_update 钩子,可暂停组件将仅存在于合约中,但实际上不会影响代币转账。
ERC20HooksTrait 还包括一个 after_update 钩子,它在代币操作完成后运行。虽然在这个合约中没有使用它,但你可以实现它以添加在转账、铸造或销毁后执行的自定义逻辑。
暴露内部组件函数
一些组件函数(如 pause() 和 mint())是内部的;它们存在于组件中,但不可公开访问。生成的代码创建了公开的包装器(wrapper)函数,在暴露这些操作的同时添加了仅限所有者的访问控制:
#[generate_trait]
#[abi(per_item)]
impl ExternalImpl of ExternalTrait {
#[external(v0)]
fn pause(ref self: ContractState) {
self.ownable.assert_only_owner();
self.pausable.pause();
}
#[external(v0)]
fn unpause(ref self: ContractState) {
self.ownable.assert_only_owner();
self.pausable.unpause();
}
#[external(v0)]
fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) {
self.ownable.assert_only_owner();
self.erc20.mint(recipient, amount);
}
}
#[generate_trait] 属性会自动从这个实现生成 ExternalTrait 接口,因此你不必手动编写 trait 定义。
#[abi(per_item)] 属性单独标记每个函数以生成 ABI,当它与每个函数上的 #[external(v0)] 结合使用时,使它们成为合约公共接口的一部分。#[external(v0)] 中的 v0 指定了 ABI 版本。
包装器函数是如何工作的
每个函数都遵循相同的模式:验证所有权,然后执行操作。例如,pause() 调用 self.ownable.assert_only_owner() 验证调用者是否为所有者,然后调用 self.pausable.pause() 暂停合约;如果调用者不是所有者,则交易回滚。
类似地,unpause() 验证所有权,然后解除合约的暂停状态,而 mint() 验证所有权,然后使用 self.erc20.mint() 向指定的接收者地址铸造新代币。
如果没有这些包装器函数,内部组件函数(如 pause()、unpause() 和 mint())虽然会存在,但所有者/部署者将无法从合约外部与它们进行交互。
测试合约
现在我们已经设置好了代币合约,让我们编写一些测试。我们将专注于测试我们添加的自定义功能:带有访问控制的 pause()、unpause() 和 mint()。
设置测试文件
导航到项目目录中的 tests/test_contract.cairo。清除随样板代码一起生成的测试,仅保留基本导入:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
为了在我们的测试中与标准的 ERC-20 函数进行交互,我们需要从 OpenZeppelin 导入 ERC-20 接口及其调度器特征(dispatcher trait):
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
// NEWLY ADDED//
use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait};
IERC20Dispatcher 允许我们在我们的合约上调用标准 ERC-20 函数,例如 transfer、balance_of 和 total_supply。
回想一下,生成的合约使用了 #[generate_trait] 属性来自动为自定义函数(pause、unpause、mint)创建 trait。这些 trait 并没有在合约中显式编写,因此要在测试中调用这些函数,需要如下所示手动定义接口:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait};
// NEWLY ADDED //
// Define the interface for our custom functions
#[starknet::interface]
trait IRareToken<TContractState> {
fn pause(ref self: TContractState);
fn unpause(ref self: TContractState);
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256);
fn decimals(self: @TContractState) -> u8;
}
上面代码中的 IRareToken 接口在测试环境中暴露了自定义函数。#[starknet::interface] 属性生成了调度器(IRareTokenDispatcher)和调度器特征( IRareTokenDispatcherTrait),它们将用于与这些函数交互。
我们需要在测试中使用一致的地址。定义常量以提供测试地址,而不是每次都创建新地址:
const OWNER: ContractAddress = 'OWNER'.try_into().unwrap();
const USER: ContractAddress = 'USER'.try_into().unwrap();
const RECIPIENT: ContractAddress = 'RECIPIENT'.try_into().unwrap();
这些常量将字符串字面量转换为合约地址。
现在我们需要一个辅助函数来在测试环境中部署我们的代币合约。在 test_contract.cairo 中添加 deploy_token 函数:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};
use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait};
#[starknet::interface]
trait IRareToken<TContractState> {
fn pause(ref self: TContractState);
fn unpause(ref self: TContractState);
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256);
fn decimals(self: @TContractState) -> u8;
}
const OWNER: ContractAddress = 'OWNER'.try_into().unwrap();
const USER: ContractAddress = 'USER'.try_into().unwrap();
const RECIPIENT: ContractAddress = 'RECIPIENT'.try_into().unwrap();
// NEWLY ADDED //
fn deploy_token() -> (ContractAddress, IERC20Dispatcher, IRareTokenDispatcher) {
let contract = declare("RareToken").unwrap().contract_class();
let mut constructor_args = array![OWNER.into()];
let (contract_address, _) = contract.deploy(@constructor_args).unwrap();
let token = IERC20Dispatcher { contract_address };
let rare_token = IRareTokenDispatcher { contract_address };
(contract_address, token, rare_token)
}
deploy_token 使用 declare("RareToken").unwrap().contract_class() 来声明 RareToken 合约并检索其合约类,这将加载已编译的合约代码。
接下来,它使用 array![OWNER.into()] 准备构造函数参数,这将创建一个包含所有者地址的数组。

构造函数期望一个参数(所有者地址),因此我们在测试中使用 .into() 将其转换为 felt252。代币名称 “RareToken” 和符号 “RTK” 已经硬编码在合约的构造函数中。
一旦参数准备就绪,contract.deploy(@constructor_args).unwrap() 将部署合约并返回合约地址。合约部署后,我们为同一个合约地址创建两个调度器:用于标准 ERC-20 函数的 IERC20Dispatcher 和用于自定义函数(如 pause()、unpause() 和 mint())的 IRareTokenDispatcher。
该函数返回一个包含合约地址和两个调度器的元组,为我们在测试中与已部署的合约交互提供了所需的一切。
测试 pause() 以防止转账
pause 函数可停止所有代币操作,这在安全事件或维护期间非常有用。
从 snforge_std 中导入 start_cheat_caller_address 和 stop_cheat_caller_address 以及其他导入,以允许我们在调用合约函数时冒充不同的地址:
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address,stop_cheat_caller_address};
现在让我们编写一个测试,验证当合约暂停时转账会被阻止:
#[test]
fn test_pause_prevents_transfer() {
let (contract_address, token, rare_token) = deploy_token();
// Get token decimals for proper amount calculation
let token_decimal = rare_token.decimals();
let amount_to_mint: u256 = 10000 * token_decimal.into();
// Mint tokens to USER
start_cheat_caller_address(contract_address, OWNER);
rare_token.mint(USER, amount_to_mint);
// Pause the contract
rare_token.pause();
stop_cheat_caller_address(contract_address);
// Try to transfer - should fail when paused
start_cheat_caller_address(contract_address, USER);
token.transfer(RECIPIENT, 100 * token_decimal.into()); // This should panic
}
测试首先通过 deploy_token() 部署合约,这会返回合约地址和我们与合约交互所需的调度器。然后我们使用 rare_token.decimals() 检索代币的小数位数。ERC-20 代币通常使用 18 位小数,因此乘以 10000 * 10^18 我们得到 10,000 个代币。
接下来,我们使用 start_cheat_caller_address 冒充 OWNER 并向 USER 铸造代币。在继续扮演 OWNER 角色时,我们调用 pause() 以激活 pause() 函数,然后使用 stop_cheat_caller_address 将调用者地址重置回默认值。
由于合约现已暂停,我们再次使用 start_cheat_caller_address 冒充 USER,并尝试向 RECIPIENT 转账代币。此转账应当失败,因为合约已暂停,这正是我们要验证的。
当你运行 scarb test test_pause_prevents_transfer 时,你应该会在终端中看到此错误:

合约正确地拒绝了转账,因为它已暂停。错误消息来自 OpenZeppelin 的 Pausable 组件。如果你检查 OpenZeppelin Pausable 组件源代码,你会发现这就是在已暂停的合约上尝试操作时抛出的确切错误:
fn assert_not_paused(self: @ComponentState<TContractState>) {
assert(!self.is_paused(), Errors::PAUSED);
}
我们可以通过使用 #[should_panic] 属性来改进测试,明确指示我们期望测试会发生 panic。这使得测试在因预期错误发生 panic 时能够通过:
#[test]
#[should_panic(expected: ('Pausable: paused',))]
fn test_pause_prevents_transfer() {
let (contract_address, token, rare_token) = deploy_token();
// Get token decimals for proper amount calculation
let token_decimal = rare_token.decimals();
let amount_to_mint: u256 = 10000 * token_decimal.into();
// Mint tokens to USER
start_cheat_caller_address(contract_address, OWNER);
rare_token.mint(USER, amount_to_mint);
// Pause the contract
rare_token.pause();
stop_cheat_caller_address(contract_address);
// Try to transfer - should fail when paused
start_cheat_caller_address(contract_address, USER);
token.transfer(RECIPIENT, 100 * token_decimal.into()); // This should panic
}
#[should_panic(expected: ('Pausable: paused',))] 属性告诉测试框架:
- 这个测试应该发生 panic
- panic 应该包含错误消息
'Pausable: paused'
如果测试没有发生 panic,或者因为不同的错误发生 panic,则该测试将失败。现在当你运行 scarb test test_pause_prevents_transfer 时,你应该会看到测试成功通过。
测试 unpause() 以允许转账
暂停合约后,你需要能够恢复正常操作。此测试验证在解除暂停后,代币转账是否按预期工作:
#[test]
fn test_unpause_allows_transfer() {
let (contract_address, token, rare_token) = deploy_token();
// Get token decimals for proper amount calculation
let token_decimal = rare_token.decimals();
let amount_to_mint: u256 = 1000 * token_decimal.into();
// Mint tokens to USER
start_cheat_caller_address(contract_address, OWNER);
rare_token.mint(USER, amount_to_mint);
// Pause then unpause the contract
rare_token.pause();
rare_token.unpause();
stop_cheat_caller_address(contract_address);
// Transfer should now succeed
start_cheat_caller_address(contract_address, USER);
token.transfer(RECIPIENT, 100 * token_decimal.into());
// Verify the transfer worked*
assert!(token.balance_of(USER) == 900 * token_decimal.into(), "User balance incorrect");
assert!(token.balance_of(RECIPIENT) == 100 * token_decimal.into(), "Recipient balance incorrect");
}
我们首先部署合约并获取代币小数位数,然后作为 OWNER 向 USER 铸造 1,000 个代币。此测试的主要区别在于我们暂停了合约,并在继续扮演 OWNER 角色的情况下立即解除暂停。在调用 stop_cheat_caller_address 之后,我们切换为冒充 USER 并尝试向 RECIPIENT 转账 100 个代币。
由于合约不再处于暂停状态,转账应成功。我们通过检查余额来验证这一点:USER 应该剩余 900 个代币 (1000 - 100),而 RECIPIENT 应该收到 100 个代币。assert! 宏确认这些余额是正确的,确保 unpause 函数正确恢复了正常的合约操作。
使用 scarb test test_unpause_allows_transfer 运行测试,它应该通过,这确认了暂停机制可以成功地开启和关闭。
测试 pause() 的访问控制
像 pause() 这样可以停止合约操作的函数需要适当的访问控制。只有合约所有者应该能够暂停合约。此测试验证了非所有者无法暂停:
#[test]
#[should_panic(expected: ('Caller is not the owner',))]
fn test_only_owner_can_pause() {
let (contract_address, _token, rare_token) = deploy_token();
// Try to pause as non-owner - should panic
start_cheat_caller_address(contract_address, USER);
rare_token.pause();
// no need to stop cheat since it doesn't reach here
}
这个测试很简单但很重要。我们部署合约,然后立即尝试作为 USER(不是所有者)调用 pause()。#[should_panic(expected: ('Caller is not the owner',))] 属性告诉测试框架,我们期望这会因为特定的错误消息而失败。
当调用 rare_token.pause() 时,它会在内部触发 Ownable 组件的 self.ownable.assert_only_owner()。由于 USER 不是所有者,因此该断言失败,并且交易按预期因错误 “Caller is not the owner” 而回滚。
使用 scarb test test_only_owner_can_pause 运行测试,它应该通过,从而确认我们的访问控制工作正常。
这是我们构建的测试文件:
use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address, stop_cheat_caller_address};
use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait};
#[starknet::interface]
trait IRareToken<TContractState> {
fn pause(ref self: TContractState);
fn unpause(ref self: TContractState);
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256);
fn decimals(self: @TContractState) -> u8;
}
const OWNER: ContractAddress = 'OWNER'.try_into().unwrap();
const USER: ContractAddress = 'USER'.try_into().unwrap();
const RECIPIENT: ContractAddress = 'RECIPIENT'.try_into().unwrap();
fn deploy_token() -> (ContractAddress, IERC20Dispatcher, IRareTokenDispatcher) {
let contract = declare("RareToken").unwrap().contract_class();
let mut constructor_args = array![OWNER.into()];
let (contract_address, _) = contract.deploy(@constructor_args).unwrap();
let token = IERC20Dispatcher { contract_address };
let rare_token = IRareTokenDispatcher { contract_address };
(contract_address, token, rare_token)
}
#[test]
#[should_panic(expected: ('Pausable: paused',))]
fn test_pause_prevents_transfer() {
let (contract_address, token, rare_token) = deploy_token();
// Get token decimals for proper amount calculation
let token_decimal = rare_token.decimals();
let amount_to_mint: u256 = 10000 * token_decimal.into();
// Mint tokens to USER
start_cheat_caller_address(contract_address, OWNER);
rare_token.mint(USER, amount_to_mint);
// Pause the contract
rare_token.pause();
stop_cheat_caller_address(contract_address);
// Try to transfer - should fail when paused
start_cheat_caller_address(contract_address, USER);
token.transfer(RECIPIENT, 100 * token_decimal.into()); // This should panic
}
#[test]
fn test_unpause_allows_transfer() {
let (contract_address, token, rare_token) = deploy_token();
// Get token decimals for proper amount calculation
let token_decimal = rare_token.decimals();
let amount_to_mint: u256 = 1000 * token_decimal.into();
// Mint tokens to USER
start_cheat_caller_address(contract_address, OWNER);
rare_token.mint(USER, amount_to_mint);
// Pause then unpause the contract
rare_token.pause();
rare_token.unpause();
stop_cheat_caller_address(contract_address);
// Transfer should now succeed
start_cheat_caller_address(contract_address, USER);
token.transfer(RECIPIENT, 100 * token_decimal.into());
// Verify the transfer worked
assert!(token.balance_of(USER) == 900 * token_decimal.into(), "User balance incorrect");
assert!(token.balance_of(RECIPIENT) == 100 * token_decimal.into(), "Recipient balance incorrect");
}
#[test]
#[should_panic(expected: ('Caller is not the owner',))]
fn test_only_owner_can_pause() {
let (contract_address, _token, rare_token) = deploy_token();
// Try to pause as non-owner - should panic
start_cheat_caller_address(contract_address, USER);
rare_token.pause();
}
作业: OpenZeppelin ERC-20 库支持销毁(burning),但此函数是内部的。你的任务是:
- 通过添加一个公开的包装器函数在合约中暴露
burn函数,类似于mint()的暴露方式 - 销毁操作应该来自
get_caller_address() - 为销毁功能编写测试:
- 测试用户可以销毁自己的代币
- 测试销毁操作会减少用户的余额
- 测试销毁操作会减少总供应量
- 测试当合约暂停时无法进行销毁操作
- 测试用户销毁的代币不能超过他们拥有的数量
本文是 Cairo Programming on Starknet 教程系列的一部分