ModulesTokens

ERC20

The ERC20 token standard is a specification for fungible tokens, a type of token where all the units are exactly equal to each other. token::erc20::ERC20Component provides an approximation of EIP-20 in Cairo for Starknet.

Prior to Contracts v0.7.0, ERC20 contracts store and read decimals from storage; however, this implementation returns a static 18. If upgrading an older ERC20 contract that has a decimals value other than 18, the upgraded contract must use a custom decimals implementation. See the Customizing decimals guide.

Usage

Using Contracts for Cairo, constructing an ERC20 contract requires setting up the constructor and instantiating the token implementation. Here’s what that looks like:

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

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

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

    #[storage]
    struct Storage {
        #[substorage(v0)]
        erc20: ERC20Component::Storage
    }

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

    #[constructor]
    fn constructor(
        ref self: ContractState,
        initial_supply: u256,
        recipient: ContractAddress
    ) {
        let name = "MyToken";
        let symbol = "MTK";

        self.erc20.initializer(name, symbol);
        self.erc20.mint(recipient, initial_supply);
    }
}

MyToken integrates both the ERC20Impl and ERC20MetadataImpl with the embed directive which marks the implementations as external in the contract. While the ERC20MetadataImpl is optional, it’s generally recommended to include it because the vast majority of ERC20 tokens provide the metadata methods. The above example also includes the ERC20InternalImpl instance. This allows the contract’s constructor to initialize the contract and create an initial supply of tokens.

For a more complete guide on ERC20 token mechanisms, see Creating ERC20 Supply.

Interface

The following interface represents the full ABI of the Contracts for Cairo ERC20Component. The interface includes the IERC20 standard interface as well as the optional IERC20Metadata.

To support older token deployments, as mentioned in Dual interfaces, the component also includes an implementation of the interface written in camelCase.

#[starknet::interface]
pub trait ERC20ABI {
    // IERC20
    fn total_supply() -> u256;
    fn balance_of(account: ContractAddress) -> u256;
    fn allowance(owner: ContractAddress, spender: ContractAddress) -> u256;
    fn transfer(recipient: ContractAddress, amount: u256) -> bool;
    fn transfer_from(
        sender: ContractAddress, recipient: ContractAddress, amount: u256
    ) -> bool;
    fn approve(spender: ContractAddress, amount: u256) -> bool;

    // IERC20Metadata
    fn name() -> ByteArray;
    fn symbol() -> ByteArray;
    fn decimals() -> u8;

    // IERC20Camel
    fn totalSupply() -> u256;
    fn balanceOf(account: ContractAddress) -> u256;
    fn transferFrom(
        sender: ContractAddress, recipient: ContractAddress, amount: u256
    ) -> bool;
}

Utilities and extensions

Safe ERC20 calls

The SafeERC20DispatcherTrait wraps calls made through an IERC20Dispatcher and panics when the token returns false. It provides assert_transfer, assert_transfer_from, assert_increase_allowance, and assert_decrease_allowance:

use openzeppelin_interfaces::erc20::IERC20Dispatcher;
use openzeppelin_token::erc20::utils::SafeERC20DispatcherTrait;

let token = IERC20Dispatcher { contract_address };
token.assert_transfer(recipient, amount);

The helpers provide a consistent failure path for conforming ERC20 contracts. They do not add compatibility with contracts that return data outside the declared Cairo ABI; strict dispatcher deserialization already rejects those responses.

Flash minting

The ERC20FlashMintComponent implements the ERC-3156 flash-lender interface by minting tokens for the duration of a loan and recovering them, plus any configured fee, before the transaction completes. Implement FlashMintConfigTrait to customize the maximum loan, fee, or fee recipient. The default configuration charges no fee; if a custom configuration charges a nonzero fee while flash_fee_receiver returns the zero address, that fee is burned.

The default max_flash_loan is derived from the numeric limit and the current total supply. It does not account for an additional supply cap imposed by ERC20Votes. Avoid combining these extensions, or override FlashMintConfigTrait so the reported maximum respects that cap.

Wrapping an underlying token

The ERC20WrapperComponent lets users deposit an underlying ERC20 and mint the same amount of wrapped tokens, or burn wrapped tokens to withdraw the underlying asset. The initializer requires the underlying token to implement IERC20Metadata and use the same number of decimals as the wrapper.

An underlying token that changes balances without explicit transfers, such as a rebasing token, can leave the wrapper undercollateralized. Restrict any exposed recovery function appropriately and verify the underlying token’s accounting model before integrating it.

ERC20 compatibility

Although Starknet is not EVM compatible, this component aims to be as close as possible to the ERC20 token standard. Some notable differences, however, can still be found, such as:

  • The ByteArray type is used to represent strings in Cairo.
  • The component offers a dual interface which supports both snake_case and camelCase methods, as opposed to just camelCase in Solidity.
  • transfer, transfer_from and approve will never return anything different from true because they will revert on any error.
  • Function selectors are calculated differently between Cairo and Solidity.

Customizing decimals

Cairo, like Solidity, does not support floating-point numbers. To get around this limitation, ERC20 token contracts may offer a decimals field which communicates to outside interfaces (wallets, exchanges, etc.) how the token should be displayed. For instance, suppose a token had a decimals value of 3 and the total token supply was 1234. An outside interface would display the token supply as 1.234. 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 static approach (SRC-107)

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:

#[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:

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.

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

#[starknet::contract]
mod MyToken {
    use openzeppelin_interfaces::erc20 as interface;
    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>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        erc20: ERC20Component::Storage,
        // The decimals value is stored locally
        decimals: u8,
    }

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

    #[constructor]
    fn constructor(
        ref self: ContractState, decimals: u8, initial_supply: u256, recipient: ContractAddress,
    ) {
        // Call the internal function that writes decimals to storage
        self._set_decimals(decimals);

        // Initialize ERC20
        let name = "MyToken";
        let symbol = "MTK";

        self.erc20.initializer(name, symbol);
        self.erc20.mint(recipient, initial_supply);
    }

    #[abi(embed_v0)]
    impl ERC20CustomMetadataImpl of interface::IERC20Metadata<ContractState> {
        fn name(self: @ContractState) -> ByteArray {
            self.erc20.ERC20_name.read()
        }

        fn symbol(self: @ContractState) -> ByteArray {
            self.erc20.ERC20_symbol.read()
        }

        fn decimals(self: @ContractState) -> u8 {
            self.decimals.read()
        }
    }

    #[generate_trait]
    impl InternalImpl of InternalTrait {
        fn _set_decimals(ref self: ContractState, decimals: u8) {
            self.decimals.write(decimals);
        }
    }
}

This contract expects a decimals argument in the constructor and uses an internal function to write the decimals to storage. Note that the decimals state variable must be defined in the contract’s storage because this variable does not exist in the component offered by OpenZeppelin Contracts for Cairo. It’s important to include a custom ERC20 metadata implementation and NOT use the Contracts for Cairo ERC20MetadataImpl in this specific case since the decimals method will always return 18.