组件

以下文档提供了关于如何使用 Cairo 组件的合约的原理和示例。

Starknet 组件是独立的模块,包含存储、事件和实现,可以集成到合约中。 组件本身不能被声明或部署。 另一种看待组件的方式是,它们是必须被实例化的抽象模块。

有关 Starknet 组件的构造和设计的更多信息,请参见 Starknet Shamans 帖子Cairo 书籍

构建合约

设置

合约应首先导入组件并使用 component! 宏声明它:

#[starknet::contract]
mod MyContract {
    // 导入组件
    use openzeppelin_security::InitializableComponent;

    // 声明组件
    component!(path: InitializableComponent, storage: initializable, event: InitializableEvent);
}

path 参数应该是导入的组件本身(在本例中为 InitializableComponent)。 storageevent 参数是在 Storage 结构体和 Event 枚举中设置的变量名。 请注意,即使组件没有定义任何事件,编译器仍然会在组件模块内创建一个空的事件枚举。

#[starknet::contract]
mod MyContract {
    use openzeppelin_security::InitializableComponent;

    component!(path: InitializableComponent, storage: initializable, event: InitializableEvent);

    #[storage]
    struct Storage {
        #[substorage(v0)]
        initializable: InitializableComponent::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        InitializableEvent: InitializableComponent::Event
    }
}

每个组件的 Storage trait 中必须包含 #[substorage(v0)] 属性。 这允许合约间接访问组件的存储。 有关更多信息,请参见 访问组件存储

但是,Event 枚举中事件的 #[flat] 属性不是必需的。 对于组件事件,事件日志中的第一个键是组件 ID。 展平组件事件会删除它,留下事件 ID 作为第一个键。

实现

组件附带不同接口的细粒度实现。 这允许合约仅集成它们将使用的实现,并避免不必要的膨胀。 集成实现如下所示:

mod MyContract {
    use openzeppelin_security::InitializableComponent;

    component!(path: InitializableComponent, storage: initializable, event: InitializableEvent);

    (...)

    // 使合约可以访问实现方法
    impl InitializableImpl =
        InitializableComponent::InitializableImpl<ContractState>;
}

定义一个 impl 使合约可以访问组件中实现的`impl`的方法。 例如,is_initializedInitializableImpl 中定义。 合约级别上的函数可以像这样暴露它:

#[starknet::contract]
mod MyContract {
    use openzeppelin_security::InitializableComponent;

    component!(path: InitializableComponent, storage: initializable, event: InitializableEvent);

    (...)

    impl InitializableImpl =
        InitializableComponent::InitializableImpl<ContractState>;

    #[external(v0)]
    fn is_initialized(ref self: ContractState) -> bool {
        self.initializable.is_initialized()
    }
}

虽然像前面的示例中那样手动暴露方法没有问题,但是对于具有许多方法的实现来说,此过程可能很乏味。 幸运的是,合约可以嵌入实现,这将暴露该实现的所有方法。 要嵌入一个实现,请在 impl 上方添加 #[abi(embed_v0)] 属性:

#[starknet::contract]
mod MyContract {
    (...)

    // 此属性暴露了 `impl` 的方法
    #[abi(embed_v0)]
    impl InitializableImpl =
        InitializableComponent::InitializableImpl<ContractState>;
}

InitializableImpl 在组件中定义了 is_initialized 方法。 通过添加 embed 属性,is_initialized 变成了 MyContract 的合约入口点。

当此库的组件中提供可嵌入的实现时,它们与内部组件实现隔离,这使得可以更安全地暴露它们。 组件还将细粒度实现与 mixin 实现分开。 API 文档设计反映了这些分组。 参见 ERC20Component 作为示例,其中包括:

  • 可嵌入的 Mixin 实现

  • 可嵌入的实现

  • 内部实现

  • 事件

Mixin

Mixin 是由更小、更具体的 impl 组合而成的 impl。 虽然将组件分成细粒度的实现提供了灵活性, 但是集成具有许多实现的组件可能会显得拥挤,尤其是当合约使用所有这些实现时。 Mixin 通过允许合约使用单个指令嵌入实现组来简化此操作。

比较以下代码块,以在使用 mixin 创建帐户合约时查看其好处。

没有 mixin 的账户

component!(path: AccountComponent, storage: account, event: AccountEvent);
component!(path: SRC5Component, storage: src5, event: SRC5Event);

#[abi(embed_v0)]
impl SRC6Impl = AccountComponent::SRC6Impl<ContractState>;
#[abi(embed_v0)]
impl DeclarerImpl = AccountComponent::DeclarerImpl<ContractState>;
#[abi(embed_v0)]
impl DeployableImpl = AccountComponent::DeployableImpl<ContractState>;
#[abi(embed_v0)]
impl PublicKeyImpl = AccountComponent::PublicKeyImpl<ContractState>;
#[abi(embed_v0)]
impl SRC6CamelOnlyImpl = AccountComponent::SRC6CamelOnlyImpl<ContractState>;
#[abi(embed_v0)]
impl PublicKeyCamelImpl = AccountComponent::PublicKeyCamelImpl<ContractState>;
impl AccountInternalImpl = AccountComponent::InternalImpl<ContractState>;

#[abi(embed_v0)]
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;

带有 mixin 的账户

component!(path: AccountComponent, storage: account, event: AccountEvent);
component!(path: SRC5Component, storage: src5, event: SRC5Event);

#[abi(embed_v0)]
impl AccountMixinImpl = AccountComponent::AccountMixinImpl<ContractState>;
impl AccountInternalImpl = AccountComponent::InternalImpl<ContractState>;

但是,合约的其余设置没有改变。 这意味着组件依赖项仍然必须包含在 Storage 结构体和 Event 枚举中。 这是一个完整的帐户合约示例,该合约嵌入了 AccountMixinImpl

#[starknet::contract]
mod Account {
    use openzeppelin_account::AccountComponent;
    use openzeppelin_introspection::src5::SRC5Component;

    component!(path: AccountComponent, storage: account, event: AccountEvent);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // 这嵌入了许多 AccountComponent 实现中的所有方法
    // 并且还包括来自 `SRC5Impl` 的 `supports_interface`
    #[abi(embed_v0)]
    impl AccountMixinImpl = AccountComponent::AccountMixinImpl<ContractState>;
    impl AccountInternalImpl = AccountComponent::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        account: AccountComponent::Storage,
        #[substorage(v0)]
        src5: SRC5Component::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        AccountEvent: AccountComponent::Event,
        #[flat]
        SRC5Event: SRC5Component::Event
    }

    #[constructor]
    fn constructor(ref self: ContractState, public_key: felt252) {
        self.account.initializer(public_key);
    }
}

初始化器

未能使用组件的 initializer 可能会导致无法修复的合约部署。 始终阅读每个集成组件的 API 文档。

一些组件在构造时需要进行某种设置。 通常,这将是构造函数的工作;但是,组件本身无法实现构造函数。 组件改为在其 InternalImpl 中提供 initializer,以便从合约的构造函数中调用。 让我们看看合约将如何集成 OwnableComponent

#[starknet::contract]
mod MyContract {
    use openzeppelin_access::ownable::OwnableComponent;
    use starknet::ContractAddress;

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

    // 实例化 `InternalImpl` 以使合约可以访问 `initializer`
    impl InternalImpl = OwnableComponent::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        ownable: OwnableComponent::Storage
    }

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

    #[constructor]
    fn constructor(ref self: ContractState, owner: ContractAddress) {
        // 调用 ownable 的 `initializer`
        self.ownable.initializer(owner);
    }
}

不可变配置

虽然初始值设定项有助于设置组件的初始状态,但有些需要配置,这些配置可以定义为常量,从而通过避免每次需要使用变量时都从存储器中读取来节省 Gas。 不可变组件配置模式通过允许实现合约定义在组件中声明的一组常量来解决此问题,从而定制其功能。

不可变组件配置标准在 SRC-107 中定义。

这是一个如何使用带有 ERC2981Component 的不可变组件配置模式的示例:

#[starknet::contract]
mod MyContract {
    use openzeppelin_introspection::src5::SRC5Component;
    use openzeppelin_token::common::erc2981::ERC2981Component;
    use starknet::contract_address_const;

    component!(path: ERC2981Component, storage: erc2981, event: ERC2981Event);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // SRC5
    #[abi(embed_v0)]
    impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;

    // 实例化 `InternalImpl` 以使合约可以访问 `initializer`
    impl InternalImpl = ERC2981Component::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        erc2981: ERC2981Component::Storage,
        #[substorage(v0)]
        src5: SRC5Component::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        ERC2981Event: ERC2981Component::Event,
        #[flat]
        SRC5Event: SRC5Component::Event
    }

    // 定义不可变配置
    pub impl ERC2981ImmutableConfig of ERC2981Component::ImmutableConfig {
        const FEE_DENOMINATOR: u128 = 10_000;
    }

    #[constructor]
    fn constructor(ref self: ContractState) {
        let default_receiver = contract_address_const::<'RECEIVER'>();
        let default_royalty_fraction = 1000;
        // 调用 erc2981 的 `initializer`
        self.erc2981.initializer(default_receiver, default_royalty_fraction);
    }
}

默认配置

有时,实现不可变组件配置模式的组件提供了一个默认配置,可以直接使用它,而无需在本地实现 ImmutableConfig trait。 提供时,此实现将命名为 DefaultConfig,并且将在包含该组件的同一模块中可用,作为同级项。

在以下示例中,DefaultConfig trait 用于定义 FEE_DENOMINATOR 配置常量。

#[starknet::contract]
mod MyContract {
    use openzeppelin_introspection::src5::SRC5Component;
    // 将 DefaultConfig trait 带入范围
    use openzeppelin_token::common::erc2981::{ERC2981Component, DefaultConfig};
    use starknet::contract_address_const;

    component!(path: ERC2981Component, storage: erc2981, event: ERC2981Event);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // SRC5
    #[abi(embed_v0)]
    impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;

    // 实例化 `InternalImpl` 以使合约可以访问 `initializer`
    impl InternalImpl = ERC2981Component::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        (...)
    }

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

    #[constructor]
    fn constructor(ref self: ContractState) {
        let default_receiver = contract_address_const::<'RECEIVER'>();
        let default_royalty_fraction = 1000;
        // 调用 erc2981 的 `initializer`
        self.erc2981.initializer(default_receiver, default_royalty_fraction);
    }
}

validate 函数

ImmutableConfig trait 也可以包含一个带有默认实现的 validate 函数,该函数断言配置正确,不能被实现合约覆盖。 有关如何使用此函数的更多信息,请参阅 SRC-107 的 validate 部分

依赖

一些组件包括对其他组件的依赖关系。 集成具有依赖关系的组件的合约还必须包括组件依赖关系。 例如,AccessControlComponent 依赖于 SRC5Component。 使用 AccessControlComponent 创建合约应如下所示:

#[starknet::contract]
mod MyContract {
    use openzeppelin_access::accesscontrol::AccessControlComponent;
    use openzeppelin_introspection::src5::SRC5Component;

    component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // AccessControl
    #[abi(embed_v0)]
    impl AccessControlImpl =
        AccessControlComponent::AccessControlImpl<ContractState>;
    #[abi(embed_v0)]
    impl AccessControlCamelImpl =
        AccessControlComponent::AccessControlCamelImpl<ContractState>;
    impl AccessControlInternalImpl = AccessControlComponent::InternalImpl<ContractState>;

    // SRC5
    #[abi(embed_v0)]
    impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        accesscontrol: AccessControlComponent::Storage,
        #[substorage(v0)]
        src5: SRC5Component::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        AccessControlEvent: AccessControlComponent::Event,
        #[flat]
        SRC5Event: SRC5Component::Event
    }

    (...)
}

自定义

自定义实现和访问组件存储可能会破坏状态、绕过安全检查并破坏组件逻辑。 务必格外小心。 参见 安全

钩子

钩子是令牌组件的业务逻辑的入口点,可以在合约级别访问。 这允许合约在令牌转移(包括铸币和销毁)之前和/或之后插入额外的行为。 在钩子之前,扩展功能需要合约创建 自定义实现

所有令牌组件都包含一个通用钩子 trait,其中包括空的默认函数。 创建令牌合约时,使用合约必须创建钩子 trait 的实现。 假设 ERC20 合约希望在令牌转移中包含可暂停功能。 以下代码段利用 before_update 钩子来包含此行为。

#[starknet::contract]
mod MyToken {
    use openzeppelin_security::pausable::PausableComponent::InternalTrait;
    use openzeppelin_security::pausable::PausableComponent;
    use openzeppelin_token::erc20::{ERC20Component, DefaultConfig};
    use starknet::ContractAddress;

    component!(path: ERC20Component, storage: erc20, event: ERC20Event);
    component!(path: PausableComponent, storage: pausable, event: PausableEvent);

    // ERC20 Mixin
    #[abi(embed_v0)]
    impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
    impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;

    #[abi(embed_v0)]
    impl PausableImpl = PausableComponent::PausableImpl<ContractState>;
    impl PausableInternalImpl = PausableComponent::InternalImpl<ContractState>;

    // 创建钩子实现
    impl ERC20HooksImpl of ERC20Component::ERC20HooksTrait<ContractState> {
        // 在令牌转移之前发生
        fn before_update(
            ref self: ERC20Component::ComponentState<ContractState>,
            from: ContractAddress,
            recipient: ContractAddress,
            amount: u256
        ) {
            // 从组件状态访问本地状态
            let contract_state = self.get_contract();
            // 从集成的组件调用函数
            contract_state.pausable.assert_not_paused();
        }

        // 省略 `after_update` 钩子,因为默认行为
        // 已经在 trait 中实现
    }

    (...)
}

请注意,self 参数需要组件状态类型。 可以传递使用合约的状态,而不是传递组件状态,这简化了语法。 然后,钩子通过 Cairo 生成的 get_contract,通过 HasComponent trait 将作用域向上移动(如本示例中的 ERC20Component 所示)。 从这里,钩子可以访问使用合约的集成组件、存储和实现。

请注意,即使令牌合约不需要钩子,仍然必须实现钩子 trait。 使用合约可以实例化 trait 的一个空 impl; 但是,Cairo 库的合约已经提供了实例化的 impl,以从合约中抽象出这一点。 使用合约只需要像这样将实现带入范围:

#[starknet::contract]
mod MyToken {
    use openzeppelin_token::erc20::{ERC20Component, DefaultConfig};
    use openzeppelin_token::erc20::ERC20HooksEmptyImpl;

    (...)
}
有关钩子的更深入指南,请参见 使用钩子扩展 Cairo 合约

自定义实现

在某些情况下,合约需要与组件实现不同的或修改后的行为。 在这些情况下,合约必须创建接口的自定义实现。 让我们分解一个可暂停的 ERC20 合约,看看它是什么样的。 这是设置:

#[starknet::contract]
mod ERC20Pausable {
    use openzeppelin_security::pausable::PausableComponent;
    use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, DefaultConfig};
    // 导入 ERC20 接口以创建自定义实现
    use openzeppelin_token::erc20::interface::{IERC20, IERC20CamelOnly};
    use starknet::ContractAddress;

    component!(path: PausableComponent, storage: pausable, event: PausableEvent);
    component!(path: ERC20Component, storage: erc20, event: ERC20Event);

    #[abi(embed_v0)]
    impl PausableImpl = PausableComponent::PausableImpl<ContractState>;
    impl PausableInternalImpl = PausableComponent::InternalImpl<ContractState>;

    // `ERC20MetadataImpl` 可以保留 embed 指令,因为实现
    // 不会改变
    #[abi(embed_v0)]
    impl ERC20MetadataImpl = ERC20Component::ERC20MetadataImpl<ContractState>;
    // 不要将 embed 指令添加到这些实现,因为
    // 这些将被自定义
    impl ERC20Impl = ERC20Component::ERC20Impl<ContractState>;
    impl ERC20CamelOnlyImpl = ERC20Component::ERC20CamelOnlyImpl<ContractState>;

    impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;

    (...)
}

首先要注意的是,合约导入了将要自定义的实现的接口。 这些将在下一个代码示例中使用。

接下来,合约包括 ERC20Component 实现;但是,ERC20ImplERC20CamelOnlyImplt 被嵌入。 相反,我们想暴露接口的自定义实现。 以下示例显示了集成到 ERC20 实现中的可暂停逻辑:

#[starknet::contract]
mod ERC20Pausable {
    (...)

    // 自定义 ERC20 实现
    #[abi(embed_v0)]
    impl CustomERC20Impl of IERC20<ContractState> {
        fn transfer(
            ref self: ContractState, recipient: ContractAddress, amount: u256
        ) -> bool {
            // 添加自定义逻辑
            self.pausable.assert_not_paused();
            // 从 `IERC20Impl` 添加原始实现方法
            self.erc20.transfer(recipient, amount)
        }

        fn total_supply(self: @ContractState) -> u256 {
            // 此方法从组件的行为没有改变
            // 实现,但是仍然必须定义此方法。
            // 只需从 `IERC20Impl` 添加原始实现方法
            self.erc20.total_supply()
        }

        (...)
    }

    // 自定义 ERC20CamelOnly 实现
    #[abi(embed_v0)]
    impl CustomERC20CamelOnlyImpl of IERC20CamelOnly<ContractState> {
        fn totalSupply(self: @ContractState) -> u256 {
            self.erc20.total_supply()
        }

        fn balanceOf(self: @ContractState, account: ContractAddress) -> u256 {
            self.erc20.balance_of(account)
        }

        fn transferFrom(
            ref self: ContractState,
            sender: ContractAddress,
            recipient: ContractAddress,
            amount: u256
        ) -> bool {
            self.pausable.assert_not_paused();
            self.erc20.transfer_from(sender, recipient, amount)
        }
    }
}

请注意,在 CustomERC20Impl 中,transfer 方法分别集成了来自 PausableImplERC20Implpausable.assert_not_paused 以及 erc20.transfer。 这就是为什么合约在前一个示例中定义了来自组件的 ERC20Impl

创建接口的自定义实现必须定义该接口的所有 所有 方法。 即使方法的行为与组件实现没有改变,也是如此(如本示例中的 total_supply 所例证的那样)。

访问组件存储

在某些情况下,合约必须读取或写入集成组件的存储。 为此,请使用与调用实现方法相同的语法,除了将方法名称替换为存储变量,如下所示:

#[starknet::contract]
mod MyContract {
    use openzeppelin_security::InitializableComponent;

    component!(path: InitializableComponent, storage: initializable, event: InitializableEvent);

    #[storage]
    struct Storage {
        #[substorage(v0)]
        initializable: InitializableComponent::Storage
    }

    (...)

    fn write_to_comp_storage(ref self: ContractState) {
        self.initializable.Initializable_initialized.write(true);
    }

    fn read_from_comp_storage(self: @ContractState) -> bool {
        self.initializable.Initializable_initialized.read()
    }
}

安全

OpenZeppelin Contracts for Cairo 的维护者主要关注库中已发布代码的正确性和安全性。

自定义实现和操作组件状态可能会破坏一些重要的假设并引入漏洞。 虽然我们尽力确保组件在面对各种潜在的自定义设置时保持安全,但这只是尽力而为。 应仔细审查和检查对组件库的任何和所有自定义设置,并对照他们正在自定义的组件的源代码进行检查,以便充分理解其影响并保证其安全性。