-
Notifications
You must be signed in to change notification settings - Fork 93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature: ERC721 NFT Contract #250
Open
0xNeshi
wants to merge
38
commits into
NethermindEth:dev
Choose a base branch
from
0xNeshi:erc721
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
38 commits
Select commit
Hold shift + click to select a range
9703d23
Add ERC721 initial impl
0xNeshi 41bd832
Rename token.cairo->erc721.cairo
0xNeshi cdc5a9a
Simplify contract
0xNeshi 286e8c4
Implement mint and burn
0xNeshi 0b1bb65
refactor erc721
0xNeshi eda9fc9
rename back contract->erc721
0xNeshi 446f735
Set up tests
0xNeshi 7a3afc7
Add all getter tests
0xNeshi fbb07af
Return contract_address from deploy
0xNeshi 15fa3ae
Add approve tests
0xNeshi dceff79
Add transfer_from tests
0xNeshi f5ed044
Add safe_transfer_from tests
0xNeshi 9e7ab1d
Add mint tests
0xNeshi 4cb576f
Add burn tests
0xNeshi 282fe6b
Add internal tests
0xNeshi e5e84e9
Move interfaces into interfaces.cairo + fix build errors
0xNeshi 6bf7d66
fix tests
0xNeshi 69bc00a
Fix approvals update in transfer_from
0xNeshi 09b01b7
Remove redundant build-external-contracts section from Scarb.toml
0xNeshi 9e79fb3
Move snforge_std dep to dev deps
0xNeshi 20b34b1
Set edition to workspace version
0xNeshi aa3c4a2
Revert edition to specific version + remove [lib]
0xNeshi fef0cd3
Merge remote-tracking branch 'origin/main' into erc721
0xNeshi 0650e1e
Update edition to point to workspace
0xNeshi 4d8e6e8
merge with upstream/dev
0xNeshi 4e9607c
fix erc20 url to OZ one
0xNeshi b92ae56
Add comment encouring devs to read the EIP
0xNeshi d30b450
add optional Metadata & Enumerable interfaces
0xNeshi e8307c6
make mint & burn internal
0xNeshi b049308
add comment for safe_transfer_from
0xNeshi c5c8bb1
implement burn and mint as additional interfaces
0xNeshi 815118b
fix tests
0xNeshi 3fc8031
add comment above metadata & enumerable
0xNeshi 65ffa82
fix links in erc721.md
0xNeshi f6983ba
refactor interfaces.cairo
0xNeshi c8ac726
move erc721 route below erc20
0xNeshi de7ba30
remove redundant newline from interfaces.cairo
0xNeshi 9541678
fix test_safe_transfer_from_to_non_receiver
0xNeshi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
target |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
[package] | ||
name = "erc721" | ||
version.workspace = true | ||
edition.workspace = true | ||
|
||
[dependencies] | ||
starknet.workspace = true | ||
openzeppelin.workspace = true | ||
|
||
[dev-dependencies] | ||
assert_macros.workspace = true | ||
snforge_std.workspace = true | ||
|
||
[scripts] | ||
test.workspace = true | ||
|
||
[[target.starknet-contract]] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<u256, ContractAddress>, | ||
pub balances: Map<ContractAddress, u256>, | ||
pub approvals: Map<u256, ContractAddress>, | ||
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<ContractState> { | ||
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<felt252> | ||
) { | ||
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<ContractState> { | ||
fn burn(ref self: ContractState, token_id: u256) { | ||
self._burn(token_id) | ||
} | ||
} | ||
|
||
#[abi(embed_v0)] | ||
pub impl ERC721Mintable of IERC721Mintable<ContractState> { | ||
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<felt252> | ||
) -> 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) | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
use starknet::ContractAddress; | ||
|
||
// [!region interface] | ||
#[starknet::interface] | ||
pub trait IERC721<TContractState> { | ||
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<felt252> | ||
); | ||
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<TContractState> { | ||
fn mint(ref self: TContractState, to: ContractAddress, token_id: u256); | ||
} | ||
|
||
#[starknet::interface] | ||
pub trait IERC721Burnable<TContractState> { | ||
fn burn(ref self: TContractState, token_id: u256); | ||
} | ||
|
||
pub const IERC721_RECEIVER_ID: felt252 = | ||
0x3a0dff5f70d80458ad14ae37bb182a728e3c8cdda0402a5daa86620bdf910bc; | ||
|
||
#[starknet::interface] | ||
pub trait IERC721Receiver<TContractState> { | ||
fn on_erc721_received( | ||
self: @TContractState, | ||
operator: ContractAddress, | ||
from: ContractAddress, | ||
token_id: u256, | ||
data: Span<felt252> | ||
) -> 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<TContractState> { | ||
fn name(self: @TContractState) -> ByteArray; | ||
fn symbol(self: @TContractState) -> ByteArray; | ||
fn token_uri(self: @TContractState, token_id: u256) -> ByteArray; | ||
} | ||
|
||
#[starknet::interface] | ||
pub trait IERC721Enumerable<TContractState> { | ||
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] | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
pub mod erc721; | ||
pub mod interfaces; | ||
mod mocks; | ||
|
||
#[cfg(test)] | ||
mod tests; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ContractState>; | ||
#[abi(embed_v0)] | ||
impl DeclarerImpl = AccountComponent::DeclarerImpl<ContractState>; | ||
#[abi(embed_v0)] | ||
impl DeployableImpl = AccountComponent::DeployableImpl<ContractState>; | ||
impl AccountInternalImpl = AccountComponent::InternalImpl<ContractState>; | ||
|
||
// SCR5 | ||
#[abi(embed_v0)] | ||
impl SRC5Impl = SRC5Component::SRC5Impl<ContractState>; | ||
|
||
#[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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
#[starknet::contract] | ||
pub mod NonReceiverMock { | ||
#[storage] | ||
pub struct Storage {} | ||
|
||
#[external(v0)] | ||
fn nope(self: @ContractState) -> bool { | ||
false | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be nice to also add the optional
ERC721Metadata
anERC721Enumerable
interfacesThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only the interfaces, no implementation?