ModulesTokens

ERC721

The ERC721 token standard is a specification for non-fungible tokens, or more colloquially: NFTs. token::erc721::ERC721Component provides an approximation of EIP-721 in Cairo for Starknet.

Usage

Using Contracts for Cairo, constructing an ERC721 contract requires integrating both ERC721Component and SRC5Component. The contract should also set up the constructor to initialize the token’s name, symbol, and interface support. Here’s an example of a basic contract:

#[starknet::contract]
mod MyNFT {
    use openzeppelin_introspection::src5::SRC5Component;
    use openzeppelin_token::erc721::{
        ERC721Component, ERC721HooksEmptyImpl, ERC721OwnerOfDefaultImpl,
        ERC721TokenURIDefaultImpl,
    };
    use starknet::ContractAddress;

    component!(path: ERC721Component, storage: erc721, event: ERC721Event);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // ERC721 Mixin
    #[abi(embed_v0)]
    impl ERC721MixinImpl = ERC721Component::ERC721MixinImpl<ContractState>;
    impl ERC721InternalImpl = ERC721Component::InternalImpl<ContractState>;

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

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

    #[constructor]
    fn constructor(
        ref self: ContractState,
        recipient: ContractAddress
    ) {
        let name = "MyNFT";
        let symbol = "NFT";
        let base_uri = "https://api.example.com/v1/";
        let token_id = 1;

        self.erc721.initializer(name, symbol, base_uri);
        self.erc721.mint(recipient, token_id);
    }
}

ERC721Component treats token ownership and token URI resolution as extension points. Basic ERC721 contracts that use the mixin must bring ERC721OwnerOfDefaultImpl and ERC721TokenURIDefaultImpl into scope, as in the example above. Use the specialized implementations supplied by an extension when ownership or URI behavior is customized.

Interface

The following interface represents the full ABI of the Contracts for Cairo ERC721Component. The interface includes the IERC721 standard interface and the optional IERC721Metadata interface.

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

#[starknet::interface]
pub trait ERC721ABI {
    // IERC721
    fn balance_of(account: ContractAddress) -> u256;
    fn owner_of(token_id: u256) -> ContractAddress;
    fn safe_transfer_from(
        from: ContractAddress,
        to: ContractAddress,
        token_id: u256,
        data: Span<felt252>
    );
    fn transfer_from(from: ContractAddress, to: ContractAddress, token_id: u256);
    fn approve(to: ContractAddress, token_id: u256);
    fn set_approval_for_all(operator: ContractAddress, approved: bool);
    fn get_approved(token_id: u256) -> ContractAddress;
    fn is_approved_for_all(owner: ContractAddress, operator: ContractAddress) -> bool;

    // IERC721Metadata
    fn name() -> ByteArray;
    fn symbol() -> ByteArray;
    fn token_uri(token_id: u256) -> ByteArray;

    // IERC721CamelOnly
    fn balanceOf(account: ContractAddress) -> u256;
    fn ownerOf(tokenId: u256) -> ContractAddress;
    fn safeTransferFrom(
        from: ContractAddress,
        to: ContractAddress,
        tokenId: u256,
        data: Span<felt252>
    );
    fn transferFrom(from: ContractAddress, to: ContractAddress, tokenId: u256);
    fn setApprovalForAll(operator: ContractAddress, approved: bool);
    fn getApproved(tokenId: u256) -> ContractAddress;
    fn isApprovedForAll(owner: ContractAddress, operator: ContractAddress) -> bool;

    // IERC721MetadataCamelOnly
    fn tokenURI(tokenId: u256) -> ByteArray;
}

ERC721 compatibility

Although Starknet is not EVM compatible, this implementation aims to be as close as possible to the ERC721 standard. This implementation does, however, include a few notable differences such as:

  • interface_ids are hardcoded and initialized by the constructor. The hardcoded values derive from Starknet’s selector calculations. See the Introspection docs.
  • safe_transfer_from can only be expressed as a single function in Cairo as opposed to the two functions declared in EIP721, because function overloading is currently not possible in Cairo. The difference between both functions consists of accepting data as an argument. safe_transfer_from by default accepts the data argument which is interpreted as Span<felt252>. If data is not used, simply pass an empty array.
  • ERC721 utilizes SRC5 to declare and query interface support on Starknet as opposed to Ethereum’s EIP165. The design for SRC5 is similar to OpenZeppelin’s ERC165Storage.
  • IERC721Receiver compliant contracts return a hardcoded interface ID according to Starknet selectors (as opposed to selector calculation in Solidity).

Token transfers

This library includes transfer_from and safe_transfer_from to transfer NFTs. If using transfer_from, the caller is responsible to confirm that the recipient is capable of receiving NFTs or else they may be permanently lost. The safe_transfer_from method mitigates this risk by querying the recipient contract’s interface support.

Usage of safe_transfer_from prevents loss, though the caller must understand this adds an external call which potentially creates a reentrancy vulnerability.

Receiving tokens

In order to be sure a non-account contract can safely accept ERC721 tokens, said contract must implement the IERC721Receiver interface. The recipient contract must also implement the SRC5 interface which, as described earlier, supports interface introspection.

IERC721Receiver

#[starknet::interface]
pub trait IERC721Receiver {
    fn on_erc721_received(
        operator: ContractAddress,
        from: ContractAddress,
        token_id: u256,
        data: Span<felt252>
    ) -> felt252;
}

Implementing the IERC721Receiver interface exposes the on_erc721_received method. When safe methods such as safe_transfer_from and safe_mint are called, they invoke the recipient contract’s on_erc721_received method which must return the IERC721Receiver interface ID. Otherwise, the transaction will fail.

For information on how to calculate interface IDs, see Computing the interface ID.

Creating a token receiver contract

The Contracts for Cairo IERC721ReceiverImpl already returns the correct interface ID for safe token transfers. To integrate the IERC721Receiver interface into a contract, simply include the ABI embed directive to the implementation and add the initializer in the contract’s constructor. Here’s an example of a simple token receiver contract:

#[starknet::contract]
mod MyTokenReceiver {
    use openzeppelin_introspection::src5::SRC5Component;
    use openzeppelin_token::erc721::ERC721ReceiverComponent;
    use starknet::ContractAddress;

    component!(path: ERC721ReceiverComponent, storage: erc721_receiver, event: ERC721ReceiverEvent);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // ERC721Receiver Mixin
    #[abi(embed_v0)]
    impl ERC721ReceiverMixinImpl = ERC721ReceiverComponent::ERC721ReceiverMixinImpl<ContractState>;
    impl ERC721ReceiverInternalImpl = ERC721ReceiverComponent::InternalImpl<ContractState>;

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

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

    #[constructor]
    fn constructor(ref self: ContractState) {
        self.erc721_receiver.initializer();
    }
}

Consecutive minting

The ERC721ConsecutiveComponent implements ERC-2309 batch minting for consecutive token IDs. mint_consecutive is restricted to constructor execution and emits one ConsecutiveTransfer event instead of an individual Transfer event for every token. The default maximum batch size is 5,000 and can be changed through the extension’s ImmutableConfig.

Consecutive minting bypasses the core ERC721 update hooks and does not call on_erc721_received. Forward normal updates to the extension’s before_update and after_update functions so ownership and burn tracking remain correct. Do not combine ERC721ConsecutiveComponent with ERC721EnumerableComponent; both customize ownership tracking and are incompatible.

Contracts using this extension use ConsecutiveERC721TokenOwnerImpl instead of ERC721OwnerOfDefaultImpl so owner_of can resolve tokens stored in the sequential ownership checkpoints.

Wrapping an underlying ERC721

The ERC721WrapperComponent locks underlying NFTs and mints wrapped NFTs with matching token IDs. Users can deposit or withdraw batches of IDs, and the receiver implementation can accept safe transfers from the configured underlying collection. The internal recover function can mint a wrapper for an underlying token already owned by the wrapper contract; expose it only with appropriate access control.

Storing ERC721 URIs

ERC721TokenURIDefaultImpl stores a base URI as a ByteArray and resolves the full token_uri by appending the token ID. This design mirrors OpenZeppelin’s default Solidity implementation.

The ERC721URIStorageComponent adds per-token URI suffixes. Use its ERC721TokenURIStorageImpl instead of ERC721TokenURIDefaultImpl; a configured suffix is combined with the base URI, while tokens without a suffix retain the default behavior.

Forward ERC721HooksTrait::after_update to ERC721URIStorageComponent::after_update. This clears a token’s stored URI when the token is burned.