组件第 2 部分:OpenZeppelin ERC-20 教程

本文介绍了如何在 Cairo 中使用 OpenZeppelin 库来构建智能合约组件,并通过 OpenZeppelin Wizard 生成 ERC20 代币合约的代码框架,然后详细解释了如何导入和集成 OpenZeppelin 组件,最后编写测试用例来测试合约的功能,包括暂停、取消暂停和铸币等。

在组件第一部分,我们学习了如何在单个文件中创建和使用组件。我们从头开始构建了一个 CounterComponent,并将它的存储、事件和实现集成到我们的合约中。

智能合约中使用的大多数组件都来自外部库。Cairo 的 OpenZeppelin Contracts 提供了所有权、访问控制、代币标准等组件,可以导入到合约中,类似于 Solidity 的 OpenZeppelin Contracts。

在本教程中,你将学习如何从 OpenZeppelin 导入和使用组件,而不是从头开始构建所有内容;了解外部 crate 组件的导入路径;并使用 OpenZeppelin Wizard 生成样板代码。

设置依赖项

在我们导入 OpenZeppelin 组件之前,我们需要将 OpenZeppelin Contracts 库作为依赖项添加到我们的项目中。

创建一个新的 scarb 项目并导航到它的目录:

scarb new erc20_component
cd erc20_component

打开 Scarb.toml 并在 [dependencies] 下添加 OpenZeppelin 依赖项 (openzeppelin = { git = "[https://github.com/OpenZeppelin/cairo-contracts.git](https://github.com/OpenZeppelin/cairo-contracts.git)", tag = "v2.0.0" }):

OpenZeppelin in Scarb.toml

tag 指定要使用的 OpenZeppelin Contracts 版本。我们使用的是 v2.0.0,这是撰写本文时最新的稳定版本。请查看 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 子域上找到。在浏览器中转到 用于 Cairo 的 OpenZeppelin Wizard 并选择 “ERC20” 作为合约类型。

在 “SETTINGS” 部分中,将名称更改为你想要的代币名称并更新符号。在 “FEATURES” 部分中,选中 (☑️) Mintable 和 Pausable;Ownable 会自动选中。

OpenZeppelin ERC-20 builders

复制右上角的代码,并将其粘贴到项目目录中的 src/lib.cairo 文件中。你生成的代码应类似于以下合约,其中包含所有必要的导入、组件声明、存储结构、事件、构造函数和自定义函数(暂停、取消暂停和铸造):

// 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 组件是如何导入和集成到合约中的

了解生成的代码

使用组件时,需要三个步骤:

  1. 导入组件,
  2. 使用 component! 宏将你的合约链接到它,以及
  3. 嵌入组件实现以在你的合约中公开其函数

让我们看看这在生成的 RareToken 合约中是如何工作的。

步骤 1:导入组件

第一步是导入组件。以下面的代码中高亮显示的导入语句将 OwnableComponentPausableComponentERC20Component 带入合约的作用域,使其功能可供使用:

OpenZeppelin import in token

步骤 2:使用 component! 宏链接组件

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

component! macro illustration

component! 宏声明了我们的合约将如何连接到每个组件。它接受三个参数:

  1. path: 组件的路径 (导入的内容)。 在这种情况下: ERC20ComponentPausableComponentOwnableComponent
  2. storage: 指向组件存储的合约中的存储变量的名称。 要访问组件的存储,你需要在合约的存储中有一个引用组件存储的变量

Storage import for OpenZeppelin

在上面的例子中,使用了存储名称 erc20pausableownable。 这些名称可以自定义,但它们必须与合约的存储结构中声明的名称匹配。

正如组件第一部分中讨论的,每个存储字段都用 #[substorage(v0)] 注释,以指示它引用组件存储。

3. event: 指向组件事件的合约中的事件变体的名称。

在下面的屏幕截图中,请注意顶部高亮显示的事件名称(第 11-13 行)如何对应于底部高亮显示的事件变体(第 42、44、46 行)。 component! 宏中的 event 参数(例如,ERC20Event)映射到合约事件枚举中的变体名称。

Event import

在这种情况下,使用了 ERC20EventPausableEventOwnableEvent。 与存储名称一样,这些名称可以是任何名称,但它们必须与合约的事件枚举中声明的名称匹配。

应用于每个事件变体的 #[flat] 属性在这里很重要。 回想一下 Events 章节的 "使用 #[flat] 属性" 部分,#[flat] 属性会更改事件选择器哈希计算,以使用内部事件名称而不是外部枚举变体名称。

如果没有 #[flat],来自 ERC20Component 的所有事件都将在 "ERC20Event" 下进行哈希处理,从而使得诸如 TransferApproval 之类的单个事件在事务日志中无法区分。

使用 #[flat],每个事件都维护自己的选择器哈希("Transfer""Approval"),从而可以进行精确的事件过滤并防止组件之间的选择器冲突。

步骤 3:组件实现

现在让我们看看生成的代码中的组件实现。 有两种类型:外部和内部。 外部实现可以从合约外部调用,而内部实现只能在合约中使用。

生成的代码包括三个外部实现,它们公开了组件的功能:

// 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: 具有诸如 transferapprovebalance_of 之类的核心函数
  • ERC20MetadataImpl: 具有诸如 namesymboldecimals 之类的元数据函数
  • ERC20CamelImpl: 具有用于兼容性的 Camel-case 函数版本(例如,balanceOftotalSupply

使用 ERC20 混入可以避免我们单独嵌入每个实现。

除了 ERC20 混入之外,合约还嵌入了其他两个外部实现:

  • 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 组件的 initialize 设置代币的名称和符号,并通过 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);
}

每个初始化器只能调用一次,在部署后锁定这些设置。

ERC20 Hook

Hook是在某些操作之前或之后自动运行的函数。 ERC20 组件提供了一个 ERC20HooksTrait,允许你添加在代币转账期间运行的逻辑。

before_update Hook

生成的代码包含一个 before_update Hook,该Hook在任何代币操作之前检查合约是否已暂停:

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() 检查合约是否已暂停
  • 如果暂停,则交易恢复; 如果未暂停,则转移继续

这就是可暂停功能的工作原理; 通过在每次代币操作之前检查暂停状态,合约可以在暂停时停止所有转移。

更新前和更新后Hook

如果在生成的代码中未实现 before_update Hook,则可暂停组件将存在于合约中,但实际上不会影响代币转账。

ERC20HooksTrait 还包括一个 after_update Hook,该Hook在代币操作完成后运行。 虽然此合约中未使用它,但你可以实现它以添加在转移、铸造或销毁后执行的自定义逻辑。

公开内部组件函数

某些组件函数(如 pause()mint())是内部函数; 它们存在于组件内部,但无法公开访问。 生成的代码会创建公共包装器函数,这些函数公开这些操作,同时添加仅所有者访问控制:

 ##[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 接口,因此你无需手动编写特征定义。

#[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};

为了在我们的测试中与标准 ERC20 函数交互,我们需要从 OpenZeppelin 导入 ERC20 接口及其调度器特征:

use starknet::ContractAddress;
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};

// NEWLY ADDED//
use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait};

IERC20Dispatcher 允许我们在我们的合约上调用标准 ERC20 函数,如 transferbalance_oftotal_supply

回想一下,生成的合约使用 #[generate_trait] 属性自动为自定义函数(pauseunpausemint)创建特征。 这些特征未在合约中显式编写,因此为了在测试中调用这些函数,需要手动进行接口定义,如下所示:

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),它们将用于与这些函数进行交互。

我们需要测试的一致地址。 定义辅助函数来生成测试地址,而不是每次都创建新地址:

fn OWNER() -> ContractAddress {
    'OWNER'.try_into().unwrap()
}

fn USER() -> ContractAddress {
    'USER'.try_into().unwrap()
}

fn RECIPIENT() -> ContractAddress {
   'RECIPIENT'.try_into().unwrap()
}

这些函数将字符串文字转换为合约地址。

现在我们需要一个辅助函数来在测试环境中部署我们的代币合约。 将 deploy_token 函数添加到 test_contract.cairo 中:

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;
}

fn OWNER() -> ContractAddress {
    'OWNER'.try_into().unwrap()
}

fn USER() -> ContractAddress {
    'USER'.try_into().unwrap()
}

fn 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()] 准备构造函数参数,这将创建一个包含所有者地址的数组。

Constructor with owner argument highlighted

构造函数需要一个参数(所有者地址),因此我们在测试中使用 .into() 将其转换为 felt252。 代币名称 “RareToken” 和符号 “RTK” 已经硬编码在合约的构造函数中。

一旦参数准备就绪,contract.deploy(@constructor_args).unwrap() 就会部署合约并返回合约地址。 部署合约后,我们为相同的合约地址创建两个调度器:用于标准 ERC20 函数的 IERC20Dispatcher 和用于自定义函数(如 pause()unpause()mint())的 IRareTokenDispatcher

该函数返回一个元组,其中包含合约地址和两个调度器,从而为我们提供了在测试中与已部署合约进行交互所需的一切。

测试 pause() 以防止转移

暂停功能会停止所有代币操作,这在发生安全事件或需要进行维护时非常有用。

snforge_std 导入 start_cheat_caller_addressstop_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() 检索代币的小数位数。 ERC20 代币通常使用 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 时,你应该在你的终端中看到此错误:

scarb test

合约正确地拒绝了转移,因为它已暂停。 该错误消息来自 OpenZeppelin 的 Pausable 组件。 如果你查看 OpenZeppelin Pausable 组件源代码,你将看到这是在暂停的合约上尝试操作时引发的确切错误:

fn assert_not_paused(self: @ComponentState<TContractState>) {
    assert(!self.is_paused(), Errors::PAUSED);
}

我们可以通过使用 #[should_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 或发生了不同的错误,则测试将失败。 现在,当你运行 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 身份将 1,000 个代币铸造到 USER。 此测试中的主要区别在于,我们在仍然充当 OWNER 的同时暂停合约并立即取消暂停合约。 在调用 stop_cheat_caller_address 后,我们切换为模拟 USER 并尝试将 100 个代币转账到 RECIPIENT。

由于合约不再暂停,因此转移应该成功。 我们通过检查余额来验证这一点:USER 应该剩余 900 个代币(1000 – 100),而 RECIPIENT 应该收到 100 个代币。 assert! 宏确认这些余额正确,确保取消暂停函数正确地恢复正常合约操作。

使用 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;
}

fn OWNER() -> ContractAddress {
    'OWNER'.try_into().unwrap()
}

fn USER() -> ContractAddress {
    'USER'.try_into().unwrap()
}

fn 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 (contractaddress, ) = 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
// 获取 token 的小数位数,用于正确的数量计算
let token_decimal = rare_token.decimals();
let amount_to_mint: u256 = 10000 * token_decimal.into();

// Mint tokens to USER
// 将 token 铸造给 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
                                                            // 这应该会 panic

}

[test]

fn test_unpause_allows_transfer() { let (contract_address, token, rare_token) = deploy_token();

// Get token decimals for proper amount calculation
// 获取 token 的小数位数,用于正确的数量计算
let token_decimal = rare_token.decimals();
let amount_to_mint: u256 = 1000 * token_decimal.into();

// Mint tokens to USER
// 将 token 铸造给 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
// 尝试以非所有者身份暂停 - 应该 panic
start_cheat_caller_address(contract_address, USER());
rare_token.pause();

}



**Homework:** The OpenZeppelin ERC-20 library supports burning, but this function is internal. Your task is to:

**作业:** OpenZeppelin ERC-20 库支持 burning,但是这个函数是 internal 的。你的任务是:

- Expose the burn function in the contract by adding a public wrapper function similar to how `mint()` is exposed
- 通过添加一个公共包装函数来暴露合约中的 burn 函数,类似于 `mint()` 的暴露方式
- The burn should come from `get_caller_address()`
- burn 应该来自 `get_caller_address()`
- Write tests for the burn functionality:
- 为 burn 功能编写测试:
  - Test that a user can burn their own tokens
  - 测试用户可以 burn 自己的 token
  - Test that burning decreases the user’s balance
  - 测试 burning 会减少用户的余额
  - Test that burning decreases the total supply
  - 测试 burning 会减少总供应量
  - Test that burning cannot happen when the contract is paused
  - 测试合约暂停时不能进行 burning
  - Test that a user cannot burn more tokens than they have
  - 测试用户不能 burn 比他们拥有的更多的 token

**This article is part of a tutorial series on [Cairo Programming on Starknet](https://rareskills.io/wp-content/uploads/post-media/cairo-components-part-2/cairo-tutorial)**

**本文是关于 [Starknet 上的 Cairo 编程](https://rareskills.io/wp-content/uploads/post-media/cairo-components-part-2/cairo-tutorial) 的系列教程的一部分**

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

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/