工厂合约是一种用于部署一个或多个合约实例的合约。
在“Understanding Starknet’s Contract Deployment Model”一章中,我们了解到在 Starknet 的 declare-deploy(声明-部署)模型中,必须首先声明一次 contract class,然后才能手动从中部署多个实例。然而,当手动部署合约实例时,每次都必须查找正确的 class hash 并传递正确的构造函数参数(如果有的话)。
工厂合约通过提供一致的部署接口解决了这个问题。你无需手动处理 class hash,只需调用工厂,它就会为你预期的合约部署一个新的合约实例:

在 Ethereum(以及其他 EVM 链)上,工厂合约在底层使用 CREATE 或 CREATE2 操作码来部署新的子合约。在 Starknet 上,工厂通过 deploy_syscall 实现相同的行为。
在本文中,你将学习如何使用 deploy_syscall 实现工厂合约。
工厂如何使用 deploy_syscall
工厂合约直接调用 deploy_syscall 来部署合约实例。以下是 deploy_syscall 函数的签名:
pub extern fn deploy_syscall(
class_hash: ClassHash,
contract_address_salt: felt252,
calldata: Span<felt252>,
deploy_from_zero: bool
) -> Result<(ContractAddress, Span<felt252>), Array<felt252>>
implicits(GasBuiltin, System) nopanic;
它接收四个参数:
class_hash:你想部署的合约的 class hashcontract_address_salt:用于地址计算的盐值(salt)calldata:新合约的构造函数参数deploy_from_zero:决定是否在合约地址计算中排除部署者的地址。当为false时,包含部署者的地址。当为true时,则将其排除,计算地址时就如同从地址0部署一样。注意,这与 UDC 接口中的not_from_zero参数正好相反。
deploy_syscall 返回一个 Result 类型,包含两种可能的结果:
- 成功时,你会得到新部署合约的
ContractAddress以及序列化后的构造函数返回数据的Span<felt252>。由于 Cairo 的构造函数通常不返回值,这个 span 通常是空的,但如果构造函数显式返回了值,它也是可用的。 - 失败时,你会得到一个
Array<felt252>,其中包含描述部署过程中出现何种错误的错误信息
注意:
implicits(GasBuiltin, System)是用于 Gas 跟踪和系统操作的隐式参数,由 Cairo 自动处理。nopanic表示该函数返回一个Result类型,而不是在出现错误时引发 panic(恐慌)。
在工厂合约中,你可以调用诸如 createContract() 的工厂函数,该函数会在内部使用所需的参数调用 deploy_syscall,即:class_hash、salt、calldata 和 deploy_from_zero,如下图所示:

在 deploy_syscall 执行期间,网络会使用在 Understanding Starknet’s Contract Deployment Model 一章中介绍的基于 Pedersen 的合约地址公式来计算合约地址。deploy_from_zero 参数决定了该公式中 deployer_address 的值:为 false 时,它被设置为工厂合约的地址;为 true 时,它被设置为 0。
随后,网络会在计算出的地址上部署该合约实例,并使用提供的 calldata 执行构造函数。
部署完成后,deploy_syscall 会将新的 ContractAddress 连同任何构造函数返回数据一起返回给工厂。如果工厂实现中包含了事件(event),它此时会触发相关部署数据的事件,然后再将 ContractAddress 返回给原始调用者。
Universal Deployer Contract 与自定义工厂合约
我们用于常规合约部署的 Universal Deployer Contract (UDC) 本身就是一个工厂合约。它封装了底层的 deploy_syscall,并暴露了一个简单的接口来部署任何已声明的合约。然而,UDC 并不跟踪已部署的合约,严格来说只用于通用的部署目的。
当你的需求超出基础部署功能时,就需要构建一个自定义工厂合约。例如,你可能想跟踪哪些合约是由谁部署的(注册表),限制谁可以部署或更新 contract classes(访问控制)。在下一节中,我们将实现一个代币工厂来展示这些理念是如何运作的。
像任何常规合约一样,工厂合约也是通过 UDC 部署的。但当工厂部署实例时,它是直接调用
deploy_syscall,而不是再次通过 UDC。
创建代币工厂合约
我们将构建一个 ERC-20 工厂合约,它抽象了 class hash 和部署参数,让你能通过一个简单的接口来部署代币实例。
更新代币合约
我们需要修改在“ERC-20 Token on Starknet”一章中编写的 ERC-20 合约。原始版本将代币的 name、symbol 和 decimals 设为硬编码值。由于工厂需要部署具有不同名称和符号的代币,因此这些值必须是可配置的:
#[constructor]
fn constructor(
ref self: ContractState, token_name: ByteArray, symbol: ByteArray, owner: ContractAddress,
) {
self.token_name.write(token_name); //newly added
self.symbol.write(symbol); // newly added
self.decimal.write(18);
self.owner.write(owner);
}
现在的 token_name 和 symbol 变成了参数而不是硬编码值,owner 作为参数传入以决定谁可以铸造代币,而 decimals 保持固定为 18(ERC-20 标准)。
在你的 Scarb 项目中创建 src/erc20.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;
fn mint(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool;
}
#[starknet::contract]
pub mod ERC20Token {
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,
>, // (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, token_name: ByteArray, symbol: ByteArray, owner: ContractAddress,
) {
self.token_name.write(token_name);
self.symbol.write(symbol);
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(), 'Caller is 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
}
}
}
定义工厂接口(IERC20Factory)
在编写任何实现代码之前,我们先来定义我们的工厂要做什么。我们的工厂合约将拥有四个主要功能:
- 部署代币:使用名称、符号和 owner 地址创建一个新的代币实例
- 在特定地址部署代币:使用用户指定的 salt 创建新代币,允许调用者决定生成的合约地址
- 查询已部署的代币:检索通过该工厂部署的所有代币,可以全局检索或按用户过滤
- 更新代币 contract class:更改用于所有未来代币部署的 ERC-20 contract class
use starknet::{ContractAddress, ClassHash};
#[starknet::interface]
pub trait IERC20Factory<TContractState> {
// Deploy a new ERC20 token contract with a user-specified salt
fn create_token_at(
ref self: TContractState,
salt: felt252,
name: ByteArray,
symbol: ByteArray,
owner: ContractAddress,
) -> ContractAddress;
// Deploy a new ERC20 token contract using a default salt
fn create_token(
ref self: TContractState,
name: ByteArray,
symbol: ByteArray,
owner: ContractAddress,
) -> ContractAddress;
// Update the stored class hash used for new ERC20 deployments
fn update_erc20_class_hash(ref self: TContractState, new_class_hash: ClassHash);
// Returns an array of all token contract addresses created by this factory
fn get_all_created_tokens(self: @TContractState) -> Array<ContractAddress>;
// Returns all token contract addresses created by a specific user
fn get_user_tokens(self: @TContractState, user: ContractAddress) -> Array<ContractAddress>;
}
存储变量(Storage Variables)
从上面的接口可以看出,我们可以确定工厂合约需要存储哪些内容。我们需要存储用于部署的 token class hash,记录我们创建的每一个代币,并按创建者对它们进行组织分类,同时还要限制谁可以更新用于未来部署的 token class hash。这就引出了我们对这些状态变量的定义:
#[storage]
struct Storage {
token_class_hash: ClassHash, // class hash of the token contract to deploy
created_tokens: Vec<ContractAddress>, // global list of all deployed token instances
user_tokens: Map<ContractAddress, Vec<ContractAddress>>, // tokens deployed per user
factory_owner: ContractAddress, // address with admin rights over the factory
}
token_class_hash 存储工厂用于部署新代币的 ERC-20 contract class。通过该工厂创建的每个代币都将是此 class hash 的一个实例。工厂 owner 以后可以更新此值来部署改进后的代币版本。
我们为已创建的代币维护两个不同的列表:
created_tokens向量提供了对通过该工厂部署的每一个代币的完整记录- 另外,
user_tokens映射为每个用户创建了单独的列表,只存储该特定地址所创建的代币。
factory_owner 存储有权更新 token class hash 的地址。
工厂的构造函数(Constructor)
工厂构造函数存储了要部署的 ERC-20 class hash 以及拥有工厂管理权限的 owner 地址:
#[constructor]
fn constructor(ref self: ContractState, token_class_hash: ClassHash, owner: ContractAddress) {
self.token_class_hash.write(token_class_hash);
self.factory_owner.write(owner);
}
事件定义(Event Definitions)
我们需要一种方式让外部应用程序来跟踪部署情况和 class hash 的变更。我们将定义两个事件来广播我们的关键活动:
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
TokenContractCreated: TokenContractCreated, // Emitted when a new token is created
ClassHashUpdated: ClassHashUpdated, // Emitted when the ERC20 class hash is updated
}
#[derive(Drop, starknet::Event)]
struct TokenContractCreated {
#[key]
creator: ContractAddress, // User who created the token
#[key]
token_address: ContractAddress, // Address of the new token contract
name: ByteArray,
symbol: ByteArray,
}
#[derive(Drop, starknet::Event)]
struct ClassHashUpdated {
old_class_hash: ClassHash,
new_class_hash: ClassHash,
}
当部署一个代币时,TokenContractCreated 会记录创建者、代币地址、名称和符号,并带有用于高效搜索的索引字段(#[key])。交代币模板(class hash)发生改变时,ClassHashUpdated 会记录从旧 class hash 到新 class hash 的过渡。
工厂合约实现
现在让我们来实现工厂接口中定义的函数。工厂提供了两种创建代币的方法,取决于用户是否需要指定他们自己的 salt 值:
1. 使用自定义 salt 创建代币
create_token_at 函数用于部署代币,带有调用者指定的 salt,该 salt 决定了生成的合约地址:
fn create_token_at(
ref self: ContractState,
salt: felt252,
name: ByteArray,
symbol: ByteArray,
owner: ContractAddress,
) -> ContractAddress {
let caller = get_caller_address();
let erc20_class_hash = self.token_class_hash.read();
// prepare constructor arguments
let mut constructor_calldata = array![];
name.serialize(ref constructor_calldata);
symbol.serialize(ref constructor_calldata);
owner.serialize(ref constructor_calldata);
// deploy the token contract
let (token_address, _) = deploy_syscall(
erc20_class_hash, salt, constructor_calldata.span(), false // deploy_from_zero
)
.unwrap_syscall();
// add to the global token list
self.created_tokens.push(token_address);
// append to user's token list
let user_tokens_entry = self.user_tokens.entry(caller);
user_tokens_entry.push(token_address);
// emit event
self
.emit(
TokenContractCreated {
creator: caller, token_address, name: name.clone(), symbol: symbol.clone(),
},
);
token_address
}
该函数首先获取调用者的地址并从存储中读取 ERC-20 class hash,然后将构造函数参数序列化为数组。Cairo 要求这些参数是一个可以被 CairoVM 理解的 felt252 值数组:
let mut constructor_calldata = array![];
name.serialize(ref constructor_calldata);
symbol.serialize(ref constructor_calldata);
owner.serialize(ref constructor_calldata);
因此该函数接受代币的 name、symbol 和 owner 地址,并按照与代币合约构造函数完全匹配的顺序进行序列化。如果这个顺序弄错了,部署将会失败,因为构造函数会收到不匹配的参数类型。
一旦数据被正确序列化,函数就会带着 class hash、salt 以及序列化后的构造函数参数调用 deploy_syscall。
// deploy the token contract
let (token_address, _) = deploy_syscall(
erc20_class_hash, salt, constructor_calldata.span(), false // deploy_from_zero
)
.unwrap_syscall();
正如在 deploy_syscall 参数部分提到过的那样,将 deploy_from_zero 传为 false 会告诉 Starknet 从工厂地址而不是零地址进行部署,这会影响最终合约地址的计算方式。
部署成功后,工厂在两个地方记录新代币:一个包含了所有创建代币的全局列表 created_tokens,以及每个用户的个人列表 user_tokens。它还会触发 TokenContractCreated 事件,然后将新的合约地址返回给调用该函数的人。
2. 使用默认 salt 创建代币
create_token 函数提供了部署新代币的最简单方法。它接受三个参数:代币 name、symbol 和 owner 地址,并将实际的部署工作委托给 create_token_at,同时使用 0 作为默认的 salt。
fn create_token(
ref self: ContractState,
name: ByteArray,
symbol: ByteArray,
owner: ContractAddress,
) -> ContractAddress {
// Use default salt of 0
self.create_token_at(0, name, symbol, owner)
}
使用 0 作为默认 salt 是一种常见的惯例,尽管在实现自己的工厂时使用任何固定的 salt 值也都可以。
为什么要使用默认的 salt?
- 用户不需要理解或管理 salt 值
- 只要参数不同,每次部署都会自动获得一个唯一的地址;否则将得到相同的地址(不过由于地址已被占用,第二次部署将会失败)
注册表与查询函数
get_all_created_tokens 函数返回该工厂部署过的每一个代币:
fn get_all_created_tokens(self: @ContractState) -> Array<ContractAddress> {
let mut tokens = array![];
for i in 0..self.created_tokens.len() {
tokens.append(self.created_tokens.at(i).read());
}
tokens
}
它遍历 created_tokens 存储向量,将每个地址追加到 tokens 数组中,并将其返回。
get_user_tokens 函数的工作原理类似,但专注于由特定用户创建的代币:
fn get_user_tokens(self: @ContractState, user: ContractAddress) -> Array<ContractAddress> {
let user_tokens_entry = self.user_tokens.entry(user);
let mut tokens = array![];
for i in 0..user_tokens_entry.len() {
tokens.append(user_tokens_entry.at(i).read());
}
tokens
}
它接受用户的地址作为输入,在 user_tokens 映射中查找该用户的条目,然后遍历其个人代币列表来构建要返回的数组。这对于用户只想查看自己创建的代币时的钱包界面或资产追踪器(portfolio trackers)来说特别有用。
这两个函数使用相同的模式:它们创建一个可变数组,遍历相关的存储结构,并追加找到的每个地址。主要区别在于,一个是从全局注册表中读取,而另一个是从特定用户的映射中读取。
Class hash 更新函数
update_erc20_class_hash 函数被工厂用来更新其用于新部署的代币 contract class:
fn update_erc20_class_hash(ref self: ContractState, new_class_hash: ClassHash) {
let caller = get_caller_address();
assert(caller == self.factory_owner.read(), 'Only owner can update');
let old_class_hash = self.token_class_hash.read();
self.token_class_hash.write(new_class_hash);
self.emit(ClassHashUpdated { old_class_hash, new_class_hash });
}
当被调用时,该函数会验证调用者是否为工厂 owner。如果不是,交易将失败并提示“Only owner can update”错误。
一旦通过权限检查,函数就会读取当前 class hash 并将其作为旧值存储,然后将新 class hash 写入存储。这意味着所有未来的代币部署都将使用更新后的合约实现,而现有的代币则保持其原始版本不变。然后该函数触发一个包含两个 class hash 值的 ClassHashUpdated 事件。
需要注意的是,只有当新的 contract class hash 遵循与原始合约相同的构造函数签名和序列化顺序时,class hash 更新才会起作用。 目前,工厂的 create_token_at 函数预期如下:
fn create_token_at(
ref self: ContractState,
salt: felt252,
name: ByteArray,
symbol: ByteArray,
owner: ContractAddress,
) -> ContractAddress
任何更新后的 class hash 必须有一个以此精确顺序包含这些参数的构造函数。
let mut constructor_calldata = array![];
name.serialize(ref constructor_calldata);
symbol.serialize(ref constructor_calldata);
owner.serialize(ref constructor_calldata);
如果新合约具有不同的构造函数参数,由于初始化期间的序列化不匹配,代币创建将会失败。
在部署和测试工厂之前,请将完整的 ERC20 工厂合约复制并粘贴到 src/erc20_factory.cairo 中:
use starknet::{ClassHash, ContractAddress};
#[starknet::interface]
pub trait IERC20Factory<TContractState> {
fn create_token(
ref self: TContractState, name: ByteArray, symbol: ByteArray, owner: ContractAddress,
) -> ContractAddress;
fn create_token_at(
ref self: TContractState,
salt: felt252,
name: ByteArray,
symbol: ByteArray,
owner: ContractAddress,
) -> ContractAddress;
fn update_erc20_class_hash(ref self: TContractState, new_class_hash: ClassHash);
fn get_all_created_tokens(self: @TContractState) -> Array<ContractAddress>;
fn get_user_tokens(self: @TContractState, user: ContractAddress) -> Array<ContractAddress>;
}
#[starknet::contract]
mod ERC20TokenFactory {
use starknet::storage::{
Map, MutableVecTrait, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess,
Vec, VecTrait,
};
use starknet::syscalls::deploy_syscall;
use starknet::{ClassHash, ContractAddress, SyscallResultTrait, get_caller_address};
#[storage]
struct Storage {
token_class_hash: ClassHash,
created_tokens: Vec<ContractAddress>,
user_tokens: Map<ContractAddress, Vec<ContractAddress>>,
factory_owner: ContractAddress,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
TokenContractCreated: TokenContractCreated,
ClassHashUpdated: ClassHashUpdated,
}
#[derive(Drop, starknet::Event)]
struct TokenContractCreated {
#[key]
creator: ContractAddress,
#[key]
token_address: ContractAddress,
name: ByteArray,
symbol: ByteArray,
}
#[derive(Drop, starknet::Event)]
struct ClassHashUpdated {
old_class_hash: ClassHash,
new_class_hash: ClassHash,
}
#[constructor]
fn constructor(ref self: ContractState, token_class_hash: ClassHash, owner: ContractAddress) {
self.token_class_hash.write(token_class_hash);
self.factory_owner.write(owner);
}
#[abi(embed_v0)]
impl ERC20FactoryImpl of super::IERC20Factory<ContractState> {
fn create_token(
ref self: ContractState, name: ByteArray, symbol: ByteArray, owner: ContractAddress,
) -> ContractAddress {
// Use default salt of 0
self.create_token_at(0, name, symbol, owner)
}
fn create_token_at(
ref self: ContractState,
salt: felt252,
name: ByteArray,
symbol: ByteArray,
owner: ContractAddress,
) -> ContractAddress {
let caller = get_caller_address();
let erc20_class_hash = self.token_class_hash.read();
// prepare constructor arguments
let mut constructor_calldata = array![];
name.serialize(ref constructor_calldata);
symbol.serialize(ref constructor_calldata);
owner.serialize(ref constructor_calldata);
// deploy the token contract
let (token_address, _) = deploy_syscall(
erc20_class_hash, salt, constructor_calldata.span(), false // deploy_from_zero
)
.unwrap_syscall();
// track the created token
self.created_tokens.push(token_address);
// track user's tokens
let user_tokens_entry = self.user_tokens.entry(caller);
user_tokens_entry.push(token_address);
// emit event
self
.emit(
TokenContractCreated {
creator: caller, token_address, name: name.clone(), symbol: symbol.clone(),
},
);
token_address
}
fn update_erc20_class_hash(ref self: ContractState, new_class_hash: ClassHash) {
let caller = get_caller_address();
assert(caller == self.factory_owner.read(), 'Only owner can update');
let old_class_hash = self.token_class_hash.read();
self.token_class_hash.write(new_class_hash);
self.emit(ClassHashUpdated { old_class_hash, new_class_hash });
}
fn get_all_created_tokens(self: @ContractState) -> Array<ContractAddress> {
let mut tokens = array![];
for i in 0..self.created_tokens.len() {
tokens.append(self.created_tokens.at(i).read());
}
tokens
}
fn get_user_tokens(self: @ContractState, user: ContractAddress) -> Array<ContractAddress> {
let user_tokens_entry = self.user_tokens.entry(user);
let mut tokens = array![];
for i in 0..user_tokens_entry.len() {
tokens.append(user_tokens_entry.at(i).read());
}
tokens
}
}
}
在 src/lib.cairo 文件中,这两个合约都需要被声明为 public 模块(modules),以便 Cairo 编译器能够访问它们:
pub mod erc20;
pub mod erc20_factory;
lib.cairo 文件作为 Cairo 项目的入口点。它告诉编译器在构建时要包含哪些模块。将两个模块声明为 pub 会使它们可以被项目中的其他模块访问。
最终的项目结构应该如下所示:
src/
├── lib.cairo
└── erc20.cairo
└── erc20_factory.cairo
使用 sncast 部署工厂
现在这两个合约都已经写好了,让我们声明它们以获取它们的 class hash,然后再部署工厂合约,该工厂合约稍后将使用 ERC-20 class hash 来创建代币。
步骤 1:声明 ERC-20 代币合约
首先,我们需要声明代币合约,将其注册在 Starknet 上并获取它的 class hash:
sncast \
--account <ACCOUNT_NAME> \
declare \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY> \
--contract-name ERC20Token
将 ACCOUNT_NAME 替换为实际的账户名称,将 YOUR_API_KEY 替换为你从 Alchemy 获取的 API Key,然后运行该命令。你会看到类似下面的输出:

我们需要保存这个 class hash,在部署工厂时将会需要用到它。
步骤 2:声明工厂合约
接下来,我们将运行下面的 sncast 命令来声明工厂合约:
sncast \
--account <ACCOUNT_NAME> \
declare \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY> \
--contract-name ERC20TokenFactory
这将会返回我们工厂合约的 class hash:

步骤 3:部署工厂合约
现在我们使用其 class hash 部署我们的工厂合约:
sncast \
--account <ACCOUNT_NAME> \
deploy \
--class-hash <ERC20TOKENFACTORY_CLASS_HASH> \
--arguments '<ERC20_CLASS_HASH>, <OWNER_ADDRESS>' \
--url https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_10/<YOUR_API_KEY>
构造函数的 calldata 包含两个参数:
- ERC-20 class hash (
0xea2b282ed...):告诉工厂要使用哪一个 contract class - 工厂 owner 地址 (
0x014154fb...):设置谁可以更新工厂的 class hash
成功部署后,我们将获得一个合约地址:

通过 UDC 验证工厂部署
随着工厂合约的部署,让我们来看看它是如何实际被部署的,以此来验证我们早前在“Universal Deployer Contract 与自定义工厂合约”一节中的解释。
在 Voyager 上,使用交易哈希(而不是合约地址)搜索该交易。
通过查看 internal calls(点击 deployContract,然后点击“More Details”),我们可以验证工厂合约本身是通过 Universal Deployer Contract (UDC) 部署的:

- UDC 部署调用:UDC(在顶部红框中高亮显示)接收工厂部署请求,并使用必要参数调用其
deployContract函数。 - 工厂 class hash:
classHash参数(黄色高亮显示)显示为0x1843d25804e7cc40c7b77d415b96d2316a6176a3e0ff454bb5a529d1696990a—— 这是我们之前声明过的工厂合约的 class hash。 - Salt:
salt参数显示为0x8bab3046b4b8227,这是由sncast生成的,用于确保唯一的合约地址。 - 构造函数参数:在
calldata数组中,我们可以看到:- 第一个参数(
0x2466cbc06f94c3e7b9a95bfc7ef94295f1546fa1917ded31710510b30d58e3d):这是我们工厂将用于部署代币的 ERC-20 代币 contract class hash - 第二个参数(
0x14154fb6dd088b5ceb46df635ecce6e1a9b0455357931ac7df4263a7dbf39a9):这是我们作为工厂 owner 传递的地址
- 第一个参数(
- 部署结果:输出显示了部署的工厂合约地址:
0xcdcb37a51075426d2b91ff49769f6909b51e8384ae1aae8dd2b82fdb923e4d
工厂合约直接使用 deploy_syscall 来创建单个代币实例,这绕过了每次部署代币都要通过 UDC 的需求。
使用工厂创建代币
让我们来看看用户如何与我们部署的工厂交互以创建他们自己的代币。我们将使用 Voyager 的界面来展示代币的创建过程。
通过工厂创建代币
当我们导航到 Voyager 上位于 0xcdcb37a51075426d2b91ff49769f6909b51e8384ae1aae8dd2b82fdb923e4d 的工厂合约,并进入 Write Contract 标签页时,我们可以看到可用的写入函数。我们拥有的第一个是 create_token 函数界面,用户可以在此输入他们的代币参数。
要创建代币,我们需要填写必填字段:
- name:“SarcToken”(代币的全名)
- symbol:“SRC”(交易符号)
- owner:将拥有和控制该代币的地址

连接钱包并点击“Transact”后,工厂会部署一个新的 ERC-20 代币合约。该交易返回我们新创建代币的地址:0x849daeb52f488856b408df096efcb3cba66243373a5ecb6bd62c1abb7c51d9。
验证已创建的代币
我们现在可以在其合约地址上与我们新创建的代币进行交互。进入 Read Contract 标签页来验证代币的详细信息。该代币实现了标准的 ERC-20 功能;用户可以铸造代币(如果是 owner 的话)、转账、检查余额,以及批准支出额度(allowance)。
调用 get_all_created_tokens 可以检索通过该工厂部署的所有代币合约的数组:

更新代币的 Class hash
通过 update_erc20_class_hash 更新 class hash 后,工厂将在后续的代币部署中使用新模板。升级之前部署的旧代币将保持其现有实现(旧 class hash)不变。
再次强调,在更新 class hash 之前,请确保新 contract class 具有与当前版本完全相同的构造函数签名和序列化顺序。如果不匹配,工厂会将错误的构造函数参数传递给新合约,导致部署失败。

结论
Starknet 上的所有部署最终都会使用 deploy_syscall 函数,但工厂合约将这种底层功能封装在了简单的接口中。我们的 ERC-20 工厂展示了一个合约如何为多个用户处理代币创建操作,同时跟踪记录已部署的内容。
update_erc20_class_hash 函数对于更新到使用相同构造函数参数的新代币版本效果很好。如果是希望工厂创建具有不同初始化或序列化要求的代币,你可以使用 replace_class_syscall 升级工厂本身,这将在另一篇文章中讨论。类似地,单个 ERC20 代币可以包含升级功能,允许其 owner 在需要时升级代币逻辑。