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",