在 Ethereum 上,通常只需单笔交易即可部署合约。Starknet 则采取了不同的方式:部署被拆分为两笔独立的交易——声明(declaration)和部署(deployment)。
声明交易将合约字节码注册到链上并生成一个 class hash,而部署交易则使用该 class hash 创建一个拥有独立地址和存储的合约实例。在整个系列中,我们将这个两步过程称为 declare-deploy(声明-部署)模型。
在本文中,你将了解到:
- Starknet 的 declare-deploy 模型在幕后是如何工作的
- 普通合约是如何通过 Universal Deployer Contract (UDC) 部署的,不过正如本文后面所述,UDC 也可以用来部署账户合约
- 账户合约是如何通过
DEPLOY_ACCOUNT交易类型部署的(在合约字节码通过DECLARE交易声明到链上之后)
Ethereum 与 Starknet 上的部署对比
假设你想发行多个 ERC-20 代币。在 Ethereum 上,每次部署都需要上传完整的合约字节码,并支付 gas 以存储所有这些数据。你需要为每一个代币合约重复这个过程,即使它们的代码几乎完全相同。这意味着你在为重复存储相同代码而不断买单。
Starknet 通过将 contract class(字节码)与 contract instance(合约实例)分离开来避免了这个问题。声明 contract class 只需将字节码在链上存储一次,然后你就可以部署任意数量引用已声明 class 的实例。
这种分离机制还实现了合约的可升级性:已部署合约的逻辑可以被替换,而无需改变其地址和存储。这在“Upgrading Contracts”一文中有详细介绍。
为了实现这种分离,Starknet 使用特定的交易类型,将声明和部署作为独立的操作来处理。
Starknet 上用于部署的交易类型
协议层面,Starknet 目前定义了四种交易类型:DECLARE、DEPLOY_ACCOUNT、INVOKE 和 L1_HANDLER。你可以在浏览器上看到所有这些类型,为了向后兼容,它可能还会显示已废弃的 DEPLOY 交易类型。我们将重点介绍与合约部署相关的三种类型(如下方红框所示):

DECLARE:将合约代码注册到链上DEPLOY_ACCOUNT:部署账户合约。INVOKE:部署普通合约并在已部署合约上执行函数调用。这是用于发送代币、在 DEX 上进行交换或执行任何其他合约交互的交易类型
DECLARE 始终用于声明,而部署步骤针对普通合约使用 INVOKE,针对账户合约则使用 DEPLOY_ACCOUNT。
在我们走完声明和部署的流程之前,需要先了解 Starknet 上的两种合约类型:普通合约(regular contracts)和账户合约(account contracts),因为它们各自使用不同的部署方法。
| 普通合约 | 账户合约 |
|---|---|
| 普通合约是一种实现应用逻辑(如 ERC-20 代币、NFT 等)的智能合约。普通合约无法自行发起交易,必须由账户合约来调用。 | 另一方面,账户合约是一种能够验证交易是否已获授权并执行交易的智能合约。账户合约充当 Starknet 上所有交易的入口点。你在 Ready 或 Braavos 钱包上的账户,本质上就是部署在链上的账户合约。 |
了解这两种合约类型后,让我们来逐步了解 declare-deploy 过程的每一个步骤。
声明 Contract Class
要声明一个合约,我们首先需要将 Cairo 源代码编译为 Starknet 期望的格式。编译过程分为两个阶段。首先,编译器将 Cairo 代码转换为 Sierra(安全中间表示法)。然后,在声明期间,定序器(sequencer,即 Starknet 上负责交易排序和构建区块的节点)将 Sierra 在链上编译为 CASM(Cairo 汇编)。
Sierra 与 CASM
- Sierra 是介于 Cairo 代码和 CASM 之间的中间表示法。它保证了即使交易被回滚(revert),每次合约执行或 Cairo 程序也都能被证明(proven)。
- Sierra class 是采用 Sierra 格式的合约代码。Sierra 和 Sierra class 之间的区别,就像 JSON 是一种格式,而 JSON 文件是该格式下的特定文档一样。Sierra class 才是被声明到链上的内容,因为它是稳定且可验证的,可确保所有合约代码始终能被证明。
- CASM 是由 Cairo VM 解释以执行合约的低级字节码。它是我们合约的最终编译形式,由 Sierra class 生成。

构建产物
当我们使用 scarb build 编译任何合约(例如我们的 ERC-20 合约)时,它会在 (target/dev) 目录中生成两个构建产物:
1. Sierra 文件(命名为 ...contract_class.json)
这是一个包含 Sierra class 的文件,作为网络用于声明的蓝图。它包含四个关键字段:
sierra_program:编译为 Sierra 字节码的合约逻辑entry_points_by_type:合约中可调用的入口点(按类型分组)。一个入口点是由selector(函数名的starknet_keccak哈希,用于标识要调用的函数)和function_idx(该函数实现在sierra_program数组中的位置)组成的键值对,用于告诉网络要调用哪个函数以及在 Sierra 程序中的位置。这三种类型是:- constructor
- 外部函数 (external functions)
- l1 handler:处理从 Ethereum 发送到 Starknet 的消息的函数。该字段是每个合约 Sierra 文件结构的一部分,但对于不与 L1 交互的合约(如 ERC-20),它是空的
abi:合约的接口,包括函数签名、参数类型、返回类型、事件和结构体。contract_class_version:contract class 格式的版本
2. Starknet 产物文件(命名为 ...starknet_artifacts.json)
该文件包含合约的元数据,并将其链接到已编译的 Sierra 文件。像 sncast 这样的工具会在本地使用它,为给定的合约名称定位正确的 Sierra 文件:

如上方图片中的产物文件所示,"casm" 字段在编译时是 null。这是因为 CASM 将在链上声明过程中从 Sierra class 生成。
DECLARE 交易期间会发生什么
准备好编译后的 Sierra class 后,我们使用 DECLARE 交易将其注册到网络。像 sncast 或 Starknet.js 这样的工具会读取 starknet_artifacts.json 文件以找到合约对应的 Sierra class 文件,然后在我们的账户签名的 DECLARE 交易中提交该 Sierra class。定序器随后将 Sierra 编译为 CASM,并计算两个哈希值:
- Class hash:使用以下公式根据 Sierra 文件中的所有四个字段计算得出:
其中class_hash = h( contract_class_version, external_entry_points, l1_handler_entry_points, constructor_entry_points, abi_hash, sierra_program_hash )h是 Poseidon 哈希函数:一种针对在 STARK 证明中使用进行了优化的哈希函数。请注意,entry_points_by_type为公式贡献了三个独立的输入:external_entry_points、l1_handler_entry_points和constructor_entry_points
在将 ABI 和 Sierra 程序纳入计算之前,它们会先分别进行哈希处理:- ABI 使用
starknet_keccak(bytes(ABI, "UTF-8"))进行哈希,生成 ABI 哈希 (abi_hash), - Sierra 程序被哈希处理以生成 Sierra 程序哈希 (
sierra_program_hash)
由于合约和包名在整个 ABI 中都有出现,例如事件类型名称erc20::ERC20::Transfer和接口名称erc20::IERC20,因此具有相同 Cairo 代码但合约或包名不同的两个合约,将产生不同的 ABI 哈希,从而产生不同的 class hash,这意味着两者都可以成功声明。同样,使用不同编译器版本编译的两个合约将产生不同的sierra_program_hash值,进而产生不同的 class hash,即使 Cairo 源代码完全相同。然而,如果这些因素都没有差异,生成的 class hash 将与链上已存在的哈希相同,网络将以“合约已声明” (contract has already been declared) 的错误拒绝该声明。
- ABI 使用
- Compiled class hash:从 Sierra class 生成的 CASM 代码计算得出。它锁定了 Cairo VM 将执行的确切机器码
一旦定序器处理了 DECLARE 交易,Sierra class、class hash 和 compiled class hash 都会被存储在链上,使得该 contract class 可以用于部署。
部署合约实例
一旦声明了 contract class,我们就可以创建该 class 的多个实例。每个实例共享相同的代码,但拥有各自的地址和存储。例如,像 Ready(前身是 Argent)这样的账户实现在 Starknet 上只需声明一次。当你创建一个新的 Ready 账户时,无需进行新的声明,因为 Ready class 已经存在于链上。你只需为账户部署付费,而不用为再次存储代码付费。你新部署的账户会获得自己唯一的地址和存储空间,但它与所有其他 Ready 账户使用的是相同的底层 Ready 代码。
每个已部署合约实例的地址由以下公式计算得出:
contract_address = pedersen(
"STARKNET_CONTRACT_ADDRESS",
deployer_address,
salt,
class_hash,
constructor_calldata_hash)
其中 pedersen 是一种兼容 STARK 证明的密码学哈希函数。它接收五个输入:一个常量前缀、部署者地址、一个 salt(盐值)、class hash,以及 constructor 参数的哈希。改变其中任何一个输入都会产生不同的地址,这就是为什么你可以通过改变 salt、constructor 参数或部署者地址来部署同一 class hash 的多个实例。
部署合约实例需要四个关键要素:
- **Reference:**使用哪个 contract class
- **Input:**向 constructor 传递什么数据
- **Fee Payment:**由哪个账户支付部署费用
- **Creates:**新合约实例获得什么地址
然而,根据你要部署的是普通合约还是账户合约,这些要素的工作方式会有所不同:
Regular Contracts (INVOKE via Universal Deployer Contract)
├── Reference: Contract class hash (already declared)
├── Input: Constructor calldata + salt
├── Fee Payment: From the sender's account
└── Creates: New contract instance at a deterministic address
Account Contracts (DEPLOY_ACCOUNT)
├── Reference: Account class hash (already declared)
├── Input: Constructor calldata + salt
├── Fee Payment: From the deployed account's own pre-funded address
└── Creates: New account contract at a counterfactual address (calculated before deployment)
Account Contracts (INVOKE via Universal Deployer Contract)
├── Reference: Account class hash (already declared)
├── Input: Constructor calldata + salt
├── Fee Payment: From the deployer's account
└── Creates: New account contract at a deterministic address, linked to the deployer
普通合约通过 Universal Deployer Contract (UDC) 利用 INVOKE 交易进行部署。现有的账户合约调用 UDC,随后 UDC 调用 deploy_syscall 来创建新合约。调用者支付交易费。(我们将在下一节中详细介绍 UDC)。
下图展示了普通合约的完整部署流程。带有编号的步骤显示了操作顺序:
DECLARE交易将 contract class 注册到链上并生成 class hash- 你的账户向 UDC 发送一笔
INVOKE交易,将 class hash、salt、calldata 和not_from_zero作为参数传递。not_from_zero是一个布尔值,决定了部署者的地址是否被计入合约地址的计算中,这在 UDC 一节中会有详细介绍。 - 随后,UDC 在内部调用
deploy_syscall创建合约实例,每个实例都会获得自己唯一的地址
图表的左侧显示了声明步骤,右侧则展示了如何复用相同的 class hash 来部署多个独立的实例:

账户合约可以通过两种方式部署:
- 使用
DEPLOY_ACCOUNT交易: 在这种类型的部署中,账户合约自己支付部署费用。生成的地址会被预先计算出来,并存入 STRK 代币。然后,DEPLOY_ACCOUNT交易会部署合约,并从该预先充值的地址中扣除费用。 - 通过 UDC 进行
INVOKE: 在这种类型的部署中,现有的账户合约像部署普通合约一样,通过INVOKE交易调用 UDC 来部署新的账户合约。部署者支付交易费。
既然我们已经在宏观层面上了解了 declare-deploy 模型,现在让我们来详细探讨每种部署方法。
通过 Universal Deployer Contract (UDC) 部署合约
UDC 是由 OpenZeppelin 创建的单例合约,它对 deploy_syscall 函数进行了封装,将其作为一个可调用的接口暴露出来,供账户合约调用。它充当 Starknet 合约的标准化通用工厂。它在 Starknet sepolia 和 mainnet 上均部署在地址 0x02ceed65a4bd731034c01113685c831b01c15d7d432f71afb1cf1634b53a2125 。
UDC 提供如下接口:
#[starknet::interface]
pub trait IUniversalDeployer {
fn deploy_contract(
class_hash: ClassHash,
salt: felt252,
not_from_zero: bool, // Determines deployment type
calldata: Span<felt252> // Constructor parameters
) -> ContractAddress;
}
参数含义:
class_hash:要部署合约的 class hashsalt:用于地址计算的 salt 值not_from_zero:一个布尔值,决定部署者地址是否被计入合约地址计算中。为true时包含在内,为false时则不包含。calldata:新部署合约的 constructor 参数
注意:当前版本的 UDC 包含相较于早期版本的修改:
deployContract被替换为了 snake_case 风格的deploy_contractunique参数被替换为了not_from_zero(语义完全相反)
UDC 部署类型
UDC 提供了两种决定合约地址如何计算的部署类型,这由 not_from_zero 参数控制:
- 源相关部署 (Origin-Dependent Deployment)
- 源无关部署 (Origin-Independent Deployment)
UDC 使用了 OpenZeppelin 合约库中的实用函数 calculate_contract_address_from_udc,它会在调用标准地址计算之前,将 not_from_zero 映射为 UDC 地址或 0:

- 源相关部署(
not_from_zero = true)
当使用源相关部署时,部署者地址(udc_address)将成为地址计算的一部分。这就创建了一个“保留地址空间”,只有部署账户的所有者(caller_address)能够向那些特定地址部署合约。
UDC 会使用 Pedersen 哈希将传入的 salt 与 caller's address(调用者地址)进行哈希处理以修改 salt:hashed_salt = pedersen(caller_address, salt)。然后,它将此修改后的 salt (hashed_salt) 与以下内容结合起来使用:
class_hash(正在部署的 contract class)constructor_calldata(constructor 参数)deployer_info.udc_address(作为部署者的 UDC 合约地址)
应用于标准的合约地址计算中。
这种方法提供了地址保留功能,防止他人部署到“你的”地址。每个部署者都获得自己的地址空间,且只有你能部署从你的账户衍生出来的地址。当你想要确保没有其他人可以部署到你预期的地址时,应当选择此方法。
- 源无关部署(
not_from_zero = false)
通过源无关部署,合约地址的计算独立于由谁部署它们。地址仅取决于 salt、class hash 和 constructor 参数。
UDC 原封不动地使用传入的原始 salt,在标准合约地址计算中传递 0 作为部署者。
这使得跨部署者的部署具有确定性,任何人使用相同的参数部署相同的合约都会得到相同的地址。不过,只有首次部署会成功;后续尝试使用相同参数部署将无法通过,因为该地址已被占用。当你希望无论由谁部署,合约地址都完全可预测时(例如跨多个网络部署标准合约),此方法非常适用。
UDC 部署的工作原理
你的账户合约向 UDC 的 deployContract 函数发起 INVOKE 调用以创建新合约:

deployContract 函数(底部蓝框所示)是一个驼峰命名法(camelCase)的包装器,它调用了底层的 deploy_contract 函数(上方主代码块所示),后者接收如下参数:class_hash、salt、not_from_zero、calldata。
定序器验证你账户的 INVOKE 交易后,UDC 接着通过 get_caller_address() 捕获调用者的地址(也就是你发起 INVOKE 交易的账户地址),并基于部署类型修改 salt:
let final_salt = if not_from_zero {
pedersen(caller_address.into(), salt) // Origin-dependent: hash caller + salt
} else {
salt // Origin-independent: use original salt
};
准备好修改后(最终)的 salt 后,UDC 会封装 deploy_syscall()(标红显示),使用 class hash、最终 salt、constructor calldata 和部署类型来创建实际的合约实例。
定序器使用提供的参数执行合约的 constructor,并从调用账户(而不是新部署的合约)中收取部署费用。
成功部署后,UDC 会发出 ContractDeployed 事件(标粉显示),其中包含用于追踪的部署信息,包括新合约地址、部署者、部署类型、class hash、calldata 以及原始 salt。随后,该函数将新部署的合约地址返回给调用者。
**注意:在部署其 constructor 中使用了 get_caller_address() 的合约时,请记住是 UDC 部署了合约,而不是你的账户直接部署的。因此,get_caller_address() 返回的是 UDC 的地址,而不是你账户的地址。**这就是为什么我们在代币合约示例中,将所有者(owner)的地址作为参数传递的原因。

账户合约使用 UDC
尽管 UDC 主要用于部署普通合约,但如前所述,它也可以用于部署账户合约。在这种情况下,由部署账户支付费用,且新的账户合约在链上与部署者关联。当你不需要该新账户处于“原始(pristine)”状态时,这种方法非常有用。
部署账户合约:DEPLOY_ACCOUNT 交易
部署你的首个账户合约会面临一个启动(bootstrap)问题:如果你还没有账户,该如何支付部署费用?
Starknet 通过反事实部署(counterfactual deployment)解决了这个问题,即在部署合约之前先计算出账户地址并为其充值。“反事实”意味着在该地址真正在链上存在之前,就将其当作已经存在来对待。借助基于 salt、class hash 和 constructor calldata 的确定性公式,你可以提前计算出该地址,向其存入资金,然后将合约准确地部署到这个地址上。
具体流程:
- 在部署之前计算账户地址
- 为预先计算出的地址充值代币(测试网可通过 faucet 获取)
- 提交一笔
DEPLOY_ACCOUNT交易
当定序器收到此交易时,它会:
- 验证部署签名
- 使用提供的参数运行 constructor
- 从新部署的账户地址中扣除费用(从而完成了启动方案的闭环!)
这种方法非常适合首次创建账户,因为账户使用预充值的余额自己支付部署费用,消除了需要现有账户合约来赞助部署的必要。当你希望账户保持“原始”状态(不与任何其他账户关联,如 Ready 或 Braavos 账户)时,这是部署账户合约的首选方法。
下表总结了两种合约部署方法之间的主要区别:
| 对比方面 | DEPLOY_ACCOUNT 交易 |
UDC 方法 |
|---|---|---|
| 用例 | 账户合约部署(独立的,不与任何部署者关联) | 普通合约部署,或账户合约部署(与部署者关联) |
| 费用支付方 | 正在部署的账户(预先充值) | 你现有的账户 |
| 交易类型 | DEPLOY_ACCOUNT |
INVOKE |
| deploy_syscall 访问方式 | 协议级机制 | 通过 UDC 封装器 |
| 启动 (Bootstrap) 问题 | 已解决(不需要事先已有账户) | 需要现有账户 |
| 地址类型 | 反事实 (Counterfactual) | 确定性 (Deterministic) |
| 生成的账户状态 | 原始的,不与任何其他账户关联 | 在链上与部署者相关联 |
总结
在本文中,我们探索了 Starknet 的 declare-deploy 模型是如何将合约代码与状态分离的,从而通过 DECLARE、INVOKE 和 DEPLOY_ACCOUNT 交易类型实现了高效部署。我们探讨了 Sierra 和 CASM 在声明期间是如何协同工作的,以及这两种部署方法(针对独立存在的账户使用 DEPLOY_ACCOUNT,针对普通合约使用 Universal Deployer Contract)如何处理不同的用例。
在下一篇文章中,我们将带领大家使用 sncast 和 Starknet.js 部署我们前一篇指南中的 ERC-20 合约,并展示如何与你已部署的合约进行交互。