What ERC-721 is?
ERC721 is a token standard on Ethereum that defines a common set of rules for creating and managing non-fungible tokens (NFTs). It ensures that all tokens behave consistently, making them interoperable with wallets, exchanges, and dApps.
When a new token is created (minted), the person who called the function becomes its owner. Tokens can be created, transferred, or destroyed.
Tokens can be transferred by:
- The owner
- An approved address
- An authorized operator
Tokens can be destroyed by burning them. Only the token owner can burn a token.
Cardano’s blockchain is fundamentally different from Polkadot in how it handles tokens, including NFTs. While Polkadot relies on smart contracts (like ERC-721) to define NFT logic, Cardano natively supports tokens and NFTs at the protocol level through its Extended UTXO (EUTXO) model.
However, while simple NFTs don’t need smart contracts, Cardano’s Plutus or Aiken smart contracts can add advanced functionality (such Dynamic NFTs, Royalty Enforcement, etc).
Feature | Native NFTs | ERC-721 |
---|---|---|
Token Logic | Enforced by the ledger (native) | Defined by smart contracts |
Minting | Policy scripts + metadata in transactions | Requires deploying/modifying contracts |
Transfers | Handled like ADA transactions | Requires contract interaction |
Security | Less attack surface (no contract execution) | Risk of contract bugs |
Complex Use Cases | Needs Plutus/Aiken scripts | Built into contract logic |
Fees | Lower | Higher |
(Source based on use-ink examples)
#![cfg_attr(not(feature = "std"), no_std, no_main)]
#[ink::contract]
mod erc721 {
use ink::storage::Mapping;
/// A token ID.
pub type TokenId = u32;
#[ink(storage)]
#[derive(Default)]
pub struct Erc721 {
/// Mapping from token to owner.
token_owner: Mapping<TokenId, AccountId>,
/// Mapping from token to approvals users.
token_approvals: Mapping<TokenId, AccountId>,
/// Mapping from owner to number of owned token.
owned_tokens_count: Mapping<AccountId, u32>,
/// Mapping from owner to operator approvals.
operator_approvals: Mapping<(AccountId, AccountId), ()>,
}
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
#[ink::scale_derive(Encode, Decode, TypeInfo)]
pub enum Error {
NotOwner,
NotApproved,
TokenExists,
TokenNotFound,
CannotInsert,
CannotFetchValue,
NotAllowed,
}
/// Event emitted when a token transfer occurs.
#[ink(event)]
pub struct Transfer {
#[ink(topic)]
from: Option<AccountId>,
#[ink(topic)]
to: Option<AccountId>,
#[ink(topic)]
id: TokenId,
}
/// Event emitted when a token approve occurs.
#[ink(event)]
pub struct Approval {
#[ink(topic)]
from: AccountId,
#[ink(topic)]
to: AccountId,
#[ink(topic)]
id: TokenId,
}
/// Event emitted when an operator is enabled or disabled for an owner.
/// The operator can manage all NFTs of the owner.
#[ink(event)]
pub struct ApprovalForAll {
#[ink(topic)]
owner: AccountId,
#[ink(topic)]
operator: AccountId,
approved: bool,
}
impl Erc721 {
/// Creates a new ERC-721 token contract.
#[ink(constructor)]
pub fn new() -> Self {
Default::default()
}
/// Returns the balance of the owner.
///
/// This represents the amount of unique tokens the owner has.
#[ink(message)]
pub fn balance_of(&self, owner: AccountId) -> u32 {
self.balance_of_or_zero(&owner)
}
/// Returns the owner of the token.
#[ink(message)]
pub fn owner_of(&self, id: TokenId) -> Option<AccountId> {
self.token_owner.get(id)
}
/// Returns the approved account ID for this token if any.
#[ink(message)]
pub fn get_approved(&self, id: TokenId) -> Option<AccountId> {
self.token_approvals.get(id)
}
/// Returns `true` if the operator is approved by the owner.
#[ink(message)]
pub fn is_approved_for_all(&self, owner: AccountId, operator: AccountId) -> bool {
self.approved_for_all(owner, operator)
}
/// Approves or disapproves the operator for all tokens of the caller.
#[ink(message)]
pub fn set_approval_for_all(
&mut self,
to: AccountId,
approved: bool,
) -> Result<(), Error> {
self.approve_for_all(to, approved)?;
Ok(())
}
/// Approves the account to transfer the specified token on behalf of the caller.
#[ink(message)]
pub fn approve(&mut self, to: AccountId, id: TokenId) -> Result<(), Error> {
self.approve_for(&to, id)?;
Ok(())
}
/// Transfers the token from the caller to the given destination.
#[ink(message)]
pub fn transfer(
&mut self,
destination: AccountId,
id: TokenId,
) -> Result<(), Error> {
let caller = self.env().caller();
self.transfer_token_from(&caller, &destination, id)?;
Ok(())
}
/// Transfer approved or owned token.
#[ink(message)]
pub fn transfer_from(
&mut self,
from: AccountId,
to: AccountId,
id: TokenId,
) -> Result<(), Error> {
self.transfer_token_from(&from, &to, id)?;
Ok(())
}
/// Creates a new token.
#[ink(message)]
pub fn mint(&mut self, id: TokenId) -> Result<(), Error> {
let caller = self.env().caller();
self.add_token_to(&caller, id)?;
self.env().emit_event(Transfer {
from: Some(AccountId::from([0x0; 32])),
to: Some(caller),
id,
});
Ok(())
}
/// Deletes an existing token. Only the owner can burn the token.
#[ink(message)]
pub fn burn(&mut self, id: TokenId) -> Result<(), Error> {
let caller = self.env().caller();
let Self {
token_owner,
owned_tokens_count,
..
} = self;
let owner = token_owner.get(id).ok_or(Error::TokenNotFound)?;
if owner != caller {
return Err(Error::NotOwner);
};
let count = owned_tokens_count
.get(caller)
.map(|c| c.checked_sub(1).unwrap())
.ok_or(Error::CannotFetchValue)?;
owned_tokens_count.insert(caller, &count);
token_owner.remove(id);
self.clear_approval(id);
self.env().emit_event(Transfer {
from: Some(caller),
to: Some(AccountId::from([0x0; 32])),
id,
});
Ok(())
}
/// Transfers token `id` `from` the sender to the `to` `AccountId`.
fn transfer_token_from(
&mut self,
from: &AccountId,
to: &AccountId,
id: TokenId,
) -> Result<(), Error> {
let caller = self.env().caller();
let owner = self.owner_of(id).ok_or(Error::TokenNotFound)?;
if !self.approved_or_owner(caller, id, owner) {
return Err(Error::NotApproved);
};
if owner != *from {
return Err(Error::NotOwner);
};
self.clear_approval(id);
self.remove_token_from(from, id)?;
self.add_token_to(to, id)?;
self.env().emit_event(Transfer {
from: Some(*from),
to: Some(*to),
id,
});
Ok(())
}
/// Removes token `id` from the owner.
fn remove_token_from(
&mut self,
from: &AccountId,
id: TokenId,
) -> Result<(), Error> {
let Self {
token_owner,
owned_tokens_count,
..
} = self;map(|c| c.checked_add(1).unwrap()
if !token_owner.contains(id) {
return Err(Error::TokenNotFound);
}
let count = owned_tokens_count
.get(from)
.map(|c| c.checked_sub(1).unwrap())
.ok_or(Error::CannotFetchValue)?;
owned_tokens_count.insert(from, &count);
token_owner.remove(id);
Ok(())
}
/// Adds the token `id` to the `to` AccountID.
fn add_token_to(&mut self, to: &AccountId, id: TokenId) -> Result<(), Error> {
let Self {
token_owner,
owned_tokens_count,
..
} = self;
if token_owner.contains(id) {
return Err(Error::TokenExists);
}
if *to == AccountId::from([0x0; 32]) {
return Err(Error::NotAllowed);
};
let count = owned_tokens_count
.get(to)
.map(|c| c.checked_add(1).unwrap())
.unwrap_or(1);
owned_tokens_count.insert(to, &count);
token_owner.insert(id, to);
Ok(())
}
/// Approves or disapproves the operator to transfer all tokens of the caller.
fn approve_for_all(
&mut self,
to: AccountId,
approved: bool,
) -> Result<(), Error> {
let caller = self.env().caller();
if to == caller {
return Err(Error::NotAllowed);
}
self.env().emit_event(ApprovalForAll {
owner: caller,
operator: to,
approved,
});
if approved {
self.operator_approvals.insert((&caller, &to), &());
} else {
self.operator_approvals.remove((&caller, &to));
}
Ok(())
}
/// Approve the passed `AccountId` to transfer the specified token on behalf of
/// the message's sender.
fn approve_for(&mut self, to: &AccountId, id: TokenId) -> Result<(), Error> {
let caller = self.env().caller();
let owner = self.owner_of(id).ok_or(Error::TokenNotFound)?;
if !(owner == caller || self.approved_for_all(owner, caller)) {
return Err(Error::NotAllowed);
};
if *to == AccountId::from([0x0; 32]) {
return Err(Error::NotAllowed);
};
if self.token_approvals.contains(id) {
return Err(Error::CannotInsert);
} else {
self.token_approvals.insert(id, to);
}
self.env().emit_event(Approval {
from: caller,
to: *to,
id,
});
Ok(())
}
/// Removes existing approval from token `id`.
fn clear_approval(&mut self, id: TokenId) {
self.token_approvals.remove(id);
}
// Returns the total number of tokens from an account.
fn balance_of_or_zero(&self, of: &AccountId) -> u32 {
self.owned_tokens_count.get(of).unwrap_or(0)
}
/// Gets an operator on other Account's behalf.
fn approved_for_all(&self, owner: AccountId, operator: AccountId) -> bool {
self.operator_approvals.contains((&owner, &operator))
}
/// Returns true if the `AccountId` `from` is the owner of token `id`
/// or it has been approved on behalf of the token `id` owner.
fn approved_or_owner(
&self,
from: AccountId,
id: TokenId,
owner: AccountId,
) -> bool {
from != AccountId::from([0x0; 32])
&& (from == owner
|| self.token_approvals.get(id) == Some(from)
|| self.approved_for_all(owner, from))
}
}
}
Let's break down this implementation step by step, focusing on its core components and logic:
#[ink(storage)]
pub struct Erc721 {
token_owner: Mapping<TokenId, AccountId>,
token_approvals: Mapping<TokenId, AccountId>,
owned_tokens_count: Mapping<AccountId, u32>,
operator_approvals: Mapping<(AccountId, AccountId), ()>,
}
token_owner
: Tracks ownership of each token (non-fungible).token_approvals
: Stores approvals for individual token transfers.owned_tokens_count
: Counts tokens per account (forbalanceOf
).operator_approvals
: Allows operators to manage all tokens of an owner.
transfer_token_from
: Core logic for transferring tokens:fn transfer_token_from(&mut self, from: &AccountId, to: &AccountId, id: TokenId) -> Result<(), Error> { // 1. Check if caller is authorized (owner, approved, or operator) // 2. Update ownership mappings // 3. Emit Transfer event }
- Validates permissions using
approved_or_owner
. - Updates
token_owner
,owned_tokens_count
, and clears approvals.
- Validates permissions using
approve
: Grants permission to transfer a specific token:fn approve_for(&mut self, to: &AccountId, id: TokenId) -> Result<(), Error> { // Ensure caller is owner or operator // Store approval in `token_approvals` // Emit Approval event }
set_approval_for_all
: Grants/revokes operator status for all tokens:fn approve_for_all(&mut self, to: AccountId, approved: bool) -> Result<(), Error> { // Update `operator_approvals` // Emit ApprovalForAll event }
mint
: Creates a new token for the caller:pub fn mint(&mut self, id: TokenId) -> Result<(), Error> { // Assign token to caller // Emit Transfer event (from zero address) }
burn
: Destroys a token (only by owner):pub fn burn(&mut self, id: TokenId) -> Result<(), Error> { // Check ownership // Update mappings // Emit Transfer event (to zero address) }
fn approved_or_owner(&self, from: AccountId, id: TokenId, owner: AccountId) -> bool {
from == owner
|| self.token_approvals.get(id) == Some(from)
|| self.approved_for_all(owner, from)
}
- Validates if the caller (
from
) is:- The owner,
- Approved for the token, or
- An approved operator.
Transfer
: Emitted on mint, burn, or transfer:#[ink(event)] pub struct Transfer { from: Option<AccountId>, to: Option<AccountId>, id: TokenId, }
Approval
: Emitted when a token is approved.ApprovalForAll
: Emitted when an operator is approved/revoked.
Let’s walk through all key functions with Alice, Bob, and Charlie interacting with the contract, in a unit test manner:
let mut erc721 = Erc721::new();
// Alice mints token 1 (representing an ID)
erc721.mint(1).unwrap();
assert_eq!(erc721.owner_of(1), Some(ALICE));
assert_eq!(erc721.balance_of(ALICE), 1);
// Alice mints token 2
erc721.mint(2).unwrap();
assert_eq!(erc721.balance_of(ALICE), 2);
// Alice approves Bob to transfer token 1
erc721.approve(BOB, 1).unwrap();
assert_eq!(erc721.get_approved(1), Some(BOB));
// Bob transfers token 1 to Charlie
erc721.transfer_from(ALICE, CHARLIE, 1).unwrap();
assert_eq!(erc721.owner_of(1), Some(CHARLIE));
assert_eq!(erc721.balance_of(ALICE), 1);
// Alice approves Bob as an operator for all her tokens
erc721.set_approval_for_all(BOB, true).unwrap();
assert!(erc721.is_approved_for_all(ALICE, BOB));
// Bob transfers token 2 (owned by Alice) to Charlie
erc721.transfer_from(ALICE, CHARLIE, 2).unwrap();
assert_eq!(erc721.owner_of(2), Some(CHARLIE));
assert_eq!(erc721.balance_of(ALICE), 0);
// Charlie burns token 1
erc721.burn(1).unwrap();
assert_eq!(erc721.owner_of(1), None);
assert_eq!(erc721.balance_of(CHARLIE), 1); // Still owns token 2
// Bob tries to burn Alice's token (fails: not owner)
assert_eq!(erc721.burn(2), Err(Error::NotOwner));
// Charlie tries to approve Bob without ownership (fails: not allowed)
assert_eq!(erc721.approve(BOB, 2), Err(Error::NotAllowed));
The zero address (0x000...000
) is a reserved address in Ethereum-like networks, often representing "burning" tokens or invalid destinations. This implementation explicitly blocks interactions with the zero address to prevent accidental token loss or misuse.
How It’s Implemented:
// In add_token_to():
if *to == AccountId::from([0x0; 32]) {
return Err(Error::NotAllowed);
}
- Minting: When minting a token (
mint()
), thefrom
field in theTransfer
event is set to the zero address, indicating the token is newly created. - Burning: When burning a token (
burn()
), theto
field in theTransfer
event is set to the zero address, indicating destruction. - Transfers: Transfers to the zero address are explicitly blocked (except in
burn()
), preventing accidental burns viatransfer()
ortransfer_from()
.
The contract uses Rust’s checked_add
and checked_sub
methods to prevent integer overflow/underflow attacks, which could corrupt token balances.
Implementation:
// When adding a token (e.g., minting or transferring):
let count = owned_tokens_count
.get(to)
.map(|c| c.checked_add(1).unwrap()) // Fails on overflow
.unwrap_or(1); // Default to 1 if no previous balance
// When removing a token (e.g., burning or transferring):
let count = owned_tokens_count
.get(from)
.map(|c| c.checked_sub(1).unwrap()) // Fails on underflow
.ok_or(Error::CannotFetchValue)?;
checked_add(1)
ensures the token count doesn’t exceedu32::MAX
.checked_sub(1)
ensures the count doesn’t drop below0
.
-
Ownership Validation:
// In burn(): let owner = token_owner.get(id).ok_or(Error::TokenNotFound)?; if owner != caller { return Err(Error::NotOwner); };
Ensures only the token owner can burn their token.
-
Approval Checks:
// In approve_for(): if !(owner == caller || self.approved_for_all(owner, caller)) { return Err(Error::NotAllowed); };
Validates that only the token owner or an approved operator can approve a token for transfer.