Skip to content

Commit

Permalink
Add SRC107 to ERC20 (simpler customizable decimals) (#1294)
Browse files Browse the repository at this point in the history
* feat: update logic and tests

* feat: update doc examples

* feat: update customizing decimals guide

* feat: update CHANGELOG

* feat: format files

* feat: add more tests

* feat: update customizing decimals section
  • Loading branch information
ericnordelo authored Feb 3, 2025
1 parent 5b0a27b commit 6dfe58c
Show file tree
Hide file tree
Showing 17 changed files with 133 additions and 58 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed (Breaking)

- Bump scarb to v2.9.2 (#1239)
- Add SRC-107 to ERC20Component (#1294)
- `decimals` are now configurable using the ImmutableConfig trait

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ For example, this is how to write an ERC20-compliant contract:
```cairo
#[starknet::contract]
mod MyToken {
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, DefaultConfig};
use starknet::ContractAddress;
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
Expand Down
6 changes: 3 additions & 3 deletions docs/modules/ROOT/pages/access.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ const MINTER_ROLE: felt252 = selector!("MINTER_ROLE");
mod MyContract {
use openzeppelin_access::accesscontrol::AccessControlComponent;
use openzeppelin_introspection::src5::SRC5Component;
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, DefaultConfig};
use starknet::ContractAddress;
use super::MINTER_ROLE;
Expand Down Expand Up @@ -267,7 +267,7 @@ const BURNER_ROLE: felt252 = selector!("BURNER_ROLE");
mod MyContract {
use openzeppelin_access::accesscontrol::AccessControlComponent;
use openzeppelin_introspection::src5::SRC5Component;
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, DefaultConfig};
use starknet::ContractAddress;
use super::{MINTER_ROLE, BURNER_ROLE};
Expand Down Expand Up @@ -390,7 +390,7 @@ mod MyContract {
use openzeppelin_access::accesscontrol::AccessControlComponent;
use openzeppelin_access::accesscontrol::DEFAULT_ADMIN_ROLE;
use openzeppelin_introspection::src5::SRC5Component;
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, DefaultConfig};
use starknet::ContractAddress;
use super::{MINTER_ROLE, BURNER_ROLE};
Expand Down
12 changes: 5 additions & 7 deletions docs/modules/ROOT/pages/components.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ mod MyContract {
=== Immutable Config

:erc2981-component: xref:/api/token_common.adoc#ERC2981Component[ERC2981Component]
:SRC-107: https://github.com/starknet-io/SNIPs/blob/963848f0752bde75c7087c2446d83b7da8118b25/SNIPS/snip-107.md[SRC-107]
:SRC-107: https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-107.md[SRC-107]

While initializers help set up the component's initial state, some require configuration that may be defined
as constants, saving gas by avoiding the necessity of reading from storage each time the variable needs to be used. The
Expand Down Expand Up @@ -397,7 +397,7 @@ mod MyContract {

==== `validate` function

:validate-section: https://github.com/starknet-io/SNIPs/blob/963848f0752bde75c7087c2446d83b7da8118b25/SNIPS/snip-107.md#validate-function[validate section of the SRC-107]
:validate-section: https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-107.md#validate-function[validate section of the SRC-107]

The `ImmutableConfig` trait may also include a `validate` function with a default implementation, which
asserts that the configuration is correct, and must not be overridden by the implementing contract. For more information
Expand Down Expand Up @@ -483,7 +483,7 @@ The following snippet leverages the `before_update` hook to include this behavio
mod MyToken {
use openzeppelin_security::pausable::PausableComponent::InternalTrait;
use openzeppelin_security::pausable::PausableComponent;
use openzeppelin_token::erc20::ERC20Component;
use openzeppelin_token::erc20::{ERC20Component, DefaultConfig};
use starknet::ContractAddress;
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
Expand Down Expand Up @@ -535,7 +535,7 @@ The using contract just needs to bring the implementation into scope like this:
----
#[starknet::contract]
mod MyToken {
use openzeppelin_token::erc20::ERC20Component;
use openzeppelin_token::erc20::{ERC20Component, DefaultConfig};
use openzeppelin_token::erc20::ERC20HooksEmptyImpl;
(...)
Expand All @@ -559,7 +559,7 @@ Here's the setup:
#[starknet::contract]
mod ERC20Pausable {
use openzeppelin_security::pausable::PausableComponent;
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, DefaultConfig};
// Import the ERC20 interfaces to create custom implementations
use openzeppelin_token::erc20::interface::{IERC20, IERC20CamelOnly};
use starknet::ContractAddress;
Expand Down Expand Up @@ -651,8 +651,6 @@ This is why the contract defined the `ERC20Impl` from the component in the previ
Creating a custom implementation of an interface must define *all* methods from that interface.
This is true even if the behavior of a method does not change from the component implementation (as `total_supply` exemplifies in this example).

TIP: The ERC20 documentation provides another custom implementation guide for {custom-decimals}.

=== Accessing component storage

There may be cases where the contract must read or write to an integrated component's storage.
Expand Down
76 changes: 52 additions & 24 deletions docs/modules/ROOT/pages/erc20.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Here's what that looks like:
----
#[starknet::contract]
mod MyToken {
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, DefaultConfig};
use starknet::ContractAddress;
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
Expand Down Expand Up @@ -125,6 +125,8 @@ Some notable differences, however, can still be found, such as:

== Customizing decimals

:SRC-107: https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-107.md[SRC-107]

:floating-point: https://en.wikipedia.org//wiki/Floating-point_arithmetic[floating-point numbers]
:eip-discussion: https://github.com/ethereum/EIPs/issues/724[EIP discussion]

Expand All @@ -136,40 +138,69 @@ In the actual contract, however, the supply would still be the integer `1234`.
In other words, *the decimals field in no way changes the actual arithmetic* because all operations are still performed on integers.

Most contracts use `18` decimals and this was even proposed to be compulsory (see the {eip-discussion}).
The Contracts for Cairo `ERC20` component includes a `decimals` function that returns `18` by default to save on gas fees.
For those who want an ERC20 token with a configurable number of decimals, the following guide shows two ways to achieve this.

NOTE: Both approaches require creating a custom implementation of the `IERC20Metadata` interface.

=== The static approach
=== The static approach (SRC-107)

The simplest way to customize `decimals` consists of returning the target value from the `decimals` method.
For example:
The Contracts for Cairo `ERC20` component leverages {SRC-107} to allow for a static and configurable number of decimals.
To use the default `18` decimals, you can use the `DefaultConfig` implementation by just importing it:

[,cairo]
----
#[abi(embed_v0)]
impl ERC20MetadataImpl of interface::IERC20Metadata<ContractState> {
fn decimals(self: @ContractState) -> u8 {
// Change the `3` below to the desired number of decimals
3
}
#[starknet::contract]
mod MyToken {
// Importing the DefaultConfig implementation would make decimals 18 by default.
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, DefaultConfig};
use starknet::ContractAddress;
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
#[abi(embed_v0)]
impl ERC20Impl = ERC20Component::ERC20Impl<ContractState>;
#[abi(embed_v0)]
impl ERC20CamelOnlyImpl = ERC20Component::ERC20CamelOnlyImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
(...)
}
----

To customize this value, you can implement the ImmutableConfig trait locally in the contract.
The following example shows how to set the decimals to `6`:

[,cairo]
----
mod MyToken {
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use starknet::ContractAddress;
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
#[abi(embed_v0)]
impl ERC20Impl = ERC20Component::ERC20Impl<ContractState>;
#[abi(embed_v0)]
impl ERC20CamelOnlyImpl = ERC20Component::ERC20CamelOnlyImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
(...)
// Custom implementation of the ERC20Component ImmutableConfig.
impl ERC20ImmutableConfig of ERC20Component::ImmutableConfig {
const DECIMALS: u8 = 6;
}
}
----

=== The storage approach

For more complex scenarios, such as a factory deploying multiple tokens with differing values for decimals, a flexible solution might be appropriate.

TIP: Note that we are not using the MixinImpl in this case, since we need to customize the IERC20Metadata implementation.
TIP: Note that we are not using the MixinImpl or the DefaultConfig in this case, since we need to customize the IERC20Metadata implementation.

[,cairo]
----
#[starknet::contract]
mod MyToken {
use openzeppelin_token::erc20::interface;
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use starknet::ContractAddress;
Expand All @@ -186,22 +217,19 @@ mod MyToken {
#[substorage(v0)]
erc20: ERC20Component::Storage,
// The decimals value is stored locally
decimals: u8
decimals: u8,
}
#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
ERC20Event: ERC20Component::Event
ERC20Event: ERC20Component::Event,
}
#[constructor]
fn constructor(
ref self: ContractState,
decimals: u8,
initial_supply: u256,
recipient: ContractAddress,
ref self: ContractState, decimals: u8, initial_supply: u256, recipient: ContractAddress,
) {
// Call the internal function that writes decimals to storage
self._set_decimals(decimals);
Expand All @@ -215,13 +243,13 @@ mod MyToken {
}
#[abi(embed_v0)]
impl ERC20MetadataImpl of interface::IERC20Metadata<ContractState> {
impl ERC20CustomMetadataImpl of interface::IERC20Metadata<ContractState> {
fn name(self: @ContractState) -> ByteArray {
self.erc20.name()
self.erc20.ERC20_name.read()
}
fn symbol(self: @ContractState) -> ByteArray {
self.erc20.symbol()
self.erc20.ERC20_symbol.read()
}
fn decimals(self: @ContractState) -> u8 {
Expand Down
2 changes: 1 addition & 1 deletion docs/modules/ROOT/pages/governance/votes.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Here's an example of how to structure a simple ERC20Votes contract:
#[starknet::contract]
mod ERC20VotesContract {
use openzeppelin_governance::votes::VotesComponent;
use openzeppelin_token::erc20::ERC20Component;
use openzeppelin_token::erc20::{ERC20Component, DefaultConfig};
use openzeppelin_utils::cryptography::nonces::NoncesComponent;
use openzeppelin_utils::cryptography::snip12::SNIP12Metadata;
use starknet::ContractAddress;
Expand Down
14 changes: 12 additions & 2 deletions docs/modules/ROOT/pages/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,28 @@ Install the library by declaring it as a dependency in the project's `Scarb.toml
openzeppelin = "0.20.0"
----

WARNING: Make sure the tag matches the target release.
The previous example would import the entire library. We can also add each package as a separate dependency to
improve the building time by not including modules that won't be used:

[,text]
----
[dependencies]
openzeppelin_access = "0.20.0"
openzeppelin_token = "0.20.0"
----

== Basic usage

This is how it looks to build an ERC20 contract using the xref:erc20.adoc[ERC20 component].
Copy the code into `src/lib.cairo`.

TIP: If you added the entire library as a dependency, use `openzeppelin::token` instead of `openzeppelin_token` for the imports.

[,cairo]
----
#[starknet::contract]
mod MyERC20Token {
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl, DefaultConfig};
use starknet::ContractAddress;
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
Expand Down
2 changes: 1 addition & 1 deletion packages/governance/src/governor/governor.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -1041,7 +1041,7 @@ pub mod GovernorComponent {
/// Implementation of the default Governor ImmutableConfig.
///
/// See
/// https://github.com/starknet-io/SNIPs/blob/963848f0752bde75c7087c2446d83b7da8118b25/SNIPS/snip-107.md#defaultconfig-implementation
/// https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-107.md#defaultconfig-implementation
///
/// The `DEFAULT_PARAMS` is set to an empty span of felts.
pub impl DefaultConfig of GovernorComponent::ImmutableConfig {
Expand Down
2 changes: 1 addition & 1 deletion packages/presets/src/erc20.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
#[starknet::contract]
pub mod ERC20Upgradeable {
use openzeppelin_access::ownable::OwnableComponent;
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_token::erc20::{DefaultConfig, ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_upgrades::UpgradeableComponent;
use openzeppelin_upgrades::interface::IUpgradeable;
use starknet::{ClassHash, ContractAddress};
Expand Down
8 changes: 4 additions & 4 deletions packages/test_common/src/mocks/erc20.cairo
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#[starknet::contract]
pub mod DualCaseERC20Mock {
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_token::erc20::{DefaultConfig, ERC20Component, ERC20HooksEmptyImpl};
use starknet::ContractAddress;

component!(path: ERC20Component, storage: erc20, event: ERC20Event);
Expand Down Expand Up @@ -41,7 +41,7 @@ pub mod DualCaseERC20Mock {

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

component!(path: ERC20Component, storage: erc20, event: ERC20Event);
Expand Down Expand Up @@ -82,7 +82,7 @@ pub mod SnakeERC20Mock {
/// This is used to test that the hooks are called with the correct arguments.
#[starknet::contract]
pub mod SnakeERC20MockWithHooks {
use openzeppelin_token::erc20::ERC20Component;
use openzeppelin_token::erc20::{DefaultConfig, ERC20Component};
use starknet::ContractAddress;

component!(path: ERC20Component, storage: erc20, event: ERC20Event);
Expand Down Expand Up @@ -161,7 +161,7 @@ pub mod SnakeERC20MockWithHooks {

#[starknet::contract]
pub mod DualCaseERC20PermitMock {
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_token::erc20::{DefaultConfig, ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_utils::cryptography::nonces::NoncesComponent;
use openzeppelin_utils::cryptography::snip12::SNIP12Metadata;
use starknet::ContractAddress;
Expand Down
2 changes: 1 addition & 1 deletion packages/test_common/src/mocks/vesting.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ pub mod StepsVestingMock {
#[starknet::contract]
pub mod ERC20OptionalTransferPanicMock {
use openzeppelin_token::erc20::interface::IERC20;
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use openzeppelin_token::erc20::{DefaultConfig, ERC20Component, ERC20HooksEmptyImpl};
use starknet::ContractAddress;
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};

Expand Down
2 changes: 1 addition & 1 deletion packages/test_common/src/mocks/votes.cairo
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#[starknet::contract]
pub mod ERC20VotesMock {
use openzeppelin_governance::votes::VotesComponent;
use openzeppelin_token::erc20::ERC20Component;
use openzeppelin_token::erc20::{DefaultConfig, ERC20Component};
use openzeppelin_utils::cryptography::nonces::NoncesComponent;
use openzeppelin_utils::cryptography::snip12::SNIP12Metadata;
use starknet::ContractAddress;
Expand Down
5 changes: 2 additions & 3 deletions packages/token/src/common/erc2981/erc2981.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
///
/// Royalty is specified as a fraction of sale price. The denominator is set by the contract by
/// using the Immutable Component Config pattern. See
/// https://github.com/starknet-io/SNIPs/blob/963848f0752bde75c7087c2446d83b7da8118b25/SNIPS/snip-107.md
/// https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-107.md
///
/// IMPORTANT: ERC-2981 only specifies a way to signal royalty information and does not enforce its
/// payment. See https://eips.ethereum.org/EIPS/eip-2981#optional-royalty-payments[Rationale] in the
Expand Down Expand Up @@ -418,14 +418,13 @@ pub mod ERC2981Component {
/// Implementation of the default ERC2981Component ImmutableConfig.
///
/// See
/// https://github.com/starknet-io/SNIPs/blob/963848f0752bde75c7087c2446d83b7da8118b25/SNIPS/snip-107.md#defaultconfig-implementation
/// https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-107.md#defaultconfig-implementation
///
/// The default fee denominator is set to `DEFAULT_FEE_DENOMINATOR`.
pub impl DefaultConfig of ERC2981Component::ImmutableConfig {
const FEE_DENOMINATOR: u128 = ERC2981Component::DEFAULT_FEE_DENOMINATOR;
}


#[cfg(test)]
mod tests {
use openzeppelin_test_common::mocks::erc2981::ERC2981Mock;
Expand Down
2 changes: 1 addition & 1 deletion packages/token/src/erc20.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ pub mod erc20;
pub mod interface;
pub mod snip12_utils;

pub use erc20::{ERC20Component, ERC20HooksEmptyImpl};
pub use erc20::{DefaultConfig, ERC20Component, ERC20HooksEmptyImpl};
pub use interface::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait};
Loading

0 comments on commit 6dfe58c

Please sign in to comment.