本文介绍了如何使用 Cairo 语言为 Starknet 构建可部署的智能合约。文章从一个简单的合约草图开始,逐步添加功能,演示了 Cairo 合约的核心构建块,包括模块、接口(trait)、存储、合约状态以及不同的注解,最后介绍了合约的编译和测试方法。
本文展示了如何为 Starknet 构建一个可部署的 Cairo 合约。从一个简单的草图开始,我们将逐步添加特性,以构建一个可用的合约,演示 Cairo 合约的核心构建块。
该合约将有一个计数器变量,该变量可以增加任意数量,还有一个函数可以检索其值。
mod Counter {
fn increase_counter(amount: felt252) {
// TODO
}
fn get_counter() -> felt252 {
// TODO
}
}
上面的代码包括以下特性:
mod 关键字表示。每个 Cairo 合约都写在一个模块内。这类似于 Solidity 中的 contract 关键字,模块的名称可以是任意的。接口定义了合约必须实现的一组函数。接口对于合约来说不是强制性的,但鼓励使用它们。
在 Cairo 中,这个相同的想法用 trait 来表示,它定义了函数列表,但不提供它们的实现。从这个意义上讲,Cairo trait 与 Solidity 中的接口扮演着相同的角色。
然而,重要的是要澄清,trait 本身不会自动被视为合约接口。我们需要显式地将 trait 标记为接口,才能将其视为接口,这可以通过注解来完成,我们将在后面的章节中看到。
目前,可以这样理解:
不可能在 impl 块中定义不属于已实现接口的函数 - 我们稍后将看到定义额外函数的另一种选择。
下面的代码通过定义 trait 并为 trait 中声明的函数提供实现来扩展 Counter 合约:
// 定义一个带有两个函数的 trait
pub trait ICounter {
fn increase_counter(amount: felt252);
fn get_counter() -> felt252;
}
mod Counter {
// 实现 `ICounter` trait 中的函数
impl CounterImpl of super::ICounter {
fn increase_counter(amount: felt252) {
// TODO
}
fn get_counter() -> felt252 {
// TODO
}
}
}
此草案添加了以下特性:
pub 和 trait 关键字表示。impl)块提供实际的逻辑,并且只能包含函数实现。此块实现了 ICounter trait。trait 或实现块的名称可以是任意的,尽管通常的做法是使用能够反映合约目的的描述性名称。按照惯例,Scarb 遵循 IContractName 模式作为接口,ContractNameImpl 模式作为定义公共函数的相应实现。接下来,我们需要一个地方来存储计数器值。Cairo 合约可以在其存储中存储任意数据。
// 存储traits
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
struct Storage {
counter: felt252,
}
这包括以下特性:
use 语句,用于导入从合约存储读取和写入所需的 trait。struct 关键字定义。该结构必须命名为 Storage。合约的所有存储变量都必须在一个 struct 中定义。
在定义存储之后,我们的合约需要一种方法来在函数调用之间访问和修改它。这就是合约状态的概念发挥作用的地方。
合约状态是指合约的内部存储。为了在函数中访问这个存储,Cairo 需要一个状态引用,一个代表合约存储的参数。
在 Cairo 中有两种定义状态引用的方法:一种是提供对存储的读写访问权限,另一种是提供只读访问权限。以下是如何使用它们:
ref 关键字的引用变量。@ 符号的快照变量。这类似于 Solidity 的 view 函数,其中函数可以从合约存储读取但不能修改它。请注意,increase_counter 函数在其参数中使用 ref 关键字来获得对合约状态的读写访问权限,而 get_counter 函数使用 @ 符号来获得只读访问权限,如下面的代码所示:
pub trait ICounter<TContractState> {
// 可以读取和修改合约状态的函数
fn increase_counter(ref self: TContractState, amount: felt252);
// 只能从合约状态读取的函数
fn get_counter(self: @TContractState) -> felt252;
}
mod Counter {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
struct Storage {
counter: felt252,
}
impl CounterImpl of super::ICounter<ContractState> {
// 使用 `ref self`:提供对存储的读写访问权限
fn increase_counter(ref self: ContractState, amount: felt252) {
self.counter.write(self.counter.read() + amount);
}
// 使用 `@`:提供对存储的只读访问权限
fn get_counter(self: @ContractState) -> felt252 {
self.counter.read()
}
}
}
到目前为止,我们对合约所做的更改允许我们通过合约状态直接与存储进行交互。主要变化如下:
将 TContractState 类型参数添加到 trait 中,作为一个占位符,而不是实际类型,因此它可以与任何合约状态布局一起使用,而不是绑定到特定的布局。
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 使用不同的注解(也称为属性)来指示合约的不同部分应该如何表现。这些注解指定了诸如以下内容:
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 函数一样。省略此注解会使函数仅在此合约内部可用。
private 或 internal 这样的可见性关键字来表示私有函数。创建私有函数的另一种方法是简单地将它们添加到 impl 块之外,而不添加任何注解。本文稍后将展示一个示例。有了这些注解,合约就可以被编译、部署以及从其他合约或客户端调用。
在 Cairo 中,也可以通过使用注解 #[external(v0)] 标记函数来在接口实现外部定义公共函数。
可以在合约中同时使用接口和带注解的外部函数。但是,建议使用接口,因为它允许外部合约在与你的合约交互时依赖于共享定义。
在下面的代码中,我们将添加一个用 #[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::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()
}
}
// ********* 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 ********* //
}
为了确保我们的代码有效并且准备好运行,我们应该编译它。 用于处理 Cairo 代码的一个流行的工具是 Scarb —— 一个 Cairo 包管理器和构建系统。如果还没有安装,请按照 Cairo for Solidity developers 一文中的说明进行安装。
安装完成后,你可以按照以下步骤创建和编译你的合约项目:
scarb new counter 初始化一个新项目,并在出现提示时继续使用默认的测试运行器。cd counter。src/lib.cairo 的内容替换为我们的合约。scarb build。如果你收到类似于 Type annotations needed 的编译错误,请确保你的 Scarb.toml 的 [dependencies] 部分下添加了 starknet = "2.12.0"。
Scarb 还在初始化新项目后生成一个测试合约。测试直接用 Cairo 编写并在本地执行,以在链上部署之前测试实际的合约逻辑。
要查看测试,请导航到 ./tests/test_contract.cairo。以下是生成的测试中发生的情况的细分。

use starknet::ContractAddress;这从 starknet 模块导入 ContractAddress。
ContractAddress 类型。use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};这从 starknet foundry 标准库 snforge_std 导入测试期间声明和部署合约所需的工具。
declare:用于在部署之前在测试环境中声明合约。这就像将合约代码提交到网络一样。ContractClassTrait:为与已声明的合约类(例如部署它们)交互提供辅助方法。DeclareResultTrait:在声明结果上公开一个函数,该函数检索合约类(相当于 Solidity 中的合约字节码)。
use counter::IHelloStarknetSafeDispatcher; 和 use counter::IHelloStarknetSafeDispatcherTrait;这从项目名称(在本例中为 counter)导入合约接口的安全版本。
IHelloStarknetSafeDispatcher:安全调度程序负责调用合约的函数。但是与 Solidity 中函数调用直接返回值不同,这里的每个调用都返回一个包装器,该包装器要么包含返回值(如果成功),要么包含错误(如果失败)。
重要的是,即使合约调用失败,执行也会在测试函数中继续。这允许安全调度程序优雅地处理错误,而不是恢复整个交易。
IHelloStarknetSafeDispatcherTrait:为调度程序公开合约中可调用的函数。每个函数的返回值都被包装,表明它可能成功或失败。
use counter::IHelloStarknetDispatcher; 和 use counter::IHelloStarknetDispatcherTrait;这从项目名称(在本例中为 counter)导入合约接口(不是安全版本)。
IHelloStarknetDispatcher:调度程序还调用合约的函数。但是,与安全版本不同,它直接返回函数的值,没有任何包装器。如果目标合约失败,则调用会立即崩溃,导致执行在测试函数中停止,并阻止任何形式的优雅错误处理。IHelloStarknetDispatcherTrait:为调度程序公开合约中可调用的函数。每个函数都返回接口的原始返回类型
此函数以合约名称(在本例中为 HelloStarknet)作为参数,部署合约并返回其合约地址。
注意: 合约名称是 lib.cairo 文件中 mod 关键字后面的标识符(mod HelloStarknet),而项目名称(例如 counter)只是使用 Scarb 初始化项目时创建的文件夹名称。
以下是函数中发生的情况的细分:
declare(name)
.contract_class()
.deploy(@ArrayTrait::new())
ArrayTrait::new() 用于传递构造函数参数(这里是一个空数组,因为构造函数不带任何参数)。
在上面的截图中,有两个测试用例:
test_increase_balance:使用常规调度程序来调用合约中的函数。test_cannot_increase_balance_with_zero_value:使用安全调度程序来调用合约中的函数。运行以下命令进行测试:
scarb test
在本文中,我们列出了 Cairo 和 Solidity 之间的多个相似之处,但也列出了各种差异。为了清楚起见,比较如下:
mod 关键字与 Solidity 的 contract 关键字的作用类似。#[starknet::interface] 注解的 trait 定义的,就像 Solidity 的 interface 一样。view 一样,请使用 @ 符号将状态作为快照传递。pure 的函数,请像我们对 get_five 函数所做的那样定义函数。public 和 external 一样,请使用 #[external(v0)] 或使用 #[abi(embed_v0)] 在 impl 块中实现它。Solidity 和 Cairo 合约的目的非常相似。虽然 Cairo 的语法有所不同,但许多核心概念对于 Solidity 开发人员来说会很熟悉。
本文中讨论的结构是一种可能的方法,但它不是 Starknet 提供的唯一架构选择。在本系列的后续文章中,我们将探索替代设计,以帮助你更好地了解 Cairo 和 Starknet 为构建可扩展、可组合的智能合约所提供的灵活性。
要继续学习 Cairo 合约,建议尝试并使用我们的 GitHub repo 中的练习。
此文章是 Starknet 上的 Cairo 编程 教程系列的一部分
- 原文链接: rareskills.io/post/cairo...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!