Starknet 上的 ERC-20 代币的工作原理与 Ethereum 上的一样。事实上,STRK(Starknet 的费用代币)本身就是一个 ERC-20 代币;在协议层面没有特殊的“原生”代币。
Starknet 上的 ETH 和 STRK 都作为标准的 ERC-20 合约存在,就像人们创建的任何其他代币一样。
在本教程中,你将学习如何在 Starknet 上构建和测试 ERC-20 代币合约。本教程假设你熟悉 ERC-20 标准,但会沿途解释每个实现步骤和 Cairo 语法。
创建 ERC-20 代币的首选方法是使用 OpenZeppelin 库。这将在即将推出的关于“组件(Components)”的教程中介绍。本教程的目的是将我们之前学到的所有内容结合起来。
项目设置
创建一个新的 scarb 项目并进入该目录:
scarb new erc20
cd erc20
合约接口
ERC-20 接口定义了每个同质化代币必须遵循的蓝图。它指定了检查代币余额、转移代币、管理支出权限和检索代币元数据所需的函数。
Starknet 上的所有 ERC-20 代币都在 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 有何不同
状态引用(State References):请注意,在上面的 Cairo IERC20 接口中,视图函数使用 self: @TContractState,而改变状态的函数使用 ref self: TContractState。@ 符号创建合约状态的只读快照,而 ref 允许修改状态。例如,检查 STRK 余额使用 @(仅视图),但转移 STRK 使用 ref(修改余额)。
<TContractState> 是一个泛型类型,使得同一个接口能够与任何 ERC-20 合约的存储布局一起工作。
类型(Types):Cairo 使用 u256 表示代币数量(类似于 Solidity 的 uint256),并使用 ContractAddress 代替 Ethereum 的 address 类型。代币接口的 name 和 symbol 函数返回 ByteArray 而不是字符串。
这些函数实现了与 Ethereum 的 ERC-20 标准相同的余额、转账、授权和元数据功能,唯一的区别在于语法。
构建 ERC-20 代币合约
我们将在 Cairo 中逐步构建 ERC-20 合约,从基本结构开始,逐步向其中添加功能,同时在过程中测试主要函数。
在 src/lib.cairo 文件中,创建一个空的合约模块和接口,我们将在此基础上进行构建:
#[starknet::interface]
pub trait IERC20<TContractState> {
// We'll add functions here as we implement them
}
#[starknet::contract]
pub mod ERC20 {}
存储设置
接下来,我们将定义存储变量,用于保存余额、授权、元数据和所有权数据。我们将从 Starknet 导入 ContractAddress 作为地址类型,并导入 Map 作为 Cairo 版本的存储映射。这些存储变量将跟踪:
balances:每个地址拥有的代币数量allowances:每个地址可以从另一个地址的余额中花费多少代币token_name、symbol、decimal是标准的 ERC-20 元数据total_supply:流通中的代币总数owner:合约所有者地址
#[starknet::interface]
pub trait IERC20<TContractState> {
// We'll add functions here as we implement them
}
#[starknet::contract]
pub mod ERC20 {
use starknet::ContractAddress;
use starknet::storage::Map;
#[storage]
pub struct Storage {
// Maps each account address to their token balance
balances: Map<ContractAddress, u256>,
// Maps (owner, spender) pairs to approved spending amounts
allowances: Map<(ContractAddress, ContractAddress), u256>,
// Token metadata
token_name: ByteArray,
symbol: ByteArray,
decimal: u8,
// Total number of tokens that exist
total_supply: u256,
// Address that can mint new tokens
owner: ContractAddress,
}
}
以下是映射(mappings)与 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
我们将创建一个名为“Rare Token”的代币,符号为“RST”。由于名称、符号和小数位数通常不会改变,我们将在构造函数中设置它们。我们还导入了 StoragePointerWriteAccess 以启用对存储的写入访问:
#[starknet::interface]
pub trait IERC20<TContractState> {
// We'll add functions here as we implement them
}
#[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) {
// Set the token's metadata
self.token_name.write("Rare Token");
self.symbol.write("RST");
self.decimal.write(18);
// Set owner
self.owner.write(owner); // Usually the deployer's address
}
}
构造函数将代币初始化为名称“Rare Token”、符号“RST”、18 位小数(大多数代币的标准)以及所有者地址。ref self: ContractState 参数允许我们修改合约的存储。
你可能会想知道,为什么我们将所有者地址作为参数传递,而不是使用
get_caller_address()自动将部署者设置为所有者。
这项设计选择是故意的,与 Starknet 上合约部署的工作原理有关。当部署一个在其构造函数中使用get_caller_address()的合约时,是 Universal Deployer Contract (UDC) 部署了该合约,而不是你的账户直接部署。因此,get_caller_address()返回的是 UDC 的地址,而不是你账户的地址。UDC 将在本系列后续的“在 Starknet 上部署合约(Deploying Contracts on Starknet)”章节中详细解释。
事件声明
在存储部分之后添加以下事件,以跟踪转账和授权:
// Define the events that this contract can emit
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
Transfer: Transfer, // Emitted when tokens are transferred
Approval: Approval, // Emitted when spending approval is granted
}
// Event emitted whenever tokens are transferred between addresses
#[derive(Drop, starknet::Event)]
pub struct Transfer {
#[key] // Indexed field (can be filtered when querying events)
from: ContractAddress, // Address sending the tokens
#[key] // Indexed field (can be filtered when querying events)
to: ContractAddress, // Address receiving the tokens
amount: u256, // Number of tokens transferred
}
// Event emitted when an owner approves a spender to use their tokens
#[derive(Drop, starknet::Event)]
pub struct Approval {
#[key] // Indexed field (can be filtered when querying events)
owner: ContractAddress, // Address that owns the tokens
#[key] // Indexed field (can be filtered when querying events)
spender: ContractAddress, // Address approved to spend the tokens
value: u256, // Amount approved for spending
}
Event 枚举包含了合约可以触发的所有事件:Transfer 和 Approval。
Transfer事件通过from和to地址以及amount跟踪代币的转移。Approval事件通过授予权限的owner、接收权限的spender以及批准的value来跟踪支出权限。
这些参数已被索引,因此我们可以轻松查询特定地址的转账或特定所有者的授权。
合约实现
现在让我们实现合约函数。由于外部合约和用户需要与我们的 ERC20 代币进行交互,我们需要使我们的实现能够从合约外部调用。我们通过添加 #[abi(embed_v0)] 属性来实现这一点,该属性将实现嵌入到合约的 ABI 中:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
// Implementation functions go here
}
ERC20Impl 实现了我们之前定义的 IERC20 接口,其中 ContractState 代表合约的存储。
元数据函数:name、symbol 和 decimals
元数据函数返回基本的代币细节,例如名称、符号和小数精度。让我们从实现这些函数开始。
将它们的函数签名添加到接口中:
#[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> {
// Returns the full name of the token
fn name(self: @ContractState) -> ByteArray {
self.token_name.read()
}
// Returns the token's symbol/ticker
fn symbol(self: @ContractState) -> ByteArray {
self.symbol.read()
}
// Returns the number of decimal places for the token
fn decimals(self: @ContractState) -> u8 {
self.decimal.read()
}
// other functions goes here
}
每个函数都读取在合约初始化期间于构造函数中设置的存储值。更新合约模块内的导入,包含 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> {
// Returns the full name of the token
fn name(self: @ContractState) -> ByteArray {
self.token_name.read()
}
// Returns the token's symbol/ticker
fn symbol(self: @ContractState) -> ByteArray {
self.symbol.read()
}
// Returns the number of decimal places for the 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(); // Declare the contract class
let constructor_args = array![owner.into()]; // Pass owner to constructor
let (contract_address, _) = contract.deploy(@constructor_args).unwrap(); // Deploy the contract and return its address
contract_address
}
IERC20Dispatcher 和 IERC20DispatcherTrait dispatcher 允许我们在测试中调用合约函数。
deploy_contract 函数声明合约类,通过 constructor_args 将所有者地址传递给构造函数,并返回已部署合约的地址供我们交互。
由于每个测试都需要部署合约,我们定义一个 OWNER 常量来提供一个一致的测试所有者地址,而不是每次都创建一个新的地址:
const 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
}
const 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 实例,以与合约进行交互。然后调用每个元数据函数并断言代币名称、符号和小数位数与构造函数中设置的值相匹配。如果任何值不匹配,测试将失败并显示相应的错误消息。
运行 scarb test test_token_constructor 以确认测试通过。你也可以使用不正确的值进行测试,以查看预期的错误。
实现 total_supply
为了跟踪存在的代币数量,我们将在接口中包含一个总供应量函数,并实现它以读取和返回已创建的代币总数。
将函数签名添加到接口中:
#[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 {
// Read the total_supply value from contract storage
self.total_supply.read()
}
}
为了测试 total_supply 函数,我们需要首先铸造(mint)代币,然后确认总供应量反映了铸造的数量。因此,我们需要实现用于铸造代币的函数。
实现 mint
如果没有 mint,就不会有代币存在,因为所有余额初始都为零。
mint 函数不在 ERC-20 规范 中,但需要用它来创建代币并增加总供应量。
将其添加到接口中:
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 {
// Get the address of whoever is calling this function
let caller = get_caller_address();
// Only the contract owner is allowed to mint new tokens
assert(caller == self.owner.read(), 'Call not owner');
// Read current values before updating
let previous_total_supply = self.total_supply.read();
let previous_balance = self.balances.entry(recipient).read();
// Increase total supply by the minted amount
self.total_supply.write(previous_total_supply + amount);
// Add the minted tokens to recipient's balance
self.balances.entry(recipient).write(previous_balance + amount);
// Emit transfer from zero address
let zero_address: ContractAddress = 0.try_into().unwrap();
self.emit(Transfer { from: zero_address, to: recipient, amount });
true // Return success
}
}
mint 接受接收者地址和数量作为参数。只有合约所有者可以调用此函数,这就是为什么要检查 caller == owner。
当铸造代币时,总供应量和接收者的余额都会增加指定的数量。
回想一下 Solidity,新铸造的代币总是表现为从零地址的转账,因为它们是凭空创建的。我们在这里遵循相同的模式,发出一个从零地址到接收者的 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
因为只有合约所有者可以铸造代币,而测试默认不会以所有者的身份运行,所以需要模拟所有者地址。
我们将使用 cheat_caller_address 临时更改合约认为的调用者身份,绕过合约中的访问控制检查。设置 CheatSpan::TargetCalls(1) 将此作弊仅应用于下一个函数调用(mint())。
从 snforge_std 导入 cheat_caller_address 和 CheatSpan,并添加一个辅助函数来生成一个测试接收者地址,用于接收铸造的代币,最后我们得到:
use starknet::ContractAddress;
use snforge_std::{
declare, ContractClassTrait, DeclareResultTrait,
cheat_caller_address, CheatSpan
};
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
}
const OWNER: ContractAddress = 'OWNER'.try_into().unwrap();
// NEWLY ADDED
// Test recipient address constant
const 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 个代币乘以小数位数(18)来计算铸造量。在调用 mint 之前,cheat_caller_address 将调用者设置为所有者地址,允许该铸造操作绕过 assert(caller == owner) 检查。在向接收者地址铸造代币后,测试检索总供应量并验证它等于铸造的数量。
将测试添加到 test_contract.cairo 文件中,然后运行 scarb test test_total_supply,查看它是否通过。
实现代币转移
transfer 函数处理将代币从调用者转移到接收者的操作。首先,将函数签名添加到接口中:
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 {
// Get the address of whoever called this function
let sender = get_caller_address();
// Read current balances for both sender and recipient
let sender_prev_balance = self.balances.entry(sender).read();
let recipient_prev_balance = self.balances.entry(recipient).read();
// Check if sender has enough tokens to transfer
assert(sender_prev_balance >= amount, 'Insufficient amount');
// Update balances: subtract from sender, add to recipient
self.balances.entry(sender).write(sender_prev_balance - amount);
self.balances.entry(recipient).write(recipient_prev_balance + amount);
// Verify the transfer worked correctly
assert(
self.balances.entry(recipient).read() > recipient_prev_balance,
'Transaction failed',
);
// Emit an event to log this Transfer
self.emit(Transfer { from: sender, to: recipient, amount });
true // Return success
}
}
}
假设 Alice 有 100 个 RareTokens,想发送 30 个给拥有 50 个的 Bob。该函数会检查 Alice 是否有足够的余额(100 >= 30),将 Alice 的余额更新为 70,将 Bob 的余额更新为 80。然后确认 Bob 的余额已增加,并发出一个包含 from: Alice、to: Bob 以及 amount: 30 的 Transfer 事件以记录此交易,最后返回 true 以表示成功完成。
为了测试转移函数,合约需要一种方法来在特定点检查每个账户的余额。
实现 balance_of
让我们添加 balance_of 来查询代币余额。将函数签名添加到接口中:
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;
}
然后在合约中实现它:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
//.....previous functions.....//
fn balance_of(self: @ContractState, account: ContractAddress) -> u256 {
// Use .entry() to access the specific account's balance in the Map
let balance = self.balances.entry(account).read();
balance
}
}
要检查一个账户的 RareToken 余额,balance_of(account_address) 会在 balances 映射中查找该地址并返回相应的值。
测试 transfer
为了测试 transfer 函数,我们首先需要账户中有代币,然后验证转移操作是否正确地将代币从发送者转移到接收者。我们将向所有者铸造代币,然后转移一部分给接收者并检查双方的余额。
由于铸造和转移都需要所有者的权限,我们将使用 start_cheat_caller_address 来在多个连续调用中模拟所有者身份,直到使用 stop_cheat_caller_address 明确停止。
从 snforge_std 中与其它内容一起导入 start_cheat_caller_address 和 stop_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() {
// Deploy the contract
let contract_address = deploy_contract("ERC20", OWNER);
let erc20_token = IERC20Dispatcher { contract_address };
// Get token decimals for proper amount calculation
let token_decimal = erc20_token.decimals();
// Define amounts: 10,000 tokens to mint, 5,000 to transfer
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);
// Mint tokens to the owner
erc20_token.mint(OWNER, amount_to_mint);
// Verify the mint was successful
assert(erc20_token.balance_of(OWNER) == amount_to_mint, 'Incorrect minted amount');
// Track recipient's balance before transfer
let receiver_previous_balance = erc20_token.balance_of(TOKEN_RECIPIENT);
// Transfer tokens from owner to recipient
erc20_token.transfer(TOKEN_RECIPIENT, amount_to_transfer);
// Stop impersonating the owner
stop_cheat_caller_address(contract_address);
// Verify sender's balance decreased correctly
assert(erc20_token.balance_of(OWNER) < amount_to_mint, 'Sender balance not reduced');
assert(erc20_token.balance_of(OWNER) == amount_to_mint - amount_to_transfer, 'Wrong sender balance');
// Verify recipient's balance increased correctly
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() 中部署合约后,测试计算了金额:10,000 个代币用于铸造,5,000 个代币用于转移。它开始使用 start_cheat_caller_address 模拟所有者身份,这允许向所有者的账户铸造代币。铸造成功后,测试在进行转移前记录了接收者的余额。
然后测试将 5,000 个代币转移给接收者并停止身份模拟。最后的断言验证交易的双方:所有者的余额精确减少了 5,000 个代币,而接收者的余额增加了相同的数量。这证实了 transfer 在账户之间正确地转移了代币。
将测试添加到 test_contract.cairo 文件中,然后运行 scarb test test_transfer 以验证其是否通过。
测试转移余额不足
让我们测试 transfer 是否正确拒绝了试图转移超过发送者拥有代币数量的操作。
修改测试中的 transfer 调用,尝试转移 11,000 个代币而不是 5,000 个:
erc20_token.transfer(TOKEN_RECIPIENT, 11000 * token_decimal.into());
当我们运行 scarb test test_transfer 时,测试应失败并显示此错误:

这证实合约工作正常,它阻止了转移超过发送者拥有的代币数量,触发了 transfer 函数中的 assert(sender_prev_balance >= amount, 'Insufficient amount') 检查。
为了保持测试通过,请将金额改回
amount_to_transfer(5,000 个代币或任何小于等于所有者 10,000 余额的金额)
替代方案:为失败用例创建专门的测试
与其修改现有的测试,不如使用 #[should_panic] 将此测试添加到 test_contract.cairo 中:
#[test]
#[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
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
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)
// This should panic with 'Insufficient amount'
erc20_token.transfer(TOKEN_RECIPIENT, transfer_amount);
// Stop impersonating the owner
stop_cheat_caller_address(contract_address);
}
这个测试验证了当试图发送超过发送者拥有的代币数量时,转移操作会失败。所有者只有 5,000 个代币,但试图转移 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<TContractState> {
// ... previous functions ...
//NEWLY ADDED
fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256;
}
然后在合约中实现它:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
//....previous functions....//
fn allowance(
self: @ContractState, owner: ContractAddress, spender: ContractAddress,
) -> u256 {
// Access the allowances Map using a tuple key (owner, spender)
self.allowances.entry((owner, spender)).read()
}
例如,要查看 Bob 可以从 Alice 的账户中花费多少个 RareTokens,你需要调用 allowance(Alice, Bob)。
实现 approve
approve 函数通过设置某人(spender,支出者)可以从某个账户(owner,所有者)的余额中提取的金额来授予支出权限。
将函数签名添加到接口中:
use starknet::ContractAddress;
#[starknet::interface]
pub trait IERC20<TContractState> {
// ... previous functions ...
//NEWLY ADDED
fn approve(ref self: TContractState, spender: ContractAddress, amount: u256) -> bool;
}
然后在合约中实现它:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
//.....previous functions.....//
fn approve(ref self: ContractState, spender: ContractAddress, amount: u256) -> bool {
// Get the address of whoever is giving the approval (owner)
let caller = get_caller_address();
// Set the allowance: how much the spender can spend on behalf of the caller (owner)
self.allowances.entry((caller, spender)).write(amount);
// Emit an event to log this Approval
self.emit(Approval { owner: caller, spender, value: amount });
true // Return success
}
}
在 self.allowances.entry((caller, spender)).write(amount) 这一行中,spender 指的是被 caller 授予额度的地址。caller(通过 get_caller_address() 获取)正在授予权限给 spender,允许其从自己的账户中花费一定数量的代币。
因此,caller 是代币的所有者,而 spender 是已被所有者批准代表他们花费一定数量代币的人。这创建了条目 allowances[(owner, spender)] = amount,稍后 transfer_from 将检查并使用它。
测试 approve
让我们测试 approve 函数是否正确设置了授权额度并且可以将其查询回来:
#[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 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');
}
测试部署了合约并定义了两个金额:用于铸造的 10,000 个代币和用于授权的 5,000 个代币。使用 start_cheat_caller_address,测试在多个连续的调用中模拟所有者。
首先,测试向所有者铸造 10,000 个代币并验证铸造成功。然后,在仍然模拟所有者的情况下,它调用 approve 授予接收者花费所有者余额中 5,000 个代币的权限。停止模拟身份后,测试验证了两件事:第一,授权额度存在(大于 0);第二,授权额度与已批准的金额完全一致(5,000 个代币)。这些断言确认了 approve 已正确地将支出权限存储在 allowances 映射中。
将测试添加到你的 test_contract.cairo 文件中,然后运行 scarb test test_approve 以验证其是否通过。
实现委托转移:transfer_from
现在,让我们实现 transfer_from,它使用预先批准的支出权限将代币从一个地址转移到另一个地址。
更新接口以包含函数签名:
#[starknet::interface]
pub trait IERC20<TContractState> {
// ... previous functions ...
fn transfer_from(
ref self: TContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256,
) -> bool;
}
transfer_from 的实现:
#[abi(embed_v0)]
impl ERC20Impl of super::IERC20<ContractState> {
//.....previous functions.....//
fn transfer_from(ref self: ContractState, sender: ContractAddress, recipient: ContractAddress, amount: u256) -> bool {
// Get the address of whoever is calling this function (the spender)
let spender = get_caller_address();
// Read current allowance: how much the spender is allowed to spend from sender's account
let spender_allowance = self.allowances.entry((sender, spender)).read();
// Read current balances for both sender and recipient
let sender_balance = self.balances.entry(sender).read();
let recipient_balance = self.balances.entry(recipient).read();
// Check if the transfer amount doesn't exceed the approved allowance
assert(amount <= spender_allowance, 'amount exceeds allowance');
// Check if sender has enough tokens to transfer
assert(amount <= sender_balance, 'amount exceeds balance');
// Update allowance: reduce by the amount being spent
self.allowances.entry((sender, spender)).write(spender_allowance - amount);
// Update balances: subtract from sender, add to recipient
self.balances.entry(sender).write(sender_balance - amount);
self.balances.entry(recipient).write(recipient_balance + amount);
// Emit an event to log this Transfer
self.emit(Transfer { from: sender, to: recipient, amount });
true // Return success
}
}
在上述代码中,spender(通过 get_caller_address() 获取)执行转移,sender 是代币所有者,而 recipient 接收代币。该函数通过读取 allowances[(sender, spender)] 检查 spender 是否有足够的授权额度,然后按转移金额扣减该授权额度。
如果不减去他们的支出,spender 将拥有无限的支出能力。
考虑以下示例,它展示了 approve 和 transfer_from 是如何协同工作的:
Alice 调用 approve(Bob, 50) 让 Bob 花费她 50 个 RareTokens。然后 Bob 可以使用 transfer_from(Alice, Charlie, 30) 将 30 个代币从 Alice 的账户转移给 Charlie,为 Bob 留下 20 个的剩余额度。
这种“先授权后提取(approve-then-withdraw)”的模式是 DeFi 协议、DEX(去中心化交易所)及其他智能合约与用户代币交互的方式。
测试 transfer_from
transfer_from 测试需要涉及三方:拥有代币的 owner(所有者),具有授权的 spender(支出者),以及一个 recipient(接收者)地址。
由于支出者利用其授权将代币从所有者账户转移到接收者账户,因此所有者和支出者都需要在测试的不同阶段被模拟:
#[test]
fn test_transfer_from() {
// 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 and transfer
let mint_amount: u256 = 10000 * token_decimal.into();
let transfer_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);
// Verify mint succeeded
assert(erc20_token.balance_of(OWNER) == mint_amount, 'Mint failed');
let spender:ContractAddress = 'SPENDER'.try_into().unwrap();
// Owner approves SPENDER to spend tokens on their behalf
erc20_token.approve(spender, transfer_amount);
// Stop impersonating owner
stop_cheat_caller_address(contract_address);
// Verify the allowance was set correctly
assert(erc20_token.allowance(OWNER, spender) == transfer_amount, 'Approval failed');
// Track balances before 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);
// Now impersonate the SPENDER to call transfer_from
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, transfer_amount);
// Verify owner's balance decreased
assert(erc20_token.balance_of(OWNER) == owner_balance_before - transfer_amount, 'Owner balance wrong');
// Verify recipient's balance increased
assert(erc20_token.balance_of(TOKEN_RECIPIENT) == recipient_balance_before + transfer_amount, 'Recipient balance wrong');
// Verify allowance decreased
assert(erc20_token.allowance(OWNER, spender) == allowance_before - transfer_amount, 'Allowance not reduced');
}
test_transfer_from 验证了完整的“授权与支出”模式。测试首先模拟所有者铸造 10,000 个代币,并授权一个支出者使用其中的 5,000 个。在停止模拟所有者之后,它验证了授权已被正确设置。
接下来,测试捕获当前状态:所有者余额、接收者余额以及支出者的授权额度。然后它模拟支出者身份,调用 transfer_from 将 5,000 个代币从所有者转移给接收者。
// Now impersonate the SPENDER to call transfer_from
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, transfer_amount);
最后的断言验证了三项更新:所有者余额减少了 5,000,接收者余额增加了 5,000,以及支出者的授权额度减少了 5,000。这些检查确认 transfer_from 正确处理了委托转移,并正确更新了授权额度。
将测试添加到 test_contract.cairo 文件中,然后运行 scarb test test_transfer_from 验证其通过。
测试授权额度不足
让我们测试 transfer_from 是否正确拒绝了试图花费超过已批准金额的操作。如果支出者尝试转移超过他们被批准额度的代币,交易应当失败。
在我们的测试中修改 transfer_from 调用,尝试转移 6,000 个代币,而不是已批准的 5,000 个:
// Attempt to transfer more than approved (6,000 instead of 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 时,测试应当失败并显示此错误:

这一错误证实了合约捕获了未经授权的支出尝试。支出者只被批准了 5,000 个代币的额度,因此尝试转移 6,000 个代币触发了 transfer_from 函数中的 assert(amount <= spender_allowance, 'amount exceeds allowance') 检查。
将金额改回 transfer_amount(5,000 个代币)以保持测试通过。
替代方案:为失败用例创建专门的测试
与其修改现有的测试,我们可以使用 #[should_panic] 属性创建一个预期会失败的独立测试:
#[test]
#[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
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
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)
// This should panic with 'amount exceeds allowance'
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 符合预期。
测试余额不足
我们还可以测试支出者具有足够的授权额度,但所有者没有足够代币的情况。将此测试添加到 test_contract.cairo:
#[test]
#[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
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
erc20_token.mint(OWNER, mint_amount);
let spender: ContractAddress = 'SPENDER'.try_into().unwrap();
// Owner approves SPENDER to spend 2,000 tokens (more than balance)
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
// This should panic with 'amount exceeds balance'
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, transfer_amount);
}
上面的 test_transfer_from_insufficient_balance 测试验证了即使具有足够的授权额度,如果代币所有者没有足够的余额,转移也会失败。支出者被批准了 2,000 个代币,但所有者只有 1,000 个,触发了 assert(amount <= sender_balance, 'amount exceeds balance') 检查。
运行 scarb test test_transfer_from_insufficient_balance 验证其通过。
下面是完整的 ERC-20 合约:
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;
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; // For testing purposes
}
#[starknet::contract]
pub mod ERC20 {
use starknet::{ContractAddress, get_caller_address};
use starknet::storage::{
Map, StoragePointerWriteAccess, StoragePointerReadAccess, StoragePathEntry,
};
#[storage]
pub struct Storage {
balances: Map<ContractAddress, u256>,
allowances: Map<
(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<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 <= spender_allowance, 'amount exceeds allowance');
assert(amount <= 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
}
const OWNER: ContractAddress = 'OWNER'.try_into().unwrap();
const TOKEN_RECIPIENT: ContractAddress = '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');
}
#[test]
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');
}
#[test]
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) < 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]
#[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
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
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)
// This should panic with 'Insufficient amount'
erc20_token.transfer(TOKEN_RECIPIENT, transfer_amount);
// Stop impersonating the owner
stop_cheat_caller_address(contract_address);
}
#[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 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');
}
#[test]
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',
);
}
#[test]
#[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
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
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)
// This should panic with 'amount exceeds allowance'
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, 6000 * token_decimal.into());
}
#[test]
#[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
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
erc20_token.mint(OWNER, mint_amount);
let spender: ContractAddress = 'SPENDER'.try_into().unwrap();
// Owner approves SPENDER to spend 2,000 tokens (more than balance)
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
// This should panic with 'amount exceeds balance'
cheat_caller_address(contract_address, spender, CheatSpan::TargetCalls(1));
erc20_token.transfer_from(OWNER, TOKEN_RECIPIENT, transfer_amount);
}
要运行所有测试,请在终端中使用命令 scarb test。这将执行所有的测试函数并显示结果。您应当看到指示每个测试已通过的输出:

练习:测试 mint 函数
编写一个针对 mint 函数的测试,以练习你所学的知识。该测试应当验证:
- 只有所有者才能铸造代币
- 接收者的余额增加了铸造的数量
- 总供应量增加了铸造的数量
完成后,运行 scarb test test_mint 验证其正常工作。
结论
本教程涵盖了在 Starknet 上构建和测试 ERC-20 代币合约的内容。从这里开始,该合约可以通过增加如暂停、访问控制等特性来进行扩展。
另外,也可以使用 OpenZeppelin 为 Cairo 预建的组件,而不是从头开始构建一切。请参阅“组件 2(Component 2)”章节,了解如何将 OpenZeppelin 的 ERC20、Ownable 和 Pausable 组件集成到合约中。
本文是 Cairo Programming on Starknet 教程系列的一部分