组件:智能合约的乐高式构建块

开发共享通用逻辑和存储的合约可能会很痛苦,而且容易出错, 因为这个逻辑很难重用,需要在每份合约中重新编写。 但是,如果有一种方法可以额外提供您在合约中需要的功能, 并与你的合约的核心逻辑是分离的会怎样呢?

组件正好提供了这一点。它们是封装可重复使用的 模块化附加逻辑、存储和事件,可以被整合到多个合约中。 它们可用于扩展合约的功能,而无需一遍又一遍地重新实现相同的逻辑。

将组件视为乐高积木。它们允许您通过插入您或其他人编写的模块方式增强您的合约。 这个模块可以是一个简单的组件,如所有权组件,或更复杂的,如成熟的 ERC20代币。

组件是一个单独的模块,可以包含存储、事件和功能。 与合约不同,你不能声明或部署组件。 其逻辑最终将成为它被嵌入的合约字节码的一部分。

组件里面有什么?

组件与合约非常相似。它可以包含:

  • 存储变量
  • 事件
  • 外部和内部函数

与合约不同,组件不能单独部署。组件的代码成为嵌入到的合约的一部分。

创建组件

要创建一个组件,首先在它自己的模块中定义它,并用#[starknet::component] 属性。 在此模块中,您可以声明一个 Storage 结构和 Event 枚举,这通常在合约中。

下一步是定义组件接口,其中包含允许从外部访问组件逻辑的函数。 您可以这样定义组件的接口,方法是使用#[starknet::interface]属性, 就像你使用合约一样。这接口通过Dispatcher模式, 用于启用对组件功能的外部访问。

组件外部逻辑的实际实现是在标记为#[embeddable_as(name)]impl代码块中完成的。 通常,这个impl块将是一个定义组件接口的trait的实现。

注意:name是我们将在合约中用来指代 组件的名称。它与 impl 的名称不同。

您还可以定义外部无法访问的内部函数, 只需省略内部impl块上方的 #[embeddable_as(name)] 属性即可。 您将能够在合约内部使用组件,但无法从外部与组件交互,因为它们不是合约 ABI 的一部分。

这些impl块中的函数需要类似ref self:ComponentState<TContractState>(用于状态修改函数)的参数 或self:@ComponentState<TContractState>(用于只读函数)。这使得 impl 基于泛型 TContractState,允许我们在任何合约中使用该组件。

示例:一个 Ownable 组件

⚠️ 下面显示的示例尚未经过审核,不应适用于 产品之中。作者对因 使用此代码产生的任何问题概不负责。

Ownable 组件的接口,定义的用于管理合约所有权外部可用方法如下所示:

#![allow(unused)]
fn main() {
#[starknet::interface]
trait IOwnable<TContractState> {
    fn owner(self: @TContractState) -> ContractAddress;
    fn transfer_ownership(ref self: TContractState, new_owner: ContractAddress);
    fn renounce_ownership(ref self: TContractState);
}
}

组件本身定义为:

#![allow(unused)]
fn main() {
#[starknet::component]
mod ownable_component {
    use starknet::ContractAddress;
    use starknet::get_caller_address;
    use super::Errors;

    #[storage]
    struct Storage {
        owner: ContractAddress
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        OwnershipTransferred: OwnershipTransferred
    }

    #[derive(Drop, starknet::Event)]
    struct OwnershipTransferred {
        previous_owner: ContractAddress,
        new_owner: ContractAddress,
    }

    #[embeddable_as(Ownable)]
    impl OwnableImpl<
        TContractState, +HasComponent<TContractState>
    > of super::IOwnable<ComponentState<TContractState>> {
        fn owner(self: @ComponentState<TContractState>) -> ContractAddress {
            self.owner.read()
        }

        fn transfer_ownership(
            ref self: ComponentState<TContractState>, new_owner: ContractAddress
        ) {
            assert(!new_owner.is_zero(), Errors::ZERO_ADDRESS_OWNER);
            self.assert_only_owner();
            self._transfer_ownership(new_owner);
        }

        fn renounce_ownership(ref self: ComponentState<TContractState>) {
            self.assert_only_owner();
            self._transfer_ownership(Zeroable::zero());
        }
    }

    #[generate_trait]
    impl InternalImpl<
        TContractState, +HasComponent<TContractState>
    > of InternalTrait<TContractState> {
        fn initializer(ref self: ComponentState<TContractState>, owner: ContractAddress) {
            self._transfer_ownership(owner);
        }

        fn assert_only_owner(self: @ComponentState<TContractState>) {
            let owner: ContractAddress = self.owner.read();
            let caller: ContractAddress = get_caller_address();
            assert(!caller.is_zero(), Errors::ZERO_ADDRESS_CALLER);
            assert(caller == owner, Errors::NOT_OWNER);
        }

        fn _transfer_ownership(
            ref self: ComponentState<TContractState>, new_owner: ContractAddress
        ) {
            let previous_owner: ContractAddress = self.owner.read();
            self.owner.write(new_owner);
            self
                .emit(
                    OwnershipTransferred { previous_owner: previous_owner, new_owner: new_owner }
                );
        }
    }
}
}

这种语法实际上与用于合约的语法非常相似。 唯一的差异与 impl 上方的 #[embeddable_as] 属性有关。 我们将详细剖析的 impl 块的泛用性。

正如你所看到的,我们的组件有两个 impl 块: 一个对应于接口trait的实现,另一个包含不暴露在外部,仅供内部使用方法。 把assert_only_owner暴露出来作为接口的一部分是没有意义的, 因为它只是旨在由嵌入组件的合约在内部使用。

进一步研究 impl

#![allow(unused)]
fn main() {
    #[embeddable_as(Ownable)]
    impl OwnableImpl<
        TContractState, +HasComponent<TContractState>
    > of super::IOwnable<ComponentState<TContractState>> {
}

#[embeddable_as]属性用于将 impl 标记为可嵌入到合约。 它允许我们指定将在合约中引用此组件。 在这种情况下,组件将在嵌入它的合约中被视为Ownable

实现本身是泛型的 ComponentState<TContractState>, 有着TContractState 必须实现HasComponent<T> trait的附加限制。 这允许我们在任何实现了HasComponent trait的合约中中使用该组件。 使用组件其实不需要你了解此机制的工作原理,但如果您对内部感到好奇,您可以阅读 组件工作原理 部分来学习更多。

这里与常规智能合约的主要区别之一是访问 存储和事件是通过通用的ComponentState<TContractState>类型完成的, 而不是ContractState。请注意,虽然类型不同,但访问 存储或发出事件都是是通过类似 self.storage_var_name.read()self.emit(...)实现的。

注意:为避免混淆嵌入的名称和 impl 名称,我们 建议在 impl 名称中保留后缀“Impl”。

将合约迁移到组件

由于合约和组件有很多相似之处,因此实际上 从合约迁移到组件非常容易。唯一需要的更改 是:

  • #[starknet::component] 属性添加到模块中。
  • #[embeddable_as(name)] 属性添加到 impl 块中,该块将嵌入到另一个合约中。
  • 将泛型参数添加到impl块:
  • 添加 'TContractState' 作为泛型参数。
  • 添加 ·+HasComponent· 作为 impl 限制。
  • impl块的函数中更改self参数的类型, 设置为ComponentState<TContractState>用以取代ContractState

对于那些没有明确定义且使用 #[generate_trait]的trait,逻辑是相同的 - 但这些trait是TContractState而不是ComponentState<TContractState>的泛型, 如上面例子中就带有InternalTrait

在合约中使用组件

组件的主要优势在于它允许你在合约中的重复使用已经构建的组件原语, 且只需要相当少的模版代码。 将组件集成到您的合约中,您需要:

  1. component!() 宏,指定

    1. 组件的路径 path::to::component
    2. 合约存储中变量的名称,引用此 组件的存储(例如ownable)。
    3. 合约事件枚举中变体的名称,引用此 组件的事件(例如OwnableEvent)。
  2. 将组件存储的路径和事件添加到合约的 StorageEvent中。它们必须与步骤 1 中提供的名称匹配(例如 ownable: ownable_component::Storage and OwnableEvent: ownable_component::Event)。

    存储变量 必须#[substorage(v0)]属性进行标注

  3. 要将组件的逻辑嵌入到合约中, 可以通过使用了impl别名的一个具体的 ContractState, 来实例化一个组件的泛型 impl 。 此别名必须用 #[abi(embed_v0)]标注,来将组件函数暴露给外部。

    如您所见,内部impl 未标有 #[abi(embed_v0)]。 事实上,我们不想从外部公开该impl其中定义的函数 但是,我们可能仍希望在内部访问它们。

例如,要嵌入上面定义的Ownable 组件,我们将执行:

#![allow(unused)]
fn main() {
#[starknet::contract]
mod OwnableCounter {
    use listing_01_ownable::component::ownable_component;

    component!(path: ownable_component, storage: ownable, event: OwnableEvent);

    #[abi(embed_v0)]
    impl OwnableImpl = ownable_component::Ownable<ContractState>;

    impl OwnableInternalImpl = ownable_component::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        counter: u128,
        #[substorage(v0)]
        ownable: ownable_component::Storage
    }


    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        OwnableEvent: ownable_component::Event
    }


    #[external(v0)]
    fn foo(ref self: ContractState) {
        self.ownable.assert_only_owner();
        self.counter.write(self.counter.read() + 1);
    }
}
}

组件的逻辑现在无缝地成为合约的一部分! 我们可以通过随着合约实例化的IOwnableDispatcher 与组件函数互动。

#![allow(unused)]
fn main() {
#[starknet::interface]
trait IOwnable<TContractState> {
    fn owner(self: @TContractState) -> ContractAddress;
    fn transfer_ownership(ref self: TContractState, new_owner: ContractAddress);
    fn renounce_ownership(ref self: TContractState);
}
}

堆叠组件以实现最大的可组合性

当组合多个组件时,组件的可组合性将大放异彩。 每个组件都将其功能添加到合约中。 以后您将可以通过依赖 Openzeppelin's 的 组件实现以快速插入你所需的合约所有常用功能。

开发人员可以专注于他们的核心合约逻辑, 同时在其他部分依靠经过实战考验的逻辑并经过审核的组件。

组件甚至可以 依赖 其他组件, 只要另一个组件通过了TContractstate这个泛型。 在我们深入研究这个机制之前,让我们先看看深入理解组件如何工作

疑难解答

尝试实现组件时可能会遇到一些错误。 不幸的是,其中一些错误缺少有意义的错误消息来帮助调试。 这部分旨在为您提供一些指导来帮助您调试代码。

  • Trait not found. Not a trait.

    当您未正确的在合约中导入组件的 impl 块时, 可能会发生此错误。请确保遵循了以下语法:

#![allow(unused)]
fn main() {
 #[abi(embed_v0)]
 impl IMPL_NAME = upgradeable::EMBEDDED_NAME<ContractState>
}

参考我们之前的示例,这里应该写成:

#![allow(unused)]
fn main() {
 #[abi(embed_v0)]
 impl OwnableImpl = upgradeable::Ownable<ContractState>
}
  • Plugin diagnostic: name is not a substorage member in the contract's Storage. Consider adding to Storage: (...)

    在您调试时,编译器给出的建议操作能够起到很大的作用。 基本上,这条错误表示您忘记将组件的存储添加到 合约的存储中。使用#[substorage(v0)]属性标注, 确保将其添加到到合约的存储中。

  • Plugin diagnostic: name is not a nested event in the contract's Event enum. Consider adding to the Event enum:

    与前面的错误类似,编译器提醒您忘记添加组件的 事件到合约的事件中。确保将组件事件路径添加到合约的事件中。

  • Components functions are not accessible externally

    这会发生在您忘了使用#[abi(embed_v0)]来标注impl块时。 请确保在嵌入组件时在合约的impl块上用此标注。

Last change: 2023-10-12, commit: 9085fd1