Standardizing Starknet component configuration avoiding unnecessary storage manipulation.
Abstract:
This standard proposes a mechanism to allow setting component’s configurable constants in the extending contracts without needing to save them in storage. Library and protocol developers can leverage this standard to make their components configurable, while keeping the constants hardcoded in the bytecode, removing the extra storage reads that using the storage for this would require.
Motivation:
Library and protocol developers often want to make components configurable over a set of parameters to leverage flexibility for the final users. In Solidity, immutable variables and/or virtual functions are often used to provide this kind of flexibility, since they are hardcoded into the bytecode, not requiring storage reads for accessing the value later, even when they may be initialized in construction time. Cairo doesn’t implement these immutable or virtual mechanisms, but this SNIP proposes a standard to allow users of the component to set these bytecode constants in the contracts that extend from the component.
Specification:
Components that use a set of one-time configurable constants, should provide an ImmutableConfig
trait inside the component module, and this trait MUST contain only associated constants, and optionally a single function named validate
.
The validate
function may be used for adding constraints for the config members, and it panic if the config is invalid, or silently pass otherwise. This function MUST only be used in tests (when testing the contract to ensure the config is valid), and shouldn’t be part of the contract release bytecode since the compiler should optimize it away.
Note that validate doesn’t enforce that your config is right directly, since is never executed on your contract flow, but is designed to catch issues when testing the contract using the component.
Example:
#[starknet::component]
pub mod ERC2981Component {
use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait;
use openzeppelin_introspection::src5::SRC5Component::SRC5Impl;
use openzeppelin_introspection::src5::SRC5Component;
#[storage]
struct Storage {
(...)
}
mod Errors {
(...)
}
/// Constants expected to be defined at the contract level used to configure the component
/// behaviour.
///
/// - `FEE_DENOMINATOR`: The denominator with which to interpret the fee set in
/// `set_token_royalty` and `set_default_royalty` as a fraction of the sale price.
pub trait ImmutableConfig {
const FEE_DENOMINATOR: u256;
}
#[embeddable_as(ERC2981Impl)]
impl ERC2981<
TContractState,
+HasComponent<TContractState>,
impl Immutable: ImmutableConfig,
impl SRC5: SRC5Component::HasComponent<TContractState>,
+Drop<TContractState>,
> of IERC2981<ComponentState<TContractState>> {
fn royalty_info(
self: @ComponentState<TContractState>, token_id: u256, sale_price: u256
) -> (ContractAddress, u256) {
(...)
let royalty_amount = sale_price
* royalty_info.royalty_fraction
/ Immutable::FEE_DENOMINATOR;
(royalty_info.receiver, royalty_amount)
}
}
(...)
}
Notice that the ERC2981 embeddable implementation depends on an implementation of the ImmutableConfig
trait.
Users of the library that want to use the component, need to provide an implementation specifying the values for each configurable const.
#[starknet::contract]
pub mod Contract {
use super::ERC2981Component::ImmutableConfig;
use super::ERC2981Component;
use openzeppelin_introspection::src5::SRC5Component;
component!(path: ERC2981Component, storage: erc2981, event: ERC2981Event);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
#[abi(embed_v0)]
impl ERC2981Impl = ERC2981Component::ERC2981Impl<ContractState>;
impl ERC2981Config of ERC2981Component::ImmutableConfig {
const DEFAULT_FEE_DENOMINATOR: u256 = 10_000;
}
(...)
}
DefaultConfig
Sometimes even while we want some constants to be configurable, we want to provide default values for them, that can be used if the users don’t want/need to modify the default values.
For this, a default implementation MAY be provided, and MUST be a sibling of the component itself (direct child of the parent module), with the name DefaultConfig
.
Example:
#[starknet::component]
pub mod ERC2981Component {
(...)
}
/// Implementation of the ERC2981Component immutable config.
///
/// The default fee denominator is set to 10_000.
pub impl DefaultConfig of ERC2981Component::ImmutableConfig {
const FEE_DENOMINATOR: u256 = 10_000;
}
Then users may use the component in contracts as follow:
#[starknet::contract]
pub mod Contract {
use super::{ERC2981Component, DefaultConfig};
use openzeppelin_introspection::src5::SRC5Component;
component!(path: ERC2981Component, storage: erc2981, event: ERC2981Event);
component!(path: SRC5Component, storage: src5, event: SRC5Event);
#[abi(embed_v0)]
impl ERC2981Impl = ERC2981Component::ERC2981Impl<ContractState>;
(...)
}
Note that we are not defining the implementation again, just bringing it into scope by importing it directly.