Immutable Component Config

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.

Hey Eric,

I have a couple of questions:

  1. How would the DefaultConfig work in case of multiple components? I can’t just import DefaultConfig twice, so I assume I’d have to rename then use super::DefaultConfig as ERC2981DefaultConfig, would that still work?

  2. Have you though about a way how a component user can mix the DefaultConfig and user config? Say if there’s a component that has 7 config values and I want to modify only 2 of them and use the remaining 5 from DefaultConfig, would that be possible?

  3. I don’t quite understand the need for validate, can you provide an example where it’s useful? TBH it feels strange to have that just for testing. Wouldn’t it be possible to test the component initialization with regular unit tests?

Let’s say we are implementing a component where the ImmutableConfig contains a “max_fee_percent” member, but the logic in the component works only if the max_fee is set below 10 percent. We may document this constraint somewhere, but we don’t have a way of enforcing it, since the final value is user-defined, and we can’t test it in the component scope.

The idea of validate is to have a standard (optional) function that users of the component (implementing a contract) may use for testing the interaction with the component, by “enforcing” these constraints in the user tests avoiding configuration mistakes that may break the component in sometimes unexpected and hard to debug ways.

I don’t like implementing a function just for testing either, but nothing better has occurred to me for addressing the issue, and I feel is worth addressing it, and validate It is meant to be optional.

Since associated constants can’t have default values (in traits), I don’t see a way to do this besides using functions (with default implementations). That would make the implementation and trait unnecessarily extra verbose imo, and slightly more expensive step-wise by calling functions instead of using constants (in the case the functions are not directly inlined by the compiler ofc, but that’s another topic). With the current proposal you would need to “re-assign” those 5 values as well.

Aliasing is what I had in mind for that case, and it should work yes.

If the component has an initializer, we can call this validate function there, and it doesn’t have to be used just in tests.

I like the idea — it definitely adds flexibility to components and makes them easier to configure. While this was already possible using initializer parameters and constants stored in storage, it involved unnecessary storage read/write operations.

It would be really helpful to have a standard way to customize components. This would lead to:

  • A more consistent codebase across projects
  • Developers getting used to the approach, making component integration smoother
  • Fewer problems when a pre-built component can’t be used due to unusual customization needs

Some thoughts on the design:

  1. Since there’s no longer control over the constant values, it would be great to validate the values at the time of deployment in the component’s initializer.
  2. I’m not sure if the validate function should be part of config. This way it can be overridden in the custom config. What do you think about making it a component’s internal function or a util function outside of the component?
  3. Although functions do provide opportunity to use default implementations, I agree that constants seem to be cleaner approach and may be more efficient in terms of computation. Also, if a custom config is used, I would prefer to have all constants declared in a single place.

I removed the test-only validate part and created a more formal specification for the proposal as a PR to the SNIP repo.

The main difference is the validate function, which is now defined like this:

validate function

Sometimes, the constants specified in the ImmutableConfig should be validated to ensure implementing contracts don’t set incorrect values that may break the component behavior. To address this, an OPTIONAL validate function SHOULD be added to the ImmutableConfig trait with the following specification:

  1. The ImmutableConfig trait MUST provide a default implementation for the validate function asserting the correctness of the set of constants.
  2. The default implementation MUST NOT be overridden by the ImmutableConfig implementations used in the implementing contract.
  3. The validate function MUST panic if the values are incorrect and silently pass otherwise.
  4. The validate function MUST be called in the component’s initializer function if there’s one.
  5. If there’s no initializer function in the component, the function MUST be called in the implementing contracts’ constructor.