基础合约的结构

本文介绍了如何使用 Cairo 语言为 Starknet 构建可部署的智能合约。文章从一个简单的合约草图开始,逐步添加功能,演示了 Cairo 合约的核心构建块,包括模块、接口(trait)、存储、合约状态以及不同的注解,最后介绍了合约的编译和测试方法。

本文展示了如何为 Starknet 构建一个可部署的 Cairo 合约。从一个简单的草图开始,我们将逐步添加特性,以构建一个可用的合约,演示 Cairo 合约的核心构建块。

该合约将有一个计数器变量,该变量可以增加任意数量,还有一个函数可以检索其值。

合约的第一个版本

mod Counter {
    fn increase_counter(amount: felt252) {
        // TODO
    }

    fn get_counter() -> felt252 {
        // TODO
    }
}

上面的代码包括以下特性:

  • 一个模块块,由 mod 关键字表示。每个 Cairo 合约都写在一个模块内。这类似于 Solidity 中的 contract 关键字,模块的名称可以是任意的。
  • 两个函数:一个用于增加计数器,另一个用于检索其当前值。

通过为 Counter 合约定义 trait 来添加“接口”

接口定义了合约必须实现的一组函数。接口对于合约来说不是强制性的,但鼓励使用它们。

在 Cairo 中,这个相同的想法用 trait 来表示,它定义了函数列表,但不提供它们的实现。从这个意义上讲,Cairo trait 与 Solidity 中的接口扮演着相同的角色。

然而,重要的是要澄清,trait 本身不会自动被视为合约接口。我们需要显式地将 trait 标记为接口,才能将其视为接口,这可以通过注解来完成,我们将在后面的章节中看到。

目前,可以这样理解:

  • 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
        }
    }
}

此草案添加了以下特性:

  • 一个公共 trait,由 pubtrait 关键字表示。
  • 一个实现(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 中有两种定义状态引用的方法:一种是提供对存储的读写访问权限,另一种是提供只读访问权限。以下是如何使用它们:

  1. 读写访问: 使用带有 ref 关键字的引用变量。
  2. 只读访问: 使用带有 @ 符号的快照变量。这类似于 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 使用不同的注解(也称为属性)来指示合约的不同部分应该如何表现。这些注解指定了诸如以下内容:

  • 哪个 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 中的 publicexternal 函数一样。省略此注解会使函数仅在此合约内部可用。

    • 在 Cairo 中,没有像 Solidity 中的 privateinternal 这样的可见性关键字来表示私有函数。创建私有函数的另一种方法是简单地将它们添加到 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 一文中的说明进行安装。

安装完成后,你可以按照以下步骤创建和编译你的合约项目:

  1. 通过运行 scarb new counter 初始化一个新项目,并在出现提示时继续使用默认的测试运行器。
  2. 导航到项目文件夹:cd counter
  3. src/lib.cairo 的内容替换为我们的合约。
  4. 编译合约:scarb build

如果你收到类似于 Type annotations needed 的编译错误,请确保你的 Scarb.toml[dependencies] 部分下添加了 starknet = "2.12.0"

测试合约

Scarb 还在初始化新项目后生成一个测试合约。测试直接用 Cairo 编写并在本地执行,以在链上部署之前测试实际的合约逻辑。

要查看测试,请导航到 ./tests/test_contract.cairo。以下是生成的测试中发生的情况的细分。

导入

cairo 合约文件顶部的导入

  1. use starknet::ContractAddress;

这从 starknet 模块导入 ContractAddress。

  • 导入 ContractAddress 类型。
  • 这是 Starknet 对合约地址的表示,并且在与已部署的合约交互或引用已部署的合约时是必需的。
    1. use snforge_std::{declare, ContractClassTrait, DeclareResultTrait};

这从 starknet foundry 标准库 snforge_std 导入测试期间声明和部署合约所需的工具。

  • declare:用于在部署之前在测试环境中声明合约。这就像将合约代码提交到网络一样。
  • ContractClassTrait:为与已声明的合约类(例如部署它们)交互提供辅助方法。
  • DeclareResultTrait:在声明结果上公开一个函数,该函数检索合约类(相当于 Solidity 中的合约字节码)。
    1. use counter::IHelloStarknetSafeDispatcher;use counter::IHelloStarknetSafeDispatcherTrait;

这从项目名称(在本例中为 counter)导入合约接口的安全版本。

  • IHelloStarknetSafeDispatcher:安全调度程序负责调用合约的函数。但是与 Solidity 中函数调用直接返回值不同,这里的每个调用都返回一个包装器,该包装器要么包含返回值(如果成功),要么包含错误(如果失败)。

    重要的是,即使合约调用失败,执行也会在测试函数中继续。这允许安全调度程序优雅地处理错误,而不是恢复整个交易。

  • IHelloStarknetSafeDispatcherTrait:为调度程序公开合约中可调用的函数。每个函数的返回值都被包装,表明它可能成功或失败。

    1. use counter::IHelloStarknetDispatcher;use counter::IHelloStarknetDispatcherTrait;

这从项目名称(在本例中为 counter)导入合约接口(不是安全版本)。

  • IHelloStarknetDispatcher:调度程序还调用合约的函数。但是,与安全版本不同,它直接返回函数的值,没有任何包装器。如果目标合约失败,则调用会立即崩溃,导致执行在测试函数中停止,并阻止任何形式的优雅错误处理。
  • IHelloStarknetDispatcherTrait:为调度程序公开合约中可调用的函数。每个函数都返回接口的原始返回类型

部署函数

在 starknet 上部署合约的代码

此函数以合约名称(在本例中为 HelloStarknet)作为参数,部署合约并返回其合约地址。

注意: 合约名称lib.cairo 文件中 mod 关键字后面的标识符(mod HelloStarknet),而项目名称(例如 counter)只是使用 Scarb 初始化项目时创建的文件夹名称。

以下是函数中发生的情况的细分:

  • declare(name)
    • 这会获取合约的名称(通常作为字节数组提供)并将其声明到 Starknet 网络。
  • .contract_class()
    • 从已声明的合约中提取合约类。
  • .deploy(@ArrayTrait::new())
    • 部署合约类。
    • ArrayTrait::new() 用于传递构造函数参数(这里是一个空数组,因为构造函数不带任何参数)。
    • 它返回一个元组,其中第一个元素是合约地址。
  • 返回值
    • 该函数返回新部署的合约的地址。

测试用例

在终端中运行的测试

在上面的截图中,有两个测试用例:

  1. test_increase_balance:使用常规调度程序来调用合约中的函数。
  2. 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 中使用 publicexternal 一样,请使用 #[external(v0)] 或使用 #[abi(embed_v0)]impl 块中实现它。

结论

Solidity 和 Cairo 合约的目的非常相似。虽然 Cairo 的语法有所不同,但许多核心概念对于 Solidity 开发人员来说会很熟悉。

本文中讨论的结构是一种可能的方法,但它不是 Starknet 提供的唯一架构选择。在本系列的后续文章中,我们将探索替代设计,以帮助你更好地了解 Cairo 和 Starknet 为构建可扩展、可组合的智能合约所提供的灵活性。

下一步

要继续学习 Cairo 合约,建议尝试并使用我们的 GitHub repo 中的练习。

此文章是 Starknet 上的 Cairo 编程 教程系列的一部分

  • 原文链接: rareskills.io/post/cairo...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/