diff --git a/Scarb.lock b/Scarb.lock index 96d58087..b2212f25 100644 --- a/Scarb.lock +++ b/Scarb.lock @@ -75,6 +75,13 @@ version = "0.1.0" name = "erc20" version = "0.1.0" +[[package]] +name = "erc721" +version = "0.1.0" +dependencies = [ + "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..cf8bff51 --- /dev/null +++ b/listings/applications/erc721/Scarb.toml @@ -0,0 +1,18 @@ +[package] +name = "erc721" +version = "0.1.0" +edition = "2023_11" + +# See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest.html + +[dependencies] +starknet.workspace = true + +[dev-dependencies] +snforge_std.workspace = true + +[scripts] +test.workspace = true + +[[target.starknet-contract]] +build-external-contracts = ["erc20::token::erc20"] diff --git a/listings/applications/erc721/src/erc721.cairo b/listings/applications/erc721/src/erc721.cairo new file mode 100644 index 00000000..b05808b9 --- /dev/null +++ b/listings/applications/erc721/src/erc721.cairo @@ -0,0 +1,208 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IERC721 { + fn balance_of(self: @TContractState, account: ContractAddress) -> u256; + fn owner_of(self: @TContractState, token_id: u256) -> ContractAddress; + fn get_approved(self: @TContractState, token_id: u256) -> ContractAddress; + fn is_approved_for_all( + self: @TContractState, owner: ContractAddress, operator: ContractAddress + ) -> bool; + fn approve(self: @TContractState, to: ContractAddress, token_id: u256); + fn set_approval_for_all(self: @TContractState, operator: ContractAddress, approved: bool); + fn transfer_from( + self: @TContractState, from: ContractAddress, to: ContractAddress, token_id: u256 + ); + fn mint(ref self: TContractState, to: ContractAddress, token_id: u256); +} + + +#[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::contract] +mod ERC721 { + use starknet::ContractAddress; + use starknet::get_caller_address; + use core::num::traits::zero::Zero; + + #[storage] + struct Storage { + name: felt252, + symbol: felt252, + owners: LegacyMap::, + balances: LegacyMap::, + token_approvals: LegacyMap::, + operator_approvals: LegacyMap::<(ContractAddress, ContractAddress), bool>, + token_uri: LegacyMap::, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + Approval: Approval, + Transfer: Transfer, + ApprovalForAll: ApprovalForAll + } + + #[derive(Drop, starknet::Event)] + struct Approval { + owner: ContractAddress, + to: ContractAddress, + token_id: u256 + } + + #[derive(Drop, starknet::Event)] + struct Transfer { + from: ContractAddress, + to: ContractAddress, + token_id: u256 + } + + #[derive(Drop, starknet::Event)] + struct ApprovalForAll { + owner: ContractAddress, + operator: ContractAddress, + approved: bool + } + + mod Errors { + // Error messages for each function + pub const ADDRESS_ZERO: felt252 = 'ERC721: balance is zero'; + pub const INVALID_OWNER: felt252 = 'ERC721: invalid owner'; + pub const INVALID_TOKEN_ID: felt252 = 'ERC721: invalid token ID'; + pub const APPROVAL_TO_CURRENT_OWNER: felt252 = 'ERC721: approval to owner'; + pub const NOT_TOKEN_OWNER: felt252 = 'ERC721: not token owner'; + pub const APPROVE_TO_CALLER: felt252 = 'ERC721: approve to caller'; + pub const CALLER_NOT_APPROVED: felt252 = 'ERC721: caller is not approved'; + pub const CALLER_IS_NOT_OWNER: felt252 = 'ERC721: caller is not owner'; + pub const TRANSFER_TO_ZERO_ADDRESS: felt252 = 'ERC721: invalid receiver'; + pub const TOKEN_ALREADY_MINTED: felt252 = 'ERC721: token already minted'; + } + + #[constructor] + fn constructor(ref self: ContractState, _name: felt252, _symbol: felt252) { + self.name.write(_name); + self.symbol.write(_symbol); + } + + #[generate_trait] + impl IERC721Impl of IERC721Trait { + fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { + assert(account.is_non_zero(), Errors::ADDRESS_ZERO); + self.balances.read(account) + } + + fn owner_of(self: @ContractState, token_id: u256) -> ContractAddress { + let owner = self.owners.read(token_id); + assert(owner.is_non_zero(), Errors::INVALID_OWNER); + owner + } + + fn get_approved(self: @ContractState, token_id: u256) -> ContractAddress { + assert(self._exists(token_id), Errors::INVALID_TOKEN_ID); + self.token_approvals.read(token_id) + } + + fn is_approved_for_all( + self: @ContractState, owner: ContractAddress, operator: ContractAddress + ) -> bool { + self.operator_approvals.read((owner, operator)) + } + + fn approve(ref self: ContractState, to: ContractAddress, token_id: u256) { + let owner = self.owner_of(token_id); + assert(to != owner, Errors::APPROVAL_TO_CURRENT_OWNER); + + // Split the combined assert into two separate asserts + assert(get_caller_address() == owner, Errors::NOT_TOKEN_OWNER); + assert( + self.is_approved_for_all(owner, get_caller_address()), Errors::CALLER_NOT_APPROVED + ); + + self.token_approvals.write(token_id, to); + self.emit(Approval { owner: self.owner_of(token_id), to: to, token_id: token_id }); + } + + + fn set_approval_for_all( + ref self: ContractState, operator: ContractAddress, approved: bool + ) { + let owner = get_caller_address(); + // assert(owner != operator, Errors::APPROVE_TO_CALLER); + self.operator_approvals.write((owner, operator), approved); + self.emit(ApprovalForAll { owner: owner, operator: operator, approved: approved }); + } + + fn transfer_from( + ref self: ContractState, from: ContractAddress, to: ContractAddress, token_id: u256 + ) { + assert( + self._is_approved_or_owner(get_caller_address(), token_id), + Errors::CALLER_NOT_APPROVED + ); + self._transfer(from, to, token_id); + } + } + + #[generate_trait] + impl ERC721HelperImpl of ERC721HelperTrait { + fn _exists(self: @ContractState, token_id: u256) -> bool { + self.owner_of(token_id).is_non_zero() + } + + fn _is_approved_or_owner( + self: @ContractState, spender: ContractAddress, token_id: u256 + ) -> bool { + let owner = self.owners.read(token_id); + spender == owner + || self.is_approved_for_all(owner, spender) + || self.get_approved(token_id) == spender + } + + fn _transfer( + ref self: ContractState, from: ContractAddress, to: ContractAddress, token_id: u256 + ) { + assert(from == self.owner_of(token_id), Errors::CALLER_IS_NOT_OWNER); + assert(to.is_non_zero(), Errors::TRANSFER_TO_ZERO_ADDRESS); + + self.token_approvals.write(token_id, Zero::zero()); + + self.balances.write(from, self.balances.read(from) - 1.into()); + self.balances.write(to, self.balances.read(to) + 1.into()); + + self.owners.write(token_id, to); + + self.emit(Transfer { from: from, to: to, token_id: token_id }); + } + + fn _mint(ref self: ContractState, to: ContractAddress, token_id: u256) { + assert(to.is_non_zero(), 'TO_IS_ZERO_ADDRESS'); + assert(!self.owner_of(token_id).is_non_zero(), Errors::TOKEN_ALREADY_MINTED); + + let receiver_balance = self.balances.read(to); + self.balances.write(to, receiver_balance + 1.into()); + + self.owners.write(token_id, to); + + self.emit(Transfer { from: Zero::zero(), to: to, token_id: token_id }); + } + + fn _burn(ref self: ContractState, token_id: u256) { + let owner = self.owner_of(token_id); + + self.token_approvals.write(token_id, Zero::zero()); + + let owner_balance = self.balances.read(owner); + self.balances.write(owner, owner_balance - 1.into()); + + self.owners.write(token_id, Zero::zero()); + + self.emit(Transfer { from: owner, to: Zero::zero(), token_id: token_id }); + } + } +} diff --git a/listings/applications/erc721/src/lib.cairo b/listings/applications/erc721/src/lib.cairo new file mode 100644 index 00000000..8030eaac --- /dev/null +++ b/listings/applications/erc721/src/lib.cairo @@ -0,0 +1 @@ +mod erc721; diff --git a/listings/applications/erc721/src/test_ERC721.cairo b/listings/applications/erc721/src/test_ERC721.cairo new file mode 100644 index 00000000..19779878 --- /dev/null +++ b/listings/applications/erc721/src/test_ERC721.cairo @@ -0,0 +1,217 @@ +use starknet::{ContractAddress, contract_address_const, get_caller_address}; + +use erc721::contracts::ERC721::{ + ERC721Contract, IExternalDispatcher as IERC721Dispatcher, + IExternalDispatcherTrait as IERC721DispatcherTrait +}; + +use traits::Into; +use traits::TryInto; +use array::ArrayTrait; +use option::OptionTrait; + +use snforge_std::{CheatTarget, ContractClassTrait, EventSpy, SpyOn, start_prank, stop_prank}; + +use super::tests_lib::{deploy_project}; + +#[test] +#[available_gas(2000000)] +fn test_get_name() { + let (project_address, _) = deploy_project('Foo', 'BAR'); + let contract = IERC721Dispatcher { contract_address: project_address }; + + let name = contract.get_name(); + assert(name == 'Foo', 'wrong name'); +} + +#[test] +#[available_gas(2000000)] +fn test_get_symbol() { + let (project_address, _) = deploy_project('Foo', 'BAR'); + let contract = IERC721Dispatcher { contract_address: project_address }; + + let symbol = contract.get_symbol(); + assert(symbol == 'BAR', 'wrong symbol'); +} + +#[test] +#[available_gas(2000000)] +fn test_balance_of() { + let (project_address, _) = deploy_project('Foo', 'BAR'); + let contract = IERC721Dispatcher { contract_address: project_address }; + let OWNER: ContractAddress = contract_address_const::<'OWNER'>(); + start_prank(CheatTarget::One(project_address), OWNER); + + let balance = contract.balance_of(OWNER); + assert(balance == 0, 'wrong balance'); + + let nft_id: u256 = 1.into(); + + contract.mint(OWNER, nft_id); + let balance = contract.balance_of(OWNER); + assert(balance == 1, 'wrong balance'); +} + +#[test] +#[available_gas(2000000)] +fn test_owner_of() { + let (project_address, _) = deploy_project('Foo', 'BAR'); + let contract = IERC721Dispatcher { contract_address: project_address }; + let OWNER: ContractAddress = contract_address_const::<'OWNER'>(); + start_prank(CheatTarget::One(project_address), OWNER); + + let nft_id: u256 = 1.into(); + contract.mint(OWNER, nft_id); + + let owner = contract.owner_of(nft_id); + assert(owner == OWNER, 'wrong owner'); +} + +#[test] +#[available_gas(2000000)] +fn test_get_approved() { + let (project_address, _) = deploy_project('Foo', 'BAR'); + let contract = IERC721Dispatcher { contract_address: project_address }; + let OWNER: ContractAddress = contract_address_const::<'OWNER'>(); + start_prank(CheatTarget::One(project_address), OWNER); + + // caller address: + let caller = get_caller_address(); + + let nft_id: u256 = 1.into(); + contract.mint(OWNER, nft_id); + + let user = starknet::contract_address_const::<'USER'>(); + contract.approve(user, nft_id); + + let approved = contract.get_approved(nft_id); + assert(approved == user, 'wrong approved'); +} + +#[test] +#[available_gas(2000000)] +fn test_is_approved_for_all() { + let (project_address, _) = deploy_project('Foo', 'BAR'); + let contract = IERC721Dispatcher { contract_address: project_address }; + + let OWNER: ContractAddress = contract_address_const::<'OWNER'>(); + start_prank(CheatTarget::One(project_address), OWNER); + + let user = starknet::contract_address_const::<'USER'>(); + + let is_approved = contract.is_approved_for_all(OWNER, user); + assert(!is_approved, 'approved for all'); + + contract.set_approval_for_all(user, true); + let is_approved = contract.is_approved_for_all(OWNER, user); + assert(is_approved, 'not approved for all'); +} + +#[test] +#[available_gas(2000000)] +fn test_get_token_uri() { + let (project_address, _) = deploy_project('Foo', 'BAR'); + let contract = IERC721Dispatcher { contract_address: project_address }; + let OWNER: ContractAddress = contract_address_const::<'OWNER'>(); + start_prank(CheatTarget::One(project_address), OWNER); + + let nft_id: u256 = 1.into(); + contract.mint(OWNER, nft_id); + + let uri = contract.get_token_uri(nft_id); + assert(uri == '', 'wrong uri'); + + contract.set_token_uri(nft_id, 'https://example.com/1'); + let uri = contract.get_token_uri(nft_id); + assert(uri == 'https://example.com/1', 'wrong uri'); +} + +#[test] +#[available_gas(2000000)] +fn test_transfer_from() { + let (project_address, _) = deploy_project('Foo', 'BAR'); + let contract = IERC721Dispatcher { contract_address: project_address }; + + let OWNER: ContractAddress = contract_address_const::<'OWNER'>(); + start_prank(CheatTarget::One(project_address), OWNER); + + let user = starknet::contract_address_const::<'USER'>(); + let nft_id: u256 = 1.into(); + contract.mint(OWNER, nft_id); + + contract.transfer_from(OWNER, user, nft_id); + + let new_owner = contract.owner_of(nft_id); + assert(new_owner == user, 'wrong new OWNER'); +} + +#[test] +#[available_gas(2000000)] +fn test_transfer_from_approved() { + let (project_address, _) = deploy_project('Foo', 'BAR'); + let contract = IERC721Dispatcher { contract_address: project_address }; + let OWNER: ContractAddress = contract_address_const::<'OWNER'>(); + start_prank(CheatTarget::One(project_address), OWNER); + + let user = starknet::contract_address_const::<'USER'>(); + let nft_id: u256 = 1.into(); + + contract.mint(OWNER, nft_id); + contract.approve(user, nft_id); + + contract.transfer_from(OWNER, user, nft_id); + + let new_owner = contract.owner_of(nft_id); + assert(new_owner == user, 'wrong new OWNER'); +} + +#[test] +#[available_gas(2000000)] +fn test_transfer_from_approved_for_all() { + let (project_address, _) = deploy_project('Foo', 'BAR'); + let contract = IERC721Dispatcher { contract_address: project_address }; + + let OWNER: ContractAddress = contract_address_const::<'OWNER'>(); + start_prank(CheatTarget::One(project_address), OWNER); + + let user = starknet::contract_address_const::<'USER'>(); + + let nft_id: u256 = 1.into(); + contract.mint(OWNER, nft_id); + contract.set_approval_for_all(user, true); + + contract.transfer_from(OWNER, user, nft_id); + + let new_owner = contract.owner_of(nft_id); + assert(new_owner == user, 'wrong new OWNER'); +} + +#[test] +#[available_gas(2000000)] +#[should_panic] +fn test_transfer_from_not_approved() { + let (project_address, _) = deploy_project('Foo', 'BAR'); + let contract = IERC721Dispatcher { contract_address: project_address }; + + let OWNER: ContractAddress = contract_address_const::<'OWNER'>(); + start_prank(CheatTarget::One(project_address), OWNER); + + let user = starknet::contract_address_const::<'USER'>(); + let nft_id: u256 = 1.into(); + + contract.mint(OWNER, nft_id); + contract.approve(user, nft_id); + + let random_user = starknet::contract_address_const::<789>(); + contract.transfer_from(random_user, user, nft_id); + + let new_owner = contract.owner_of(nft_id); + assert(new_owner == user, 'wrong new OWNER'); +} + +#[test] +#[available_gas(2000000)] +fn test_erc721_burn() { + let erc721_address = get_erc721_contract_address(); + let erc721_dispatcher = IERC721Dispatcher { contract_address: erc721_address }; +}