Skip to content
This repository was archived by the owner on Aug 1, 2025. It is now read-only.

Commit f50bfe4

Browse files
0xNeshijulio4
andauthored
Feature: ERC721 NFT Contract (#250)
* Add ERC721 initial impl * Rename token.cairo->erc721.cairo * Simplify contract * Implement mint and burn * refactor erc721 * rename back contract->erc721 * Set up tests * Add all getter tests * Return contract_address from deploy * Add approve tests * Add transfer_from tests * Add safe_transfer_from tests * Add mint tests * Add burn tests * Add internal tests * Move interfaces into interfaces.cairo + fix build errors * fix tests * Fix approvals update in transfer_from * Remove redundant build-external-contracts section from Scarb.toml * Move snforge_std dep to dev deps * Set edition to workspace version * Revert edition to specific version + remove [lib] * Update edition to point to workspace * Prepare release 2.8.2 (#257) * dep: fix patch to latest shikijs for cairo hl * fix(app): correct sidebar placement * fix(app): responsive content centering * fix(app): responsive content centering * doc: branch guidelines * fix(app): top section nav links * fix(app): correct sidebar placement * fix(app): responsive content centering * fix(app): responsive content centering * Update issue templates, Close #273 close #273 * fix erc20 url to OZ one * Add comment encouring devs to read the EIP * add optional Metadata & Enumerable interfaces * make mint & burn internal * add comment for safe_transfer_from * implement burn and mint as additional interfaces * fix tests * add comment above metadata & enumerable * fix links in erc721.md * refactor interfaces.cairo * move erc721 route below erc20 * remove redundant newline from interfaces.cairo * fix test_safe_transfer_from_to_non_receiver * revert changes to pnpm-lock * revert changes to pnpm-lock * make erc721 cairo link version-agnostic * update openzeppelin dep declarations * fix fmt errors * fix fmt errors * fix Scarb.lock * add missing newlines in interfaces.cairo * remove error.log --------- Co-authored-by: Julio <[email protected]>
1 parent 0af5851 commit f50bfe4

File tree

15 files changed

+1267
-1
lines changed

15 files changed

+1267
-1
lines changed

CONTRIBUTING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ You should never open a pull request to merge your changes directly into `main`.
1010

1111
The `dev` branch is deployed at <https://starknet-by-example-dev.voyager.online/>
1212

13+
The release branch is `main`. The development branch is `dev` and is considered stable (but not released yet).
14+
When you want to contribute, please create a new branch from `dev` and open a pull request to merge your changes back into `dev`.
15+
You should never open a pull request to merge your changes directly into `main`.
16+
17+
The `dev` branch is deployed at https://starknet-by-example-dev.voyager.online/
18+
1319
Please note we have a code of conduct, please follow it in all your interactions with the project.
1420

1521
## Table of Contents

Scarb.lock

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ dependencies = [
9898
"snforge_std",
9999
]
100100

101+
[[package]]
102+
name = "erc721"
103+
version = "0.1.0"
104+
dependencies = [
105+
"openzeppelin_account",
106+
"openzeppelin_introspection",
107+
"snforge_std",
108+
]
109+
101110
[[package]]
102111
name = "errors"
103112
version = "0.1.0"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
target
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "erc721"
3+
version.workspace = true
4+
edition.workspace = true
5+
6+
[dependencies]
7+
starknet.workspace = true
8+
openzeppelin_account.workspace = true
9+
openzeppelin_introspection.workspace = true
10+
11+
[dev-dependencies]
12+
assert_macros.workspace = true
13+
snforge_std.workspace = true
14+
15+
[scripts]
16+
test.workspace = true
17+
18+
[[target.starknet-contract]]
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
#[starknet::contract]
2+
pub mod ERC721 {
3+
use core::num::traits::Zero;
4+
use starknet::get_caller_address;
5+
use starknet::ContractAddress;
6+
use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess};
7+
use openzeppelin_introspection::interface::{ISRC5Dispatcher, ISRC5DispatcherTrait};
8+
use erc721::interfaces::{
9+
IERC721, IERC721ReceiverDispatcher, IERC721ReceiverDispatcherTrait, IERC721_RECEIVER_ID,
10+
IERC721Mintable, IERC721Burnable,
11+
};
12+
13+
#[storage]
14+
pub struct Storage {
15+
pub owners: Map<u256, ContractAddress>,
16+
pub balances: Map<ContractAddress, u256>,
17+
pub approvals: Map<u256, ContractAddress>,
18+
pub operator_approvals: Map<(ContractAddress, ContractAddress), bool>,
19+
}
20+
21+
#[event]
22+
#[derive(Drop, starknet::Event)]
23+
pub enum Event {
24+
Transfer: Transfer,
25+
Approval: Approval,
26+
ApprovalForAll: ApprovalForAll,
27+
}
28+
29+
#[derive(Drop, starknet::Event)]
30+
pub struct Transfer {
31+
pub from: ContractAddress,
32+
pub to: ContractAddress,
33+
pub token_id: u256,
34+
}
35+
36+
#[derive(Drop, starknet::Event)]
37+
pub struct Approval {
38+
pub owner: ContractAddress,
39+
pub approved: ContractAddress,
40+
pub token_id: u256,
41+
}
42+
43+
#[derive(Drop, starknet::Event)]
44+
pub struct ApprovalForAll {
45+
pub owner: ContractAddress,
46+
pub operator: ContractAddress,
47+
pub approved: bool,
48+
}
49+
50+
pub mod Errors {
51+
pub const INVALID_TOKEN_ID: felt252 = 'ERC721: invalid token ID';
52+
pub const INVALID_ACCOUNT: felt252 = 'ERC721: invalid account';
53+
pub const INVALID_OPERATOR: felt252 = 'ERC721: invalid operator';
54+
pub const UNAUTHORIZED: felt252 = 'ERC721: unauthorized caller';
55+
pub const INVALID_RECEIVER: felt252 = 'ERC721: invalid receiver';
56+
pub const INVALID_SENDER: felt252 = 'ERC721: invalid sender';
57+
pub const SAFE_TRANSFER_FAILED: felt252 = 'ERC721: safe transfer failed';
58+
pub const ALREADY_MINTED: felt252 = 'ERC721: token already minted';
59+
}
60+
61+
#[abi(embed_v0)]
62+
impl ERC721 of IERC721<ContractState> {
63+
fn owner_of(self: @ContractState, token_id: u256) -> ContractAddress {
64+
self._require_owned(token_id)
65+
}
66+
67+
fn balance_of(self: @ContractState, owner: ContractAddress) -> u256 {
68+
assert(!owner.is_zero(), Errors::INVALID_ACCOUNT);
69+
self.balances.read(owner)
70+
}
71+
72+
fn set_approval_for_all(
73+
ref self: ContractState, operator: ContractAddress, approved: bool,
74+
) {
75+
assert(!operator.is_zero(), Errors::INVALID_OPERATOR);
76+
let owner = get_caller_address();
77+
self.operator_approvals.write((owner, operator), approved);
78+
self.emit(ApprovalForAll { owner, operator, approved });
79+
}
80+
81+
fn approve(ref self: ContractState, approved: ContractAddress, token_id: u256) {
82+
let owner = self._require_owned(token_id);
83+
let caller = get_caller_address();
84+
assert(
85+
caller == owner || self.is_approved_for_all(owner, caller), Errors::UNAUTHORIZED,
86+
);
87+
88+
self.approvals.write(token_id, approved);
89+
self.emit(Approval { owner, approved, token_id });
90+
}
91+
92+
fn get_approved(self: @ContractState, token_id: u256) -> ContractAddress {
93+
self._require_owned(token_id);
94+
self.approvals.read(token_id)
95+
}
96+
97+
fn transfer_from(
98+
ref self: ContractState, from: ContractAddress, to: ContractAddress, token_id: u256,
99+
) {
100+
let previous_owner = self._require_owned(token_id);
101+
assert(from == previous_owner, Errors::INVALID_SENDER);
102+
assert(!to.is_zero(), Errors::INVALID_RECEIVER);
103+
assert(
104+
self._is_approved_or_owner(from, get_caller_address(), token_id),
105+
Errors::UNAUTHORIZED,
106+
);
107+
108+
self.balances.write(from, self.balances.read(from) - 1);
109+
self.balances.write(to, self.balances.read(to) + 1);
110+
self.owners.write(token_id, to);
111+
self.approvals.write(token_id, Zero::zero());
112+
113+
self.emit(Transfer { from, to, token_id });
114+
}
115+
116+
fn safe_transfer_from(
117+
ref self: ContractState,
118+
from: ContractAddress,
119+
to: ContractAddress,
120+
token_id: u256,
121+
data: Span<felt252>,
122+
) {
123+
Self::transfer_from(ref self, from, to, token_id);
124+
assert(
125+
self._check_on_erc721_received(from, to, token_id, data),
126+
Errors::SAFE_TRANSFER_FAILED,
127+
);
128+
}
129+
130+
fn is_approved_for_all(
131+
self: @ContractState, owner: ContractAddress, operator: ContractAddress,
132+
) -> bool {
133+
self.operator_approvals.read((owner, operator))
134+
}
135+
}
136+
137+
#[abi(embed_v0)]
138+
pub impl ERC721Burnable of IERC721Burnable<ContractState> {
139+
fn burn(ref self: ContractState, token_id: u256) {
140+
self._burn(token_id)
141+
}
142+
}
143+
144+
#[abi(embed_v0)]
145+
pub impl ERC721Mintable of IERC721Mintable<ContractState> {
146+
fn mint(ref self: ContractState, to: ContractAddress, token_id: u256) {
147+
self._mint(to, token_id)
148+
}
149+
}
150+
151+
#[generate_trait]
152+
pub impl InternalImpl of InternalTrait {
153+
fn _mint(ref self: ContractState, to: ContractAddress, token_id: u256) {
154+
assert(!to.is_zero(), Errors::INVALID_RECEIVER);
155+
assert(self.owners.read(token_id).is_zero(), Errors::ALREADY_MINTED);
156+
157+
self.balances.write(to, self.balances.read(to) + 1);
158+
self.owners.write(token_id, to);
159+
160+
self.emit(Transfer { from: Zero::zero(), to, token_id });
161+
}
162+
163+
fn _burn(ref self: ContractState, token_id: u256) {
164+
let owner = self._require_owned(token_id);
165+
166+
self.balances.write(owner, self.balances.read(owner) - 1);
167+
168+
self.owners.write(token_id, Zero::zero());
169+
self.approvals.write(token_id, Zero::zero());
170+
171+
self.emit(Transfer { from: owner, to: Zero::zero(), token_id });
172+
}
173+
174+
fn _require_owned(self: @ContractState, token_id: u256) -> ContractAddress {
175+
let owner = self.owners.read(token_id);
176+
assert(!owner.is_zero(), Errors::INVALID_TOKEN_ID);
177+
owner
178+
}
179+
180+
fn _is_approved_or_owner(
181+
self: @ContractState, owner: ContractAddress, spender: ContractAddress, token_id: u256,
182+
) -> bool {
183+
!spender.is_zero()
184+
&& (owner == spender
185+
|| self.is_approved_for_all(owner, spender)
186+
|| spender == self.get_approved(token_id))
187+
}
188+
189+
fn _check_on_erc721_received(
190+
self: @ContractState,
191+
from: ContractAddress,
192+
to: ContractAddress,
193+
token_id: u256,
194+
data: Span<felt252>,
195+
) -> bool {
196+
let src5_dispatcher = ISRC5Dispatcher { contract_address: to };
197+
198+
if src5_dispatcher.supports_interface(IERC721_RECEIVER_ID) {
199+
IERC721ReceiverDispatcher { contract_address: to }
200+
.on_erc721_received(
201+
get_caller_address(), from, token_id, data,
202+
) == IERC721_RECEIVER_ID
203+
} else {
204+
src5_dispatcher.supports_interface(openzeppelin_account::interface::ISRC6_ID)
205+
}
206+
}
207+
}
208+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use starknet::ContractAddress;
2+
3+
// [!region interface]
4+
#[starknet::interface]
5+
pub trait IERC721<TContractState> {
6+
fn balance_of(self: @TContractState, owner: ContractAddress) -> u256;
7+
fn owner_of(self: @TContractState, token_id: u256) -> ContractAddress;
8+
// The function `safe_transfer_from(address _from, address _to, uint256 _tokenId)`
9+
// is not included because the same behavior can be achieved by calling
10+
// `safe_transfer_from(from, to, tokenId, data)` with an empty `data`
11+
// parameter. This approach reduces redundancy in the contract's interface.
12+
fn safe_transfer_from(
13+
ref self: TContractState,
14+
from: ContractAddress,
15+
to: ContractAddress,
16+
token_id: u256,
17+
data: Span<felt252>,
18+
);
19+
fn transfer_from(
20+
ref self: TContractState, from: ContractAddress, to: ContractAddress, token_id: u256,
21+
);
22+
fn approve(ref self: TContractState, approved: ContractAddress, token_id: u256);
23+
fn set_approval_for_all(ref self: TContractState, operator: ContractAddress, approved: bool);
24+
fn get_approved(self: @TContractState, token_id: u256) -> ContractAddress;
25+
fn is_approved_for_all(
26+
self: @TContractState, owner: ContractAddress, operator: ContractAddress,
27+
) -> bool;
28+
}
29+
30+
#[starknet::interface]
31+
pub trait IERC721Mintable<TContractState> {
32+
fn mint(ref self: TContractState, to: ContractAddress, token_id: u256);
33+
}
34+
35+
#[starknet::interface]
36+
pub trait IERC721Burnable<TContractState> {
37+
fn burn(ref self: TContractState, token_id: u256);
38+
}
39+
40+
pub const IERC721_RECEIVER_ID: felt252 =
41+
0x3a0dff5f70d80458ad14ae37bb182a728e3c8cdda0402a5daa86620bdf910bc;
42+
43+
#[starknet::interface]
44+
pub trait IERC721Receiver<TContractState> {
45+
fn on_erc721_received(
46+
self: @TContractState,
47+
operator: ContractAddress,
48+
from: ContractAddress,
49+
token_id: u256,
50+
data: Span<felt252>,
51+
) -> felt252;
52+
}
53+
54+
// The `IERC721Metadata` and `IERC721Enumerable` interfaces are included here
55+
// as optional extensions to the ERC721 standard. While they provide additional
56+
// functionality (such as token metadata and enumeration), they are not
57+
// implemented in this example. Including these interfaces demonstrates how they
58+
// can be integrated and serves as a starting point for developers who wish to
59+
// extend the functionality.
60+
#[starknet::interface]
61+
pub trait IERC721Metadata<TContractState> {
62+
fn name(self: @TContractState) -> ByteArray;
63+
fn symbol(self: @TContractState) -> ByteArray;
64+
fn token_uri(self: @TContractState, token_id: u256) -> ByteArray;
65+
}
66+
67+
#[starknet::interface]
68+
pub trait IERC721Enumerable<TContractState> {
69+
fn total_supply(self: @TContractState) -> u256;
70+
fn token_by_index(self: @TContractState, index: u256) -> u256;
71+
fn token_of_owner_by_index(self: @TContractState, owner: ContractAddress, index: u256) -> u256;
72+
}
73+
// [!endregion interface]
74+
75+
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pub mod erc721;
2+
pub mod interfaces;
3+
mod mocks;
4+
5+
#[cfg(test)]
6+
mod tests;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
pub mod account;
2+
pub mod receiver;
3+
pub mod non_receiver;
4+
5+
pub use account::AccountMock;
6+
pub use non_receiver::NonReceiverMock;
7+
pub use receiver::ERC721ReceiverMock;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//! Copied with modifications from OpenZeppelin's repo
2+
//! https://github.com/OpenZeppelin/cairo-contracts/blob/6e60ba9310fa7953f045d0c30b343b0ffc168c14/packages/test_common/src/mocks/account.cairo
3+
4+
#[starknet::contract(account)]
5+
pub mod AccountMock {
6+
use openzeppelin_account::AccountComponent;
7+
use openzeppelin_introspection::src5::SRC5Component;
8+
9+
component!(path: AccountComponent, storage: account, event: AccountEvent);
10+
component!(path: SRC5Component, storage: src5, event: SRC5Event);
11+
12+
// Account
13+
#[abi(embed_v0)]
14+
impl SRC6Impl = AccountComponent::SRC6Impl<ContractState>;
15+
#[abi(embed_v0)]
16+
impl DeclarerImpl = AccountComponent::DeclarerImpl<ContractState>;
17+
#[abi(embed_v0)]
18+
impl DeployableImpl = AccountComponent::DeployableImpl<ContractState>;
19+
impl AccountInternalImpl = AccountComponent::InternalImpl<ContractState>;
20+
21+
// SCR5
22+
#[abi(embed_v0)]
23+
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>;
24+
25+
#[storage]
26+
pub struct Storage {
27+
#[substorage(v0)]
28+
pub account: AccountComponent::Storage,
29+
#[substorage(v0)]
30+
pub src5: SRC5Component::Storage,
31+
}
32+
33+
#[event]
34+
#[derive(Drop, starknet::Event)]
35+
enum Event {
36+
#[flat]
37+
AccountEvent: AccountComponent::Event,
38+
#[flat]
39+
SRC5Event: SRC5Component::Event,
40+
}
41+
42+
#[constructor]
43+
fn constructor(ref self: ContractState, public_key: felt252) {
44+
self.account.initializer(public_key);
45+
}
46+
}

0 commit comments

Comments
 (0)