Starknet上的ERC-20代币

本文详细介绍了如何在Starknet上构建和测试一个ERC-20代币合约,内容涵盖了ERC-20接口的定义、合约的存储设置、事件声明、以及各个功能的具体实现,包括元数据函数、total_supply、mint、transfer、balance_of、allowance、approve和transfer_from等关键功能,并提供了相应的测试用例和潜在问题的解决方案。

Starknet 上的 ERC-20 token 的运作方式与 Ethereum 上相同。 事实上,STRK(Starknet 的费用 token)本身就是一个 ERC-20 token;在协议层面没有特殊的“原生”token。

Starknet 上的 ETHSTRK 都以标准的 ERC-20 合约形式存在,就像创建的任何其他 token 一样。

在本教程中,你将学习如何在 Starknet 上构建和测试 ERC-20 token 合约。 本教程假定读者熟悉 ERC-20 标准,但会解释每个实现步骤和 Cairo 语法。

创建 ERC-20 token 的首选方法是使用 OpenZeppelin 库。 这将在即将推出的关于“组件”的教程中介绍。 本教程的目的是将我们之前所学的所有内容联系起来。

项目设置

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

scarb new erc20
cd erc20

合约接口

ERC-20 接口定义了每个 fungible token 必须遵循的蓝图。 它规定了用于检查 token 余额、转移 token、管理支出权限以及检索 token 元数据的必需函数。

Starknet 上的所有 ERC-20 token 都在 Cairo 中实现以下接口:

use starknet::{ContractAddress};

##[starknet::interface]
pub trait IERC20<TContractState> {
    fn total_supply(self: @TContractState) -> u256;
    fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
    fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
    fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
    fn transfer_from(ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool;
    fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
    fn name(self: @TContractState) -> ByteArray;
    fn symbol(self: @TContractState) -> ByteArray;
    fn decimals(self: @TContractState) -> u8;
}

此接口镜像了 Ethereum 的 ERC-20 标准,但使用了 Cairo 特定的语法和约定。 在下一节中,我们将了解它与 Solidity 的不同之处。

Cairo ERC-20 接口语法与 Solidity 的不同之处

状态引用:请注意,在上面的 Cairo IERC20 接口中,对于 view 函数使用self: @TContractState,对于更改状态的函数使用 ref self: TContractState@ 符号创建合约状态的只读快照,而 ref 允许状态修改。 例如,检查 STRK 余额使用 @(仅查看),但转移 STRK 使用 ref(修改余额)。

<TContractState> 是一种通用类型,该类型允许相同的接口与任何 ERC-20 合约的存储结构一起使用。

类型:Cairo 使用 u256 作为 token 金额(类似于 Solidity 的 uint256),并使用 ContractAddress 而不是 Ethereum 的 address 类型。 token 接口的 namesymbol 函数返回 ByteArray 而不是字符串。

这些函数实现了与 Ethereum 的 ERC-20 标准相同的余额、转移、授权和元数据,仅在语法上有所不同。

构建 ERC-20 Token 合约

我们将在 Cairo 中逐步构建 ERC-20 合约,从基本结构开始,逐步添加功能,并在进行过程中测试主要功能。

src/lib.cairo文件中,创建一个空的合约模块和接口,我们将在其基础上构建:

##[starknet::interface]
pub trait IERC20<TContractState> {
    // 我们将在实现时在此处添加函数
}

##[starknet::contract]
pub mod ERC20 {}

存储设置

接下来,我们将定义存储变量,这些变量将保存余额、授权、元数据和所有权数据。 我们将从 Starknet 导入 ContractAddress 作为地址类型,从 Starknet 导入 Map 作为 Cairo 版本的存储映射。 存储变量将跟踪:

  • balances:每个地址拥有的 token 数量
  • allowances:每个地址可以从另一个地址的余额中支出的金额
  • token_namesymboldecimal 是标准的 ERC-20 元数据
  • total_supply:流通中的 token 总数
  • owner:合约所有者地址
##[starknet::interface]
pub trait IERC20<TContractState> {
    // 我们将在实现时在此处添加函数
}

##[starknet::contract]
pub mod ERC20 {
    use starknet::ContractAddress;
    use starknet::storage::Map;

    #[storage]
    pub struct Storage {
        // 将每个帐户地址映射到其 token 余额
        balances: Map<ContractAddress, u256>,

        // 将(所有者,支出者)对映射到已批准的支出金额
        allowances: Map<(ContractAddress, ContractAddress), u256>,

        // Token 元数据
        token_name: ByteArray,
        symbol: ByteArray,
        decimal: u8,

        // 存在的 token 总数
        total_supply: u256,

        // 可以铸造新 token 的地址
        owner: ContractAddress,
    }
}

以下是映射与 Solidity 的比较方式:

mapping(address => uint256) balances;
mapping(address => mapping(address => uint256)) allowances;

Cairo 使用元组 (ContractAddress, ContractAddress) 进行嵌套映射,而不是 Solidity 的嵌套结构:

balances: Map<ContractAddress, u256>,  // owner -> amount
allowances: Map<(ContractAddress, ContractAddress), u256>, // (owner, spender) -> amount

我们将创建一个符号为 "RST" 的 "Rare Token"。 由于名称、符号和小数通常不会更改,因此我们将在构造函数中设置它们。 我们还导入 StoragePointerWriteAccess 以启用对存储的写入访问:

##[starknet::interface]
pub trait IERC20<TContractState> {
    // 我们将在实现时在此处添加函数
}

##[starknet::contract]
pub mod ERC20 {
    use starknet::ContractAddress;
    use starknet::storage::{Map, StoragePointerWriteAccess};

    #[storage]
    pub struct Storage {
        balances: Map<ContractAddress, u256>,
        allowances: Map<(ContractAddress, ContractAddress), u256>, //  (owner, spender) -> amount
        token_name: ByteArray,
        symbol: ByteArray,
        decimal: u8,
        total_supply: u256,
        owner: ContractAddress,
    }

     //NEWLY ADDED
    #[constructor]
    fn constructor(ref self: ContractState, owner: ContractAddress) {
        // 设置 token 的元数据
        self.token_name.write("Rare Token");
        self.symbol.write("RST");
        self.decimal.write(18);

        // 设置所有者
        self.owner.write(owner);  // 通常是部署者的地址
    }
}

构造函数使用名称 "Rare Token"、符号 "RST"、18 位小数(大多数 token 的标准)和所有者地址初始化 token。 ref self: ContractState 参数允许我们修改合约的存储。

你可能想知道为什么我们将所有者地址作为参数传递,而不是使用 get_caller_address() 自动将部署者设置为所有者。 这种设计选择是故意的,并且与合约部署在 Starknet 上的工作方式有关。 当部署一个在其构造函数中使用 get_caller_address() 的合约时,通用部署合约 (UDC) 部署该合约,而不是你的帐户直接部署。 因此,get_caller_address() 返回 UDC 的地址,而不是你的帐户的地址。 UDC 在本系列的后面“在 Starknet 上部署合约”一章中详细解释。

事件声明

在存储部分之后添加以下事件以跟踪转移和批准:

// 定义此合约可以发出的事件
##[event]
##[derive(Drop, starknet::Event)]
pub enum Event {
    Transfer: Transfer,    // 在 token 转移时发出
    Approval: Approval,    // 在授予支出批准时发出
}

// 只要 token 在地址之间转移,就会发出事件
##[derive(Drop, starknet::Event)]
pub struct Transfer {
    #[key]  // 索引字段 - 在查询事件时可以过滤
    from: ContractAddress,     // 发送 token 的地址
    #[key]  // 索引字段 - 在查询事件时可以过滤
    to: ContractAddress,       // 接收 token 的地址
    amount: u256,              // 转移的 token 数量
}

// 当所有者批准使用者使用其 token 时发出的事件
##[derive(Drop, starknet::Event)]
pub struct Approval {
    #[key]  // 索引字段 - 在查询事件时可以过滤
    owner: ContractAddress,    // 拥有 token 的地址
    #[key]  // 索引字段 - 在查询事件时可以过滤
    spender: ContractAddress,  // 批准支出 token 的地址
    value: u256,               // 批准的支出金额
}

Event 枚举包含合约可以发出的所有事件:TransferApproval

  • Transfer 事件使用 fromto 地址以及 amount 跟踪 token 移动。
  • Approval 事件还使用授予许可的 owner、接收许可的 spender 和批准的 value 跟踪支出权限。

参数被索引,因此我们可以轻松地查询来自特定地址的转移或对特定所有者的批准。

合约实现

现在让我们实现合约函数。 由于外部合约和用户需要与我们的 ERC20 token 交互,我们需要使我们的实现可以从合约外部调用。 我们通过添加 #[abi(embed_v0)] 属性来实现这一点,该属性将实现嵌入到合约的 ABI 中:

##[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
    // 实现函数放在这里
}

ERC20Impl 实现了我们之前定义的 IERC20 接口,其中 ContractState 表示合约的存储。

元数据函数:namesymboldecimals

元数据函数返回基本的 token 详细信息,例如名称、符号和小数精度。 让我们从实现函数开始。

将它们的函数签名添加到接口:

##[starknet::interface]
pub trait IERC20<TContractState> {
    fn name(self: @TContractState) -> ByteArray;
    fn symbol(self: @TContractState) -> ByteArray;
    fn decimals(self: @TContractState) -> u8;
}

然后在 ERC20Impl 块内实现这些函数:

##[abi(embed_v0)]
    impl ERC20Impl of super::IERC20<ContractState> {
        // 返回 token 的全名
        fn name(self: @ContractState) -> ByteArray {
            self.token_name.read()
        }

        // 返回 token 的符号/代码
        fn symbol(self: @ContractState) -> ByteArray {
            self.symbol.read()
        }

        // 返回 token 的小数位数
        fn decimals(self: @ContractState) -> u8 {
            self.decimal.read()
        }

        // 其他函数放在这里
    }

每个函数都读取在合约初始化期间在构造函数中设置的存储值。 更新合约模块中的导入,以包含 StoragePointerReadAccess 以启用这些读取:

use starknet::storage::{
    Map, StoragePointerWriteAccess, StoragePointerReadAccess
};

这是到目前为止的完整代码:

##[starknet::interface]
pub trait IERC20<TContractState> {
    fn name(self: @TContractState) -> ByteArray;
    fn symbol(self: @TContractState) -> ByteArray;
    fn decimals(self: @TContractState) -> u8;
}

##[starknet::contract]
pub mod ERC20 {
    use starknet::ContractAddress;
    use starknet::storage::{Map, StoragePointerReadAccess, StoragePointerWriteAccess};

    #[storage]
    pub struct Storage {
        balances: Map<ContractAddress, u256>,
        allowances: Map<(ContractAddress, ContractAddress), u256>,
        token_name: ByteArray,
        symbol: ByteArray,
        decimal: u8,
        total_supply: u256,
        owner: ContractAddress,
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    pub enum Event {
        Transfer: Transfer,
        Approval: Approval,
    }

    #[derive(Drop, starknet::Event)]
    pub struct Transfer {
        #[key]
        from: ContractAddress,
        #[key]
        to: ContractAddress,
        amount: u256,
    }

    #[derive(Drop, starknet::Event)]
    pub struct Approval {
        #[key]
        owner: ContractAddress,
        #[key]
        spender: ContractAddress,
        value: u256,
    }

    #[constructor]
    fn constructor(ref self: ContractState, owner: ContractAddress) {
        self.token_name.write("Rare Token");
        self.symbol.write("RST");
        self.decimal.write(18);
        self.owner.write(owner);
    }

    #[abi(embed_v0)]
    impl ERC20Impl of super::IERC20<ContractState> {
        // 返回 token 的全名
        fn name(self: @ContractState) -> ByteArray {
            self.token_name.read()
        }

        // 返回 token 的符号/代码
        fn symbol(self: @ContractState) -> ByteArray {
            self.symbol.read()
        }

        // 返回 token 的小数位数
        fn decimals(self: @ContractState) -> u8 {
            self.decimal.read()
        }
    }
}

测试设置

导航到项目目录中的 test/test_contract.cairo。 清除样板测试,仅留下基本导入:

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

合约构造函数需要一个所有者地址作为参数:

##[constructor]
fn constructor(ref self: ContractState, owner: ContractAddress) {
    self.token_name.write("Rare Token");
    self.symbol.write("RST");
    self.decimal.write(18);
    self.owner.write(owner);
}

由于构造函数需要一个所有者地址,因此我们需要在测试中部署合约时提供一个地址。 为了处理这个问题,创建一个 deploy_contract 辅助函数,该函数将所有者地址作为参数传递,并将其传递给构造函数。

此外,在测试中导入 dispatcher 以与已部署的合约交互,因此总的来说我们有:

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

//NEWLY ADDED BELOW//
use erc20::IERC20Dispatcher;
use erc20::IERC20DispatcherTrait;

// Helper function to deploy the ERC20 contract with a specified owner
fn deploy_contract(name: ByteArray, owner: ContractAddress) -> ContractAddress {
    let contract = declare(name).unwrap().contract_class(); // 声明合约类
    let constructor_args = array![owner.into()];  // 将所有者传递给构造函数
    let (contract_address, _) = contract.deploy(@constructor_args).unwrap(); // 部署合约并返回其地址
    contract_address
}

IERC20DispatcherIERC20DispatcherTrait dispatcher 允许我们从测试中调用合约函数。

deploy_contract 函数声明合约类,通过 constructor_args 将所有者地址传递给构造函数,并返回已部署合约的地址供我们与之交互。

由于每个测试都需要合约部署,因此定义一个 OWNER() 辅助函数来生成一致的测试所有者地址,而不是每次都创建一个新的地址:

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

这样,每个测试都可以简单地调用 deploy_contract("ERC20", OWNER()) 来部署具有一致所有者地址的合约。

测试构造函数初始化

下一步是确认构造函数正确初始化元数据。 以下测试部署合约,调用其元数据函数(name()symbol()decimal()),并检查返回的值:

use starknet::ContractAddress;

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

use erc20::IERC20Dispatcher;
use erc20::IERC20DispatcherTrait;

fn deploy_contract(name: ByteArray, owner: ContractAddress) -> ContractAddress {
    let contract = declare(name).unwrap().contract_class();
    let constructor_args = array![owner.into()];
    let (contract_address, _) = contract.deploy(@constructor_args).unwrap();
    contract_address
}

// helper function to create a test owner address
fn OWNER() -> ContractAddress {
    'OWNER'.try_into().unwrap()
}

// NEWLY ADDED BELOW
##[test]
fn test_token_constructor() {
    // Deploy the ERC20 contract with OWNER as the owner
    let contract_address = deploy_contract("ERC20", OWNER());

    // Create a dispatcher to interact with the deployed contract
    let erc20_token = IERC20Dispatcher { contract_address };

    // Retrieve token metadata from the contract
    let token_name = erc20_token.name();
    let token_symbol = erc20_token.symbol();
    let token_decimal = erc20_token.decimals();

    // Verify that the constructor set the correct values
    assert(token_name == "Rare Token", 'Wrong token name');
    assert(token_symbol == "RST", 'Wrong token symbol');
    assert(token_decimal == 18, 'Wrong token decimal');
}

test_token_constructor 中部署合约后,我们使用已部署合约的地址创建一个 IERC20Dispatcher 实例来与合约交互。 然后调用每个元数据函数,并断言 token 名称、符号和小数位数与构造函数中设置的值匹配。 如果任何值不匹配,测试将失败并显示相应的错误消息。

运行scarb test test_token_constructor以确认测试通过。 你也可以使用不正确的值进行测试,以查看预期的错误。

实现 total_supply

为了跟踪存在多少 token,我们将在接口中包含一个总供应量函数,并实现它以读取和返回已创建 token 的总数。

将函数签名添加到接口:

##[starknet::interface]
pub trait IERC20<TContractState> {
    fn name(self: @TContractState) -> ByteArray;
    fn symbol(self: @TContractState) -> ByteArray;
    fn decimals(self: @TContractState) -> u8;

    //NEWLY ADDED
    fn total_supply(self: @TContractState) -> u256;
}

然后在合约中实现它:

##[abi(embed_v0)]
    impl ERC20Impl of super::IERC20<ContractState> {
        // ...previous functions....

        fn total_supply(self: @ContractState) -> u256 {
            // 从合约存储中读取 total_supply 值
            self.total_supply.read()
        }
    }

为了测试 total_supply 函数,我们需要首先铸造 token,然后确认总供应量反映了铸造的金额。 因此,我们需要实现铸造 token 的函数。

实现 mint

如果没有 mint,则不会存在任何 token,因为所有余额都从零开始。

mint 函数不在 ERC-20 规范 中,但它是创建 token 和增加总供应量所必需的。

将其添加到接口:

use starknet::ContractAddress;

##[starknet::interface]
pub trait IERC20<TContractState> {
    fn name(self: @TContractState) -> ByteArray;
    fn symbol(self: @TContractState) -> ByteArray;
    fn decimals(self: @TContractState) -> u8;
    fn total_supply(self: @TContractState) -> u256;

    //NEWLY ADDED
    fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
}

我们导入 ContractAddress,因为 mint 函数将其用作参数类型。

然后在合约中实现 mint 函数:

 #[abi(embed_v0)]
    impl ERC20Impl of super::IERC20<ContractState> {
        // ....previous functions.....//

        fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
            // 获取调用此函数的人的地址
            let caller = get_caller_address();

            // 只有合约所有者才能铸造新 token
            assert(caller == self.owner.read(), 'Call not owner');

            // 更新之前读取当前值
            let previous_total_supply = self.total_supply.read();
            let previous_balance = self.balances.entry(recipient).read();

            // 按铸造量增加总供应量
            self.total_supply.write(previous_total_supply + amount);

            // 将铸造的 token 添加到接收者的余额
            self.balances.entry(recipient).write(previous_balance + amount);

            // 从零地址发出转移
            let zero_address: ContractAddress = 0.try_into().unwrap();
            self.emit(Transfer { from: zero_address, to: recipient, amount });

            true // 返回成功
        }
    }

mint 接受一个接收者地址和金额作为参数。 只有合约所有者才能调用此函数,这就是为什么检查 caller == owner 的原因。

当铸造 token 时,总供应量和接收者的余额都会增加指定的金额。

从 Solidity 中回想一下,新铸造的 token 总是显示为从零地址的转移,因为它们是从无到有创建的。 我们在这里遵循相同的模式,发出一个从零地址到接收者的 Transfer 事件。

 let zero_address: ContractAddress = 0.try_into().unwrap();
 self.emit(Transfer { from: zero_address, to: recipient, amount });

我们导入 StoragePathEntry,因为我们使用 .entry() 来访问 Map 键,这会创建到特定映射条目的路径,还导入 get_caller_address 以获取当前调用者的地址。

更新导入:

use starknet::storage::{
    Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
};
use starknet::{ContractAddress, get_caller_address};

这是到目前为止的完整代码:

use starknet::ContractAddress;

##[starknet::interface]
pub trait IERC20<TContractState> {
    fn name(self: @TContractState) -> ByteArray;
    fn symbol(self: @TContractState) -> ByteArray;
    fn decimals(self: @TContractState) -> u8;

    fn total_supply(self: @TContractState) -> u256;
    fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
}

##[starknet::contract]
pub mod ERC20 {
    use starknet::storage::{
        Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
    };
    use starknet::{ContractAddress, get_caller_address};

    #[storage]
    pub struct Storage {
        balances: Map<ContractAddress, u256>,
        allowances: Map<(ContractAddress, ContractAddress), u256>,
        token_name: ByteArray,
        symbol: ByteArray,
        decimal: u8,
        total_supply: u256,
        owner: ContractAddress,
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    pub enum Event {
        Transfer: Transfer,
        Approval: Approval,
    }

    #[derive(Drop, starknet::Event)]
    pub struct Transfer {
        #[key]
        from: ContractAddress,
        #[key]
        to: ContractAddress,
        amount: u256,
    }

    #[derive(Drop, starknet::Event)]
    pub struct Approval {
        #[key]
        owner: ContractAddress,
        #[key]
        spender: ContractAddress,
        value: u256,
    }

    #[constructor]
    fn constructor(ref self: ContractState, owner: ContractAddress) {
        self.token_name.write("Rare Token");
        self.symbol.write("RST");
        self.decimal.write(18);
        self.owner.write(owner);
    }

    #[abi(embed_v0)]
    impl ERC20Impl of super::IERC20<ContractState> {
        fn name(self: @ContractState) -> ByteArray {
            self.token_name.read()
        }

        fn symbol(self: @ContractState) -> ByteArray {
            self.symbol.read()
        }

        fn decimals(self: @ContractState) -> u8 {
            self.decimal.read()
        }

        fn total_supply(self: @ContractState) -> u256 {
            self.total_supply.read()
        }

        fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
            let caller = get_caller_address();

            assert(caller == self.owner.read(), 'Call not owner');

            let previous_total_supply = self.total_supply.read();
            let previous_balance = self.balances.entry(recipient).read();

            self.total_supply.write(previous_total_supply + amount);

            self.balances.entry(recipient).write(previous_balance + amount);

            let zero_address: ContractAddress = 0.try_into().unwrap();
            self.emit(Transfer { from: zero_address, to: recipient, amount });

            true
        }
    }
}

测试 total_supply

由于只有合约所有者才能铸造 token,并且测试默认情况下不会以所有者身份运行,因此需要模拟所有者地址。

我们将使用 cheat_caller_address 临时更改合约认为的调用者,绕过合约中的访问控制检查。 设置 CheatSpan::TargetCalls(1) 以仅将此 cheat 应用于下一个函数调用 (mint())。

snforge_std 导入 cheat_caller_addressCheatSpan,并添加一个辅助函数来生成一个测试接收者地址以接收铸造的 token,因此我们最终得到:

use starknet::ContractAddress;
use snforge_std::{
    declare, ContractClassTrait, DeclareResultTrait,
    cheat_caller_address, CheatSpan
};
use erc20::IERC20Dispatcher;
use erc20::IERC20DispatcherTrait;

// Helper function to deploy the ERC20 contract with a specified owner
fn deploy_contract(name: ByteArray, owner: ContractAddress) -> ContractAddress {
    let contract = declare(name).unwrap().contract_class();
    let constructor_args = array![owner.into()];
    let (contract_address, _) = contract.deploy(@constructor_args).unwrap();
    contract_address
}

// Helper function to create a test owner address
fn OWNER() -> ContractAddress {
    'OWNER'.try_into().unwrap()
}

// NEWLY ADDED
// Helper function to create a test recipient address
fn TOKEN_RECIPIENT() -> ContractAddress {
    'RECIPIENT'.try_into().unwrap()
}

现在编写测试:

##[test]
fn test_total_supply() {
    // Deploy the contract
    let contract_address = deploy_contract("ERC20", OWNER());

    // Create dispatcher to interact with the contract
    let erc20_token = IERC20Dispatcher { contract_address };

    // Calculate mint amount: 1000 tokens adjusted for decimals
    let token_decimal = erc20_token.decimals();
    let mint_amount = 1000 * token_decimal.into();

    // Impersonate the owner for the next function call (mint)
    cheat_caller_address(contract_address, OWNER(), CheatSpan::TargetCalls(1));
    erc20_token.mint(TOKEN_RECIPIENT(), mint_amount);

    // Get the total supply
    let supply = erc20_token.total_supply();

    // Verify total supply matches the minted amount
    assert(supply == mint_amount, 'Incorrect Supply');
}

test_total_supply 测试部署合约并通过将 1000 个 token 乘以小数位数 (18) 来计算 mint 金额。 在调用 mint 之前,cheat_caller_address 将调用者设置为所有者地址,允许 mint 绕过 assert(caller == owner) 检查。 在 mint 到接收者地址后,测试检索总供应量并验证它是否等于 mint 金额。

将测试添加到test_contract.cairo文件,然后运行scarb test test_total_supply以查看其是否通过。

实现 token 转移

transfer 函数处理将 token 从调用者移动到接收者。 首先,将函数签名添加到接口:

use starknet::ContractAddress;

##[starknet::interface]
pub trait IERC20<TContractState> {
    fn name(self: @TContractState) -> ByteArray;
    fn symbol(self: @TContractState) -> ByteArray;
    fn decimals(self: @TContractState) -> u8;
    fn total_supply(self: @TContractState) -> u256;
    fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;

    //NEWLY ADDED
    fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
}

现在,在 ERC20Impl 块内实现 transfer 函数:

   #[abi(embed_v0)]
    impl ERC20Impl of super::IERC20<ContractState> {
        //.....previous functions.....//

        fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
            // 获取调用此函数的人的地址
            let sender = get_caller_address();

            // 读取发送者和接收者的当前余额
            let sender_prev_balance = self.balances.entry(sender).read();
            let recipient_prev_balance = self.balances.entry(recipient).read();

            // 检查发送者是否有足够的 token 来转移
            assert(sender_prev_balance >= amount, 'Insufficient amount');

            // 更新余额:从发送者减去,添加到接收者
            self.balances.entry(sender).write(sender_prev_balance - amount);
            self.balances.entry(recipient).write(recipient_prev_balance + amount);

            // 验证转移是否正确
            assert(
                self.balances.entry(recipient).read() > recipient_prev_balance,
                'Transaction failed',
            );

            // 发出事件以记录此转移
            self.emit(Transfer { from: sender, to: recipient, amount });

            true // 返回成功
        }
    }
}

假设 Alice 有 100 个 RareToken 并且想要发送 30 个给有 50 个的 Bob。 该函数检查 Alice 是否有足够的 token (100 >= 30),将 Alice 的余额更新为 70,将 Bob 的余额更新为 80。 然后它确认 Bob 的余额增加,并发出一个 from: Aliceto: Bobamount: 30Transfer 事件以记录此交易,并返回 true 以表示成功完成。

为了测试转移函数,合约需要一种方法来检查特定点每个帐户的余额。

实现 balance_of

让我们添加 balance_of 来查询 token 余额。 将函数签名添加到接口:

use starknet::ContractAddress;
```##[starknet::interface]

pub trait IERC20<TContractState> { fn name(self: @TContractState) -> ByteArray; fn symbol(self: @TContractState) -> ByteArray; fn decimals(self: @TContractState) -> u8; fn total_supply(self: @TContractState) -> u256; fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;

 //NEWLY ADDED
fn balance_of(self: @TContractState, account: ContractAddress) -> u256;

}


然后在合约中实现它:

```rust
##[abi(embed_v0)]
impl ERC20Impl of super::IERC20&lt;ContractState> {
    //.....之前的函数.....//

    fn balance_of(self: @ContractState, account: ContractAddress) -> u256 {
        // 使用 .entry() 访问 Map 中特定账户的余额
        let balance = self.balances.entry(account).read();
        balance
    }
}

要检查账户的 RareToken 余额,balance_of(account_address) 会在余额 mapping 中查找地址并返回相应的值。

测试 transfer

要测试 transfer 函数,我们首先需要在账户中拥有 token,然后验证 transfer 是否正确地将 token 从发送者转移到接收者。我们将向所有者 mint token,然后将一些 token transfer 给接收者并检查两个余额。

由于 mint 和 transfer 都需要所有者的许可,我们将使用 start_cheat_caller_address 来模拟所有者进行多次连续调用,直到使用 stop_cheat_caller_address 显式停止。

snforge_std 中导入 start_cheat_caller_addressstop_cheat_caller_address 以及其他导入:

use snforge_std::{
    declare, ContractClassTrait, DeclareResultTrait,
    cheat_caller_address, CheatSpan,
    start_cheat_caller_address, stop_cheat_caller_address
};

这是测试:

##[test]
fn test_transfer() {
    // 部署合约
    let contract_address = deploy_contract("ERC20", OWNER());
    let erc20_token = IERC20Dispatcher { contract_address };

    // 获取 token 的 decimals 以进行正确的金额计算
    let token_decimal = erc20_token.decimals();

    // 定义金额:mint 10,000 个 token,transfer 5,000 个
    let amount_to_mint: u256 = 10000 * token_decimal.into();
    let amount_to_transfer: u256 = 5000 * token_decimal.into();

    // 开始模拟所有者进行多次调用
    start_cheat_caller_address(contract_address, OWNER());

    // 向所有者 mint token
    erc20_token.mint(OWNER(), amount_to_mint);

    // 验证 mint 是否成功
    assert(erc20_token.balance_of(OWNER()) == amount_to_mint, 'Incorrect minted amount');

    // 跟踪接收者 transfer 前的余额
    let receiver_previous_balance = erc20_token.balance_of(TOKEN_RECIPIENT());

    // 从所有者 transfer token 到接收者
    erc20_token.transfer(TOKEN_RECIPIENT(), amount_to_transfer);

    // 停止模拟所有者
    stop_cheat_caller_address(contract_address);

    // 验证发送者的余额是否正确减少
    assert(erc20_token.balance_of(OWNER()) &lt; amount_to_mint, 'Sender balance not reduced');
    assert(erc20_token.balance_of(OWNER()) == amount_to_mint - amount_to_transfer, 'Wrong sender balance');

    // 验证接收者的余额是否正确增加
    assert(erc20_token.balance_of(TOKEN_RECIPIENT()) > receiver_previous_balance, 'Recipient balance unchanged');
    assert(erc20_token.balance_of(TOKEN_RECIPIENT()) == amount_to_transfer, 'Wrong recipient amount');
}

test_transfer() 中部署合约后,测试计算金额:mint 10,000 个 token,transfer 5,000 个。 使用 start_cheat_caller_address 开始模拟所有者,这允许将 token mint 到所有者的账户。 mint 成功后,测试记录接收者在进行 transfer 之前的余额。

然后测试 transfer 5,000 个 token 给接收者并停止模拟。 最后的断言验证交易的双方:所有者的余额减少了 5,000 个 token,接收者的余额增加了相同的金额。 这证实了 transfer 正确地在账户之间转移 token。

将测试添加到 test_contract.cairo 文件中,然后运行 scarb test test_transfer 以验证它是否通过。

测试 transfer 的余额不足

让我们测试 transfer 是否正确拒绝尝试 transfer 比发送者拥有的 token 更多的 token。

修改我们测试中的 transfer 调用,尝试 transfer 11,000 个 token 而不是 5000 个:

 erc20_token.transfer(TOKEN_RECIPIENT(), 11000 * token_decimal.into());

当我们运行 scarb test test_transfer 时,测试应该会失败,并显示以下错误:

一个测试显示由于余额不足而导致 transfer 失败

这证实了合约工作正常,它阻止了 transfer 比发送者拥有的 token 更多的 token,触发了 transfer 函数中的 assert(sender_prev_balance >= amount, 'Insufficient amount') 检查。

为了保持测试通过,将金额改回 amount_to_transfer(5,000 个 token 或任何小于或等于所有者 10,000 个 token 余额的金额)

替代方案:为失败情况创建一个专用测试

与其修改现有测试,不如使用 #[should_panic] 将此测试添加到 test_contract.cairo

##[test]
##[should_panic(expected: ('Insufficient amount',))]
fn test_transfer_insufficient_balance() {
    // 部署合约
    let contract_address = deploy_contract("ERC20", OWNER());
    let erc20_token = IERC20Dispatcher { contract_address };

    let token_decimal = erc20_token.decimals();

    // 定义金额:仅 mint 5,000 个 token,但尝试 transfer 10,000 个
    let mint_amount: u256 = 5000 * token_decimal.into();
    let transfer_amount: u256 = 10000 * token_decimal.into();

    // 开始模拟所有者
    start_cheat_caller_address(contract_address, OWNER());

    // 仅向所有者 mint 5,000 个 token
    erc20_token.mint(OWNER(), mint_amount);

    // 验证 mint 是否成功
    assert(erc20_token.balance_of(OWNER()) == mint_amount, 'Mint failed');

    // 尝试 transfer 比余额更多的 token(只有 5,000 个 token 时 transfer 10,000 个)
    // 这应该会因为 'Insufficient amount' 而 panic
    erc20_token.transfer(TOKEN_RECIPIENT(), transfer_amount);

    // 停止模拟所有者
    stop_cheat_caller_address(contract_address);
}

此测试验证了当尝试发送比发送者拥有的 token 更多的 token 时 transfer 失败。 所有者只有 5,000 个 token,但尝试 transfer 10,000 个,触发了 transfer 函数中的 assert(sender_prev_balance >= amount, 'Insufficient amount') 检查。 #[should_panic] 属性告诉测试框架,预计此测试会因为特定的错误消息 'Insufficient amount' 而 panic。

运行 scarb test test_transfer_insufficient_balance 以验证它是否通过。

实现 allowance

allowance 函数检查一个地址被允许代表另一个地址花费多少。 让我们将函数签名添加到接口:

use starknet::ContractAddress;

##[starknet::interface]
pub trait IERC20&lt;TContractState> {
    // ... 之前的函数 ...

    //NEWLY ADDED
    fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
}

然后在合约中实现它:

##[abi(embed_v0)]
impl ERC20Impl of super::IERC20&lt;ContractState> {
   //....之前的函数....//

   fn allowance(
       self: @ContractState, owner: ContractAddress, spender: ContractAddress,
    ) -> u256 {
        // 使用元组键 (owner, spender) 访问 allowances Map
        self.allowances.entry((owner, spender)).read()
    }

例如,要查看 Bob 可以从 Alice 的账户花费多少 RareTokens,你可以调用 allowance(Alice, Bob)

实现 approve

approve 函数通过设置某人(spender)可以从账户余额(owner)中提取多少来授予花费权限。

将函数签名添加到接口:

use starknet::ContractAddress;

##[starknet::interface]
pub trait IERC20&lt;TContractState> {
    // ... 之前的函数 ...

    //NEWLY ADDED
    fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
}

然后在合约中实现它:

##[abi(embed_v0)]
impl ERC20Impl of super::IERC20&lt;ContractState> {
    //.....之前的函数.....//

    fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool {
        // 获取授予批准的人(owner)的地址
        let caller = get_caller_address();

        // 设置 allowance:spender 可以代表 caller(owner)花费多少
        self.allowances.entry((caller, spender)).write(amount);

        // 发出事件以记录此批准
        self.emit(Approval { owner: caller, spender, value: amount });

        true  // 返回成功
    }
}

self.allowances.entry((caller, spender)).write(amount) 行中,spender 指的是被 caller 授予 allowance 的地址。 caller(来自 get_caller_address())正在授予 spender 权限从他们的账户中花费一定数量的 token。

因此,caller 是 token 的所有者,而 spender 是已被所有者批准代表他们花费一定数量的 token 的人。 这会创建 allowances[(owner, spender)] = amount 条目,transfer_from 稍后将检查和使用该条目。

测试 approve

让我们测试 approve 函数是否正确设置了 allowance,并且可以查询回来:

##[test]
fn test_approve() {
    let contract_address = deploy_contract("ERC20", OWNER());
    let erc20_token = IERC20Dispatcher { contract_address };

    let token_decimal = erc20_token.decimals();
    let mint_amount: u256 = 10000 * token_decimal.into();
    let approval_amount: u256 = 5000 * token_decimal.into();

    // 开始模拟所有者
    start_cheat_caller_address(contract_address, OWNER());

    // 首先向所有者 mint token
    erc20_token.mint(OWNER(), mint_amount);

    // 验证 mint 是否成功
    assert(erc20_token.balance_of(OWNER()) == mint_amount, 'Mint failed');

    // 所有者批准接收者花费 token
    erc20_token.approve(TOKEN_RECIPIENT(), approval_amount);

    // 停止模拟所有者
    stop_cheat_caller_address(contract_address);

    // 验证 allowance 是否已设置
    assert(erc20_token.allowance(OWNER(), TOKEN_RECIPIENT()) > 0, 'Incorrect allowance');
    assert(erc20_token.allowance(OWNER(), TOKEN_RECIPIENT()) == approval_amount, 'Wrong allowance amount');
}

该测试部署了合约并定义了两个金额:mint 10,000 个 token 和批准 5,000 个 token。 使用 start_cheat_caller_address,测试模拟所有者进行多次连续调用。

首先,测试向所有者 mint 10,000 个 token 并验证 mint 是否成功。 然后,在仍然模拟所有者的情况下,它调用 approve 以授予接收者权限从所有者的余额中花费 5,000 个 token。 停止模拟后,测试验证两件事:首先,allowance 是否存在(大于 0),其次,allowance 金额是否与批准的金额完全匹配(5,000 个 token)。 这些断言证实 approve 正确地将花费权限存储在 allowances mapping 中。

将测试添加到 test_contract.cairo 文件中,然后运行 scarb test test_approve 以验证它是否通过。

实现委托 transfers:transfer_from

现在,让我们实现 transfer_from,它使用预先批准的花费权限将 token 从一个地址移动到另一个地址。

更新接口以包含函数签名:

##[starknet::interface]
pub trait IERC20&lt;TContractState> {
    // ... 之前的函数 ...

    fn transfer_from(
        ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256,
    ) -> bool;
}

transfer_from 实现:

##[abi(embed_v0)]
impl ERC20Impl of super::IERC20&lt;ContractState> {
    //.....之前的函数.....//

    fn transfer_from(ref self: ContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool {
        // 获取调用此函数的人(spender)的地址
        let spender = get_caller_address();

        // 读取当前 allowance:spender 被允许从 sender 的账户花费多少
        let spender_allowance = self.allowances.entry((sender, spender)).read();

        // 读取 sender 和 recipient 的当前余额
        let sender_balance = self.balances.entry(sender).read();
        let recipient_balance = self.balances.entry(recipient).read();

        // 检查 transfer 金额是否超过批准的 allowance
        assert(amount &lt;= spender_allowance, 'amount exceeds allowance');

        // 检查 sender 是否有足够的 token 进行 transfer
        assert(amount &lt;= sender_balance, 'amount exceeds balance');

        // 更新 allowance:减少正在花费的金额
        self.allowances.entry((sender, spender)).write(spender_allowance - amount);

        // 更新余额:从 sender 中减去,添加到 recipient
        self.balances.entry(sender).write(sender_balance - amount);
        self.balances.entry(recipient).write(recipient_balance + amount);

        // 发出事件以记录此 Transfer
        self.emit(Transfer { from: sender, to: recipient, amount });

        true  // 返回成功
    }
 }

在上面的代码中,spender(来自 get_caller_address())执行 transfer,sender 是 token 的所有者,recipient 接收 token。 该函数通过读取 allowances[(sender, spender)] 来检查 spender 是否具有足够的 allowance,然后将 allowance 减少 transfer 的金额。

如果不减少花费,spender 将拥有无限的花费权。

考虑以下示例,该示例展示了 approvetransfer_from 如何协同工作:

Alice 调用 approve(Bob, 50) 以允许 Bob 花费她的 50 个 RareTokens。 然后,Bob 可以使用 transfer_from(Alice, Charlie, 30) 将 30 个 token 从 Alice 的账户移动到 Charlie,使 Bob 剩余 20 个 allowance。

这种 approve-then-withdraw 模式是 DeFi 协议、DEX 和其他智能合约如何与用户 token 交互的。

测试 transfer_from

transfer_from 测试需要三方:拥有 token 的所有者、拥有批准的 spender 以及一个 recipient 地址。

由于 spender 使用其批准将 token 从所有者的账户移动到 recipient,因此需要在测试的不同阶段模拟所有者和 spender:

##[test]
fn test_transfer_from() {
    // 部署合约
    let contract_address = deploy_contract("ERC20", OWNER());
    let erc20_token = IERC20Dispatcher { contract_address };

    let token_decimal = erc20_token.decimals();

    // 定义金额:mint 10,000 个 token,批准和 transfer 5,000 个
    let mint_amount: u256 = 10000 * token_decimal.into();
    let transfer_amount: u256 = 5000 * token_decimal.into();

    // 开始模拟所有者
    start_cheat_caller_address(contract_address, OWNER());

    // 向所有者 mint token
    erc20_token.mint(OWNER(), mint_amount);

    // 验证 mint 是否成功
    assert(erc20_token.balance_of(OWNER()) == mint_amount, 'Mint failed');

    let spender:ContractAddress = 'SPENDER'.try_into().unwrap();

    // 所有者批准 SPENDER 代表他们花费 token
    erc20_token.approve(spender, transfer_amount);

    // 停止模拟所有者
    stop_cheat_caller_address(contract_address);

    // 验证 allowance 是否设置正确
    assert(erc20_token.allowance(OWNER(), spender) == transfer_amount, 'Approval failed');

    // 跟踪 transfer 前的余额
    let owner_balance_before = erc20_token.balance_of(OWNER());
    let recipient_balance_before = erc20_token.balance_of(TOKEN_RECIPIENT());
    let allowance_before = erc20_token.allowance(OWNER(), spender);

    // 现在模拟 SPENDER 以调用 transfer_from
    cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
    erc20_token.transfer_from(OWNER(), TOKEN_RECIPIENT(), transfer_amount);

    // 验证所有者的余额是否减少
    assert(erc20_token.balance_of(OWNER()) == owner_balance_before - transfer_amount, 'Owner balance wrong');

    // 验证 recipient 的余额是否增加
    assert(erc20_token.balance_of(TOKEN_RECIPIENT()) == recipient_balance_before + transfer_amount, 'Recipient balance wrong');

    // 验证 allowance 是否减少
    assert(erc20_token.allowance(OWNER(), spender) == allowance_before - transfer_amount, 'Allowance not reduced');
}

test_transfer_from 验证了完整的批准和花费模式。 该测试首先模拟所有者以 mint 10,000 个 token 并批准 spender 使用其中的 5,000 个。 停止所有者的模拟后,它会验证批准是否设置正确。

接下来,测试捕获当前状态:所有者的余额、recipient 的余额和 spender 的 allowance。 然后,它模拟 spender 并调用 transfer_from 以将 5,000 个 token 从所有者移动到 recipient。

// 现在模拟 SPENDER 以调用 transfer_from
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER(), TOKEN_RECIPIENT(), transfer_amount);

最后的断言验证了三个更新:所有者的余额减少了 5,000 个,recipient 的余额增加了 5,000 个,spender 的 allowance 减少了 5,000 个。 这些检查证实 transfer_from 正确处理了委托 transfers 并正确更新了 allowances。

将测试添加到 test_contract.cairo 文件中,然后运行 scarb test test_transfer_from 以验证它是否通过。

测试 Allowance 不足

让我们测试 transfer_from 是否正确拒绝尝试花费超过批准金额的尝试。 如果 spender 尝试 transfer 比他们被批准的 token 更多的 token,则交易应该失败。

修改我们测试中的 transfer_from 调用,尝试 transfer 6,000 个 token 而不是批准的 5,000 个:

*// 尝试 transfer 比批准的更多的 token(6,000 而不是 5,000)*
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER(), TOKEN_RECIPIENT(), 6000 * token_decimal.into());

当我们运行 scarb test test_transfer_from 时,测试应该会失败,并显示以下错误:

一个测试显示 transfer_from 的 allowance 超出

此错误证实合约捕获了未经授权的花费尝试。 Spender 仅被批准了 5,000 个 token,因此尝试 transfer 6,000 个 token 会触发 transfer_from 函数中的 assert(amount &lt;= spender_allowance, 'amount exceeds allowance') 检查。

将金额改回 transfer_amount(5,000 个 token)以保持测试通过。

替代方法:为失败情况创建一个专用测试

与其修改现有测试,不如使用 #[should_panic] 属性创建一个单独的测试,该测试期望失败:

##[test]
##[should_panic(expected: ('amount exceeds allowance',))]
fn test_transfer_from_insufficient_allowance() {
    // 部署合约
    let contract_address = deploy_contract("ERC20", OWNER());
    let erc20_token = IERC20Dispatcher { contract_address };

    let token_decimal = erc20_token.decimals();

    // 定义金额:mint 10,000 个 token,批准 5,000 个
    let mint_amount: u256 = 10000 * token_decimal.into();
    let approval_amount: u256 = 5000 * token_decimal.into();

    // 开始模拟所有者
    start_cheat_caller_address(contract_address, OWNER());

    // 向所有者 mint token
    erc20_token.mint(OWNER(), mint_amount);

    let spender: ContractAddress = 'SPENDER'.try_into().unwrap();

    // 所有者批准 SPENDER 花费 5,000 个 token
    erc20_token.approve(spender, approval_amount);

    // 停止模拟所有者
    stop_cheat_caller_address(contract_address);

    // 尝试 transfer 比批准的更多的 token(6,000 而不是 5,000)
    // 这应该会因为 'amount exceeds allowance' 而 panic
    cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
    erc20_token.transfer_from(OWNER(), TOKEN_RECIPIENT(), 6000 * token_decimal.into());
}

同样,#[should_panic] 属性告诉测试框架,预计此测试会因为特定的错误消息 'amount exceeds allowance' 而失败。 当你将此测试添加到你的 test_contract.cairo 文件中,然后运行 scarb test test_transfer_from_insufficient_allowance 时,此测试将通过,因为 panic 按预期发生。

测试余额不足

我们还可以测试 spender 有足够的 allowance 但所有者没有足够的 token 的情况。 将此测试添加到 test_contract.cairo

##[test]
##[should_panic(expected: ('amount exceeds balance',))]
fn test_transfer_from_insufficient_balance() {
    // 部署合约
    let contract_address = deploy_contract("ERC20", OWNER());
    let erc20_token = IERC20Dispatcher { contract_address };

    let token_decimal = erc20_token.decimals();

    // 定义金额:仅 mint 1,000 个 token,但批准和尝试 2,000 个
    let mint_amount: u256 = 1000 * token_decimal.into();
    let approval_amount: u256 = 2000 * token_decimal.into();
    let transfer_amount: u256 = 2000 * token_decimal.into();

    // 开始模拟所有者
    start_cheat_caller_address(contract_address, OWNER());

    // 仅向所有者 mint 1,000 个 token
    erc20_token.mint(OWNER(), mint_amount);

    let spender: ContractAddress = 'SPENDER'.try_into().unwrap();

    // 所有者批准 SPENDER 花费 2,000 个 token(超过余额)
    erc20_token.approve(spender, approval_amount);

    // 停止模拟所有者
    stop_cheat_caller_address(contract_address);

    // Spender 有足够的 allowance 但所有者没有足够的余额
    // 这应该会因为 'amount exceeds balance' 而 panic
    cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
    erc20_token.transfer_from(OWNER(), TOKEN_RECIPIENT(), transfer_amount);
}

上面的 test_transfer_from_insufficient_balance 测试验证了即使有足够的 allowance,如果 token 所有者没有足够的余额,transfer 也会失败。 Spender 被批准了 2,000 个 token,但所有者只有 1,000 个,触发 assert(amount &lt;= sender_balance, 'amount exceeds balance') 检查。

运行 scarb test test_transfer_from_insufficient_balance 以验证它是否通过。

以下是完整的 ERC-20 合约:

use starknet::ContractAddress;

##[starknet::interface]
pub trait IERC20&lt;TContractState> {
    fn total_supply(self: @TContractState) -> u256;
    fn balance_of(self: @TContractState, account: ContractAddress) -> u256;
    fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
    fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
    fn transfer_from(
        ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256,
    ) -> bool;
    fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;

    fn name(self: @TContractState) -> ByteArray;
    fn symbol(self: @TContractState) -> ByteArray;
    fn decimals(self: @TContractState) -> u8;

    fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; // 为了测试目的
}

##[starknet::contract]
pub mod ERC20 {
    use starknet::{ContractAddress, get_caller_address};
    use starknet::storage::{
        Map, StoragePointerWriteAccess, StoragePointerReadAccess, StoragePathEntry,
    };

    #[storage]
    pub struct Storage {
        balances: Map&lt;ContractAddress, u256>,
        allowances: Map&lt;
            (ContractAddress, ContractAddress), u256,
        >, //  (owner, spender) -> amount, amount>
        token_name: ByteArray,
        symbol: ByteArray,
        decimal: u8,
        total_supply: u256,
        owner: ContractAddress,
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    pub enum Event {
        Transfer: Transfer,
        Approval: Approval,
    }

    #[derive(Drop, starknet::Event)]
    pub struct Transfer {
        #[key]
        from: ContractAddress,
        #[key]
        to: ContractAddress,
        amount: u256,
    }

    #[derive(Drop, starknet::Event)]
    pub struct Approval {
        #[key]
        owner: ContractAddress,
        #[key]
        spender: ContractAddress,
        value: u256,
    }

      #[constructor]
    fn constructor(ref self: ContractState, owner: ContractAddress) {
        self.token_name.write("Rare Token");
        self.symbol.write("RST");
        self.decimal.write(18);
        self.owner.write(owner);
    }

    #[abi(embed_v0)]
    impl ERC20Impl of super::IERC20&lt;ContractState> {
        fn total_supply(self: @ContractState) -> u256 {
            self.total_supply.read()
        }
        fn balance_of(self: @ContractState, account: ContractAddress) -> u256 {
            let balance = self.balances.entry(account).read();
            balance
        }

        fn name(self: @ContractState) -> ByteArray {
            self.token_name.read()
        }

        fn symbol(self: @ContractState) -> ByteArray {
            self.symbol.read()
        }

        fn decimals(self: @ContractState) -> u8 {
            self.decimal.read()
        }

        fn allowance(
            self: @ContractState, owner: ContractAddress, spender: ContractAddress,
        ) -> u256 {
            let allowance = self.allowances.entry((owner, spender)).read();

            allowance
        }

        fn transfer(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
            let sender = get_caller_address();

            let sender_prev_balance = self.balances.entry(sender).read();
            let recipient_prev_balance = self.balances.entry(recipient).read();

            assert(sender_prev_balance >= amount, 'Insufficient amount');

            self.balances.entry(sender).write(sender_prev_balance - amount);
            self.balances.entry(recipient).write(recipient_prev_balance + amount);

            assert(
                self.balances.entry(recipient).read() > recipient_prev_balance,
                'Transaction failed',
            );
            self.emit(Transfer { from: sender, to: recipient, amount });

            true
        }

        fn transfer_from(
            ref self: ContractState,
            sender: ContractAddress,
            recipient: ContractAddress,
            amount: u256,
        ) -> bool {
            let spender = get_caller_address();

            let spender_allowance = self.allowances.entry((sender, spender)).read();
            let sender_balance = self.balances.entry(sender).read();
            let recipient_balance = self.balances.entry(recipient).read();

            assert(amount &lt;= spender_allowance, 'amount exceeds allowance');
            assert(amount &lt;= sender_balance, 'amount exceeds balance');

            self.allowances.entry((sender, spender)).write(spender_allowance - amount);
            self.balances.entry(sender).write(sender_balance - amount);
            self.balances.entry(recipient).write(recipient_balance + amount);

            self.emit(Transfer { from: sender, to: recipient, amount });

            true
        }

        fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool {
            let caller = get_caller_address();

            self.allowances.entry((caller, spender)).write(amount);

            self.emit(Approval { owner: caller, spender, value: amount });

            true
        }

        fn mint(ref self: ContractState, recipient: ContractAddress, amount: u256) -> bool {
            let caller = get_caller_address();
            assert(caller == self.owner.read(), 'Call not owner');

            let previous_total_supply = self.total_supply.read();
            let previous_balance = self.balances.entry(recipient).read();

            self.total_supply.write(previous_total_supply + amount);
            self.balances.entry(recipient).write(previous_balance + amount);

            let zero_address: ContractAddress = 0.try_into().unwrap();

            self.emit(Transfer { from: zero_address, to: recipient, amount });

            true
        }
    }
}

以下是完整的测试:

use erc20::{IERC20Dispatcher, IERC20DispatcherTrait};
use snforge_std::{
    CheatSpan, ContractClassTrait, DeclareResultTrait, cheat_caller_address, declare,
    start_cheat_caller_address, stop_cheat_caller_address,
};
use starknet::ContractAddress;

fn deploy_contract(name: ByteArray, owner: ContractAddress) -> ContractAddress {
    let contract = declare(name).unwrap().contract_class();
    let constructor_args = array![owner.into()];
    let (contract_address, _) = contract.deploy(@constructor_args).unwrap();
    contract_address
}

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

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

##[test]
fn test_token_constructor() {
    let contract_address = deploy_contract("ERC20", OWNER());

    let erc20_token = IERC20Dispatcher { contract_address };

    let token_name = erc20_token.name();
    let token_symbol = erc20_token.symbol();
    let token_decimal = erc20_token.decimals();

    assert(token_name == "Rare Token", 'Wrong token name');
    assert(token_symbol == "RST", 'Wrong token symbol');
    assert(token_decimal == 18, 'Wrong token decimal');
}
```##[测试]

fn test_total_supply() { let contract_address = deploy_contract("ERC20", OWNER());

let erc20_token = IERC20Dispatcher { contract_address };

let token_decimal = erc20_token.decimals();
let mint_amount = 1000 * token_decimal.into();

// cheat caller address to be the owner
// 欺骗调用者地址为所有者
cheat_caller_address(contract_address, OWNER(), CheatSpan::TargetCalls(1));
erc20_token.mint(TOKEN_RECIPIENT(), mint_amount);

let supply = erc20_token.total_supply();

assert(supply == mint_amount, 'Incorrect Supply');

}

##[测试]

fn test_transfer() { let contract_address = deploy_contract("ERC20", OWNER()); let erc20_token = IERC20Dispatcher { contract_address };

let token_decimal = erc20_token.decimals();

let amount_to_mint: u256 = 10000 * token_decimal.into();
let amount_to_transfer: u256 = 5000 * token_decimal.into();

// Start impersonating the owner for multiple calls
// 开始模拟所有者进行多次调用
start_cheat_caller_address(contract_address, OWNER());

erc20_token.mint(OWNER(), amount_to_mint);

assert(erc20_token.balance_of(OWNER()) == amount_to_mint, 'Incorrect minted amount');

let receiver_previous_balance = erc20_token.balance_of(TOKEN_RECIPIENT());
erc20_token.transfer(TOKEN_RECIPIENT(), amount_to_transfer);

stop_cheat_caller_address(contract_address);

assert(erc20_token.balance_of(OWNER()) &lt; amount_to_mint, 'Sender balance not reduced');
assert(
    erc20_token.balance_of(OWNER()) == amount_to_mint - amount_to_transfer,
    'Wrong sender balance',
);

assert(
    erc20_token.balance_of(TOKEN_RECIPIENT()) > receiver_previous_balance,
    'Recipient balance unchanged',
);
assert(
    erc20_token.balance_of(TOKEN_RECIPIENT()) == amount_to_transfer, 'Wrong recipient amount',
);

}

##[测试]

[should_panic(expected: ('Insufficient amount',))]

fn test_transfer_insufficient_balance() { // Deploy the contract // 部署合约 let contract_address = deploy_contract("ERC20", OWNER()); let erc20_token = IERC20Dispatcher { contract_address };

let token_decimal = erc20_token.decimals();

// Define amounts: only 5,000 tokens minted, but attempting to transfer 10,000
// 定义数量:仅铸造 5,000 个代币,但尝试转移 10,000 个
let mint_amount: u256 = 5000 * token_decimal.into();
let transfer_amount: u256 = 10000 * token_decimal.into();

// Start impersonating the owner
// 开始模拟所有者
start_cheat_caller_address(contract_address, OWNER());

// Mint only 5,000 tokens to the owner
// 仅向所有者铸造 5,000 个代币
erc20_token.mint(OWNER(), mint_amount);

// Verify the mint was successful
// 验证铸造是否成功
assert(erc20_token.balance_of(OWNER()) == mint_amount, 'Mint failed');

// Attempt to transfer more than balance (10,000 tokens when only 5,000 exist)
// 尝试转移超过余额的数量(当只有 5,000 个代币时转移 10,000 个代币)
// This should panic with 'Insufficient amount'
// 这应该会因为 “Insufficient amount” 而 panic
erc20_token.transfer(TOKEN_RECIPIENT(), transfer_amount);

// Stop impersonating the owner
// 停止模拟所有者
stop_cheat_caller_address(contract_address);

}

##[测试]

fn test_approve() { let contract_address = deploy_contract("ERC20", OWNER()); let erc20_token = IERC20Dispatcher { contract_address };

let token_decimal = erc20_token.decimals();
let mint_amount: u256 = 10000 * token_decimal.into();
let approval_amount: u256 = 5000 * token_decimal.into();

// Start impersonating the owner
// 开始模拟所有者
start_cheat_caller_address(contract_address, OWNER());

// Mint tokens to the owner first
// 首先向所有者铸造代币
erc20_token.mint(OWNER(), mint_amount);

// Verify mint succeeded
// 验证铸造成功
assert(erc20_token.balance_of(OWNER()) == mint_amount, 'Mint failed');

// Owner approves the recipient to spend tokens
// 所有者批准接收者花费代币
erc20_token.approve(TOKEN_RECIPIENT(), approval_amount);

// Stop impersonating the owner
// 停止模拟所有者
stop_cheat_caller_address(contract_address);

// Verify the allowance was set
// 验证津贴已设置
assert(erc20_token.allowance(OWNER(), TOKEN_RECIPIENT()) > 0, 'Incorrect allowance');
assert(erc20_token.allowance(OWNER(), TOKEN_RECIPIENT()) == approval_amount, 'Wrong allowance amount');

}

##[测试]

fn test_transfer_from() { let contract_address = deploy_contract("ERC20", OWNER()); let erc20_token = IERC20Dispatcher { contract_address };

let token_decimal = erc20_token.decimals();
let mint_amount: u256 = 10000 * token_decimal.into();
let transfer_amount: u256 = 5000 * token_decimal.into();

start_cheat_caller_address(contract_address, OWNER());

erc20_token.mint(OWNER(), mint_amount);

assert(erc20_token.balance_of(OWNER()) == mint_amount, 'Mint failed');

let spender: ContractAddress = 'SPENDER'.try_into().unwrap();
erc20_token.approve(spender, transfer_amount);

stop_cheat_caller_address(contract_address);

assert(erc20_token.allowance(OWNER(), spender) == transfer_amount, 'Approval failed');

let owner_balance_before = erc20_token.balance_of(OWNER());
let recipient_balance_before = erc20_token.balance_of(TOKEN_RECIPIENT());
let allowance_before = erc20_token.allowance(OWNER(), spender);

cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER(), TOKEN_RECIPIENT(), 5000 * token_decimal.into());

assert(
    erc20_token.balance_of(OWNER()) == owner_balance_before - transfer_amount,
    'Owner balance wrong',
);

assert(
    erc20_token.balance_of(TOKEN_RECIPIENT()) == recipient_balance_before + transfer_amount,
    'Recipient balance wrong',
);

assert(
    erc20_token.allowance(OWNER(), spender) == allowance_before - transfer_amount,
    'Allowance not reduced',
);

}

##[测试]

[should_panic(expected: ('amount exceeds allowance',))]

fn test_transfer_from_insufficient_allowance() { // Deploy the contract // 部署合约 let contract_address = deploy_contract("ERC20", OWNER()); let erc20_token = IERC20Dispatcher { contract_address };

let token_decimal = erc20_token.decimals();

// Define amounts: 10,000 tokens to mint, 5,000 to approve
// 定义数量:铸造 10,000 个代币,批准 5,000 个
let mint_amount: u256 = 10000 * token_decimal.into();
let approval_amount: u256 = 5000 * token_decimal.into();

// Start impersonating the owner
// 开始模拟所有者
start_cheat_caller_address(contract_address, OWNER());

// Mint tokens to the owner
// 向所有者铸造代币
erc20_token.mint(OWNER(), mint_amount);

let spender: ContractAddress = 'SPENDER'.try_into().unwrap();

// Owner approves SPENDER to spend 5,000 tokens
// 所有者批准 SPENDER 花费 5,000 个代币
erc20_token.approve(spender, approval_amount);

// Stop impersonating owner
// 停止模拟所有者
stop_cheat_caller_address(contract_address);

// Attempt to transfer more than approved (6,000 instead of 5,000)
// 尝试转移超过批准的数量(6,000 而不是 5,000)
// This should panic with 'amount exceeds allowance'
// 这应该会因为 “amount exceeds allowance” 而 panic
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER(), TOKEN_RECIPIENT(), 6000 * token_decimal.into());

}

##[测试]

[should_panic(expected: ('amount exceeds balance',))]

fn test_transfer_from_insufficient_balance() { // Deploy the contract // 部署合约 let contract_address = deploy_contract("ERC20", OWNER()); let erc20_token = IERC20Dispatcher { contract_address };

let token_decimal = erc20_token.decimals();

// Define amounts: only 1,000 tokens minted, but 2,000 approved and attempted
// 定义数量:仅铸造 1,000 个代币,但批准并尝试转移 2,000 个
let mint_amount: u256 = 1000 * token_decimal.into();
let approval_amount: u256 = 2000 * token_decimal.into();
let transfer_amount: u256 = 2000 * token_decimal.into();

// Start impersonating the owner
// 开始模拟所有者
start_cheat_caller_address(contract_address, OWNER());

// Mint only 1,000 tokens to the owner
// 仅向所有者铸造 1,000 个代币
erc20_token.mint(OWNER(), mint_amount);

let spender: ContractAddress = 'SPENDER'.try_into().unwrap();

// Owner approves SPENDER to spend 2,000 tokens (more than balance)
// 所有者批准 SPENDER 花费 2,000 个代币(超过余额)
erc20_token.approve(spender, approval_amount);

// Stop impersonating owner
// 停止模拟所有者
stop_cheat_caller_address(contract_address);

// Spender has sufficient allowance but owner doesn't have enough balance
// SPENDER 有足够的津贴,但所有者没有足够的余额
// This should panic with 'amount exceeds balance'
// 这应该会因为 “amount exceeds balance” 而 panic
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER(), TOKEN_RECIPIENT(), transfer_amount);

}



要运行所有测试,请在你的终端中使用命令 `scarb test`。 这将执行所有测试函数并显示结果。 你应该看到输出指示每个测试通过:

![A screenshot showing tests passing](https://img.learnblockchain.cn/2025/11/25/image3.png)

#### 练习:测试 `mint` 函数

编写一个 `mint` 函数的测试,以练习你所学的知识。 该测试应验证:

- 只有所有者可以铸造代币
- 接收者的余额会增加铸造的数量
- 总供应量会增加铸造的数量

完成后,运行 `scarb test test_mint` 以验证它是否有效。

## 结论

本教程介绍了在 Starknet 上构建和测试 ERC-20 代币合约。 从这里开始,可以使用诸如暂停、访问控制等功能来扩展合约。

或者,可以使用 OpenZeppelin 针对 Cairo 的预构建组件,而不是从头开始构建所有内容。 请参阅“组件 2\[LINK\]”章节,以了解如何将 OpenZeppelin 的 ERC20、Ownable 和 Pausable 组件集成到合约中。

**本文是 [Starknet 上的 Cairo 编程](https://rareskills.io/cairo-tutorial) 教程系列的一部分**

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

0 条评论

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