diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5a2dbf1b..95993cd2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -10,6 +10,12 @@ You should never open a pull request to merge your changes directly into `main`.
The `dev` branch is deployed at
+The release branch is `main`. The development branch is `dev` and is considered stable (but not released yet).
+When you want to contribute, please create a new branch from `dev` and open a pull request to merge your changes back into `dev`.
+You should never open a pull request to merge your changes directly into `main`.
+
+The `dev` branch is deployed at https://starknet-by-example-dev.voyager.online/
+
Please note we have a code of conduct, please follow it in all your interactions with the project.
## Table of Contents
diff --git a/Scarb.lock b/Scarb.lock
index c337f67b..1916bfcd 100644
--- a/Scarb.lock
+++ b/Scarb.lock
@@ -92,6 +92,15 @@ version = "0.1.0"
name = "erc20"
version = "0.1.0"
+[[package]]
+name = "erc721"
+version = "0.1.0"
+dependencies = [
+ "openzeppelin_account",
+ "openzeppelin_introspection",
+ "snforge_std",
+]
+
[[package]]
name = "errors"
version = "0.1.0"
diff --git a/listings/applications/erc721/.gitignore b/listings/applications/erc721/.gitignore
new file mode 100644
index 00000000..eb5a316c
--- /dev/null
+++ b/listings/applications/erc721/.gitignore
@@ -0,0 +1 @@
+target
diff --git a/listings/applications/erc721/Scarb.toml b/listings/applications/erc721/Scarb.toml
new file mode 100644
index 00000000..b69dad80
--- /dev/null
+++ b/listings/applications/erc721/Scarb.toml
@@ -0,0 +1,18 @@
+[package]
+name = "erc721"
+version.workspace = true
+edition.workspace = true
+
+[dependencies]
+starknet.workspace = true
+openzeppelin_account.workspace = true
+openzeppelin_introspection.workspace = true
+
+[dev-dependencies]
+assert_macros.workspace = true
+snforge_std.workspace = true
+
+[scripts]
+test.workspace = true
+
+[[target.starknet-contract]]
diff --git a/listings/applications/erc721/src/erc721.cairo b/listings/applications/erc721/src/erc721.cairo
new file mode 100644
index 00000000..5dcb23ad
--- /dev/null
+++ b/listings/applications/erc721/src/erc721.cairo
@@ -0,0 +1,208 @@
+#[starknet::contract]
+pub mod ERC721 {
+ use core::num::traits::Zero;
+ use starknet::get_caller_address;
+ use starknet::ContractAddress;
+ use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess};
+ use openzeppelin_introspection::interface::{ISRC5Dispatcher, ISRC5DispatcherTrait};
+ use erc721::interfaces::{
+ IERC721, IERC721ReceiverDispatcher, IERC721ReceiverDispatcherTrait, IERC721_RECEIVER_ID,
+ IERC721Mintable, IERC721Burnable,
+ };
+
+ #[storage]
+ pub struct Storage {
+ pub owners: Map,
+ pub balances: Map,
+ pub approvals: Map,
+ pub operator_approvals: Map<(ContractAddress, ContractAddress), bool>,
+ }
+
+ #[event]
+ #[derive(Drop, starknet::Event)]
+ pub enum Event {
+ Transfer: Transfer,
+ Approval: Approval,
+ ApprovalForAll: ApprovalForAll,
+ }
+
+ #[derive(Drop, starknet::Event)]
+ pub struct Transfer {
+ pub from: ContractAddress,
+ pub to: ContractAddress,
+ pub token_id: u256,
+ }
+
+ #[derive(Drop, starknet::Event)]
+ pub struct Approval {
+ pub owner: ContractAddress,
+ pub approved: ContractAddress,
+ pub token_id: u256,
+ }
+
+ #[derive(Drop, starknet::Event)]
+ pub struct ApprovalForAll {
+ pub owner: ContractAddress,
+ pub operator: ContractAddress,
+ pub approved: bool,
+ }
+
+ pub mod Errors {
+ pub const INVALID_TOKEN_ID: felt252 = 'ERC721: invalid token ID';
+ pub const INVALID_ACCOUNT: felt252 = 'ERC721: invalid account';
+ pub const INVALID_OPERATOR: felt252 = 'ERC721: invalid operator';
+ pub const UNAUTHORIZED: felt252 = 'ERC721: unauthorized caller';
+ pub const INVALID_RECEIVER: felt252 = 'ERC721: invalid receiver';
+ pub const INVALID_SENDER: felt252 = 'ERC721: invalid sender';
+ pub const SAFE_TRANSFER_FAILED: felt252 = 'ERC721: safe transfer failed';
+ pub const ALREADY_MINTED: felt252 = 'ERC721: token already minted';
+ }
+
+ #[abi(embed_v0)]
+ impl ERC721 of IERC721 {
+ fn owner_of(self: @ContractState, token_id: u256) -> ContractAddress {
+ self._require_owned(token_id)
+ }
+
+ fn balance_of(self: @ContractState, owner: ContractAddress) -> u256 {
+ assert(!owner.is_zero(), Errors::INVALID_ACCOUNT);
+ self.balances.read(owner)
+ }
+
+ fn set_approval_for_all(
+ ref self: ContractState, operator: ContractAddress, approved: bool,
+ ) {
+ assert(!operator.is_zero(), Errors::INVALID_OPERATOR);
+ let owner = get_caller_address();
+ self.operator_approvals.write((owner, operator), approved);
+ self.emit(ApprovalForAll { owner, operator, approved });
+ }
+
+ fn approve(ref self: ContractState, approved: ContractAddress, token_id: u256) {
+ let owner = self._require_owned(token_id);
+ let caller = get_caller_address();
+ assert(
+ caller == owner || self.is_approved_for_all(owner, caller), Errors::UNAUTHORIZED,
+ );
+
+ self.approvals.write(token_id, approved);
+ self.emit(Approval { owner, approved, token_id });
+ }
+
+ fn get_approved(self: @ContractState, token_id: u256) -> ContractAddress {
+ self._require_owned(token_id);
+ self.approvals.read(token_id)
+ }
+
+ fn transfer_from(
+ ref self: ContractState, from: ContractAddress, to: ContractAddress, token_id: u256,
+ ) {
+ let previous_owner = self._require_owned(token_id);
+ assert(from == previous_owner, Errors::INVALID_SENDER);
+ assert(!to.is_zero(), Errors::INVALID_RECEIVER);
+ assert(
+ self._is_approved_or_owner(from, get_caller_address(), token_id),
+ Errors::UNAUTHORIZED,
+ );
+
+ self.balances.write(from, self.balances.read(from) - 1);
+ self.balances.write(to, self.balances.read(to) + 1);
+ self.owners.write(token_id, to);
+ self.approvals.write(token_id, Zero::zero());
+
+ self.emit(Transfer { from, to, token_id });
+ }
+
+ fn safe_transfer_from(
+ ref self: ContractState,
+ from: ContractAddress,
+ to: ContractAddress,
+ token_id: u256,
+ data: Span,
+ ) {
+ Self::transfer_from(ref self, from, to, token_id);
+ assert(
+ self._check_on_erc721_received(from, to, token_id, data),
+ Errors::SAFE_TRANSFER_FAILED,
+ );
+ }
+
+ fn is_approved_for_all(
+ self: @ContractState, owner: ContractAddress, operator: ContractAddress,
+ ) -> bool {
+ self.operator_approvals.read((owner, operator))
+ }
+ }
+
+ #[abi(embed_v0)]
+ pub impl ERC721Burnable of IERC721Burnable {
+ fn burn(ref self: ContractState, token_id: u256) {
+ self._burn(token_id)
+ }
+ }
+
+ #[abi(embed_v0)]
+ pub impl ERC721Mintable of IERC721Mintable {
+ fn mint(ref self: ContractState, to: ContractAddress, token_id: u256) {
+ self._mint(to, token_id)
+ }
+ }
+
+ #[generate_trait]
+ pub impl InternalImpl of InternalTrait {
+ fn _mint(ref self: ContractState, to: ContractAddress, token_id: u256) {
+ assert(!to.is_zero(), Errors::INVALID_RECEIVER);
+ assert(self.owners.read(token_id).is_zero(), Errors::ALREADY_MINTED);
+
+ self.balances.write(to, self.balances.read(to) + 1);
+ self.owners.write(token_id, to);
+
+ self.emit(Transfer { from: Zero::zero(), to, token_id });
+ }
+
+ fn _burn(ref self: ContractState, token_id: u256) {
+ let owner = self._require_owned(token_id);
+
+ self.balances.write(owner, self.balances.read(owner) - 1);
+
+ self.owners.write(token_id, Zero::zero());
+ self.approvals.write(token_id, Zero::zero());
+
+ self.emit(Transfer { from: owner, to: Zero::zero(), token_id });
+ }
+
+ fn _require_owned(self: @ContractState, token_id: u256) -> ContractAddress {
+ let owner = self.owners.read(token_id);
+ assert(!owner.is_zero(), Errors::INVALID_TOKEN_ID);
+ owner
+ }
+
+ fn _is_approved_or_owner(
+ self: @ContractState, owner: ContractAddress, spender: ContractAddress, token_id: u256,
+ ) -> bool {
+ !spender.is_zero()
+ && (owner == spender
+ || self.is_approved_for_all(owner, spender)
+ || spender == self.get_approved(token_id))
+ }
+
+ fn _check_on_erc721_received(
+ self: @ContractState,
+ from: ContractAddress,
+ to: ContractAddress,
+ token_id: u256,
+ data: Span,
+ ) -> bool {
+ let src5_dispatcher = ISRC5Dispatcher { contract_address: to };
+
+ if src5_dispatcher.supports_interface(IERC721_RECEIVER_ID) {
+ IERC721ReceiverDispatcher { contract_address: to }
+ .on_erc721_received(
+ get_caller_address(), from, token_id, data,
+ ) == IERC721_RECEIVER_ID
+ } else {
+ src5_dispatcher.supports_interface(openzeppelin_account::interface::ISRC6_ID)
+ }
+ }
+ }
+}
diff --git a/listings/applications/erc721/src/interfaces.cairo b/listings/applications/erc721/src/interfaces.cairo
new file mode 100644
index 00000000..af620bb0
--- /dev/null
+++ b/listings/applications/erc721/src/interfaces.cairo
@@ -0,0 +1,75 @@
+use starknet::ContractAddress;
+
+// [!region interface]
+#[starknet::interface]
+pub trait IERC721 {
+ fn balance_of(self: @TContractState, owner: ContractAddress) -> u256;
+ fn owner_of(self: @TContractState, token_id: u256) -> ContractAddress;
+ // The function `safe_transfer_from(address _from, address _to, uint256 _tokenId)`
+ // is not included because the same behavior can be achieved by calling
+ // `safe_transfer_from(from, to, tokenId, data)` with an empty `data`
+ // parameter. This approach reduces redundancy in the contract's interface.
+ fn safe_transfer_from(
+ ref self: TContractState,
+ from: ContractAddress,
+ to: ContractAddress,
+ token_id: u256,
+ data: Span,
+ );
+ fn transfer_from(
+ ref self: TContractState, from: ContractAddress, to: ContractAddress, token_id: u256,
+ );
+ fn approve(ref self: TContractState, approved: ContractAddress, token_id: u256);
+ fn set_approval_for_all(ref self: TContractState, operator: ContractAddress, approved: bool);
+ fn get_approved(self: @TContractState, token_id: u256) -> ContractAddress;
+ fn is_approved_for_all(
+ self: @TContractState, owner: ContractAddress, operator: ContractAddress,
+ ) -> bool;
+}
+
+#[starknet::interface]
+pub trait IERC721Mintable {
+ fn mint(ref self: TContractState, to: ContractAddress, token_id: u256);
+}
+
+#[starknet::interface]
+pub trait IERC721Burnable {
+ fn burn(ref self: TContractState, token_id: u256);
+}
+
+pub const IERC721_RECEIVER_ID: felt252 =
+ 0x3a0dff5f70d80458ad14ae37bb182a728e3c8cdda0402a5daa86620bdf910bc;
+
+#[starknet::interface]
+pub trait IERC721Receiver {
+ fn on_erc721_received(
+ self: @TContractState,
+ operator: ContractAddress,
+ from: ContractAddress,
+ token_id: u256,
+ data: Span,
+ ) -> felt252;
+}
+
+// The `IERC721Metadata` and `IERC721Enumerable` interfaces are included here
+// as optional extensions to the ERC721 standard. While they provide additional
+// functionality (such as token metadata and enumeration), they are not
+// implemented in this example. Including these interfaces demonstrates how they
+// can be integrated and serves as a starting point for developers who wish to
+// extend the functionality.
+#[starknet::interface]
+pub trait IERC721Metadata {
+ fn name(self: @TContractState) -> ByteArray;
+ fn symbol(self: @TContractState) -> ByteArray;
+ fn token_uri(self: @TContractState, token_id: u256) -> ByteArray;
+}
+
+#[starknet::interface]
+pub trait IERC721Enumerable {
+ fn total_supply(self: @TContractState) -> u256;
+ fn token_by_index(self: @TContractState, index: u256) -> u256;
+ fn token_of_owner_by_index(self: @TContractState, owner: ContractAddress, index: u256) -> u256;
+}
+// [!endregion interface]
+
+
diff --git a/listings/applications/erc721/src/lib.cairo b/listings/applications/erc721/src/lib.cairo
new file mode 100644
index 00000000..c5084627
--- /dev/null
+++ b/listings/applications/erc721/src/lib.cairo
@@ -0,0 +1,6 @@
+pub mod erc721;
+pub mod interfaces;
+mod mocks;
+
+#[cfg(test)]
+mod tests;
diff --git a/listings/applications/erc721/src/mocks.cairo b/listings/applications/erc721/src/mocks.cairo
new file mode 100644
index 00000000..0cffa4d7
--- /dev/null
+++ b/listings/applications/erc721/src/mocks.cairo
@@ -0,0 +1,7 @@
+pub mod account;
+pub mod receiver;
+pub mod non_receiver;
+
+pub use account::AccountMock;
+pub use non_receiver::NonReceiverMock;
+pub use receiver::ERC721ReceiverMock;
diff --git a/listings/applications/erc721/src/mocks/account.cairo b/listings/applications/erc721/src/mocks/account.cairo
new file mode 100644
index 00000000..2a617c17
--- /dev/null
+++ b/listings/applications/erc721/src/mocks/account.cairo
@@ -0,0 +1,46 @@
+//! Copied with modifications from OpenZeppelin's repo
+//! https://github.com/OpenZeppelin/cairo-contracts/blob/6e60ba9310fa7953f045d0c30b343b0ffc168c14/packages/test_common/src/mocks/account.cairo
+
+#[starknet::contract(account)]
+pub mod AccountMock {
+ use openzeppelin_account::AccountComponent;
+ use openzeppelin_introspection::src5::SRC5Component;
+
+ component!(path: AccountComponent, storage: account, event: AccountEvent);
+ component!(path: SRC5Component, storage: src5, event: SRC5Event);
+
+ // Account
+ #[abi(embed_v0)]
+ impl SRC6Impl = AccountComponent::SRC6Impl;
+ #[abi(embed_v0)]
+ impl DeclarerImpl = AccountComponent::DeclarerImpl;
+ #[abi(embed_v0)]
+ impl DeployableImpl = AccountComponent::DeployableImpl;
+ impl AccountInternalImpl = AccountComponent::InternalImpl;
+
+ // SCR5
+ #[abi(embed_v0)]
+ impl SRC5Impl = SRC5Component::SRC5Impl;
+
+ #[storage]
+ pub struct Storage {
+ #[substorage(v0)]
+ pub account: AccountComponent::Storage,
+ #[substorage(v0)]
+ pub src5: SRC5Component::Storage,
+ }
+
+ #[event]
+ #[derive(Drop, starknet::Event)]
+ enum Event {
+ #[flat]
+ AccountEvent: AccountComponent::Event,
+ #[flat]
+ SRC5Event: SRC5Component::Event,
+ }
+
+ #[constructor]
+ fn constructor(ref self: ContractState, public_key: felt252) {
+ self.account.initializer(public_key);
+ }
+}
diff --git a/listings/applications/erc721/src/mocks/non_receiver.cairo b/listings/applications/erc721/src/mocks/non_receiver.cairo
new file mode 100644
index 00000000..953521be
--- /dev/null
+++ b/listings/applications/erc721/src/mocks/non_receiver.cairo
@@ -0,0 +1,10 @@
+#[starknet::contract]
+pub mod NonReceiverMock {
+ #[storage]
+ pub struct Storage {}
+
+ #[external(v0)]
+ fn nope(self: @ContractState) -> bool {
+ false
+ }
+}
diff --git a/listings/applications/erc721/src/mocks/receiver.cairo b/listings/applications/erc721/src/mocks/receiver.cairo
new file mode 100644
index 00000000..abd186d8
--- /dev/null
+++ b/listings/applications/erc721/src/mocks/receiver.cairo
@@ -0,0 +1,50 @@
+const SUCCESS: felt252 = 'SUCCESS';
+
+#[starknet::contract]
+pub mod ERC721ReceiverMock {
+ use openzeppelin_introspection::src5::SRC5Component;
+ use erc721::interfaces::{IERC721Receiver, IERC721_RECEIVER_ID};
+ use starknet::ContractAddress;
+
+ component!(path: SRC5Component, storage: src5, event: SRC5Event);
+
+ // SRC5
+ #[abi(embed_v0)]
+ impl SRC5Impl = SRC5Component::SRC5Impl;
+ impl SRC5InternalImpl = SRC5Component::InternalImpl;
+
+ #[storage]
+ pub struct Storage {
+ #[substorage(v0)]
+ pub src5: SRC5Component::Storage,
+ }
+
+ #[event]
+ #[derive(Drop, starknet::Event)]
+ enum Event {
+ #[flat]
+ SRC5Event: SRC5Component::Event,
+ }
+
+ #[constructor]
+ fn constructor(ref self: ContractState) {
+ self.src5.register_interface(IERC721_RECEIVER_ID);
+ }
+
+ #[abi(embed_v0)]
+ impl ExternalImpl of IERC721Receiver {
+ fn on_erc721_received(
+ self: @ContractState,
+ operator: ContractAddress,
+ from: ContractAddress,
+ token_id: u256,
+ data: Span,
+ ) -> felt252 {
+ if *data.at(0) == super::SUCCESS {
+ IERC721_RECEIVER_ID
+ } else {
+ 0
+ }
+ }
+ }
+}
diff --git a/listings/applications/erc721/src/tests.cairo b/listings/applications/erc721/src/tests.cairo
new file mode 100644
index 00000000..5b19ac56
--- /dev/null
+++ b/listings/applications/erc721/src/tests.cairo
@@ -0,0 +1,803 @@
+use core::num::traits::Zero;
+use erc721::interfaces::{
+ IERC721, IERC721Dispatcher, IERC721DispatcherTrait, IERC721SafeDispatcher,
+ IERC721SafeDispatcherTrait, IERC721Mintable, IERC721MintableDispatcher,
+ IERC721MintableDispatcherTrait, IERC721BurnableDispatcher, IERC721BurnableDispatcherTrait,
+};
+use erc721::erc721::{ERC721, ERC721::{Event, Transfer, Approval, ApprovalForAll, InternalTrait}};
+use snforge_std::{
+ declare, test_address, DeclareResultTrait, ContractClassTrait, start_cheat_caller_address,
+ spy_events, EventSpyAssertionsTrait,
+};
+use starknet::{ContractAddress, contract_address_const};
+
+pub const SUCCESS: felt252 = 'SUCCESS';
+pub const FAILURE: felt252 = 'FAILURE';
+pub const PUBKEY: felt252 = 'PUBKEY';
+pub const TOKEN_ID: u256 = 21;
+pub const NONEXISTENT_TOKEN_ID: u256 = 7;
+
+pub fn CALLER() -> ContractAddress {
+ contract_address_const::<'CALLER'>()
+}
+
+pub fn OPERATOR() -> ContractAddress {
+ contract_address_const::<'OPERATOR'>()
+}
+
+pub fn OTHER() -> ContractAddress {
+ contract_address_const::<'OTHER'>()
+}
+
+pub fn OWNER() -> ContractAddress {
+ contract_address_const::<'OWNER'>()
+}
+
+pub fn RECIPIENT() -> ContractAddress {
+ contract_address_const::<'RECIPIENT'>()
+}
+
+pub fn SPENDER() -> ContractAddress {
+ contract_address_const::<'SPENDER'>()
+}
+
+pub fn ZERO() -> ContractAddress {
+ contract_address_const::<0>()
+}
+
+pub fn DATA(success: bool) -> Span {
+ let value = if success {
+ SUCCESS
+ } else {
+ FAILURE
+ };
+ array![value].span()
+}
+
+fn deploy_account() -> ContractAddress {
+ let contract = declare("AccountMock").unwrap().contract_class();
+ let (contract_address, _) = contract.deploy(@array![PUBKEY]).unwrap();
+ contract_address
+}
+
+fn deploy_receiver() -> ContractAddress {
+ let contract = declare("ERC721ReceiverMock").unwrap().contract_class();
+ let (contract_address, _) = contract.deploy(@array![]).unwrap();
+ contract_address
+}
+
+fn deploy_non_receiver() -> ContractAddress {
+ let contract = declare("NonReceiverMock").unwrap().contract_class();
+ let (contract_address, _) = contract.deploy(@array![]).unwrap();
+ contract_address
+}
+
+fn setup(mint_to: ContractAddress) -> (IERC721Dispatcher, ContractAddress) {
+ let contract = declare("ERC721").unwrap().contract_class();
+ let (contract_address, _) = contract.deploy(@array![]).unwrap();
+ if mint_to != ZERO() {
+ let contract = IERC721MintableDispatcher { contract_address };
+ contract.mint(mint_to, TOKEN_ID);
+ }
+ let contract = IERC721Dispatcher { contract_address };
+ (contract, contract_address)
+}
+
+fn setup_internals() -> ERC721::ContractState {
+ let mut state = ERC721::contract_state_for_testing();
+ state.mint(OWNER(), TOKEN_ID);
+ state
+}
+
+//
+// Getters
+//
+
+#[test]
+fn test_balance_of() {
+ let (contract, _) = setup(OWNER());
+ assert_eq!(contract.balance_of(OWNER()), 1);
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: invalid account',))]
+fn test_balance_of_zero() {
+ let (contract, _) = setup(OWNER());
+ contract.balance_of(ZERO());
+}
+
+#[test]
+fn test_owner_of() {
+ let (contract, _) = setup(OWNER());
+ assert_eq!(contract.owner_of(TOKEN_ID), OWNER());
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: invalid token ID',))]
+fn test_owner_of_non_minted() {
+ let (contract, _) = setup(OWNER());
+ contract.owner_of(NONEXISTENT_TOKEN_ID);
+}
+
+#[test]
+fn test_get_approved() {
+ let (mut contract, contract_address) = setup(OWNER());
+ let spender = SPENDER();
+ let token_id = TOKEN_ID;
+
+ start_cheat_caller_address(contract_address, OWNER());
+
+ assert_eq!(contract.get_approved(token_id), ZERO());
+ contract.approve(spender, token_id);
+ assert_eq!(contract.get_approved(token_id), spender);
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: invalid token ID',))]
+fn test_get_approved_nonexistent() {
+ let (contract, _) = setup(OWNER());
+ contract.get_approved(NONEXISTENT_TOKEN_ID);
+}
+
+//
+// approve
+//
+
+#[test]
+fn test_approve_from_owner() {
+ let (mut contract, contract_address) = setup(OWNER());
+ let mut spy = spy_events();
+
+ start_cheat_caller_address(contract_address, OWNER());
+ contract.approve(SPENDER(), TOKEN_ID);
+
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Approval(
+ Approval { owner: OWNER(), approved: SPENDER(), token_id: TOKEN_ID },
+ ),
+ ),
+ ],
+ );
+
+ let approved = contract.get_approved(TOKEN_ID);
+ assert_eq!(approved, SPENDER());
+}
+
+#[test]
+fn test_approve_from_operator() {
+ let (mut contract, contract_address) = setup(OWNER());
+
+ start_cheat_caller_address(contract_address, OWNER());
+ contract.set_approval_for_all(OPERATOR(), true);
+
+ let mut spy = spy_events();
+
+ start_cheat_caller_address(contract_address, OPERATOR());
+ contract.approve(SPENDER(), TOKEN_ID);
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Approval(
+ Approval { owner: OWNER(), approved: SPENDER(), token_id: TOKEN_ID },
+ ),
+ ),
+ ],
+ );
+
+ let approved = contract.get_approved(TOKEN_ID);
+ assert_eq!(approved, SPENDER());
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: unauthorized caller',))]
+fn test_approve_from_unauthorized() {
+ let (mut contract, contract_address) = setup(OWNER());
+
+ start_cheat_caller_address(contract_address, OTHER());
+ contract.approve(SPENDER(), TOKEN_ID);
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: invalid token ID',))]
+fn test_approve_nonexistent() {
+ let (mut contract, _) = setup(OWNER());
+ contract.approve(SPENDER(), NONEXISTENT_TOKEN_ID);
+}
+
+#[test]
+fn test_approve_auth_is_approved_for_all() {
+ let (mut contract, contract_address) = setup(OWNER());
+ let auth = CALLER();
+
+ start_cheat_caller_address(contract_address, OWNER());
+ contract.set_approval_for_all(auth, true);
+
+ let mut spy = spy_events();
+
+ start_cheat_caller_address(contract_address, auth);
+ contract.approve(SPENDER(), TOKEN_ID);
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Approval(
+ Approval { owner: OWNER(), approved: SPENDER(), token_id: TOKEN_ID },
+ ),
+ ),
+ ],
+ );
+
+ let approved = contract.get_approved(TOKEN_ID);
+ assert_eq!(approved, SPENDER());
+}
+
+//
+// set_approval_for_all
+//
+
+#[test]
+fn test_set_approval_for_all() {
+ let (mut contract, contract_address) = setup(OWNER());
+ let mut spy = spy_events();
+
+ start_cheat_caller_address(contract_address, OWNER());
+ let not_approved_for_all = !contract.is_approved_for_all(OWNER(), OPERATOR());
+ assert!(not_approved_for_all);
+
+ contract.set_approval_for_all(OPERATOR(), true);
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::ApprovalForAll(
+ ApprovalForAll { owner: OWNER(), operator: OPERATOR(), approved: true },
+ ),
+ ),
+ ],
+ );
+
+ let is_approved_for_all = contract.is_approved_for_all(OWNER(), OPERATOR());
+ assert!(is_approved_for_all);
+
+ contract.set_approval_for_all(OPERATOR(), false);
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::ApprovalForAll(
+ ApprovalForAll { owner: OWNER(), operator: OPERATOR(), approved: false },
+ ),
+ ),
+ ],
+ );
+
+ let not_approved_for_all = !contract.is_approved_for_all(OWNER(), OPERATOR());
+ assert!(not_approved_for_all);
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: invalid operator',))]
+fn test_set_approval_for_all_invalid_operator() {
+ let (mut contract, _) = setup(OWNER());
+ contract.set_approval_for_all(ZERO(), true);
+}
+
+//
+// transfer_from
+//
+
+#[test]
+fn test_transfer_from_owner() {
+ let owner = OWNER();
+ let recipient = RECIPIENT();
+ let (mut contract, contract_address) = setup(owner);
+
+ // set approval to check reset
+ start_cheat_caller_address(contract_address, owner);
+ contract.approve(OTHER(), TOKEN_ID);
+
+ assert_state_before_transfer(contract, owner, recipient, TOKEN_ID);
+
+ let approved = contract.get_approved(TOKEN_ID);
+ assert_eq!(approved, OTHER());
+
+ let mut spy = spy_events();
+
+ contract.transfer_from(owner, recipient, TOKEN_ID);
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Transfer(Transfer { from: owner, to: recipient, token_id: TOKEN_ID }),
+ ),
+ ],
+ );
+
+ assert_state_after_transfer(contract, owner, recipient, TOKEN_ID);
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: invalid token ID',))]
+fn test_transfer_from_nonexistent() {
+ let (mut contract, contract_address) = setup(OWNER());
+ start_cheat_caller_address(contract_address, OWNER());
+ contract.transfer_from(ZERO(), RECIPIENT(), NONEXISTENT_TOKEN_ID);
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: invalid receiver',))]
+fn test_transfer_from_to_zero() {
+ let (mut contract, contract_address) = setup(OWNER());
+ start_cheat_caller_address(contract_address, OWNER());
+ contract.transfer_from(OWNER(), ZERO(), TOKEN_ID);
+}
+
+#[test]
+fn test_transfer_from_to_owner() {
+ let owner = OWNER();
+ let (mut contract, contract_address) = setup(owner);
+ let mut spy = spy_events();
+
+ assert_eq!(contract.owner_of(TOKEN_ID), owner);
+ assert_eq!(contract.balance_of(owner), 1);
+
+ start_cheat_caller_address(contract_address, owner);
+ contract.transfer_from(owner, owner, TOKEN_ID);
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Transfer(Transfer { from: owner, to: owner, token_id: TOKEN_ID }),
+ ),
+ ],
+ );
+
+ assert_eq!(contract.owner_of(TOKEN_ID), owner);
+ assert_eq!(contract.balance_of(owner), 1);
+}
+
+#[test]
+fn test_transfer_from_approved() {
+ let token_id = TOKEN_ID;
+ let owner = OWNER();
+ let recipient = RECIPIENT();
+ let (mut contract, contract_address) = setup(owner);
+
+ assert_state_before_transfer(contract, owner, recipient, token_id);
+
+ start_cheat_caller_address(contract_address, owner);
+ contract.approve(OPERATOR(), token_id);
+
+ let mut spy = spy_events();
+
+ start_cheat_caller_address(contract_address, OPERATOR());
+ contract.transfer_from(owner, recipient, token_id);
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Transfer(Transfer { from: owner, to: recipient, token_id }),
+ ),
+ ],
+ );
+
+ assert_state_after_transfer(contract, owner, recipient, token_id);
+}
+
+#[test]
+fn test_transfer_from_approved_for_all() {
+ let token_id = TOKEN_ID;
+ let owner = OWNER();
+ let recipient = RECIPIENT();
+ let (mut contract, contract_address) = setup(owner);
+
+ assert_state_before_transfer(contract, owner, recipient, token_id);
+
+ start_cheat_caller_address(contract_address, owner);
+ contract.set_approval_for_all(OPERATOR(), true);
+
+ let mut spy = spy_events();
+
+ start_cheat_caller_address(contract_address, OPERATOR());
+ contract.transfer_from(owner, recipient, token_id);
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Transfer(Transfer { from: owner, to: recipient, token_id }),
+ ),
+ ],
+ );
+
+ assert_state_after_transfer(contract, owner, recipient, token_id);
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: unauthorized caller',))]
+fn test_transfer_from_unauthorized() {
+ let (mut contract, contract_address) = setup(OWNER());
+ start_cheat_caller_address(contract_address, OTHER());
+ contract.transfer_from(OWNER(), RECIPIENT(), TOKEN_ID);
+}
+
+//
+// safe_transfer_from
+//
+
+#[test]
+fn test_safe_transfer_from_to_account() {
+ let account = deploy_account();
+ let mut spy = spy_events();
+ let owner = OWNER();
+ let (mut contract, contract_address) = setup(owner);
+
+ assert_state_before_transfer(contract, owner, account, TOKEN_ID);
+
+ start_cheat_caller_address(contract_address, owner);
+ contract.safe_transfer_from(owner, account, TOKEN_ID, DATA(true));
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Transfer(Transfer { from: owner, to: account, token_id: TOKEN_ID }),
+ ),
+ ],
+ );
+
+ assert_state_after_transfer(contract, owner, account, TOKEN_ID);
+}
+
+#[test]
+fn test_safe_transfer_from_to_receiver() {
+ let receiver = deploy_receiver();
+ let mut spy = spy_events();
+ let owner = OWNER();
+ let (mut contract, contract_address) = setup(owner);
+
+ assert_state_before_transfer(contract, owner, receiver, TOKEN_ID);
+
+ start_cheat_caller_address(contract_address, owner);
+ contract.safe_transfer_from(owner, receiver, TOKEN_ID, DATA(true));
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Transfer(Transfer { from: owner, to: receiver, token_id: TOKEN_ID }),
+ ),
+ ],
+ );
+
+ assert_state_after_transfer(contract, owner, receiver, TOKEN_ID);
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: safe transfer failed',))]
+fn test_safe_transfer_from_to_receiver_failure() {
+ let receiver = deploy_receiver();
+ let owner = OWNER();
+ let (mut contract, contract_address) = setup(owner);
+
+ start_cheat_caller_address(contract_address, owner);
+ contract.safe_transfer_from(owner, receiver, TOKEN_ID, DATA(false));
+}
+
+#[test]
+#[should_panic(expected: ('ENTRYPOINT_NOT_FOUND', 'ENTRYPOINT_FAILED'))]
+fn test_safe_transfer_from_to_non_receiver() {
+ let none_receiver = deploy_non_receiver();
+ let owner = OWNER();
+ let (mut contract, contract_address) = setup(owner);
+
+ start_cheat_caller_address(contract_address, owner);
+ contract.safe_transfer_from(owner, none_receiver, TOKEN_ID, DATA(true));
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: invalid token ID',))]
+fn test_safe_transfer_from_nonexistent() {
+ let (mut contract, contract_address) = setup(OWNER());
+ start_cheat_caller_address(contract_address, OWNER());
+ contract.safe_transfer_from(ZERO(), RECIPIENT(), NONEXISTENT_TOKEN_ID, DATA(true));
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: invalid receiver',))]
+fn test_safe_transfer_from_to_zero() {
+ let (mut contract, contract_address) = setup(OWNER());
+ start_cheat_caller_address(contract_address, OWNER());
+ contract.safe_transfer_from(OWNER(), ZERO(), TOKEN_ID, DATA(true));
+}
+
+#[test]
+fn test_safe_transfer_from_to_owner() {
+ let owner = deploy_receiver();
+ let (mut contract, contract_address) = setup(owner);
+
+ assert_eq!(contract.owner_of(TOKEN_ID), owner);
+ assert_eq!(contract.balance_of(owner), 1);
+
+ let mut spy = spy_events();
+
+ start_cheat_caller_address(contract_address, owner);
+ contract.safe_transfer_from(owner, owner, TOKEN_ID, DATA(true));
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Transfer(Transfer { from: owner, to: owner, token_id: TOKEN_ID }),
+ ),
+ ],
+ );
+
+ assert_eq!(contract.owner_of(TOKEN_ID), owner);
+ assert_eq!(contract.balance_of(owner), 1);
+}
+
+#[test]
+fn test_safe_transfer_from_approved() {
+ let receiver = deploy_receiver();
+ let owner = OWNER();
+ let (mut contract, contract_address) = setup(owner);
+
+ assert_state_before_transfer(contract, owner, receiver, TOKEN_ID);
+
+ start_cheat_caller_address(contract_address, owner);
+ contract.approve(OPERATOR(), TOKEN_ID);
+
+ let mut spy = spy_events();
+
+ start_cheat_caller_address(contract_address, OPERATOR());
+ contract.safe_transfer_from(owner, receiver, TOKEN_ID, DATA(true));
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Transfer(Transfer { from: owner, to: receiver, token_id: TOKEN_ID }),
+ ),
+ ],
+ );
+
+ assert_state_after_transfer(contract, owner, receiver, TOKEN_ID);
+}
+
+#[test]
+fn test_safe_transfer_from_approved_for_all() {
+ let receiver = deploy_receiver();
+ let owner = OWNER();
+ let (mut contract, contract_address) = setup(owner);
+
+ assert_state_before_transfer(contract, owner, receiver, TOKEN_ID);
+
+ start_cheat_caller_address(contract_address, owner);
+ contract.set_approval_for_all(OPERATOR(), true);
+
+ let mut spy = spy_events();
+
+ start_cheat_caller_address(contract_address, OPERATOR());
+ contract.safe_transfer_from(owner, receiver, TOKEN_ID, DATA(true));
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Transfer(Transfer { from: owner, to: receiver, token_id: TOKEN_ID }),
+ ),
+ ],
+ );
+
+ assert_state_after_transfer(contract, owner, receiver, TOKEN_ID);
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: unauthorized caller',))]
+fn test_safe_transfer_from_unauthorized() {
+ let (mut contract, contract_address) = setup(OWNER());
+ start_cheat_caller_address(contract_address, OTHER());
+ contract.safe_transfer_from(OWNER(), RECIPIENT(), TOKEN_ID, DATA(true));
+}
+
+//
+// mint
+//
+
+#[test]
+fn test_mint() {
+ let mut spy = spy_events();
+ let recipient = RECIPIENT();
+ let (mut contract, contract_address) = setup(ZERO());
+
+ assert!(contract.balance_of(recipient).is_zero());
+
+ {
+ let mut contract = IERC721MintableDispatcher { contract_address };
+ contract.mint(recipient, TOKEN_ID);
+ }
+
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Transfer(Transfer { from: ZERO(), to: recipient, token_id: TOKEN_ID }),
+ ),
+ ],
+ );
+
+ assert_eq!(contract.owner_of(TOKEN_ID), recipient);
+ assert_eq!(contract.balance_of(recipient), 1);
+ assert!(contract.get_approved(TOKEN_ID).is_zero());
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: invalid receiver',))]
+fn test_mint_to_zero() {
+ let (_, contract_address) = setup(ZERO());
+ let mut contract = IERC721MintableDispatcher { contract_address };
+ contract.mint(ZERO(), TOKEN_ID);
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: token already minted',))]
+fn test_mint_already_exist() {
+ let (_, contract_address) = setup(OWNER());
+ let mut contract = IERC721MintableDispatcher { contract_address };
+ contract.mint(RECIPIENT(), TOKEN_ID);
+}
+
+//
+// burn
+//
+
+#[test]
+#[feature("safe_dispatcher")]
+fn test_burn() {
+ let (mut contract, contract_address) = setup(OWNER());
+
+ start_cheat_caller_address(contract_address, OWNER());
+ // we test that approvals get removed when burning
+ contract.approve(OTHER(), TOKEN_ID);
+
+ assert_eq!(contract.owner_of(TOKEN_ID), OWNER());
+ assert_eq!(contract.balance_of(OWNER()), 1);
+ assert_eq!(contract.get_approved(TOKEN_ID), OTHER());
+
+ let mut spy = spy_events();
+
+ {
+ let mut contract = IERC721BurnableDispatcher { contract_address };
+ contract.burn(TOKEN_ID);
+ }
+
+ spy
+ .assert_emitted(
+ @array![
+ (
+ contract_address,
+ Event::Transfer(Transfer { from: OWNER(), to: ZERO(), token_id: TOKEN_ID }),
+ ),
+ ],
+ );
+
+ assert_eq!(contract.balance_of(OWNER()), 0);
+
+ let contract = IERC721SafeDispatcher { contract_address };
+ match contract.owner_of(TOKEN_ID) {
+ Result::Ok(_) => panic!("`owner_of` did not panic"),
+ Result::Err(panic_data) => {
+ assert(*panic_data.at(0) == 'ERC721: invalid token ID', *panic_data.at(0));
+ },
+ };
+ match contract.get_approved(TOKEN_ID) {
+ Result::Ok(_) => panic!("`get_approved` did not panic"),
+ Result::Err(panic_data) => {
+ assert(*panic_data.at(0) == 'ERC721: invalid token ID', *panic_data.at(0));
+ },
+ };
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: invalid token ID',))]
+fn test_burn_nonexistent() {
+ let (_, contract_address) = setup(OWNER());
+ let mut contract = IERC721BurnableDispatcher { contract_address };
+ contract.burn(NONEXISTENT_TOKEN_ID);
+}
+
+//
+// Internals
+//
+
+#[test]
+fn test__require_owned() {
+ let mut state = setup_internals();
+ let owner = state._require_owned(TOKEN_ID);
+ assert_eq!(owner, OWNER());
+}
+
+#[test]
+#[should_panic(expected: ('ERC721: invalid token ID',))]
+fn test__require_owned_nonexistent() {
+ let mut state = setup_internals();
+ state._require_owned(NONEXISTENT_TOKEN_ID);
+}
+
+#[test]
+fn test__is_approved_or_owner_owner() {
+ let mut state = setup_internals();
+ let authorized = state._is_approved_or_owner(OWNER(), OWNER(), TOKEN_ID);
+ assert!(authorized);
+}
+
+#[test]
+fn test__is_approved_or_owner_approved_for_all() {
+ let mut state = setup_internals();
+
+ start_cheat_caller_address(test_address(), OWNER());
+ state.set_approval_for_all(SPENDER(), true);
+
+ let authorized = state._is_approved_or_owner(OWNER(), SPENDER(), TOKEN_ID);
+ assert!(authorized);
+}
+
+#[test]
+fn test__is_approved_or_owner_approved() {
+ let mut state = setup_internals();
+
+ start_cheat_caller_address(test_address(), OWNER());
+ state.approve(SPENDER(), TOKEN_ID);
+
+ let authorized = state._is_approved_or_owner(OWNER(), SPENDER(), TOKEN_ID);
+ assert!(authorized);
+}
+
+#[test]
+fn test__is_approved_or_owner_not_authorized() {
+ let mut state = setup_internals();
+ let not_authorized = !state._is_approved_or_owner(OWNER(), CALLER(), TOKEN_ID);
+ assert!(not_authorized);
+}
+
+#[test]
+fn test__is_approved_or_owner_zero_address() {
+ let mut state = setup_internals();
+ let not_authorized = !state._is_approved_or_owner(OWNER(), ZERO(), TOKEN_ID);
+ assert!(not_authorized);
+}
+
+//
+// Helpers
+//
+
+fn assert_state_before_transfer(
+ contract: IERC721Dispatcher, owner: ContractAddress, recipient: ContractAddress, token_id: u256,
+) {
+ assert_eq!(contract.owner_of(token_id), owner);
+ assert_eq!(contract.balance_of(owner), 1);
+ assert!(contract.balance_of(recipient).is_zero());
+}
+
+fn assert_state_after_transfer(
+ contract: IERC721Dispatcher, owner: ContractAddress, recipient: ContractAddress, token_id: u256,
+) {
+ assert_eq!(contract.owner_of(token_id), recipient);
+ assert_eq!(contract.balance_of(owner), 0);
+ assert_eq!(contract.balance_of(recipient), 1);
+ assert!(contract.get_approved(token_id).is_zero());
+}
diff --git a/pages/applications/erc20.md b/pages/applications/erc20.md
index 07120417..ff41c6fe 100644
--- a/pages/applications/erc20.md
+++ b/pages/applications/erc20.md
@@ -17,4 +17,4 @@ Here's an implementation of the ERC20 interface in Cairo:
// [!include ~/listings/applications/erc20/src/token.cairo:erc20]
```
-There's several other implementations, such as the [Open Zeppelin](https://docs.openzeppelin.com/contracts-cairo/1.0.0/erc20) or the [Cairo By Example](https://cairo-by-example.com/examples/erc20/) ones.
+There's several other implementations, such as the [Open Zeppelin](https://docs.openzeppelin.com/contracts-cairo/erc20) or the [Cairo By Example](https://cairo-by-example.com/examples/erc20/) ones.
diff --git a/pages/applications/erc721.md b/pages/applications/erc721.md
new file mode 100644
index 00000000..6421f62b
--- /dev/null
+++ b/pages/applications/erc721.md
@@ -0,0 +1,23 @@
+# ERC721 Token
+
+Contracts that follow the [ERC721 Standard](https://eips.ethereum.org/EIPS/eip-721) are called ERC721 tokens. They are used to represent non-fungible assets.
+
+:::note
+For a deeper understanding of the ERC721 interface specifications and its functionality, we highly recommend reading the EIP in detail.
+:::
+
+To create an ERC721 contract, it must implement the following interface:
+
+```cairo
+// [!include ~/listings/applications/erc721/src/interfaces.cairo:interface]
+```
+
+Because function names in Starknet should be written in _snake_case_, the Starknet ERC721 interface is slightly different from the Solidity ERC721 interface which uses _camelCase_.
+
+Here's an implementation of the ERC721 interface in Cairo:
+
+```cairo
+// [!include ~/listings/applications/erc721/src/erc721.cairo]
+```
+
+There are other implementations, such as the [Open Zeppelin](https://docs.openzeppelin.com/contracts-cairo/erc721) one.
diff --git a/routes.ts b/routes.ts
index 18e7cca1..4363f0d4 100644
--- a/routes.ts
+++ b/routes.ts
@@ -134,6 +134,10 @@ const config: Sidebar = [
text: "ERC20 Token",
link: "/applications/erc20",
},
+ {
+ text: "ERC721 NFT",
+ link: "/applications/erc721",
+ },
{
text: "NFT Dutch Auction",
link: "/applications/nft_dutch_auction",