diff --git a/Scarb.lock b/Scarb.lock index 31034f8c..ef3d064e 100644 --- a/Scarb.lock +++ b/Scarb.lock @@ -88,6 +88,14 @@ version = "0.1.0" name = "mappings" version = "0.1.0" +[[package]] +name = "nft_dutch_auction" +version = "0.1.0" +dependencies = [ + "erc20", + "snforge_std", +] + [[package]] name = "openzeppelin" version = "0.11.0" diff --git a/Scarb.toml b/Scarb.toml index 4a232645..4c5c042d 100644 --- a/Scarb.toml +++ b/Scarb.toml @@ -3,22 +3,19 @@ members = [ "listings/getting-started/*", "listings/applications/*", "listings/advanced-concepts/*", - "listings/templates/*" + "listings/templates/*", ] [workspace.scripts] test = "$(git rev-parse --show-toplevel)/scripts/test_resolver.sh" -# [workspace.tool.snforge] +[workspace.tool.snforge] [workspace.dependencies] starknet = ">=2.6.3" openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag="v0.11.0" } components = { path = "listings/applications/components" } - -# [workspace.dev-dependencies] snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "v0.24.0" } -# openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag="v0.11.0" } [workspace.package] description = "Collection of examples of how to use the Cairo programming language to create smart contracts on Starknet." diff --git a/listings/applications/erc20/src/lib.cairo b/listings/applications/erc20/src/lib.cairo index 40d3ff58..79c66ba6 100644 --- a/listings/applications/erc20/src/lib.cairo +++ b/listings/applications/erc20/src/lib.cairo @@ -1 +1 @@ -mod token; +pub mod token; diff --git a/listings/applications/erc20/src/token.cairo b/listings/applications/erc20/src/token.cairo index 7d04f8b8..c2477789 100644 --- a/listings/applications/erc20/src/token.cairo +++ b/listings/applications/erc20/src/token.cairo @@ -249,7 +249,7 @@ mod tests { fn test_deploy_when_recipient_is_address_zero() { let recipient: ContractAddress = Zero::zero(); - let (contract_address, _) = deploy_syscall( + let (_contract_address, _) = deploy_syscall( erc20::TEST_CLASS_HASH.try_into().unwrap(), recipient.into(), array![recipient.into(), token_name, decimals.into(), initial_supply, symbols].span(), diff --git a/listings/applications/nft_dutch_auction/.gitignore b/listings/applications/nft_dutch_auction/.gitignore new file mode 100644 index 00000000..eb5a316c --- /dev/null +++ b/listings/applications/nft_dutch_auction/.gitignore @@ -0,0 +1 @@ +target diff --git a/listings/applications/nft_dutch_auction/Scarb.toml b/listings/applications/nft_dutch_auction/Scarb.toml new file mode 100644 index 00000000..a6af238d --- /dev/null +++ b/listings/applications/nft_dutch_auction/Scarb.toml @@ -0,0 +1,17 @@ +[package] +name = "nft_dutch_auction" +version.workspace = true +edition = '2023_11' + +[dependencies] +erc20 = { path = "../erc20" } +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/nft_dutch_auction/src/erc721.cairo b/listings/applications/nft_dutch_auction/src/erc721.cairo new file mode 100644 index 00000000..9e1733ca --- /dev/null +++ b/listings/applications/nft_dutch_auction/src/erc721.cairo @@ -0,0 +1,289 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IERC721 { + fn get_name(self: @TContractState) -> felt252; + fn get_symbol(self: @TContractState) -> felt252; + fn get_token_uri(self: @TContractState, token_id: u256) -> felt252; + 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(ref self: TContractState, to: ContractAddress, token_id: u256); + fn set_approval_for_all(ref self: TContractState, operator: ContractAddress, approved: bool); + fn transfer_from( + ref self: TContractState, from: ContractAddress, to: ContractAddress, token_id: u256 + ); + fn mint(ref self: TContractState, to: ContractAddress, token_id: u256); +} + +#[starknet::contract] +mod ERC721 { + //////////////////////////////// + // library imports + //////////////////////////////// + use starknet::{ContractAddress, get_caller_address}; + use core::traits::TryInto; + use core::num::traits::zero::Zero; + + //////////////////////////////// + // storage variables + //////////////////////////////// + #[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 + } + + //////////////////////////////// + // Approval event emitted on token approval + //////////////////////////////// + #[derive(Drop, starknet::Event)] + struct Approval { + owner: ContractAddress, + to: ContractAddress, + token_id: u256 + } + + //////////////////////////////// + // Transfer event emitted on token transfer + //////////////////////////////// + #[derive(Drop, starknet::Event)] + struct Transfer { + from: ContractAddress, + to: ContractAddress, + token_id: u256 + } + + //////////////////////////////// + // ApprovalForAll event emitted on approval for operators + //////////////////////////////// + #[derive(Drop, starknet::Event)] + struct ApprovalForAll { + owner: ContractAddress, + operator: ContractAddress, + approved: bool + } + + + //////////////////////////////// + // Constructor - initialized on deployment + //////////////////////////////// + #[constructor] + fn constructor(ref self: ContractState, _name: felt252, _symbol: felt252) { + self.name.write(_name); + self.symbol.write(_symbol); + } + + #[abi(embed_v0)] + impl IERC721Impl of super::IERC721 { + //////////////////////////////// + // get_name function returns token name + //////////////////////////////// + fn get_name(self: @ContractState) -> felt252 { + self.name.read() + } + + //////////////////////////////// + // get_symbol function returns token symbol + //////////////////////////////// + fn get_symbol(self: @ContractState) -> felt252 { + self.symbol.read() + } + + //////////////////////////////// + // token_uri returns the token uri + //////////////////////////////// + fn get_token_uri(self: @ContractState, token_id: u256) -> felt252 { + assert(self._exists(token_id), 'ERC721: invalid token ID'); + self.token_uri.read(token_id) + } + + //////////////////////////////// + // balance_of function returns token balance + //////////////////////////////// + fn balance_of(self: @ContractState, account: ContractAddress) -> u256 { + assert(account.is_non_zero(), 'ERC721: address zero'); + self.balances.read(account) + } + + //////////////////////////////// + // owner_of function returns owner of token_id + //////////////////////////////// + fn owner_of(self: @ContractState, token_id: u256) -> ContractAddress { + let owner = self.owners.read(token_id); + owner + } + + //////////////////////////////// + // get_approved function returns approved address for a token + //////////////////////////////// + fn get_approved(self: @ContractState, token_id: u256) -> ContractAddress { + assert(self._exists(token_id), 'ERC721: invalid token ID'); + self.token_approvals.read(token_id) + } + + //////////////////////////////// + // is_approved_for_all function returns approved operator for a token + //////////////////////////////// + fn is_approved_for_all( + self: @ContractState, owner: ContractAddress, operator: ContractAddress + ) -> bool { + self.operator_approvals.read((owner, operator)) + } + + //////////////////////////////// + // approve function approves an address to spend a token + //////////////////////////////// + fn approve(ref self: ContractState, to: ContractAddress, token_id: u256) { + let owner = self.owner_of(token_id); + assert(to != owner, 'Approval to current owner'); + assert( + get_caller_address() == owner + || self.is_approved_for_all(owner, get_caller_address()), + 'Not token owner' + ); + self.token_approvals.write(token_id, to); + self.emit(Approval { owner: self.owner_of(token_id), to: to, token_id: token_id }); + } + + //////////////////////////////// + // set_approval_for_all function approves an operator to spend all tokens + //////////////////////////////// + fn set_approval_for_all( + ref self: ContractState, operator: ContractAddress, approved: bool + ) { + let owner = get_caller_address(); + assert(owner != operator, 'ERC721: approve to caller'); + self.operator_approvals.write((owner, operator), approved); + self.emit(ApprovalForAll { owner: owner, operator: operator, approved: approved }); + } + + //////////////////////////////// + // transfer_from function is used to transfer a token + //////////////////////////////// + fn transfer_from( + ref self: ContractState, from: ContractAddress, to: ContractAddress, token_id: u256 + ) { + assert( + self._is_approved_or_owner(get_caller_address(), token_id), + 'neither owner nor approved' + ); + self._transfer(from, to, token_id); + } + + fn mint(ref self: ContractState, to: ContractAddress, token_id: u256) { + self._mint(to, token_id); + } + } + + #[generate_trait] + impl ERC721HelperImpl of ERC721HelperTrait { + //////////////////////////////// + // internal function to check if a token exists + //////////////////////////////// + fn _exists(self: @ContractState, token_id: u256) -> bool { + // check that owner of token is not zero + self.owner_of(token_id).is_non_zero() + } + + //////////////////////////////// + // _is_approved_or_owner checks if an address is an approved spender or owner + //////////////////////////////// + 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 + } + + //////////////////////////////// + // internal function that sets the token uri + //////////////////////////////// + fn _set_token_uri(ref self: ContractState, token_id: u256, token_uri: felt252) { + assert(self._exists(token_id), 'ERC721: invalid token ID'); + self.token_uri.write(token_id, token_uri) + } + + //////////////////////////////// + // internal function that performs the transfer logic + //////////////////////////////// + fn _transfer( + ref self: ContractState, from: ContractAddress, to: ContractAddress, token_id: u256 + ) { + // check that from address is equal to owner of token + assert(from == self.owner_of(token_id), 'ERC721: Caller is not owner'); + // check that to address is not zero + assert(to.is_non_zero(), 'ERC721: transfer to 0 address'); + + // remove previously made approvals + self.token_approvals.write(token_id, Zero::zero()); + + // increase balance of to address, decrease balance of from address + self.balances.write(from, self.balances.read(from) - 1.into()); + self.balances.write(to, self.balances.read(to) + 1.into()); + + // update token_id owner + self.owners.write(token_id, to); + + // emit the Transfer event + self.emit(Transfer { from: from, to: to, token_id: token_id }); + } + + //////////////////////////////// + // _mint function mints a new token to the to address + //////////////////////////////// + fn _mint(ref self: ContractState, to: ContractAddress, token_id: u256) { + assert(to.is_non_zero(), 'TO_IS_ZERO_ADDRESS'); + + // Ensures token_id is unique + assert(!self.owner_of(token_id).is_non_zero(), 'ERC721: Token already minted'); + + // Increase receiver balance + let receiver_balance = self.balances.read(to); + self.balances.write(to, receiver_balance + 1.into()); + + // Update token_id owner + self.owners.write(token_id, to); + + // emit Transfer event + self.emit(Transfer { from: Zero::zero(), to: to, token_id: token_id }); + } + + //////////////////////////////// + // _burn function burns token from owner's account + //////////////////////////////// + fn _burn(ref self: ContractState, token_id: u256) { + let owner = self.owner_of(token_id); + + // Clear approvals + self.token_approvals.write(token_id, Zero::zero()); + + // Decrease owner balance + let owner_balance = self.balances.read(owner); + self.balances.write(owner, owner_balance - 1.into()); + + // Delete owner + self.owners.write(token_id, Zero::zero()); + // emit the Transfer event + self.emit(Transfer { from: owner, to: Zero::zero(), token_id: token_id }); + } + } +} diff --git a/listings/applications/nft_dutch_auction/src/lib.cairo b/listings/applications/nft_dutch_auction/src/lib.cairo new file mode 100644 index 00000000..33c4ff24 --- /dev/null +++ b/listings/applications/nft_dutch_auction/src/lib.cairo @@ -0,0 +1,5 @@ +pub mod nft_dutch_auction; +pub mod erc721; + +#[cfg(test)] +pub mod tests; diff --git a/listings/applications/nft_dutch_auction/src/nft_dutch_auction.cairo b/listings/applications/nft_dutch_auction/src/nft_dutch_auction.cairo new file mode 100644 index 00000000..2ffaf7e5 --- /dev/null +++ b/listings/applications/nft_dutch_auction/src/nft_dutch_auction.cairo @@ -0,0 +1,135 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IERC20 { + fn get_name(self: @TContractState) -> felt252; + fn get_symbol(self: @TContractState) -> felt252; + fn get_decimals(self: @TContractState) -> u8; + fn get_total_supply(self: @TContractState) -> felt252; + fn balance_of(self: @TContractState, account: ContractAddress) -> felt252; + fn allowance( + self: @TContractState, owner: ContractAddress, spender: ContractAddress + ) -> felt252; + fn transfer(ref self: TContractState, recipient: ContractAddress, amount: felt252); + fn transfer_from( + ref self: TContractState, + sender: ContractAddress, + recipient: ContractAddress, + amount: felt252 + ); + fn approve(ref self: TContractState, spender: ContractAddress, amount: felt252); + fn increase_allowance(ref self: TContractState, spender: ContractAddress, added_value: felt252); + fn decrease_allowance( + ref self: TContractState, spender: ContractAddress, subtracted_value: felt252 + ); +} + +#[starknet::interface] +trait IERC721 { + fn get_name(self: @TContractState) -> felt252; + fn get_symbol(self: @TContractState) -> felt252; + fn get_token_uri(self: @TContractState, token_id: u256) -> felt252; + 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(ref self: TContractState, to: ContractAddress, token_id: u256); + fn set_approval_for_all(ref self: TContractState, operator: ContractAddress, approved: bool); + fn transfer_from( + ref self: TContractState, from: ContractAddress, to: ContractAddress, token_id: u256 + ); + fn mint(ref self: TContractState, to: ContractAddress, token_id: u256); +} + +#[starknet::interface] +pub trait INFTDutchAuction { + fn buy(ref self: TContractState, token_id: u256); + fn get_price(self: @TContractState) -> u64; +} + +#[starknet::contract] +pub mod NFTDutchAuction { + use super::{IERC20Dispatcher, IERC20DispatcherTrait, IERC721Dispatcher, IERC721DispatcherTrait}; + use starknet::{ContractAddress, get_caller_address, get_contract_address, get_block_timestamp}; + + #[storage] + struct Storage { + erc20_token: ContractAddress, + erc721_token: ContractAddress, + starting_price: u64, + seller: ContractAddress, + duration: u64, + discount_rate: u64, + start_at: u64, + expires_at: u64, + purchase_count: u128, + total_supply: u128 + } + + mod Errors { + pub const AUCTION_ENDED: felt252 = 'auction has ended'; + pub const LOW_STARTING_PRICE: felt252 = 'low starting price'; + pub const INSUFFICIENT_BALANCE: felt252 = 'insufficient balance'; + } + + #[constructor] + fn constructor( + ref self: ContractState, + erc20_token: ContractAddress, + erc721_token: ContractAddress, + starting_price: u64, + seller: ContractAddress, + duration: u64, + discount_rate: u64, + total_supply: u128 + ) { + assert(starting_price >= discount_rate * duration, Errors::LOW_STARTING_PRICE); + + self.erc20_token.write(erc20_token); + self.erc721_token.write(erc721_token); + self.starting_price.write(starting_price); + self.seller.write(seller); + self.duration.write(duration); + self.discount_rate.write(discount_rate); + self.start_at.write(get_block_timestamp()); + self.expires_at.write(get_block_timestamp() + duration * 1000); + self.total_supply.write(total_supply); + } + + #[abi(embed_v0)] + impl NFTDutchAuction of super::INFTDutchAuction { + fn get_price(self: @ContractState) -> u64 { + let time_elapsed = (get_block_timestamp() - self.start_at.read()) + / 1000; // Ignore milliseconds + let discount = self.discount_rate.read() * time_elapsed; + self.starting_price.read() - discount + } + + fn buy(ref self: ContractState, token_id: u256) { + // Check duration + assert(get_block_timestamp() < self.expires_at.read(), Errors::AUCTION_ENDED); + // Check total supply + assert(self.purchase_count.read() < self.total_supply.read(), Errors::AUCTION_ENDED); + + let erc20_dispatcher = IERC20Dispatcher { contract_address: self.erc20_token.read() }; + let erc721_dispatcher = IERC721Dispatcher { + contract_address: self.erc721_token.read() + }; + + let caller = get_caller_address(); + // Get NFT price + let price: u256 = self.get_price().into(); + let buyer_balance: u256 = erc20_dispatcher.balance_of(caller).into(); + // Ensure buyer has enough token for payment + assert(buyer_balance >= price, Errors::INSUFFICIENT_BALANCE); + // Transfer payment token from buyer to seller + erc20_dispatcher.transfer_from(caller, self.seller.read(), price.try_into().unwrap()); + // Mint token to buyer's address + erc721_dispatcher.mint(caller, token_id); + // Increase purchase count + self.purchase_count.write(self.purchase_count.read() + 1); + } + } +} diff --git a/listings/applications/nft_dutch_auction/src/tests.cairo b/listings/applications/nft_dutch_auction/src/tests.cairo new file mode 100644 index 00000000..be89a25f --- /dev/null +++ b/listings/applications/nft_dutch_auction/src/tests.cairo @@ -0,0 +1,215 @@ +use core::option::OptionTrait; +use core::traits::{Into, TryInto}; +use starknet::ContractAddress; +use snforge_std::{ + BlockId, declare, ContractClassTrait, ContractClass, cheat_caller_address, CheatSpan, + cheat_block_timestamp +}; +use super::{ + erc721::{IERC721Dispatcher, IERC721DispatcherTrait}, + nft_dutch_auction::{INFTDutchAuctionDispatcher, INFTDutchAuctionDispatcherTrait} +}; +use erc20::token::{IERC20Dispatcher, IERC20DispatcherTrait}; + +// ERC721 token +pub const erc721_name: felt252 = 'My NFT'; +pub const erc721_symbol: felt252 = 'MNFT'; + +// ERC20 token +pub const erc20_name: felt252 = 'My Token'; +pub const erc20_symbol: felt252 = 'MTKN'; +pub const erc20_recipient: felt252 = 'admin'; +pub const erc20_decimals: u8 = 1_u8; +pub const erc20_initial_supply: u128 = 10000_u128; + +// NFT Auction +pub const starting_price: felt252 = 500; +pub const seller: felt252 = 'seller'; +pub const duration: felt252 = 60; // in seconds +pub const discount_rate: felt252 = 5; +pub const total_supply: felt252 = 2; + +fn get_contract_addresses() -> (ContractAddress, ContractAddress, ContractAddress) { + let erc721 = declare("ERC721").unwrap(); + let erc721_constructor_calldata = array![erc721_name, erc721_symbol]; + let (erc721_address, _) = erc721.deploy(@erc721_constructor_calldata).unwrap(); + let erc20 = declare("erc20").unwrap(); + let erc20_constructor_calldata = array![ + erc20_recipient, + erc20_name, + erc20_decimals.into(), + erc20_initial_supply.into(), + erc20_symbol + ]; + let (erc20_address, _) = erc20.deploy(@erc20_constructor_calldata).unwrap(); + let nft_auction = declare("NFTDutchAuction").unwrap(); + let nft_auction_constructor_calldata = array![ + erc20_address.into(), + erc721_address.into(), + starting_price, + seller, + duration, + discount_rate, + total_supply + ]; + let (nft_auction_address, _) = nft_auction.deploy(@nft_auction_constructor_calldata).unwrap(); + (erc721_address, erc20_address, nft_auction_address) +} + +#[test] +fn test_buy() { + let (erc721_address, erc20_address, nft_auction_address) = get_contract_addresses(); + let erc721_dispatcher = IERC721Dispatcher { contract_address: erc721_address }; + let erc20_dispatcher = IERC20Dispatcher { contract_address: erc20_address }; + let nft_auction_dispatcher = INFTDutchAuctionDispatcher { + contract_address: nft_auction_address + }; + let erc20_admin: ContractAddress = 'admin'.try_into().unwrap(); + let seller: ContractAddress = 'seller'.try_into().unwrap(); + let buyer: ContractAddress = 'buyer'.try_into().unwrap(); + + // Transfer erc20 tokens to buyer + assert_eq!(erc20_dispatcher.balance_of(buyer), 0.into()); + cheat_caller_address(erc20_address, erc20_admin, CheatSpan::TargetCalls(1)); + let transfer_amt = 5000; + erc20_dispatcher.transfer(buyer, transfer_amt.into()); + assert_eq!(erc20_dispatcher.balance_of(buyer), transfer_amt.into()); + + // Buy token + cheat_caller_address(nft_auction_address, buyer, CheatSpan::TargetCalls(3)); + cheat_caller_address(erc20_address, buyer, CheatSpan::TargetCalls(2)); + + let nft_id_1 = 1; + let seller_bal_before_buy = erc20_dispatcher.balance_of(seller); + let buyer_bal_before_buy = erc20_dispatcher.balance_of(buyer); + let nft_price = nft_auction_dispatcher.get_price().into(); + + // buyer approves nft auction contract to spend own erc20 token + erc20_dispatcher.approve(nft_auction_address, nft_price); + + nft_auction_dispatcher.buy(nft_id_1); + + let seller_bal_after_buy = erc20_dispatcher.balance_of(seller); + let buyer_bal_after_buy = erc20_dispatcher.balance_of(buyer); + + assert_eq!(seller_bal_after_buy, seller_bal_before_buy + nft_price); + assert_eq!(buyer_bal_after_buy, buyer_bal_before_buy - nft_price); + assert_eq!(erc721_dispatcher.owner_of(nft_id_1), buyer); + + // Forward block timestamp in order for a reduced nft price + let forward_blocktime_by = 4000; // milliseconds + cheat_block_timestamp(nft_auction_address, forward_blocktime_by, CheatSpan::TargetCalls(1)); + + // Buy token again after some time + let nft_id_2 = 2; + + // buyer approves nft auction contract to spend own erc20 token + erc20_dispatcher.approve(nft_auction_address, nft_price); + + assert_ne!(erc721_dispatcher.owner_of(nft_id_2), buyer); + nft_auction_dispatcher.buy(nft_id_2); + assert_eq!(erc721_dispatcher.owner_of(nft_id_2), buyer); +} + +#[test] +#[should_panic(expected: 'auction has ended')] +fn test_buy_should_panic_when_total_supply_reached() { + let (_, erc20_address, nft_auction_address) = get_contract_addresses(); + let erc20_dispatcher = IERC20Dispatcher { contract_address: erc20_address }; + let nft_auction_dispatcher = INFTDutchAuctionDispatcher { + contract_address: nft_auction_address + }; + let erc20_admin: ContractAddress = 'admin'.try_into().unwrap(); + let buyer: ContractAddress = 'buyer'.try_into().unwrap(); + + // Transfer erc20 tokens to buyer + cheat_caller_address(erc20_address, erc20_admin, CheatSpan::TargetCalls(1)); + let transfer_amt = 5000; + erc20_dispatcher.transfer(buyer, transfer_amt.into()); + + // Buy token + cheat_caller_address(nft_auction_address, buyer, CheatSpan::TargetCalls(4)); + cheat_caller_address(erc20_address, buyer, CheatSpan::TargetCalls(3)); + + let nft_id_1 = 1; + let nft_price = nft_auction_dispatcher.get_price().into(); + + // buyer approves nft auction contract to spend own erc20 token + erc20_dispatcher.approve(nft_auction_address, nft_price); + nft_auction_dispatcher.buy(nft_id_1); + + // Forward block timestamp in order for a reduced nft price + let forward_blocktime_by = 4000; // 4 seconds (in milliseconds) + cheat_block_timestamp(nft_auction_address, forward_blocktime_by, CheatSpan::TargetCalls(1)); + + // Buy token again after some time + let nft_id_2 = 2; + // buyer approves nft auction contract to spend own erc20 token + erc20_dispatcher.approve(nft_auction_address, nft_price); + nft_auction_dispatcher.buy(nft_id_2); + + // Buy token again after the total supply has reached + let nft_id_3 = 3; + // buyer approves nft auction contract to spend own erc20 token + erc20_dispatcher.approve(nft_auction_address, nft_price); + nft_auction_dispatcher.buy(nft_id_3); +} + +#[test] +#[should_panic(expected: 'auction has ended')] +fn test_buy_should_panic_when_duration_ended() { + let (_, erc20_address, nft_auction_address) = get_contract_addresses(); + let erc20_dispatcher = IERC20Dispatcher { contract_address: erc20_address }; + let nft_auction_dispatcher = INFTDutchAuctionDispatcher { + contract_address: nft_auction_address + }; + let erc20_admin: ContractAddress = 'admin'.try_into().unwrap(); + let buyer: ContractAddress = 'buyer'.try_into().unwrap(); + + // Transfer erc20 tokens to buyer + cheat_caller_address(erc20_address, erc20_admin, CheatSpan::TargetCalls(1)); + let transfer_amt = 5000; + erc20_dispatcher.transfer(buyer, transfer_amt.into()); + + // Buy token + cheat_caller_address(nft_auction_address, buyer, CheatSpan::TargetCalls(4)); + cheat_caller_address(erc20_address, buyer, CheatSpan::TargetCalls(3)); + + let nft_id_1 = 1; + let nft_price = nft_auction_dispatcher.get_price().into(); + + // buyer approves nft auction contract to spend own erc20 token + erc20_dispatcher.approve(nft_auction_address, nft_price); + nft_auction_dispatcher.buy(nft_id_1); + + // Forward block timestamp to a time after duration has ended + // During deployment, duration was set to 60 seconds + let forward_blocktime_by = 61000; // 61 seconds (in milliseconds) + cheat_block_timestamp(nft_auction_address, forward_blocktime_by, CheatSpan::TargetCalls(1)); + + // Buy token again after some time + let nft_id_2 = 2; + // buyer approves nft auction contract to spend own erc20 token + erc20_dispatcher.approve(nft_auction_address, nft_price); + nft_auction_dispatcher.buy(nft_id_2); +} + +#[test] +fn test_price_decreases_after_some_time() { + let (_, _, nft_auction_address) = get_contract_addresses(); + let nft_auction_dispatcher = INFTDutchAuctionDispatcher { + contract_address: nft_auction_address + }; + + let nft_price_before_time_travel = nft_auction_dispatcher.get_price(); + + // Forward time + let forward_blocktime_by = 10000; // 10 seconds (in milliseconds) + cheat_block_timestamp(nft_auction_address, forward_blocktime_by, CheatSpan::TargetCalls(1)); + + let nft_price_after_time_travel = nft_auction_dispatcher.get_price(); + + println!("price: {:?}", nft_price_after_time_travel); + + assert_gt!(nft_price_before_time_travel, nft_price_after_time_travel); +} diff --git a/src/SUMMARY.md b/src/SUMMARY.md index 799cb263..955beabe 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -55,6 +55,7 @@ Summary - [Upgradeable Contract](./ch01/upgradeable_contract.md) - [Defi Vault](./ch01/simple_vault.md) - [ERC20 Token](./ch01/erc20.md) +- [NFT Dutch Auction](./ch01/nft_dutch_auction.md) - [Constant Product AMM](./ch01/constant-product-amm.md) - [TimeLock](./ch01/timelock.md) - [Staking](./ch01/staking.md) diff --git a/src/ch01/nft_dutch_auction.md b/src/ch01/nft_dutch_auction.md new file mode 100644 index 00000000..765b3736 --- /dev/null +++ b/src/ch01/nft_dutch_auction.md @@ -0,0 +1,14 @@ +# NFT Dutch Auction + +This is the Cairo adaptation (with some modifications) of the [Solidity by example NFT Dutch Auction](https://solidity-by-example.org/app/dutch-auction/). + +Here's how it works: +- The seller of the NFT deploys this contract with a startingPrice. +- The auction lasts for a specified duration. +- The price decreases over time. +- Participants can purchase NFTs at any time as long as the totalSupply has not been reached. +- The auction ends when either the totalSupply is reached or the duration has elapsed. + +```rust +{{#include ../../listings/applications/nft_auction/src/nft_auction.cairo}} +``` \ No newline at end of file