本文展示了如何为 Starknet 构建一个可部署的 Cairo 合约。从一个简单的草图开始,我们将逐步添加功能以构建一个可运行的合约,以此来演示 Cairo 合约的核心构建模块。
该合约将包含一个可以增加任意数量的计数器变量,以及一个用于获取其值的函数。
我们合约的第一个版本
mod Counter {
fn increase_counter(amount: felt252) {
// TODO
}
fn get_counter() -> felt252 {
// TODO
}
}
上述代码包含以下特性:
- 一个模块代码块,由
mod关键字表示。每个 Cairo 合约都写在一个模块内。这类似于 Solidity 中的contract关键字,且模块的名称可以是任意的。 - 两个函数:一个用于增加计数器,另一个用于获取其当前值。
通过为 Counter 合约定义 trait 来添加“接口”
在 Solidity 中,接口(interface)定义了合约必须实现的一组函数。接口对于合约来说不是强制性的,但鼓励使用。
在 Cairo 中,相同的概念是通过 trait 来表示的,它定义了一组函数列表而不提供其具体实现。从这个意义上说,Cairo 的 trait 扮演着与 Solidity 中接口相同的角色。
然而,需要明确的是,trait 本身并不会自动被视为合约接口。我们需要显式地将 trait 标记为接口,才能使其被当作接口对待,而这是通过注解(annotation)来完成的,我们将在后面的小节中看到具体用法。
现在,可以这样理解:
- trait 描述了合约必须具备哪些函数,
- 而注解(我们稍后会介绍)则告诉 trait 它应该如何表现,在当前场景下,即表现为合约接口。
我们无法在其中实现不属于已定义接口范围内的函数——稍后我们将看到实现额外函数的另一种选项。
下面的代码通过定义一个 trait 并为其中声明的函数提供实现,从而扩展了 Counter 合约:
// Define a trait with two functions
pub trait ICounter {
fn increase_counter(amount: felt252);
fn get_counter() -> felt252;
}
mod Counter {
// Implement the functions within the `ICounter` trait
impl CounterImpl of super::ICounter {
fn increase_counter(amount: felt252) {
// TODO
}
fn get_counter() -> felt252 {
// TODO
}
}
}
这个草案添加了以下特性:
- 一个公共 trait,由
pub和trait关键字表示。 - 一个实现(
impl)代码块包含具体的函数实现。该代码块实现了ICountertrait。trait 或实现代码块的名称可以是任意的,但通常的做法是使用能反映合约用途的描述性名称。按照惯例,Scarb 遵循IContractName作为接口名称、ContractNameImpl作为定义公共函数的对应实现名称的模式。
添加存储 (Storage)
接下来,我们需要一个地方来存储计数器的值。由于计数器的值需要在交易之间持久化,它应该作为合约状态的一部分被存储起来。在 Cairo 中,持久化数据被存储在一个名为 Storage 的单一结构体(struct)中,该结构体将所有状态变量组合在一起。
// Storage traits
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
struct Storage {
counter: felt252,
}
这包括以下特性:
- 一个
use语句,导入了读写合约存储所需的 trait。 - 一个用于合约存储的新结构体,使用
struct关键字定义。该结构体必须命名为Storage。 - 存储内部实际的
counter变量。
添加状态与逻辑
现在我们有了存储,我们的函数需要一种与之交互的方式。在 Cairo 中,合约状态并不会自动可用;它必须作为参数显式传递给函数。
为了方便实现这一点,Cairo 使用了状态引用(state reference),这是一个特定的参数,代表并允许访问合约的存储。
在 Cairo 中有两种定义状态引用的方式:一种提供对存储的读写访问权限,另一种提供只读访问权限。以下是它们的使用方法:
- 读写访问权限: 使用带有
ref关键字的引用变量。 - 只读访问权限: 使用带有
@符号的快照(snapshot)变量。这类似于 Solidity 的view函数,即函数可以读取但不能修改合约存储。
请注意,increase_counter 函数在其参数中使用了 ref 关键字以获取对合约状态的读写访问权限,而 get_counter 函数使用了 @ 符号以获取只读访问权限,如下面的代码所示:
pub trait ICounter<TContractState> {
// Function that can read and modify the contract's state
fn increase_counter(ref self: TContractState, amount: felt252);
// Function that can only read from the contract's state
fn get_counter(self: @TContractState) -> felt252;
}
mod Counter {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
struct Storage {
counter: felt252,
}
impl CounterImpl of super::ICounter<ContractState> {
// Uses `ref self`: gives read and write access to the storage
fn increase_counter(ref self: ContractState, amount: felt252) {
self.counter.write(self.counter.read() + amount);
}
// Uses `@`: gives read-only access to the storage
fn get_counter(self: @ContractState) -> felt252 {
self.counter.read()
}
}
}
到目前为止,我们对合约所做的修改允许我们通过合约状态直接与存储进行交互。主要变动如下:
- 在 trait 中添加了
TContractState类型参数作为一个占位符(而非实际类型),这样它就可以与任何合约状态布局配合使用,而不是绑定到某个特定的布局上。pub trait ICounter {变为了pub trait ICounter<TContractState> {
- 在 impl 代码块中,
TContractState占位符被替换为实际的合约状态类型(ContractState):impl CounterImpl of super::ICounter {变为了impl CounterImpl of super::ICounter<ContractState> {
- 为这两个函数都添加了对状态的引用。一个对存储具有写入权限,另一个仅具有读取权限:
fn increase_counter(amount: felt252) {变为了fn increase_counter(ref self: ContractState, amount: felt252) {fn get_counter() -> felt252 {变为了fn get_counter(self: @ContractState) -> felt252 {
- 添加了使用
self增加计数器和读取它的逻辑,self的类型为ContractState,代表合约的状态:- 添加了逻辑
self.counter.write(self.counter.read() + amount); - 添加了逻辑
self.counter.read()
- 添加了逻辑
使用注解完成合约
Cairo 使用不同的注解(也称为属性 [attributes])来指示合约各个部分应如何表现。这些注解指定了诸如以下内容:
- 哪个 trait 定义了接口,
- 哪个模块是可部署的合约,
- 哪个结构体是存储结构体,
- 以及哪个实现代码块向外界暴露函数。
Cairo 中的每个注解都以 #[] 开头,并直接放置在其应用的代码上方。例如,将 #[starknet::interface] 属性放置在某段代码上,即表明它应被作为合约的接口处理。
以下是带有注解的完整合约:
#[starknet::interface]
pub trait ICounter<TContractState> {
fn increase_counter(ref self: TContractState, amount: felt252);
fn get_counter(self: @TContractState) -> felt252;
}
#[starknet::contract]
mod Counter {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
counter: felt252,
}
#[abi(embed_v0)]
impl CounterImpl of super::ICounter<ContractState> {
fn increase_counter(ref self: ContractState, amount: felt252) {
self.counter.write(self.counter.read() + amount);
}
fn get_counter(self: @ContractState) -> felt252 {
self.counter.read()
}
}
}
添加的注解如下:
#[starknet::interface]将 trait 标记为接口。如果没有带有注解的接口,你就不能拥有对应的impl代码块。#[starknet::contract]将模块标记为 Starknet 智能合约。#[storage]指示定义合约存储布局的结构体。一个合约必须只有一个带有此注解的存储struct。#[abi(embed_v0)]使impl代码块内部的函数成为合约公共 ABI 的一部分——就像 Solidity 中的public或external函数。如果省略此注解,这些函数将只能在当前合约内部使用。embed_v0意味着使用 ABI 格式的第 0 版(v0)来嵌入 ABI。在v0中,函数选择器(function selectors)仅从函数名称派生。impl的名称不被考虑在内,这意味着如果一个合约有不同的impl代码块(实现不同的 trait),并且它们都定义了同名函数,就会发生名称冲突。未来的版本(如v1)可能会通过在选择器派生中包含更多上下文来改进这一点,同时不会破坏向后兼容性。- 在 Cairo 中,没有类似 Solidity 的
private或internal这样的可见性关键字来表示私有函数。创建私有函数的另一种方法是直接将它们添加到impl代码块之外,且不加任何注解。本文稍后会展示一个例子。
有了这些配置,合约就可以被编译、部署并供其他合约或客户端调用了。
接口之外的函数
在 Cairo 中,也可以通过使用 #[external(v0)] 注解进行标记,在接口实现之外定义公共函数。就像 #[abi(embed_v0)] 一样,#[external(v0)] 中的 v0 表示使用 ABI 的第 0 版。
在合约中可以同时使用接口和带有注解的外部函数。不过,推荐使用接口,因为这允许外部合约在与你的合约交互时依赖共享的定义。
在以下代码中,我们将添加一个带有 #[external(v0)] 注解的新函数 increase_counter_by_five。该函数可以被外部调用并且包含在合约的 ABI 中,即使它不是通过接口定义的(它表现得像公共函数,但没有接口)。
这个新函数调用了另一个新的私有函数 get_five。该私有函数只能在此合约内部调用。
#[starknet::interface]
pub trait IHelloStarknet<TContractState> {
fn increase_counter(ref self: TContractState, amount: felt252);
fn get_counter(self: @TContractState) -> felt252;
}
#[starknet::contract]
mod HelloStarknet {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
#[storage]
struct Storage {
counter: felt252,
}
#[abi(embed_v0)]
impl CounterImpl of super::IHelloStarknet<ContractState> {
fn increase_counter(ref self: ContractState, amount: felt252) {
self.counter.write(self.counter.read() + amount);
}
fn get_counter(self: @ContractState) -> felt252 {
self.counter.read()
}
}
// ********* NEWLY ADDED - START ********* //
#[external(v0)]
fn increase_counter_by_five(ref self: ContractState) {
self.counter.write(self.counter.read() + get_five());
}
fn get_five() -> felt252 {
5
}
// ********* NEWLY ADDED - END ********* //
}
注意:任何
public或external函数的第一个参数都必须是对合约状态的引用。
编译合约
为了确保我们的代码有效并准备好运行,我们应该对其进行编译。
处理 Cairo 代码时一个常用的工具是 Scarb——这是一个 Cairo 的包管理器和构建系统。如果你还没有安装它,请按照 Cairo for Solidity developers 文章中的说明进行安装。
安装完成后,你可以按照以下步骤创建并编译你的合约项目:
- 运行
scarb new counter初始化一个新项目,并在系统提示时使用默认的测试运行器(test runner)继续。 - 导航到项目文件夹:
cd counter。 - 用我们的合约代码替换
src/lib.cairo的内容。 - 编译合约:
scarb build。
如果你遇到类似于 Type annotations needed 的编译错误,请确保在 Scarb.toml 的 [dependencies] 部分下添加了 starknet = "2.12.0"。
测试合约
Scarb 在初始化新项目后也会生成一个测试合约。这些测试直接用 Cairo 编写,并在本地执行,以便在链上部署之前测试实际的合约逻辑。
要查看测试,请导航至 ./tests/test_contract.cairo。以下是生成的测试中所发生情况的拆解说明。
导入 (Imports)

-
use starknet::ContractAddress;这从
starknet模块中导入了ContractAddress。- 导入
ContractAddress类型。 - 这是 Starknet 对合约地址的表示形式,在与已部署的合约交互或引用它们时都需要用到它。
- 导入
-
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};这从 Starknet Foundry 标准库
snforge_std中导入了在测试期间声明和部署合约所需的工具。declare:用于在部署之前在测试环境中声明一个合约。这就像向网络提交合约代码一样。ContractClassTrait:提供与已声明的合约类进行交互的辅助方法(比如部署它们)。DeclareResultTrait:在声明结果上暴露一个函数,用于获取合约类(等同于 Solidity 中的合约字节码)。
-
use counter::IHelloStarknetSafeDispatcher;和use counter::IHelloStarknetSafeDispatcherTrait;这从项目名称(在我们的例子中是
counter)中导入合约接口的安全(safe)版本。-
IHelloStarknetSafeDispatcher:安全调度器负责调用合约的函数。但与 Solidity 中函数调用直接返回结果值不同,在这里,每次调用都会返回一个包装器(wrapper),其中要么包含返回的值(如果成功),要么包含一个错误(如果失败)。重要的是,即使合约调用失败,执行仍会在测试函数内继续。这允许安全调度器优雅地处理错误,而不是撤销(revert)整个交易。
-
IHelloStarknetSafeDispatcherTrait:为调度器暴露合约中的可调用函数。每个函数的返回值都会被包装,表明它可能会成功或失败。
-
-
use counter::IHelloStarknetDispatcher;和use counter::IHelloStarknetDispatcherTrait;这从项目名称(在我们的例子中是
counter)中导入合约接口(非安全版本)。IHelloStarknetDispatcher:该调度器也用于调用合约的函数。然而,与安全版本不同的是,它不带任何包装直接返回函数的值。如果目标合约执行失败,该调用会立即引发 panic,导致执行在测试函数中停止,并阻止任何形式的优雅错误处理。IHelloStarknetDispatcherTrait:为调度器暴露合约中的可调用函数。每个函数直接返回接口定义的原始返回类型。
部署函数 (Deploy function)

该函数将合约名称(在我们的例子中是 HelloStarknet)作为参数,部署合约,并返回其合约地址。
注意: 合约名称是 lib.cairo 文件中位于 mod 关键字之后的标识符(即 mod HelloStarknet),而 项目名称(如 counter)仅仅是使用 Scarb 初始化项目时创建的文件夹名称。
下面是该函数中发生情况的拆解说明:
declare(name)- 它接受合约的名称(通常以字节数组形式提供)并将其声明到 Starknet 网络中。
.contract_class()- 从已声明的合约中提取合约类。
.deploy(@ArrayTrait::new())- 部署该合约类。
ArrayTrait::new()用于传递构造函数参数(此处为空数组,因为构造函数不接受任何参数)。- 它返回一个元组,其中的第一个元素即为合约地址。
- 返回值
- 该函数返回新部署的合约地址。
测试用例 (Test cases)

在上面的截图中,有两个测试用例:
test_increase_balance:使用常规调度器调用合约中的函数。test_cannot_increase_balance_with_zero_value:使用安全调度器调用合约中的函数。
测试命令
运行以下命令进行测试:
scarb test
主要差异与相似点总结
在本文中,我们列举了 Cairo 和 Solidity 之间的多个相似之处,但也指出了许多不同点。为了清晰起见,对比如下:
- Cairo 的
mod关键字扮演着与 Solidity 中的contract关键字类似的角色。 - Cairo 的接口通过使用带有
#[starknet::interface]注解的 trait 来定义,这类似于 Solidity 的interface。 - 若要在 Cairo 中创建只读函数(类似于 Solidity 中的
view),需使用@符号将状态作为快照传递。 - 若要在 Cairo 中创建类似于 Solidity 中
pure的函数,可以像我们定义get_five函数那样进行定义。 - 要使函数可被外部调用(类似于 Solidity 中的
public和external),需使用#[external(v0)],或将其在一个带有#[abi(embed_v0)]注解的impl代码块中实现。
结论
Solidity 和 Cairo 合约的服务目的非常相似。虽然 Cairo 的语法不同,但 Solidity 开发者会对许多核心概念感到熟悉。
本文讨论的结构是一种可行的方法,但它并不是 Starknet 提供的唯一架构选择。在本系列后续的文章中,我们将探讨其他设计方案,以帮助你更好地理解 Cairo 和 Starknet 为构建可扩展、可组合的智能合约所提供的灵活性。
下一步
要继续学习有关 Cairo 合约的知识,鼓励大家尝试并在我们的 GitHub 仓库 中进行练习。
本文是 Starknet 上的 Cairo 编程 教程系列的一部分。