The ERC721 token standard is a specification for non-fungible tokens, or more colloquially: NFTs. The ERC721.cairo
contract implements an approximation of EIP-721 in Cairo for StarkNet.
@contract_interface
namespace IERC721:
func balanceOf(owner: felt) -> (balance: Uint256):
end
func ownerOf(tokenId: Uint256) -> (owner: felt):
end
func safeTransferFrom(
from_: felt,
to: felt,
tokenId: Uint256,
data_len: felt,
data: felt*
):
func transferFrom(from_: felt, to: felt, tokenId: Uint256):
end
func approve(approved: felt, tokenId: Uint256):
end
func setApprovalForAll(operator: felt, approved: felt):
end
func getApproved(tokenId: Uint256) -> (approved: felt):
end
func isApprovedForAll(owner: felt, operator: felt) -> (isApproved: felt):
end
--------------- IERC165 ---------------
func supportsInterface(interfaceId: felt) -> (success: felt):
end
end
Although StarkNet is not EVM compatible, this implementation aims to be as close as possible to the ERC721 standard in the following ways:
- it uses Cairo's
uint256
instead offelt
- it returns
TRUE
(a constant representing1
) as success - it makes use of Cairo's short strings to simulate
name
andsymbol
But some differences can still be found, such as:
-
tokenURI
returns a felt representation of the queried token's URI. The EIP721 standard, however, states that the return value should be of type string. If a token's URI is not set, the returned value is0
. Note that URIs cannot exceed 31 characters. See Interpreting ERC721 URIs -
interface_id
s are hardcoded and initialized by the constructor. The hardcoded values derive from Solidity's selector calculations. See Supporting Interfaces -
safeTransferFrom
can only be expressed as a single function in Cairo as opposed to the two functions declared in EIP721. The difference between both functions consists of acceptingdata
as an argument. Because function overloading is currently not possible in Cairo,safeTransferFrom
by default accepts thedata
argument. Ifdata
is not used, simply insert0
. -
safeTransferFrom
is specified such that the optionaldata
argument should be of type bytes. In Solidity, this means a dynamically-sized array. To be as close as possible to the standard, it accepts a dynamic array of felts. In Cairo, arrays are expressed with the array length preceding the actual array; hence, the method acceptsdata_len
anddata
respectively as typesfelt
andfelt*
-
ERC165_register_interface
allows contracts to set and communicate which interfaces they support. This follows OpenZeppelin's ERC165Storage -
IERC721_Receiver
compliant contracts (ERC721_Holder
) return a hardcoded selector id according to EVM selectors, since selectors are calculated differently in Cairo. This is in line with the ERC165 interfaces design choice towards EVM compatibility -
IERC721_Receiver
compliant contracts (ERC721_Holder
) must support ERC165 by registering theIERC721_Receiver
selector id in its constructor and exposing thesupportsInterface
method. In doing so, recipient contracts (both accounts and non-accounts) can be verified that they support ERC721 transfers -
ERC721_Enumerable
tracks the total number of tokens with theall_tokens
andall_tokens_len
storage variables mimicking the array of the Solidity implementation.
Use cases go from artwork, digital collectibles, physical property, and many more.
To show a standard use case, we'll use the ERC721_Mintable
preset which allows for only the owner to mint
and burn
tokens. To create a token you need to first deploy both Account and ERC721 contracts respectively. As most StarkNet contracts, ERC721 expects to be called by another contract and it identifies it through get_caller_address
(analogous to Solidity's this.address
). This is why we need an Account contract to interact with it.
Considering that the ERC721 constructor method looks like this:
func constructor(
name: felt, # Token name as Cairo short string
symbol: felt, # Token symbol as Cairo short string
owner: felt # Address designated as the contract owner
):
Deployment of both contracts looks like this:
account = await starknet.deploy(
"contracts/Account.cairo",
constructor_calldata=[signer.public_key]
)
erc721 = await starknet.deploy(
"contracts/token/ERC721_Mintable.cairo",
constructor_calldata=[
str_to_felt("Token"), # name
str_to_felt("TKN"), # symbol
account.contract_address # owner
]
)
To mint a non-fungible token, send a transaction like this:
signer = Signer(PRIVATE_KEY)
tokenId = uint(1)
await signer.send_transaction(
account, erc721.contract_address, 'mint', [
recipient_address,
*tokenId
]
)
EIP721 discourages the use of transferFrom
and favors safeTransferFrom
in regard to token transfers. The safe function adds the following conditional logic:
- if the calling address is an account contract, the token transfer will behave as if
transferFrom
was called - if the calling address is not an account contract, the safe function will check that the contract supports ERC721 tokens
The current implementation of safeTansferFrom
checks for onERC721Received
and requires that the recipient contract supports ERC165 and exposes the supportsInterface
method. See ERC721Received
Please be aware that transferring tokens with transferFrom
to a contract that does not support ERC721 can result in lost tokens forever.
Token URIs in Cairo are stored as single field elements. Each field element equates to 252-bits (or 31.5 bytes) which means that a token's URI can be no longer than 31 characters.
Note that storing the URI as an array of felts was considered to accommodate larger strings. While this approach is more flexible regarding URIs, a returned array further deviates from the standard set in EIP721. Therefore, this library's ERC721 implementation sets URIs as a single field element.
The utils.py
module includes utility methods for converting to/from Cairo field elements. To properly interpret a URI from ERC721, simply trim the null bytes and decode the remaining bits as an ASCII string. For example:
# HELPER METHODS
def str_to_felt(text):
b_text = bytes(text, 'ascii')
return int.from_bytes(b_text, "big")
def felt_to_str(felt):
b_felt = felt.to_bytes(31, "big")
return b_felt.decode()
token_id = uint(1)
sample_uri = str_to_felt('mock://mytoken')
await signer.send_transaction(
account, erc721.contract_address, 'setTokenURI', [
*token_id, sample_uri]
)
felt_uri = await erc721.tokenURI(first_token_id).call()
string_uri = felt_to_str(felt_uri)
In order to be sure a contract can safely accept ERC721 tokens, said contract must implement the ERC721_Receiver
interface (as expressed in the EIP721 specification). Methods such as safeTransferFrom
and safeMint
call the recipient contract's onERC721Received
method. If the contract fails to return the correct magic value, the transaction fails.
StarkNet contracts that support safe transfers, however, must also support ERC165 and include supportsInterface
as proposed in #100. safeTransferFrom
requires a means of differentiating between account and non-account contracts. Currently, StarkNet does not support error handling from the contract level;
therefore, the current ERC721 implementation requires that all contracts that support safe ERC721 transfers (both accounts and non-accounts) include the supportsInterface
method. Further, supportsInterface
should return TRUE
if the recipient contract supports the IERC721_Receiver
magic value 0x150b7a02
(which invokes onERC721Received
). If the recipient contract supports the IAccount
magic value 0x50b70dcb
, supportsInterface
should return TRUE
. Otherwise, safeTransferFrom
should fail.
Interface for any contract that wants to support safeTransfers from ERC721 asset contracts.
@contract_interface
namespace IERC721_Receiver:
func onERC721Received(
operator: felt,
from_: felt,
tokenId: Uint256,
data_len: felt
data: felt*
) -> (selector: felt):
end
end
In order to ensure EVM/StarkNet compatibility, this ERC721 implementation does not calculate interface identifiers. Instead, the interface IDs are hardcoded from their EVM calculations. On the EVM, the interface ID is calculated from the selector's first four bytes of the hash of the function's signature while Cairo selectors are 252 bytes long. Due to this difference, hardcoding EVM's already-calculated interface IDs is the most consistent approach to both follow the EIP165 standard and EVM compatibility.
Further, this implementation stores supported interfaces in a mapping (similar to OpenZeppelin's ERC165Storage).
ERC721 presets have been created to allow for quick deployments as-is. To be as explicit as possible, each preset includes the additional features they offer in the contract name. For example:
ERC721_Mintable_Burnable
includesmint
andburn
ERC721_Mintable_Pausable
includesmint
,pause
, andunpause
ERC721_Enumerable_Mintable_Burnable
includesmint
,burn
, and IERC721_Enumerable methods
Ready-to-use presets are a great option for testing and prototyping. See Presets.
Following the contracts extensibility pattern, this implementation is set up to include all ERC721 storage and function logic within the ERC721_base.cairo
library. Library methods with the prefix ERC721_
must be imported in the smart contract and inserted into an external
method with the requisite name. This is already done in the preset contracts; however, additional functionality can be added. For instance, you could:
- Implement a pausing mechanism
- Add roles such as owner or minter
- Modify the
transferFrom
function to mimic the_beforeTokenTransfer
and_afterTokenTransfer
hooks
Just be sure that the exposed external
methods invoke their imported function logic a la approve
invokes ERC721_approve
. As an example, see below.
from contracts.token.ERC721_base import ERC721_approve
@external
func approve{
pedersen_ptr: HashBuiltin*,
syscall_ptr: felt*,
range_check_ptr
}(to: felt, tokenId: Uint256):
ERC721_approve(to, tokenId)
return()
end
The following contract presets are ready to deploy and can be used as-is for quick prototyping and testing. Each preset includes a contract owner, which is set in the constructor
, to offer simple access control on sensitive methods such as mint
and burn
.
The ERC721_Mintable_Burnable
preset offers a quick and easy setup for creating NFTs. The contract owner can create tokens with mint
, whereas token owners can destroy their tokens with burn
.
The ERC721_Mintable_Pausable
preset creates a contract with pausable token transfers and minting capabilities. This preset proves useful for scenarios such as preventing trades until the end of an evaluation period and having an emergency switch for freezing all token transfers in the event of a large bug. In this preset, only the contract owner can mint
, pause
, and unpause
.
The ERC721_Enumerable_Mintable_Burnable
preset adds enumerability of all the token ids in the contract as well as all token ids owned by each account. This allows contracts to publish its full list of NFTs and make them discoverable.
In regard to implementation, contracts should import the following view methods:
ERC721_Enumerable_totalSupply
ERC721_Enumerable_tokenByIndex
ERC721_Enumerable_tokenOfOwnerByIndex
In order for the tokens to be correctly indexed, the contract should also import the following methods (which supercede some of the ERC721_base
methods):
ERC721_Enumerable_transferFrom
ERC721_Enumerable_safeTransferFrom
ERC721_Enumerable_mint
ERC721_Enumerable_burn
@contract_interface
namespace IERC721_Enumerable:
func totalSupply() -> (totalSupply: Uint256):
end
func tokenByIndex(index: Uint256) -> (tokenId: Uint256):
end
func tokenOfOwnerByIndex(owner: felt, index: Uint256) -> (tokenId: Uint256):
end
end
The ERC721_Metadata
extension allows your smart contract to be interrogated for its name and for details about the assets which your NFTs represent.
We follow OpenZeppelin's Solidity approach of integrating the Metadata methods name
, symbol
, and tokenURI
into all ERC721 implementations. If preferred, a contract can be created that does not import the Metadata methods from the ERC721_base
library. Note that the IERC721_Metadata
interface id should be removed from the constructor as well.
@contract_interface
namespace IERC721_Metadata:
func name() -> (name: felt):
end
func symbol() -> (symbol: felt):
end
func tokenURI(tokenId: Uint256) -> (tokenURI: felt):
end
end
Implementation of the IERC721Receiver
interface.
Accepts all token transfers. Make sure the contract is able to use its token with IERC721.safeTransferFrom
, IERC721.approve
or IERC721.setApprovalForAll
.
Also utilizes the ERC165 method supportsInterface
to determine if the contract is an account. See ERC721Received
func balanceOf(owner: felt) -> (balance: Uint256):
end
func ownerOf(tokenId: Uint256) -> (owner: felt):
end
func safeTransferFrom(
from_: felt,
to: felt,
tokenId: Uint256,
data_len: felt,
data: felt*
):
end
func transferFrom(from_: felt, to: felt, tokenId: Uint256):
end
func approve(approved: felt, tokenId: Uint256):
end
func setApprovalForAll(operator: felt, approved: felt):
end
func getApproved(tokenId: Uint256) -> (approved: felt):
end
func isApprovedForAll(owner: felt, operator: felt) -> (isApproved: felt):
end
Returns the number of tokens in owner
's account.
Parameters:
owner: felt
Returns:
balance: Uint256
Returns the owner of the tokenId
token.
Parameters:
tokenId: Uint256
Returns:
owner: felt
Safely transfers tokenId
token from from_
to to
, checking first that contract recipients are aware of the ERC721 protocol to prevent tokens from being forever locked. For information regarding how contracts communicate their awareness of the ERC721 protocol, see ERC721Received.
Emits a Transfer event.
Parameters:
from_: felt
to: felt
tokenId: Uint256
data_len: felt
data: felt*
Returns:
None.
Transfers tokenId
token from from_
to to
.
Note that this function should be used instead of
safeTransferFrom
to transfer tokens. Exercise caution as tokens sent to a contract that does not support ERC721 can be lost forever.
Emits a Transfer event.
Parameters:
from_: felt
to: felt
tokenId: Uint256
Returns:
None.
Gives permission to to
to transfer tokenId
token to another account. The approval is cleared when the token is transferred.
Emits an Approval event.
Parameters:
to: felt
tokenId: Uint256
Returns:
None.
Returns the account approved for tokenId
token.
Parameters:
tokenId: Uint256
Returns:
operator: felt
Approve or remove operator
as an operator for the caller. Operators can call transferFrom
or safeTransferFrom
for any token owned by the caller.
Emits an ApprovalForAll event.
Parameters:
operator: felt
Returns:
None.
Returns if the operator
is allowed to manage all of the assets of owner
.
Parameters:
owner: felt
operator: felt
Returns:
isApproved: felt
Emitted when owner
enables approved
to manage the tokenId
token.
Parameters:
owner: felt
approved: felt
tokenId: Uint256
Emitted when owner
enables or disables (approved
) operator
to manage all of its assets.
Parameters:
owner: felt
operator: felt
approved: felt
Emitted when tokenId
token is transferred from from_
to to
.
Parameters:
from_: felt
to: felt
tokenId: Uint256
func name() -> (name: felt):
end
func symbol() -> (symbol: felt):
end
func tokenURI(tokenId: Uint256) -> (tokenURI: felt):
end
Returns the token collection name.
Parameters:
None.
Returns:
name: felt
Returns the token collection symbol.
Parameters:
None.
Returns:
symbol: felt
Returns the Uniform Resource Identifier (URI) for tokenID
token. If the URI is not set for the tokenId
, the return value will be 0
.
Parameters:
tokenId: Uint256
Returns:
tokenURI: felt
func totalSupply() -> (totalSupply: Uint256):
end
func tokenByIndex(index: Uint256) -> (tokenId: Uint256):
end
func tokenOfOwnerByIndex(owner: felt, index: Uint256) -> (tokenId: Uint256):
end
Returns the total amount of tokens stored by the contract.
Parameters: None
Returns:
totalSupply: Uint256
Returns a token ID owned by owner
at a given index
of its token list. Use along with balanceOf to enumerate all of owner
's tokens.
Parameters:
index: Uint256
Returns:
tokenId: Uint256
Returns a token ID at a given index
of all the tokens stored by the contract. Use along with totalSupply to enumerate all tokens.
Parameters:
owner: felt
index: Uint256
Returns:
tokenId: Uint256
func onERC721Received(
operator: felt,
from_: felt,
tokenId: Uint256,
data_len: felt
data: felt*
) -> (selector: felt):
end
Whenever an IERC721 tokenId
token is transferred to this non-account contract via safeTransferFrom
by operator
from from_
, this function is called.
Parameters:
operator: felt
from_: felt
tokenId: Uint256
data_len: felt
data: felt*
Returns:
selector: felt
The ERC165 standard allows smart contracts to exercise type introspection on other contracts, that is, examining which functions can be called on them. This is usually referred to as a contract’s interface.
Cairo contracts, like Ethereum contracts, have no native concept of an interface, so applications must usually simply trust they are not making an incorrect call. For trusted setups this is a non-issue, but often unknown and untrusted third-party addresses need to be interacted with. There may even not be any direct calls to them! (e.g. ERC20 tokens may be sent to a contract that lacks a way to transfer them out of it, locking them forever). In these cases, a contract declaring its interface can be very helpful in preventing errors.
It should be noted that the constants library includes constant variables referencing all of the interface ids used in these contracts. This allows for more legible code i.e. using IERC165_ID
instead of 0x01ffc9a7
.
@contract_interface
namespace IERC165:
func supportsInterface(interfaceId: felt) -> (success: felt):
end
end
func supportsInterface(interfaceId: felt) -> (success: felt):
end
Returns true if this contract implements the interface defined by interfaceId
.
Parameters:
interfaceId: felt
Returns:
success: felt