From 52fbc5ce262cb81746b2a9dcf86d90bdab305da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nenad=20Misi=C4=87?= Date: Thu, 27 Jun 2024 04:09:58 +0200 Subject: [PATCH] feat: Advanced factory contract (#219) * add initial factory * add ownable component * add caller to CounterCreated event * turn counter into campaign * fix Campaign interfaced funcs + implement donate * add _assert_is_ended + update error messages * _assert_active->_assert_campaign_active * _assert_is_ended->_assert_campaign_ended * implement withdraw * add missing assert success in donate * add title & description * update comment * implement upgrade * clean up internal funcs and imports * move hardcoded errors in Errors mod * donate -> contribute + event rename * withdraw -> claim * add store impl for contract addr. array * remove store impl * add dynamic array impl * remove dyn. array * remove descr + convert title to felt + convert target to u128 * implement updating class hashes * Make title ByteArray again + target into u256 + update ctor arg serialization * refactor serialization + add back description * remove unused contracts * add 1 test * add get_description * add correct deps * add alexandria to toml * format factory.cairo * add missing snforge workspace * add missing getters + tests * add factory deploy tests * add class hash update test + event assertions * assert old class hash prior to update * remove commented out test * use common alex. storage workspace in using_lists * add missing newline in toml * move factory tests to separate file * add scaffold docs for contracts * add end_time asserts * refactor private asserts * check if target reached before claiming * add ability to withdraw funds * make contributions into a component (now iterable) * refactor 'withhold' - contrs map to amt_idx * add get_contributors func * get_contributors -> get_contributions * total_contributors->contributor_count * add tests for campaign upgrade and deploy + update all relevant code in factory * add status to campaign * add close fn * pass desired donation token in ctor * merge all getters into get_details * return total_contributions in details * remove rev version from alexandria dep * verbose names * reorg. folder structure * add tag to alexandria dep * campaign_upgrade.cairo->mock_upgrade.cairo * add explicit alexandria rev + make crowdfunding contracts standalone chapters * add status pending * field rename: factory->creator * refund users when upgrading campaign * Make owner the calling address, and creator is the campaign manager * add get_contributor (amount) func * Add successful campaign test * update comment for upgrade * _refund_all->_withdraw_all * update checks for withdraw * rework contribute * rework all funcs * unsuccessful -> failed * calc end_time in start fn * calc end_time in upgrade fn * makes upgrades callable only by creators in factory * fix factory tests * fix crowdfunding tests * reduce total contri. when withdraw from act. camp * add refund fn * refactor withdraw_all to use _refund * pending->draft * fix mock and tests * add test for close * add test for withdraw * upgrade > update end_time only if duration provided * close->cancel * rename to more align with Solidity by example * target->goal * remove comment * err CLOSED->CANCELED + check active in unpledge * contributor->pledger * add campaign doc content * remove draft status * add start_time * remove Status * update doc for campaign * move total_pledges to pledgeable * reorder alphabetically * remove Launched event + upgrade mock * TARGET->GOAL * reorder params in Details * add inline to _refund * add new pledgeable tests * add getX tests + add get_pledge_count * refactor pledger_to_amount_index->pledger_to_amount * Add tests with 1000 pledgers * add test for add + update existing pledger * reenable lib * Add link to adv. factory in crowdfunding point 9 * write the adv. factory chapter * upgrade_campaign_implementation-> upgrade_campaign + comment updates * rename get_pledgers_as_arr->array * Use ERC20Upgradeable instead of ERC20 preset * Add missing token recipient ctor argument in crowdfunding tests --------- Co-authored-by: Nenad --- Scarb.lock | 19 + Scarb.toml | 2 + .../advanced-concepts/using_lists/Scarb.toml | 2 +- .../applications/advanced_factory/.gitignore | 2 + .../applications/advanced_factory/Scarb.toml | 18 + .../advanced_factory/src/contract.cairo | 152 +++++ .../advanced_factory/src/lib.cairo | 5 + .../advanced_factory/src/mock_upgrade.cairo | 8 + .../advanced_factory/src/tests.cairo | 194 ++++++ listings/applications/crowdfunding/.gitignore | 2 + listings/applications/crowdfunding/Scarb.toml | 19 + .../crowdfunding/src/campaign.cairo | 352 +++++++++++ .../src/campaign/pledgeable.cairo | 556 ++++++++++++++++++ .../applications/crowdfunding/src/lib.cairo | 5 + .../crowdfunding/src/mock_upgrade.cairo | 289 +++++++++ .../applications/crowdfunding/src/tests.cairo | 480 +++++++++++++++ src/SUMMARY.md | 2 + src/applications/advanced_factory.md | 13 + src/applications/crowdfunding.md | 26 + 19 files changed, 2145 insertions(+), 1 deletion(-) create mode 100644 listings/applications/advanced_factory/.gitignore create mode 100644 listings/applications/advanced_factory/Scarb.toml create mode 100644 listings/applications/advanced_factory/src/contract.cairo create mode 100644 listings/applications/advanced_factory/src/lib.cairo create mode 100644 listings/applications/advanced_factory/src/mock_upgrade.cairo create mode 100644 listings/applications/advanced_factory/src/tests.cairo create mode 100644 listings/applications/crowdfunding/.gitignore create mode 100644 listings/applications/crowdfunding/Scarb.toml create mode 100644 listings/applications/crowdfunding/src/campaign.cairo create mode 100644 listings/applications/crowdfunding/src/campaign/pledgeable.cairo create mode 100644 listings/applications/crowdfunding/src/lib.cairo create mode 100644 listings/applications/crowdfunding/src/mock_upgrade.cairo create mode 100644 listings/applications/crowdfunding/src/tests.cairo create mode 100644 src/applications/advanced_factory.md create mode 100644 src/applications/crowdfunding.md diff --git a/Scarb.lock b/Scarb.lock index c5dc5433..a055e8a9 100644 --- a/Scarb.lock +++ b/Scarb.lock @@ -1,6 +1,16 @@ # Code generated by scarb DO NOT EDIT. version = 1 +[[package]] +name = "advanced_factory" +version = "0.1.0" +dependencies = [ + "alexandria_storage", + "components", + "crowdfunding", + "snforge_std", +] + [[package]] name = "alexandria_storage" version = "0.3.0" @@ -44,6 +54,15 @@ version = "0.1.0" name = "counter" version = "0.1.0" +[[package]] +name = "crowdfunding" +version = "0.1.0" +dependencies = [ + "components", + "openzeppelin", + "snforge_std", +] + [[package]] name = "custom_type_serde" version = "0.1.0" diff --git a/Scarb.toml b/Scarb.toml index d15c540a..8df8fcb2 100644 --- a/Scarb.toml +++ b/Scarb.toml @@ -16,6 +16,8 @@ starknet = ">=2.6.4" openzeppelin = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag="v0.14.0" } components = { path = "listings/applications/components" } snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry.git", tag = "v0.25.0" } +# The latest Alexandria release supports only Cairo v2.6.0, so using explicit rev that supports Cairo v2.6.3 +alexandria_storage = { git = "https://github.com/keep-starknet-strange/alexandria.git", rev="800f5ad" } [workspace.package] description = "Collection of examples of how to use the Cairo programming language to create smart contracts on Starknet." diff --git a/listings/advanced-concepts/using_lists/Scarb.toml b/listings/advanced-concepts/using_lists/Scarb.toml index 3ccb4af4..20fc9020 100644 --- a/listings/advanced-concepts/using_lists/Scarb.toml +++ b/listings/advanced-concepts/using_lists/Scarb.toml @@ -5,7 +5,7 @@ edition = '2023_11' [dependencies] starknet.workspace = true -alexandria_storage = { git = "https://github.com/keep-starknet-strange/alexandria.git", rev="800f5ad"} +alexandria_storage.workspace = true [scripts] test.workspace = true diff --git a/listings/applications/advanced_factory/.gitignore b/listings/applications/advanced_factory/.gitignore new file mode 100644 index 00000000..73aa31e6 --- /dev/null +++ b/listings/applications/advanced_factory/.gitignore @@ -0,0 +1,2 @@ +target +.snfoundry_cache/ diff --git a/listings/applications/advanced_factory/Scarb.toml b/listings/applications/advanced_factory/Scarb.toml new file mode 100644 index 00000000..5935e01f --- /dev/null +++ b/listings/applications/advanced_factory/Scarb.toml @@ -0,0 +1,18 @@ +[package] +name = "advanced_factory" +version.workspace = true +edition = "2023_11" + +[dependencies] +starknet.workspace = true +components.workspace = true +alexandria_storage.workspace = true +snforge_std.workspace = true +crowdfunding = { path = "../crowdfunding" } + +[scripts] +test.workspace = true + +[[target.starknet-contract]] +casm = true +build-external-contracts = ["crowdfunding::campaign::Campaign"] diff --git a/listings/applications/advanced_factory/src/contract.cairo b/listings/applications/advanced_factory/src/contract.cairo new file mode 100644 index 00000000..c07000f5 --- /dev/null +++ b/listings/applications/advanced_factory/src/contract.cairo @@ -0,0 +1,152 @@ +// ANCHOR: contract +pub use starknet::{ContractAddress, ClassHash}; + +#[starknet::interface] +pub trait ICampaignFactory { + fn create_campaign( + ref self: TContractState, + title: ByteArray, + description: ByteArray, + goal: u256, + start_time: u64, + end_time: u64, + token_address: ContractAddress + ) -> ContractAddress; + fn get_campaign_class_hash(self: @TContractState) -> ClassHash; + fn update_campaign_class_hash(ref self: TContractState, new_class_hash: ClassHash); + fn upgrade_campaign( + ref self: TContractState, campaign_address: ContractAddress, new_end_time: Option + ); +} + +#[starknet::contract] +pub mod CampaignFactory { + use core::num::traits::zero::Zero; + use starknet::{ + ContractAddress, ClassHash, SyscallResultTrait, syscalls::deploy_syscall, + get_caller_address, get_contract_address + }; + use alexandria_storage::list::{List, ListTrait}; + use crowdfunding::campaign::{ICampaignDispatcher, ICampaignDispatcherTrait}; + use components::ownable::ownable_component; + + component!(path: ownable_component, storage: ownable, event: OwnableEvent); + + #[abi(embed_v0)] + impl OwnableImpl = ownable_component::Ownable; + impl OwnableInternalImpl = ownable_component::OwnableInternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + ownable: ownable_component::Storage, + /// Store all of the created campaign instances' addresses and thei class hashes + campaigns: LegacyMap<(ContractAddress, ContractAddress), ClassHash>, + /// Store the class hash of the contract to deploy + campaign_class_hash: ClassHash, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + #[flat] + OwnableEvent: ownable_component::Event, + CampaignClassHashUpgraded: CampaignClassHashUpgraded, + CampaignCreated: CampaignCreated, + ClassHashUpdated: ClassHashUpdated, + } + + #[derive(Drop, starknet::Event)] + pub struct ClassHashUpdated { + pub new_class_hash: ClassHash, + } + + #[derive(Drop, starknet::Event)] + pub struct CampaignClassHashUpgraded { + pub campaign: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct CampaignCreated { + pub creator: ContractAddress, + pub contract_address: ContractAddress + } + + pub mod Errors { + pub const CLASS_HASH_ZERO: felt252 = 'Class hash cannot be zero'; + pub const ZERO_ADDRESS: felt252 = 'Zero address'; + pub const SAME_IMPLEMENTATION: felt252 = 'Implementation is unchanged'; + pub const CAMPAIGN_NOT_FOUND: felt252 = 'Campaign not found'; + } + + #[constructor] + fn constructor(ref self: ContractState, class_hash: ClassHash) { + assert(class_hash.is_non_zero(), Errors::CLASS_HASH_ZERO); + self.campaign_class_hash.write(class_hash); + self.ownable._init(get_caller_address()); + } + + + #[abi(embed_v0)] + impl CampaignFactory of super::ICampaignFactory { + fn create_campaign( + ref self: ContractState, + title: ByteArray, + description: ByteArray, + goal: u256, + start_time: u64, + end_time: u64, + token_address: ContractAddress, + ) -> ContractAddress { + let creator = get_caller_address(); + + // Create contructor arguments + let mut constructor_calldata: Array:: = array![]; + ((creator, title, description, goal), start_time, end_time, token_address) + .serialize(ref constructor_calldata); + + // Contract deployment + let (contract_address, _) = deploy_syscall( + self.campaign_class_hash.read(), 0, constructor_calldata.span(), false + ) + .unwrap_syscall(); + + // track new campaign instance + self.campaigns.write((creator, contract_address), self.campaign_class_hash.read()); + + self.emit(Event::CampaignCreated(CampaignCreated { creator, contract_address })); + + contract_address + } + + fn get_campaign_class_hash(self: @ContractState) -> ClassHash { + self.campaign_class_hash.read() + } + + fn update_campaign_class_hash(ref self: ContractState, new_class_hash: ClassHash) { + self.ownable._assert_only_owner(); + assert(new_class_hash.is_non_zero(), Errors::CLASS_HASH_ZERO); + + self.campaign_class_hash.write(new_class_hash); + + self.emit(Event::ClassHashUpdated(ClassHashUpdated { new_class_hash })); + } + + fn upgrade_campaign( + ref self: ContractState, campaign_address: ContractAddress, new_end_time: Option + ) { + assert(campaign_address.is_non_zero(), Errors::ZERO_ADDRESS); + + let creator = get_caller_address(); + let old_class_hash = self.campaigns.read((creator, campaign_address)); + assert(old_class_hash.is_non_zero(), Errors::CAMPAIGN_NOT_FOUND); + assert(old_class_hash != self.campaign_class_hash.read(), Errors::SAME_IMPLEMENTATION); + + let campaign = ICampaignDispatcher { contract_address: campaign_address }; + campaign.upgrade(self.campaign_class_hash.read(), new_end_time); + } + } +} +// ANCHOR_END: contract + + diff --git a/listings/applications/advanced_factory/src/lib.cairo b/listings/applications/advanced_factory/src/lib.cairo new file mode 100644 index 00000000..541355ed --- /dev/null +++ b/listings/applications/advanced_factory/src/lib.cairo @@ -0,0 +1,5 @@ +mod contract; +mod mock_upgrade; + +#[cfg(test)] +mod tests; diff --git a/listings/applications/advanced_factory/src/mock_upgrade.cairo b/listings/applications/advanced_factory/src/mock_upgrade.cairo new file mode 100644 index 00000000..919c40ae --- /dev/null +++ b/listings/applications/advanced_factory/src/mock_upgrade.cairo @@ -0,0 +1,8 @@ +#[starknet::contract] +pub mod MockContract { + #[storage] + struct Storage {} + #[event] + #[derive(Drop, starknet::Event)] + enum Event {} +} diff --git a/listings/applications/advanced_factory/src/tests.cairo b/listings/applications/advanced_factory/src/tests.cairo new file mode 100644 index 00000000..eb0cc1b5 --- /dev/null +++ b/listings/applications/advanced_factory/src/tests.cairo @@ -0,0 +1,194 @@ +use core::traits::TryInto; +use core::clone::Clone; +use core::result::ResultTrait; +use advanced_factory::contract::{ + CampaignFactory, ICampaignFactoryDispatcher, ICampaignFactoryDispatcherTrait +}; +use starknet::{ + ContractAddress, ClassHash, get_block_timestamp, contract_address_const, get_caller_address +}; +use snforge_std::{ + declare, ContractClass, ContractClassTrait, start_cheat_caller_address, + stop_cheat_caller_address, spy_events, SpyOn, EventSpy, EventAssertions, get_class_hash +}; + +// Define a goal contract to deploy +use crowdfunding::campaign::{Campaign, ICampaignDispatcher, ICampaignDispatcherTrait}; +use components::ownable::{IOwnableDispatcher, IOwnableDispatcherTrait}; + + +/// Deploy a campaign factory contract with the provided campaign class hash +fn deploy_factory_with(campaign_class_hash: ClassHash) -> ICampaignFactoryDispatcher { + let mut constructor_calldata: @Array:: = @array![campaign_class_hash.into()]; + + let contract = declare("CampaignFactory").unwrap(); + let contract_address = contract.precalculate_address(constructor_calldata); + let factory_owner: ContractAddress = contract_address_const::<'factory_owner'>(); + start_cheat_caller_address(contract_address, factory_owner); + + contract.deploy(constructor_calldata).unwrap(); + + stop_cheat_caller_address(contract_address); + + ICampaignFactoryDispatcher { contract_address } +} + +/// Deploy a campaign factory contract with default campaign class hash +fn deploy_factory() -> ICampaignFactoryDispatcher { + let campaign_class_hash = declare("Campaign").unwrap().class_hash; + deploy_factory_with(campaign_class_hash) +} + +#[test] +fn test_deploy_factory() { + let campaign_class_hash = declare("Campaign").unwrap().class_hash; + let factory = deploy_factory_with(campaign_class_hash); + + assert_eq!(factory.get_campaign_class_hash(), campaign_class_hash); + + let factory_owner: ContractAddress = contract_address_const::<'factory_owner'>(); + let factory_ownable = IOwnableDispatcher { contract_address: factory.contract_address }; + assert_eq!(factory_ownable.owner(), factory_owner); +} + +#[test] +fn test_create_campaign() { + let factory = deploy_factory(); + + let mut spy = spy_events(SpyOn::One(factory.contract_address)); + + let campaign_creator: ContractAddress = contract_address_const::<'campaign_creator'>(); + start_cheat_caller_address(factory.contract_address, campaign_creator); + + let title: ByteArray = "New campaign"; + let description: ByteArray = "Some description"; + let goal: u256 = 10000; + let start_time = get_block_timestamp(); + let end_time = start_time + 60; + let token = contract_address_const::<'token'>(); + + let campaign_address = factory + .create_campaign(title.clone(), description.clone(), goal, start_time, end_time, token); + let campaign = ICampaignDispatcher { contract_address: campaign_address }; + + let details = campaign.get_details(); + assert_eq!(details.title, title); + assert_eq!(details.description, description); + assert_eq!(details.goal, goal); + assert_eq!(details.start_time, start_time); + assert_eq!(details.end_time, end_time); + assert_eq!(details.claimed, false); + assert_eq!(details.canceled, false); + assert_eq!(details.token, token); + assert_eq!(details.total_pledges, 0); + assert_eq!(details.creator, campaign_creator); + + let campaign_ownable = IOwnableDispatcher { contract_address: campaign_address }; + assert_eq!(campaign_ownable.owner(), factory.contract_address); + + spy + .assert_emitted( + @array![ + ( + factory.contract_address, + CampaignFactory::Event::CampaignCreated( + CampaignFactory::CampaignCreated { + creator: campaign_creator, contract_address: campaign_address + } + ) + ) + ] + ); +} + +#[test] +fn test_uprade_campaign_class_hash() { + let factory = deploy_factory(); + let old_class_hash = factory.get_campaign_class_hash(); + let new_class_hash = declare("MockContract").unwrap().class_hash; + + let token = contract_address_const::<'token'>(); + + // deploy a pending campaign with the old class hash + let start_time_pending = get_block_timestamp() + 20; + let end_time_pending = start_time_pending + 60; + let pending_campaign_creator = contract_address_const::<'pending_campaign_creator'>(); + start_cheat_caller_address(factory.contract_address, pending_campaign_creator); + let pending_campaign = factory + .create_campaign( + "title 1", "description 1", 10000, start_time_pending, end_time_pending, token + ); + + assert_eq!(old_class_hash, get_class_hash(pending_campaign)); + + // deploy an active campaign with the old class hash + let start_time_active = get_block_timestamp(); + let end_time_active = start_time_active + 60; + let active_campaign_creator = contract_address_const::<'active_campaign_creator'>(); + start_cheat_caller_address(factory.contract_address, active_campaign_creator); + let active_campaign = factory + .create_campaign( + "title 2", "description 2", 20000, start_time_active, end_time_active, token + ); + + assert_eq!(old_class_hash, get_class_hash(active_campaign)); + + // update the factory's campaign class hash value + let mut spy = spy_events( + SpyOn::Multiple(array![factory.contract_address, pending_campaign, active_campaign]) + ); + + let factory_owner = contract_address_const::<'factory_owner'>(); + start_cheat_caller_address(factory.contract_address, factory_owner); + factory.update_campaign_class_hash(new_class_hash); + + assert_eq!(factory.get_campaign_class_hash(), new_class_hash); + assert_eq!(old_class_hash, get_class_hash(pending_campaign)); + assert_eq!(old_class_hash, get_class_hash(active_campaign)); + + spy + .assert_emitted( + @array![ + ( + factory.contract_address, + CampaignFactory::Event::ClassHashUpdated( + CampaignFactory::ClassHashUpdated { new_class_hash } + ) + ) + ] + ); + + // upgrade pending campaign + start_cheat_caller_address(factory.contract_address, pending_campaign_creator); + factory.upgrade_campaign(pending_campaign, Option::None); + + assert_eq!(get_class_hash(pending_campaign), new_class_hash); + assert_eq!(get_class_hash(active_campaign), old_class_hash); + + spy + .assert_emitted( + @array![ + ( + pending_campaign, + Campaign::Event::Upgraded(Campaign::Upgraded { implementation: new_class_hash }) + ) + ] + ); + + // upgrade active campaign + start_cheat_caller_address(factory.contract_address, active_campaign_creator); + factory.upgrade_campaign(active_campaign, Option::None); + + assert_eq!(get_class_hash(pending_campaign), new_class_hash); + assert_eq!(get_class_hash(active_campaign), new_class_hash); + + spy + .assert_emitted( + @array![ + ( + active_campaign, + Campaign::Event::Upgraded(Campaign::Upgraded { implementation: new_class_hash }) + ) + ] + ); +} diff --git a/listings/applications/crowdfunding/.gitignore b/listings/applications/crowdfunding/.gitignore new file mode 100644 index 00000000..73aa31e6 --- /dev/null +++ b/listings/applications/crowdfunding/.gitignore @@ -0,0 +1,2 @@ +target +.snfoundry_cache/ diff --git a/listings/applications/crowdfunding/Scarb.toml b/listings/applications/crowdfunding/Scarb.toml new file mode 100644 index 00000000..074a2713 --- /dev/null +++ b/listings/applications/crowdfunding/Scarb.toml @@ -0,0 +1,19 @@ +[package] +name = "crowdfunding" +version.workspace = true +edition = "2023_11" + +[lib] + +[dependencies] +starknet.workspace = true +openzeppelin.workspace = true +components.workspace = true +snforge_std.workspace = true + +[scripts] +test.workspace = true + +[[target.starknet-contract]] +casm = true +build-external-contracts = ["openzeppelin::presets::erc20::ERC20Upgradeable"] diff --git a/listings/applications/crowdfunding/src/campaign.cairo b/listings/applications/crowdfunding/src/campaign.cairo new file mode 100644 index 00000000..e5b5faf6 --- /dev/null +++ b/listings/applications/crowdfunding/src/campaign.cairo @@ -0,0 +1,352 @@ +pub mod pledgeable; + +// ANCHOR: contract +use starknet::{ClassHash, ContractAddress}; + +#[derive(Drop, Serde)] +pub struct Details { + pub canceled: bool, + pub claimed: bool, + pub creator: ContractAddress, + pub description: ByteArray, + pub end_time: u64, + pub goal: u256, + pub start_time: u64, + pub title: ByteArray, + pub token: ContractAddress, + pub total_pledges: u256, +} + +#[starknet::interface] +pub trait ICampaign { + fn claim(ref self: TContractState); + fn cancel(ref self: TContractState, reason: ByteArray); + fn pledge(ref self: TContractState, amount: u256); + fn get_pledge(self: @TContractState, pledger: ContractAddress) -> u256; + fn get_pledgers(self: @TContractState) -> Array; + fn get_details(self: @TContractState) -> Details; + fn refund(ref self: TContractState, pledger: ContractAddress, reason: ByteArray); + fn upgrade(ref self: TContractState, impl_hash: ClassHash, new_end_time: Option); + fn unpledge(ref self: TContractState, reason: ByteArray); +} + +#[starknet::contract] +pub mod Campaign { + use components::ownable::ownable_component::OwnableInternalTrait; + use core::num::traits::zero::Zero; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::{ + ClassHash, ContractAddress, SyscallResultTrait, get_block_timestamp, contract_address_const, + get_caller_address, get_contract_address, class_hash::class_hash_const + }; + use components::ownable::ownable_component; + use super::pledgeable::pledgeable_component; + use super::Details; + + component!(path: ownable_component, storage: ownable, event: OwnableEvent); + component!(path: pledgeable_component, storage: pledges, event: PledgeableEvent); + + #[abi(embed_v0)] + pub impl OwnableImpl = ownable_component::Ownable; + impl OwnableInternalImpl = ownable_component::OwnableInternalImpl; + #[abi(embed_v0)] + impl PledgeableImpl = pledgeable_component::Pledgeable; + + #[storage] + struct Storage { + canceled: bool, + claimed: bool, + creator: ContractAddress, + description: ByteArray, + end_time: u64, + goal: u256, + #[substorage(v0)] + ownable: ownable_component::Storage, + #[substorage(v0)] + pledges: pledgeable_component::Storage, + start_time: u64, + title: ByteArray, + token: IERC20Dispatcher, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + Claimed: Claimed, + Canceled: Canceled, + #[flat] + OwnableEvent: ownable_component::Event, + PledgeableEvent: pledgeable_component::Event, + PledgeMade: PledgeMade, + Refunded: Refunded, + RefundedAll: RefundedAll, + Unpledged: Unpledged, + Upgraded: Upgraded, + } + + #[derive(Drop, starknet::Event)] + pub struct Canceled { + pub reason: ByteArray, + } + + #[derive(Drop, starknet::Event)] + pub struct Claimed { + pub amount: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct PledgeMade { + #[key] + pub pledger: ContractAddress, + pub amount: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct Refunded { + #[key] + pub pledger: ContractAddress, + pub amount: u256, + pub reason: ByteArray, + } + + #[derive(Drop, starknet::Event)] + pub struct RefundedAll { + pub reason: ByteArray, + } + + #[derive(Drop, starknet::Event)] + pub struct Unpledged { + #[key] + pub pledger: ContractAddress, + pub amount: u256, + pub reason: ByteArray, + } + + #[derive(Drop, starknet::Event)] + pub struct Upgraded { + pub implementation: ClassHash + } + + pub mod Errors { + pub const CANCELED: felt252 = 'Campaign canceled'; + pub const CLAIMED: felt252 = 'Campaign already claimed'; + pub const CLASS_HASH_ZERO: felt252 = 'Class hash zero'; + pub const CREATOR_ZERO: felt252 = 'Creator address zero'; + pub const ENDED: felt252 = 'Campaign already ended'; + pub const END_BEFORE_NOW: felt252 = 'End time < now'; + pub const END_BEFORE_START: felt252 = 'End time < start time'; + pub const END_BIGGER_THAN_MAX: felt252 = 'End time > max duration'; + pub const NOTHING_TO_REFUND: felt252 = 'Nothing to refund'; + pub const NOTHING_TO_UNPLEDGE: felt252 = 'Nothing to unpledge'; + pub const NOT_CREATOR: felt252 = 'Not creator'; + pub const NOT_STARTED: felt252 = 'Campaign not started'; + pub const PLEDGES_LOCKED: felt252 = 'Goal reached, pledges locked'; + pub const START_TIME_IN_PAST: felt252 = 'Start time < now'; + pub const STILL_ACTIVE: felt252 = 'Campaign not ended'; + pub const GOAL_NOT_REACHED: felt252 = 'Goal not reached'; + pub const TITLE_EMPTY: felt252 = 'Title empty'; + pub const TRANSFER_FAILED: felt252 = 'Transfer failed'; + pub const ZERO_ADDRESS_CALLER: felt252 = 'Caller address zero'; + pub const ZERO_ADDRESS_PLEDGER: felt252 = 'Pledger address zero'; + pub const ZERO_ADDRESS_TOKEN: felt252 = 'Token address zerp'; + pub const ZERO_DONATION: felt252 = 'Donation must be > 0'; + pub const ZERO_GOAL: felt252 = 'Goal must be > 0'; + pub const ZERO_PLEDGES: felt252 = 'No pledges to claim'; + } + + const NINETY_DAYS: u64 = consteval_int!(90 * 24 * 60 * 60); + + #[constructor] + fn constructor( + ref self: ContractState, + creator: ContractAddress, + title: ByteArray, + description: ByteArray, + goal: u256, + start_time: u64, + end_time: u64, + token_address: ContractAddress, + ) { + assert(creator.is_non_zero(), Errors::CREATOR_ZERO); + assert(title.len() > 0, Errors::TITLE_EMPTY); + assert(goal > 0, Errors::ZERO_GOAL); + assert(start_time >= get_block_timestamp(), Errors::START_TIME_IN_PAST); + assert(end_time >= start_time, Errors::END_BEFORE_START); + assert(end_time <= get_block_timestamp() + NINETY_DAYS, Errors::END_BIGGER_THAN_MAX); + assert(token_address.is_non_zero(), Errors::ZERO_ADDRESS_TOKEN); + + self.creator.write(creator); + self.title.write(title); + self.goal.write(goal); + self.description.write(description); + self.start_time.write(start_time); + self.end_time.write(end_time); + self.token.write(IERC20Dispatcher { contract_address: token_address }); + self.ownable._init(get_caller_address()); + } + + #[abi(embed_v0)] + impl Campaign of super::ICampaign { + fn cancel(ref self: ContractState, reason: ByteArray) { + self._assert_only_creator(); + assert(!self.canceled.read(), Errors::CANCELED); + assert(!self.claimed.read(), Errors::CLAIMED); + + self.canceled.write(true); + + self._refund_all(reason.clone()); + + self.emit(Event::Canceled(Canceled { reason })); + } + + /// Sends the funds to the campaign creator. + /// It leaves the pledge data intact as a testament to campaign success + fn claim(ref self: ContractState) { + self._assert_only_creator(); + assert(self._is_started(), Errors::NOT_STARTED); + assert(self._is_ended(), Errors::STILL_ACTIVE); + assert(!self.claimed.read(), Errors::CLAIMED); + assert(self._is_goal_reached(), Errors::GOAL_NOT_REACHED); + // no need to check if canceled; if it was, then the goal wouldn't have been reached + + let this = get_contract_address(); + let token = self.token.read(); + let amount = token.balance_of(this); + assert(amount > 0, Errors::ZERO_PLEDGES); + + self.claimed.write(true); + + let owner = get_caller_address(); + let success = token.transfer(owner, amount); + assert(success, Errors::TRANSFER_FAILED); + + self.emit(Event::Claimed(Claimed { amount })); + } + + fn get_details(self: @ContractState) -> Details { + Details { + canceled: self.canceled.read(), + claimed: self.claimed.read(), + creator: self.creator.read(), + description: self.description.read(), + end_time: self.end_time.read(), + goal: self.goal.read(), + start_time: self.start_time.read(), + title: self.title.read(), + token: self.token.read().contract_address, + total_pledges: self.pledges.get_total(), + } + } + + fn get_pledge(self: @ContractState, pledger: ContractAddress) -> u256 { + self.pledges.get(pledger) + } + + fn get_pledgers(self: @ContractState) -> Array { + self.pledges.array() + } + + fn pledge(ref self: ContractState, amount: u256) { + assert(self._is_started(), Errors::NOT_STARTED); + assert(!self._is_ended(), Errors::ENDED); + assert(!self.canceled.read(), Errors::CANCELED); + assert(amount > 0, Errors::ZERO_DONATION); + + let pledger = get_caller_address(); + let this = get_contract_address(); + let success = self.token.read().transfer_from(pledger, this, amount); + assert(success, Errors::TRANSFER_FAILED); + + self.pledges.add(pledger, amount); + + self.emit(Event::PledgeMade(PledgeMade { pledger, amount })); + } + + fn refund(ref self: ContractState, pledger: ContractAddress, reason: ByteArray) { + self._assert_only_creator(); + assert(self._is_started(), Errors::NOT_STARTED); + assert(!self.claimed.read(), Errors::CLAIMED); + assert(!self.canceled.read(), Errors::CANCELED); + assert(pledger.is_non_zero(), Errors::ZERO_ADDRESS_PLEDGER); + assert(self.pledges.get(pledger) != 0, Errors::NOTHING_TO_REFUND); + + let amount = self._refund(pledger); + + self.emit(Event::Refunded(Refunded { pledger, amount, reason })) + } + + fn unpledge(ref self: ContractState, reason: ByteArray) { + assert(self._is_started(), Errors::NOT_STARTED); + assert(!self._is_goal_reached(), Errors::PLEDGES_LOCKED); + assert(self.pledges.get(get_caller_address()) != 0, Errors::NOTHING_TO_UNPLEDGE); + + let pledger = get_caller_address(); + let amount = self._refund(pledger); + + self.emit(Event::Unpledged(Unpledged { pledger, amount, reason })); + } + + fn upgrade(ref self: ContractState, impl_hash: ClassHash, new_end_time: Option) { + self.ownable._assert_only_owner(); + assert(impl_hash.is_non_zero(), Errors::CLASS_HASH_ZERO); + + // only active campaigns have pledges to refund and an end time to update + if self._is_started() { + if let Option::Some(end_time) = new_end_time { + assert(end_time >= get_block_timestamp(), Errors::END_BEFORE_NOW); + assert( + end_time <= get_block_timestamp() + NINETY_DAYS, Errors::END_BIGGER_THAN_MAX + ); + self.end_time.write(end_time); + }; + self._refund_all("contract upgraded"); + } + + starknet::syscalls::replace_class_syscall(impl_hash).unwrap_syscall(); + + self.emit(Event::Upgraded(Upgraded { implementation: impl_hash })); + } + } + + #[generate_trait] + impl CampaignInternalImpl of CampaignInternalTrait { + fn _assert_only_creator(self: @ContractState) { + let caller = get_caller_address(); + assert(caller.is_non_zero(), Errors::ZERO_ADDRESS_CALLER); + assert(caller == self.creator.read(), Errors::NOT_CREATOR); + } + + fn _is_ended(self: @ContractState) -> bool { + get_block_timestamp() >= self.end_time.read() + } + + fn _is_goal_reached(self: @ContractState) -> bool { + self.pledges.get_total() >= self.goal.read() + } + + fn _is_started(self: @ContractState) -> bool { + get_block_timestamp() >= self.start_time.read() + } + + #[inline(always)] + fn _refund(ref self: ContractState, pledger: ContractAddress) -> u256 { + let amount = self.pledges.remove(pledger); + + let success = self.token.read().transfer(pledger, amount); + assert(success, Errors::TRANSFER_FAILED); + + amount + } + + fn _refund_all(ref self: ContractState, reason: ByteArray) { + let mut pledges = self.pledges.array(); + while let Option::Some(pledger) = pledges.pop_front() { + self._refund(pledger); + }; + self.emit(Event::RefundedAll(RefundedAll { reason })); + } + } +} +// ANCHOR_END: contract + + diff --git a/listings/applications/crowdfunding/src/campaign/pledgeable.cairo b/listings/applications/crowdfunding/src/campaign/pledgeable.cairo new file mode 100644 index 00000000..4000057a --- /dev/null +++ b/listings/applications/crowdfunding/src/campaign/pledgeable.cairo @@ -0,0 +1,556 @@ +// ANCHOR: component +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IPledgeable { + fn add(ref self: TContractState, pledger: ContractAddress, amount: u256); + fn get(self: @TContractState, pledger: ContractAddress) -> u256; + fn get_pledger_count(self: @TContractState) -> u32; + fn array(self: @TContractState) -> Array; + fn get_total(self: @TContractState) -> u256; + fn remove(ref self: TContractState, pledger: ContractAddress) -> u256; +} + +#[starknet::component] +pub mod pledgeable_component { + use core::array::ArrayTrait; + use starknet::{ContractAddress}; + use core::num::traits::Zero; + + #[storage] + struct Storage { + index_to_pledger: LegacyMap, + pledger_to_amount: LegacyMap, + pledger_count: u32, + total_amount: u256, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event {} + + mod Errors { + pub const INCONSISTENT_STATE: felt252 = 'Non-indexed pledger found'; + } + + #[embeddable_as(Pledgeable)] + pub impl PledgeableImpl< + TContractState, +HasComponent + > of super::IPledgeable> { + fn add(ref self: ComponentState, pledger: ContractAddress, amount: u256) { + let old_amount: u256 = self.pledger_to_amount.read(pledger); + + if old_amount == 0 { + let index = self.pledger_count.read(); + self.index_to_pledger.write(index, pledger); + self.pledger_count.write(index + 1); + } + + self.pledger_to_amount.write(pledger, old_amount + amount); + self.total_amount.write(self.total_amount.read() + amount); + } + + fn get(self: @ComponentState, pledger: ContractAddress) -> u256 { + self.pledger_to_amount.read(pledger) + } + + fn get_pledger_count(self: @ComponentState) -> u32 { + self.pledger_count.read() + } + + fn array(self: @ComponentState) -> Array { + let mut result = array![]; + + let mut index = self.pledger_count.read(); + while index != 0 { + index -= 1; + let pledger = self.index_to_pledger.read(index); + result.append(pledger); + }; + + result + } + + fn get_total(self: @ComponentState) -> u256 { + self.total_amount.read() + } + + fn remove(ref self: ComponentState, pledger: ContractAddress) -> u256 { + let amount: u256 = self.pledger_to_amount.read(pledger); + + // check if the pledge even exists + if amount == 0 { + return 0; + } + + let last_index = self.pledger_count.read() - 1; + + // if there are other pledgers, we need to update our indices + if last_index != 0 { + let mut pledger_index = last_index; + loop { + if self.index_to_pledger.read(pledger_index) == pledger { + break; + } + // if pledger_to_amount contains a pledger, then so does index_to_pledger + // thus this will never underflow + pledger_index -= 1; + }; + + self.index_to_pledger.write(pledger_index, self.index_to_pledger.read(last_index)); + } + + // last_index == new pledger count + self.pledger_count.write(last_index); + self.pledger_to_amount.write(pledger, 0); + self.index_to_pledger.write(last_index, Zero::zero()); + + self.total_amount.write(self.total_amount.read() - amount); + + amount + } + } +} +// ANCHOR_END: component + +#[cfg(test)] +mod tests { + #[starknet::contract] + mod MockContract { + use super::super::pledgeable_component; + + component!(path: pledgeable_component, storage: pledges, event: PledgeableEvent); + + #[storage] + struct Storage { + #[substorage(v0)] + pledges: pledgeable_component::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + PledgeableEvent: pledgeable_component::Event + } + + #[abi(embed_v0)] + impl Pledgeable = pledgeable_component::Pledgeable; + } + + use super::{pledgeable_component, IPledgeableDispatcher, IPledgeableDispatcherTrait}; + use super::pledgeable_component::{PledgeableImpl}; + use starknet::{ContractAddress, contract_address_const}; + use core::num::traits::Zero; + + type TestingState = pledgeable_component::ComponentState; + + // You can derive even `Default` on this type alias + impl TestingStateDefault of Default { + fn default() -> TestingState { + pledgeable_component::component_state_for_testing() + } + } + + #[test] + fn test_add() { + let mut pledgeable: TestingState = Default::default(); + let pledger_1 = contract_address_const::<'pledger_1'>(); + let pledger_2 = contract_address_const::<'pledger_2'>(); + + assert_eq!(pledgeable.get_pledger_count(), 0); + assert_eq!(pledgeable.get_total(), 0); + assert_eq!(pledgeable.get(pledger_1), 0); + assert_eq!(pledgeable.get(pledger_2), 0); + + // 1st pledge + pledgeable.add(pledger_1, 1000); + + assert_eq!(pledgeable.get_pledger_count(), 1); + assert_eq!(pledgeable.get_total(), 1000); + assert_eq!(pledgeable.get(pledger_1), 1000); + assert_eq!(pledgeable.get(pledger_2), 0); + + // 2nd pledge should be added onto 1st + pledgeable.add(pledger_1, 1000); + + assert_eq!(pledgeable.get_pledger_count(), 1); + assert_eq!(pledgeable.get_total(), 2000); + assert_eq!(pledgeable.get(pledger_1), 2000); + assert_eq!(pledgeable.get(pledger_2), 0); + + // different pledger stored separately + pledgeable.add(pledger_2, 500); + + assert_eq!(pledgeable.get_pledger_count(), 2); + assert_eq!(pledgeable.get_total(), 2500); + assert_eq!(pledgeable.get(pledger_1), 2000); + assert_eq!(pledgeable.get(pledger_2), 500); + } + + #[test] + fn test_add_1000_pledgers() { + let mut pledgeable: TestingState = Default::default(); + + // set up 1000 pledgers + let expected_pledger_count: u32 = 1000; + let mut expected_total: u256 = 0; // actual value set up in the while loop + let mut pledgers: Array::<(ContractAddress, u256)> = array![]; + + let mut i: felt252 = expected_pledger_count.into(); + while i != 0 { + let pledger: ContractAddress = i.try_into().unwrap(); + let amount: u256 = i.into() * 100; + pledgeable.add(pledger, amount); + pledgers.append((pledger, amount)); + expected_total += amount; + i -= 1; + }; + + assert_eq!(pledgeable.get_pledger_count(), expected_pledger_count); + assert_eq!(pledgeable.get_total(), expected_total); + + while let Option::Some((pledger, expected_amount)) = pledgers + .pop_front() { + assert_eq!(pledgeable.get(pledger), expected_amount); + } + } + + #[test] + fn test_add_update_first_of_1000_pledgers() { + let mut pledgeable: TestingState = Default::default(); + + let expected_pledger_count: u32 = 1000; + let mut expected_total: u256 = 0; + + // set up 1000 pledgers + let mut i: felt252 = expected_pledger_count.into(); + let first_pledger: ContractAddress = i.try_into().unwrap(); + let first_amount: u256 = i.into() * 100; + pledgeable.add(first_pledger, first_amount); + expected_total += first_amount; + + i -= 1; + while i != 0 { + let pledger: ContractAddress = i.try_into().unwrap(); + let amount: u256 = i.into() * 100; + pledgeable.add(pledger, amount); + expected_total += amount; + i -= 1; + }; + + // first pledger makes another pledge + pledgeable.add(first_pledger, 2000); + expected_total += 2000; + let expected_amount = first_amount + 2000; + + let amount = pledgeable.get(first_pledger); + assert_eq!(amount, expected_amount); + assert_eq!(pledgeable.get_total(), expected_total); + assert_eq!(pledgeable.get_pledger_count(), expected_pledger_count); + } + + #[test] + fn test_add_update_middle_of_1000_pledgers() { + let mut pledgeable: TestingState = Default::default(); + + let expected_pledger_count: u32 = 1000; + let mut expected_total: u256 = 0; + + // set up 1000 pledgers + let mut middle_pledger: ContractAddress = Zero::zero(); + let mut middle_amount = 0; + + let mut i: felt252 = 1000; + while i != 0 { + let pledger: ContractAddress = i.try_into().unwrap(); + let amount: u256 = i.into() * 100; + pledgeable.add(pledger, amount); + expected_total += amount; + + if i == 500 { + middle_pledger = pledger; + middle_amount = amount; + } + + i -= 1; + }; + + // middle pledger makes another pledge + pledgeable.add(middle_pledger, 2000); + expected_total += 2000; + let expected_amount = middle_amount + 2000; + + let amount = pledgeable.get(middle_pledger); + assert_eq!(amount, expected_amount); + assert_eq!(pledgeable.get_total(), expected_total); + assert_eq!(pledgeable.get_pledger_count(), expected_pledger_count); + } + + #[test] + fn test_add_update_last_of_1000_pledgers() { + let mut pledgeable: TestingState = Default::default(); + + let expected_pledger_count: u32 = 1000; + let mut expected_total: u256 = 0; + + // set up 1000 pledgers + let mut i: felt252 = 1000; + // remember last pledger, add it after while loop + let last_pledger: ContractAddress = i.try_into().unwrap(); + let last_amount = 100000; + + i -= 1; // leave place for the last pledger + while i != 0 { + let pledger: ContractAddress = i.try_into().unwrap(); + let amount: u256 = i.into() * 100; + pledgeable.add(pledger, amount); + expected_total += amount; + i -= 1; + }; + // add last pledger + pledgeable.add(last_pledger, last_amount); + expected_total += last_amount; + + // last pledger makes another pledge + pledgeable.add(last_pledger, 2000); + expected_total += 2000; + let expected_amount = last_amount + 2000; + + let amount = pledgeable.get(last_pledger); + assert_eq!(amount, expected_amount); + assert_eq!(pledgeable.get_total(), expected_total); + assert_eq!(pledgeable.get_pledger_count(), expected_pledger_count); + } + + #[test] + fn test_remove() { + let mut pledgeable: TestingState = Default::default(); + let pledger_1 = contract_address_const::<'pledger_1'>(); + let pledger_2 = contract_address_const::<'pledger_2'>(); + let pledger_3 = contract_address_const::<'pledger_3'>(); + + pledgeable.add(pledger_1, 2000); + pledgeable.add(pledger_2, 3000); + // pledger_3 not added + + assert_eq!(pledgeable.get_pledger_count(), 2); + assert_eq!(pledgeable.get_total(), 5000); + assert_eq!(pledgeable.get(pledger_1), 2000); + assert_eq!(pledgeable.get(pledger_2), 3000); + assert_eq!(pledgeable.get(pledger_3), 0); + + let amount = pledgeable.remove(pledger_1); + + assert_eq!(amount, 2000); + assert_eq!(pledgeable.get_pledger_count(), 1); + assert_eq!(pledgeable.get_total(), 3000); + assert_eq!(pledgeable.get(pledger_1), 0); + assert_eq!(pledgeable.get(pledger_2), 3000); + assert_eq!(pledgeable.get(pledger_3), 0); + + let amount = pledgeable.remove(pledger_2); + + assert_eq!(amount, 3000); + assert_eq!(pledgeable.get_pledger_count(), 0); + assert_eq!(pledgeable.get_total(), 0); + assert_eq!(pledgeable.get(pledger_1), 0); + assert_eq!(pledgeable.get(pledger_2), 0); + assert_eq!(pledgeable.get(pledger_3), 0); + + // pledger_3 not added, so this should do nothing and return 0 + let amount = pledgeable.remove(pledger_3); + + assert_eq!(amount, 0); + assert_eq!(pledgeable.get_pledger_count(), 0); + assert_eq!(pledgeable.get_total(), 0); + assert_eq!(pledgeable.get(pledger_1), 0); + assert_eq!(pledgeable.get(pledger_2), 0); + assert_eq!(pledgeable.get(pledger_3), 0); + } + + #[test] + fn test_remove_first_of_1000_pledgers() { + let mut pledgeable: TestingState = Default::default(); + + // set up 1000 pledgers + let expected_pledger_count: u32 = 1000; + let mut expected_total: u256 = 0; // actual value set up in the while loop + + let mut i: felt252 = expected_pledger_count.into(); + let first_pledger: ContractAddress = i.try_into().unwrap(); + let first_amount = 100000; + pledgeable.add(first_pledger, first_amount); + expected_total += first_amount; + i -= 1; + + while i != 0 { + let pledger: ContractAddress = i.try_into().unwrap(); + let amount: u256 = i.into() * 100; + pledgeable.add(pledger, amount); + expected_total += amount; + i -= 1; + }; + + assert_eq!(pledgeable.get_total(), expected_total); + assert_eq!(pledgeable.get_pledger_count(), expected_pledger_count); + assert_eq!(pledgeable.get(first_pledger), first_amount); + + let removed_amount = pledgeable.remove(first_pledger); + + expected_total -= first_amount; + + assert_eq!(removed_amount, first_amount); + assert_eq!(pledgeable.get_total(), expected_total); + assert_eq!(pledgeable.get_pledger_count(), expected_pledger_count - 1); + assert_eq!(pledgeable.get(first_pledger), 0); + } + + #[test] + fn test_remove_middle_of_1000_pledgers() { + let mut pledgeable: TestingState = Default::default(); + + // set up 1000 pledgers + let expected_pledger_count: u32 = 1000; + let mut expected_total: u256 = 0; // actual value set up in the while loop + + let mut middle_pledger: ContractAddress = Zero::zero(); + let mut middle_amount = 0; + + let mut i: felt252 = expected_pledger_count.into(); + while i != 0 { + let pledger: ContractAddress = i.try_into().unwrap(); + let amount: u256 = i.into() * 100; + pledgeable.add(pledger, amount); + expected_total += amount; + + if i == 500 { + middle_pledger = pledger; + middle_amount = amount; + } + + i -= 1; + }; + + assert_eq!(pledgeable.get_total(), expected_total); + assert_eq!(pledgeable.get_pledger_count(), expected_pledger_count); + assert_eq!(pledgeable.get(middle_pledger), middle_amount); + + let removed_amount = pledgeable.remove(middle_pledger); + + expected_total -= middle_amount; + + assert_eq!(removed_amount, middle_amount); + assert_eq!(pledgeable.get_total(), expected_total); + assert_eq!(pledgeable.get_pledger_count(), expected_pledger_count - 1); + assert_eq!(pledgeable.get(middle_pledger), 0); + } + + #[test] + fn test_remove_last_of_1000_pledgers() { + let mut pledgeable: TestingState = Default::default(); + + // set up 1000 pledgers + let expected_pledger_count: u32 = 1000; + let mut expected_total: u256 = 0; // actual value set up in the while loop + + let mut i: felt252 = expected_pledger_count.into(); + let last_pledger: ContractAddress = i.try_into().unwrap(); + let last_amount = 100000; + i -= 1; // leave place for the last pledger + + while i != 0 { + let pledger: ContractAddress = i.try_into().unwrap(); + let amount: u256 = i.into() * 100; + pledgeable.add(pledger, amount); + expected_total += amount; + i -= 1; + }; + + // add last pledger + pledgeable.add(last_pledger, last_amount); + expected_total += last_amount; + + assert_eq!(pledgeable.get_total(), expected_total); + assert_eq!(pledgeable.get_pledger_count(), expected_pledger_count); + assert_eq!(pledgeable.get(last_pledger), last_amount); + + let removed_amount = pledgeable.remove(last_pledger); + + expected_total -= last_amount; + + assert_eq!(removed_amount, last_amount); + assert_eq!(pledgeable.get_total(), expected_total); + assert_eq!(pledgeable.get_pledger_count(), expected_pledger_count - 1); + assert_eq!(pledgeable.get(last_pledger), 0); + } + + #[test] + fn test_array() { + let mut pledgeable: TestingState = Default::default(); + let pledger_1 = contract_address_const::<'pledger_1'>(); + let pledger_2 = contract_address_const::<'pledger_2'>(); + let pledger_3 = contract_address_const::<'pledger_3'>(); + + pledgeable.add(pledger_1, 1000); + pledgeable.add(pledger_2, 500); + pledgeable.add(pledger_3, 2500); + // 2nd pledge by pledger_2 *should not* increase the pledge count + pledgeable.add(pledger_2, 1500); + + let pledgers_arr = pledgeable.array(); + + assert_eq!(pledgers_arr.len(), 3); + assert_eq!(pledger_3, *pledgers_arr[0]); + assert_eq!(2500, pledgeable.get(*pledgers_arr[0])); + assert_eq!(pledger_2, *pledgers_arr[1]); + assert_eq!(2000, pledgeable.get(*pledgers_arr[1])); + assert_eq!(pledger_1, *pledgers_arr[2]); + assert_eq!(1000, pledgeable.get(*pledgers_arr[2])); + } + + #[test] + fn test_array_1000_pledgers() { + let mut pledgeable: TestingState = Default::default(); + + // set up 1000 pledgers + let mut pledgers: Array:: = array![]; + let mut i: felt252 = 1000; + while i != 0 { + let pledger: ContractAddress = i.try_into().unwrap(); + let amount: u256 = i.into() * 100; + pledgeable.add(pledger, amount); + pledgers.append(pledger); + i -= 1; + }; + + let pledgers_arr: Array:: = pledgeable.array(); + + assert_eq!(pledgers_arr.len(), pledgers.len()); + + let mut i = 1000; + while let Option::Some(expected_pledger) = pledgers + .pop_front() { + i -= 1; + // pledgers are fetched in reversed order + let actual_pledger: ContractAddress = *pledgers_arr.at(i); + assert_eq!(expected_pledger, actual_pledger); + } + } + + #[test] + fn test_get() { + let mut pledgeable: TestingState = Default::default(); + let pledger_1 = contract_address_const::<'pledger_1'>(); + let pledger_2 = contract_address_const::<'pledger_2'>(); + let pledger_3 = contract_address_const::<'pledger_3'>(); + + pledgeable.add(pledger_1, 1000); + pledgeable.add(pledger_2, 500); + // pledger_3 not added + + assert_eq!(pledgeable.get(pledger_1), 1000); + assert_eq!(pledgeable.get(pledger_2), 500); + assert_eq!(pledgeable.get(pledger_3), 0); + } +} + diff --git a/listings/applications/crowdfunding/src/lib.cairo b/listings/applications/crowdfunding/src/lib.cairo new file mode 100644 index 00000000..3e5429ad --- /dev/null +++ b/listings/applications/crowdfunding/src/lib.cairo @@ -0,0 +1,5 @@ +pub mod campaign; +mod mock_upgrade; + +#[cfg(test)] +mod tests; diff --git a/listings/applications/crowdfunding/src/mock_upgrade.cairo b/listings/applications/crowdfunding/src/mock_upgrade.cairo new file mode 100644 index 00000000..33348d16 --- /dev/null +++ b/listings/applications/crowdfunding/src/mock_upgrade.cairo @@ -0,0 +1,289 @@ +#[starknet::contract] +pub mod MockUpgrade { + use components::ownable::ownable_component::OwnableInternalTrait; + use core::num::traits::zero::Zero; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::{ + ClassHash, ContractAddress, SyscallResultTrait, get_block_timestamp, contract_address_const, + get_caller_address, get_contract_address, class_hash::class_hash_const + }; + use components::ownable::ownable_component; + use crowdfunding::campaign::pledgeable::pledgeable_component; + use crowdfunding::campaign::{ICampaign, Details, Campaign::Errors}; + + component!(path: ownable_component, storage: ownable, event: OwnableEvent); + component!(path: pledgeable_component, storage: pledges, event: PledgeableEvent); + + #[abi(embed_v0)] + pub impl OwnableImpl = ownable_component::Ownable; + impl OwnableInternalImpl = ownable_component::OwnableInternalImpl; + #[abi(embed_v0)] + impl PledgeableImpl = pledgeable_component::Pledgeable; + + #[storage] + struct Storage { + canceled: bool, + claimed: bool, + creator: ContractAddress, + description: ByteArray, + end_time: u64, + goal: u256, + #[substorage(v0)] + ownable: ownable_component::Storage, + #[substorage(v0)] + pledges: pledgeable_component::Storage, + start_time: u64, + title: ByteArray, + token: IERC20Dispatcher, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + Claimed: Claimed, + Canceled: Canceled, + #[flat] + OwnableEvent: ownable_component::Event, + PledgeableEvent: pledgeable_component::Event, + PledgeMade: PledgeMade, + Refunded: Refunded, + RefundedAll: RefundedAll, + Unpledged: Unpledged, + Upgraded: Upgraded, + } + + #[derive(Drop, starknet::Event)] + pub struct Canceled { + pub reason: ByteArray, + } + + #[derive(Drop, starknet::Event)] + pub struct Claimed { + pub amount: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct PledgeMade { + #[key] + pub pledger: ContractAddress, + pub amount: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct Refunded { + #[key] + pub pledger: ContractAddress, + pub amount: u256, + pub reason: ByteArray, + } + + #[derive(Drop, starknet::Event)] + pub struct RefundedAll { + pub reason: ByteArray, + } + + #[derive(Drop, starknet::Event)] + pub struct Unpledged { + #[key] + pub pledger: ContractAddress, + pub amount: u256, + pub reason: ByteArray, + } + + #[derive(Drop, starknet::Event)] + pub struct Upgraded { + pub implementation: ClassHash + } + + const NINETY_DAYS: u64 = consteval_int!(90 * 24 * 60 * 60); + + #[constructor] + fn constructor( + ref self: ContractState, + creator: ContractAddress, + title: ByteArray, + description: ByteArray, + goal: u256, + start_time: u64, + end_time: u64, + token_address: ContractAddress, + ) { + assert(creator.is_non_zero(), Errors::CREATOR_ZERO); + assert(title.len() > 0, Errors::TITLE_EMPTY); + assert(goal > 0, Errors::ZERO_GOAL); + assert(start_time >= get_block_timestamp(), Errors::START_TIME_IN_PAST); + assert(end_time >= start_time, Errors::END_BEFORE_START); + assert(end_time <= get_block_timestamp() + NINETY_DAYS, Errors::END_BIGGER_THAN_MAX); + assert(token_address.is_non_zero(), Errors::ZERO_ADDRESS_TOKEN); + + self.creator.write(creator); + self.title.write(title); + self.goal.write(goal); + self.description.write(description); + self.start_time.write(start_time); + self.end_time.write(end_time); + self.token.write(IERC20Dispatcher { contract_address: token_address }); + self.ownable._init(get_caller_address()); + } + + #[abi(embed_v0)] + impl MockUpgrade of ICampaign { + fn cancel(ref self: ContractState, reason: ByteArray) { + self._assert_only_creator(); + assert(!self.canceled.read(), Errors::CANCELED); + assert(!self.claimed.read(), Errors::CLAIMED); + + self.canceled.write(true); + + self._refund_all(reason.clone()); + + self.emit(Event::Canceled(Canceled { reason })); + } + + fn claim(ref self: ContractState) { + self._assert_only_creator(); + assert(self._is_started(), Errors::NOT_STARTED); + assert(self._is_ended(), Errors::STILL_ACTIVE); + assert(self._is_goal_reached(), Errors::GOAL_NOT_REACHED); + assert(!self.claimed.read(), Errors::CLAIMED); + + let this = get_contract_address(); + let token = self.token.read(); + let amount = token.balance_of(this); + assert(amount > 0, Errors::ZERO_PLEDGES); + + self.claimed.write(true); + + // no need to reset the pledges, as the campaign has ended + // and the data can be used as a testament to how much was raised + + let owner = get_caller_address(); + let success = token.transfer(owner, amount); + assert(success, Errors::TRANSFER_FAILED); + + self.emit(Event::Claimed(Claimed { amount })); + } + + fn get_details(self: @ContractState) -> Details { + Details { + creator: self.creator.read(), + title: self.title.read(), + description: self.description.read(), + goal: self.goal.read(), + start_time: self.start_time.read(), + end_time: self.end_time.read(), + claimed: self.claimed.read(), + canceled: self.canceled.read(), + token: self.token.read().contract_address, + total_pledges: self.pledges.get_total(), + } + } + + fn get_pledge(self: @ContractState, pledger: ContractAddress) -> u256 { + self.pledges.get(pledger) + } + + fn get_pledgers(self: @ContractState) -> Array { + self.pledges.array() + } + + fn pledge(ref self: ContractState, amount: u256) { + assert(self._is_started(), Errors::NOT_STARTED); + assert(!self._is_ended(), Errors::ENDED); + assert(!self.canceled.read(), Errors::CANCELED); + assert(amount > 0, Errors::ZERO_DONATION); + + let pledger = get_caller_address(); + let this = get_contract_address(); + let success = self.token.read().transfer_from(pledger, this, amount); + assert(success, Errors::TRANSFER_FAILED); + + self.pledges.add(pledger, amount); + + self.emit(Event::PledgeMade(PledgeMade { pledger, amount })); + } + + fn refund(ref self: ContractState, pledger: ContractAddress, reason: ByteArray) { + self._assert_only_creator(); + assert(self._is_started(), Errors::NOT_STARTED); + assert(!self.claimed.read(), Errors::CLAIMED); + assert(!self.canceled.read(), Errors::CANCELED); + assert(pledger.is_non_zero(), Errors::ZERO_ADDRESS_PLEDGER); + assert(self.pledges.get(pledger) != 0, Errors::NOTHING_TO_REFUND); + + let amount = self._refund(pledger); + + self.emit(Event::Refunded(Refunded { pledger, amount, reason })) + } + + fn unpledge(ref self: ContractState, reason: ByteArray) { + assert(self._is_started(), Errors::NOT_STARTED); + assert(!self._is_goal_reached(), Errors::PLEDGES_LOCKED); + assert(self.pledges.get(get_caller_address()) != 0, Errors::NOTHING_TO_UNPLEDGE); + + let pledger = get_caller_address(); + let amount = self._refund(pledger); + + self.emit(Event::Unpledged(Unpledged { pledger, amount, reason })); + } + + fn upgrade(ref self: ContractState, impl_hash: ClassHash, new_end_time: Option) { + self.ownable._assert_only_owner(); + assert(impl_hash.is_non_zero(), Errors::CLASS_HASH_ZERO); + + // only active campaigns have funds to refund and an end time to update + if self._is_started() { + if let Option::Some(end_time) = new_end_time { + assert(end_time >= get_block_timestamp(), Errors::END_BEFORE_NOW); + assert( + end_time <= get_block_timestamp() + NINETY_DAYS, Errors::END_BIGGER_THAN_MAX + ); + self.end_time.write(end_time); + }; + self._refund_all("contract upgraded"); + } + + starknet::syscalls::replace_class_syscall(impl_hash).unwrap_syscall(); + + self.emit(Event::Upgraded(Upgraded { implementation: impl_hash })); + } + } + + #[generate_trait] + impl MockUpgradeInternalImpl of MockUpgradeInternalTrait { + fn _assert_only_creator(self: @ContractState) { + let caller = get_caller_address(); + assert(caller.is_non_zero(), Errors::ZERO_ADDRESS_CALLER); + assert(caller == self.creator.read(), Errors::NOT_CREATOR); + } + + fn _is_ended(self: @ContractState) -> bool { + get_block_timestamp() >= self.end_time.read() + } + + fn _is_goal_reached(self: @ContractState) -> bool { + self.pledges.get_total() >= self.goal.read() + } + + fn _is_started(self: @ContractState) -> bool { + get_block_timestamp() >= self.start_time.read() + } + + fn _refund(ref self: ContractState, pledger: ContractAddress) -> u256 { + let amount = self.pledges.remove(pledger); + + let success = self.token.read().transfer(pledger, amount); + assert(success, Errors::TRANSFER_FAILED); + + amount + } + + fn _refund_all(ref self: ContractState, reason: ByteArray) { + let mut pledges = self.pledges.array(); + while let Option::Some(pledger) = pledges.pop_front() { + self._refund(pledger); + }; + self.emit(Event::RefundedAll(RefundedAll { reason })); + } + } +} diff --git a/listings/applications/crowdfunding/src/tests.cairo b/listings/applications/crowdfunding/src/tests.cairo new file mode 100644 index 00000000..fde363c2 --- /dev/null +++ b/listings/applications/crowdfunding/src/tests.cairo @@ -0,0 +1,480 @@ +use core::traits::TryInto; +use core::clone::Clone; +use core::result::ResultTrait; +use starknet::{ + ContractAddress, ClassHash, get_block_timestamp, contract_address_const, get_caller_address, +}; +use snforge_std::{ + declare, ContractClass, ContractClassTrait, start_cheat_caller_address, + stop_cheat_caller_address, spy_events, SpyOn, EventSpy, EventAssertions, get_class_hash, + cheat_block_timestamp_global +}; + +use crowdfunding::campaign::{Campaign, ICampaignDispatcher, ICampaignDispatcherTrait}; +use components::ownable::{IOwnableDispatcher, IOwnableDispatcherTrait}; +use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + +/// Deploy a campaign contract with the provided data +fn deploy( + contract: ContractClass, + title: ByteArray, + description: ByteArray, + goal: u256, + start_time: u64, + end_time: u64, + token: ContractAddress +) -> ICampaignDispatcher { + let creator = contract_address_const::<'creator'>(); + let mut calldata: Array:: = array![]; + ((creator, title, description, goal), start_time, end_time, token).serialize(ref calldata); + + let contract_address = contract.precalculate_address(@calldata); + let owner = contract_address_const::<'owner'>(); + start_cheat_caller_address(contract_address, owner); + + contract.deploy(@calldata).unwrap(); + + stop_cheat_caller_address(contract_address); + + ICampaignDispatcher { contract_address } +} + +fn deploy_with_token( + contract: ContractClass, token: ContractClass +) -> (ICampaignDispatcher, IERC20Dispatcher) { + // define ERC20 data + let token_name: ByteArray = "My Token"; + let token_symbol: ByteArray = "MTKN"; + let token_supply: u256 = 100000; + let token_owner = contract_address_const::<'token_owner'>(); + let token_recipient = token_owner; + + // deploy ERC20 token + let mut token_constructor_calldata = array![]; + ((token_name, token_symbol, token_supply, token_recipient), token_owner) + .serialize(ref token_constructor_calldata); + let (token_address, _) = token.deploy(@token_constructor_calldata).unwrap(); + + // transfer amounts to some pledgers + let pledger_1 = contract_address_const::<'pledger_1'>(); + let pledger_2 = contract_address_const::<'pledger_2'>(); + let pledger_3 = contract_address_const::<'pledger_3'>(); + + start_cheat_caller_address(token_address, token_owner); + let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; + token_dispatcher.transfer(pledger_1, 10000); + token_dispatcher.transfer(pledger_2, 10000); + token_dispatcher.transfer(pledger_3, 10000); + + // deploy the actual Campaign contract + let start_time = get_block_timestamp(); + let end_time = start_time + 60; + let campaign_dispatcher = deploy( + contract, "title 1", "description 1", 10000, start_time, end_time, token_address + ); + + // approve the pledges for each pledger + start_cheat_caller_address(token_address, pledger_1); + token_dispatcher.approve(campaign_dispatcher.contract_address, 10000); + start_cheat_caller_address(token_address, pledger_2); + token_dispatcher.approve(campaign_dispatcher.contract_address, 10000); + start_cheat_caller_address(token_address, pledger_3); + token_dispatcher.approve(campaign_dispatcher.contract_address, 10000); + + // NOTE: don't forget to stop the caller address cheat on the ERC20 contract!! + // Otherwise, any call to this contract from any source will have the cheated + // address as the caller + stop_cheat_caller_address(token_address); + + (campaign_dispatcher, token_dispatcher) +} + +#[test] +fn test_deploy() { + let start_time = get_block_timestamp(); + let end_time = start_time + 60; + let contract = declare("Campaign").unwrap(); + let campaign = deploy( + contract, + "title 1", + "description 1", + 10000, + start_time, + end_time, + contract_address_const::<'token'>() + ); + + let details = campaign.get_details(); + assert_eq!(details.title, "title 1"); + assert_eq!(details.description, "description 1"); + assert_eq!(details.goal, 10000); + assert_eq!(details.start_time, start_time); + assert_eq!(details.end_time, end_time); + assert_eq!(details.claimed, false); + assert_eq!(details.canceled, false); + assert_eq!(details.token, contract_address_const::<'token'>()); + assert_eq!(details.total_pledges, 0); + assert_eq!(details.creator, contract_address_const::<'creator'>()); + + let owner: ContractAddress = contract_address_const::<'owner'>(); + let campaign_ownable = IOwnableDispatcher { contract_address: campaign.contract_address }; + assert_eq!(campaign_ownable.owner(), owner); +} + +#[test] +fn test_successful_campaign() { + let token_class = declare("ERC20Upgradeable").unwrap(); + let contract_class = declare("Campaign").unwrap(); + let (campaign, token) = deploy_with_token(contract_class, token_class); + + let creator = contract_address_const::<'creator'>(); + let pledger_1 = contract_address_const::<'pledger_1'>(); + let pledger_2 = contract_address_const::<'pledger_2'>(); + let pledger_3 = contract_address_const::<'pledger_3'>(); + + let mut spy = spy_events(SpyOn::One(campaign.contract_address)); + + // 1st donation + start_cheat_caller_address(campaign.contract_address, pledger_1); + let mut prev_balance = token.balance_of(pledger_1); + campaign.pledge(3000); + assert_eq!(campaign.get_details().total_pledges, 3000); + assert_eq!(campaign.get_pledge(pledger_1), 3000); + assert_eq!(token.balance_of(pledger_1), prev_balance - 3000); + + spy + .assert_emitted( + @array![ + ( + campaign.contract_address, + Campaign::Event::PledgeMade( + Campaign::PledgeMade { pledger: pledger_1, amount: 3000 } + ) + ) + ] + ); + + // 2nd donation + start_cheat_caller_address(campaign.contract_address, pledger_2); + prev_balance = token.balance_of(pledger_2); + campaign.pledge(500); + assert_eq!(campaign.get_details().total_pledges, 3500); + assert_eq!(campaign.get_pledge(pledger_2), 500); + assert_eq!(token.balance_of(pledger_2), prev_balance - 500); + + spy + .assert_emitted( + @array![ + ( + campaign.contract_address, + Campaign::Event::PledgeMade( + Campaign::PledgeMade { pledger: pledger_2, amount: 500 } + ) + ) + ] + ); + + // 3rd donation + start_cheat_caller_address(campaign.contract_address, pledger_3); + prev_balance = token.balance_of(pledger_3); + campaign.pledge(7000); + assert_eq!(campaign.get_details().total_pledges, 10500); + assert_eq!(campaign.get_pledge(pledger_3), 7000); + assert_eq!(token.balance_of(pledger_3), prev_balance - 7000); + + spy + .assert_emitted( + @array![ + ( + campaign.contract_address, + Campaign::Event::PledgeMade( + Campaign::PledgeMade { pledger: pledger_3, amount: 7000 } + ) + ) + ] + ); + + // claim + cheat_block_timestamp_global(campaign.get_details().end_time); + start_cheat_caller_address(campaign.contract_address, creator); + prev_balance = token.balance_of(creator); + campaign.claim(); + assert_eq!(token.balance_of(creator), prev_balance + 10500); + assert!(campaign.get_details().claimed); + + spy + .assert_emitted( + @array![ + ( + campaign.contract_address, + Campaign::Event::Claimed(Campaign::Claimed { amount: 10500 }) + ) + ] + ); +} + +#[test] +fn test_upgrade_class_hash() { + let new_class_hash = declare("MockUpgrade").unwrap().class_hash; + let owner = contract_address_const::<'owner'>(); + + // test pending campaign + let contract_class = declare("Campaign").unwrap(); + let token_class = declare("ERC20Upgradeable").unwrap(); + let (campaign, _) = deploy_with_token(contract_class, token_class); + let mut spy = spy_events(SpyOn::One(campaign.contract_address)); + + start_cheat_caller_address(campaign.contract_address, owner); + campaign.upgrade(new_class_hash, Option::None); + stop_cheat_caller_address(campaign.contract_address); + + assert_eq!(get_class_hash(campaign.contract_address), new_class_hash); + + spy + .assert_emitted( + @array![ + ( + campaign.contract_address, + Campaign::Event::Upgraded(Campaign::Upgraded { implementation: new_class_hash }) + ) + ] + ); + + // test active campaign + let (campaign, token) = deploy_with_token(contract_class, token_class); + let mut spy = spy_events(SpyOn::One(campaign.contract_address)); + let duration: u64 = 60; + let pledger_1 = contract_address_const::<'pledger_1'>(); + let pledger_2 = contract_address_const::<'pledger_2'>(); + let pledger_3 = contract_address_const::<'pledger_3'>(); + let prev_balance_pledger_1 = token.balance_of(pledger_1); + let prev_balance_pledger_2 = token.balance_of(pledger_2); + let prev_balance_pledger_3 = token.balance_of(pledger_3); + + start_cheat_caller_address(campaign.contract_address, pledger_1); + campaign.pledge(3000); + start_cheat_caller_address(campaign.contract_address, pledger_2); + campaign.pledge(1000); + start_cheat_caller_address(campaign.contract_address, pledger_3); + campaign.pledge(2000); + + start_cheat_caller_address(campaign.contract_address, owner); + campaign.upgrade(new_class_hash, Option::Some(duration)); + stop_cheat_caller_address(campaign.contract_address); + + assert_eq!(prev_balance_pledger_1, token.balance_of(pledger_1)); + assert_eq!(prev_balance_pledger_2, token.balance_of(pledger_2)); + assert_eq!(prev_balance_pledger_3, token.balance_of(pledger_3)); + assert_eq!(campaign.get_details().total_pledges, 0); + assert_eq!(campaign.get_details().end_time, get_block_timestamp() + duration); + + spy + .assert_emitted( + @array![ + ( + campaign.contract_address, + Campaign::Event::Upgraded(Campaign::Upgraded { implementation: new_class_hash }) + ), + ( + campaign.contract_address, + Campaign::Event::RefundedAll( + Campaign::RefundedAll { reason: "contract upgraded" } + ) + ) + ] + ); +} + +#[test] +fn test_cancel() { + let contract_class = declare("Campaign").unwrap(); + let token_class = declare("ERC20Upgradeable").unwrap(); + + // test canceled campaign + let (campaign, token) = deploy_with_token(contract_class, token_class); + let mut spy = spy_events(SpyOn::One(campaign.contract_address)); + let creator = contract_address_const::<'creator'>(); + let pledger_1 = contract_address_const::<'pledger_1'>(); + let pledger_2 = contract_address_const::<'pledger_2'>(); + let pledger_3 = contract_address_const::<'pledger_3'>(); + let pledge_1: u256 = 3000; + let pledge_2: u256 = 3000; + let pledge_3: u256 = 3000; + let prev_balance_pledger_1 = token.balance_of(pledger_1); + let prev_balance_pledger_2 = token.balance_of(pledger_2); + let prev_balance_pledger_3 = token.balance_of(pledger_3); + + start_cheat_caller_address(campaign.contract_address, pledger_1); + campaign.pledge(pledge_1); + start_cheat_caller_address(campaign.contract_address, pledger_2); + campaign.pledge(pledge_2); + start_cheat_caller_address(campaign.contract_address, pledger_3); + campaign.pledge(pledge_3); + assert_eq!(campaign.get_details().total_pledges, pledge_1 + pledge_2 + pledge_3); + assert_eq!(token.balance_of(pledger_1), prev_balance_pledger_1 - pledge_1); + assert_eq!(token.balance_of(pledger_2), prev_balance_pledger_2 - pledge_2); + assert_eq!(token.balance_of(pledger_3), prev_balance_pledger_3 - pledge_3); + + start_cheat_caller_address(campaign.contract_address, creator); + campaign.cancel("testing"); + stop_cheat_caller_address(campaign.contract_address); + + assert_eq!(prev_balance_pledger_1, token.balance_of(pledger_1)); + assert_eq!(prev_balance_pledger_2, token.balance_of(pledger_2)); + assert_eq!(prev_balance_pledger_3, token.balance_of(pledger_3)); + assert_eq!(campaign.get_details().total_pledges, 0); + assert!(campaign.get_details().canceled); + + spy + .assert_emitted( + @array![ + ( + campaign.contract_address, + Campaign::Event::RefundedAll(Campaign::RefundedAll { reason: "testing" }) + ), + ( + campaign.contract_address, + Campaign::Event::Canceled(Campaign::Canceled { reason: "testing" }) + ) + ] + ); + + // test failed campaign + let (campaign, token) = deploy_with_token(contract_class, token_class); + let mut spy = spy_events(SpyOn::One(campaign.contract_address)); + let creator = contract_address_const::<'creator'>(); + let pledger_1 = contract_address_const::<'pledger_1'>(); + let pledger_2 = contract_address_const::<'pledger_2'>(); + let pledger_3 = contract_address_const::<'pledger_3'>(); + let pledge_1: u256 = 3000; + let pledge_2: u256 = 3000; + let pledge_3: u256 = 3000; + let prev_balance_pledger_1 = token.balance_of(pledger_1); + let prev_balance_pledger_2 = token.balance_of(pledger_2); + let prev_balance_pledger_3 = token.balance_of(pledger_3); + + start_cheat_caller_address(campaign.contract_address, pledger_1); + campaign.pledge(pledge_1); + start_cheat_caller_address(campaign.contract_address, pledger_2); + campaign.pledge(pledge_2); + start_cheat_caller_address(campaign.contract_address, pledger_3); + campaign.pledge(pledge_3); + assert_eq!(campaign.get_details().total_pledges, pledge_1 + pledge_2 + pledge_3); + assert_eq!(token.balance_of(pledger_1), prev_balance_pledger_1 - pledge_1); + assert_eq!(token.balance_of(pledger_2), prev_balance_pledger_2 - pledge_2); + assert_eq!(token.balance_of(pledger_3), prev_balance_pledger_3 - pledge_3); + + cheat_block_timestamp_global(campaign.get_details().end_time); + + start_cheat_caller_address(campaign.contract_address, creator); + campaign.cancel("testing"); + stop_cheat_caller_address(campaign.contract_address); + + assert_eq!(prev_balance_pledger_1, token.balance_of(pledger_1)); + assert_eq!(prev_balance_pledger_2, token.balance_of(pledger_2)); + assert_eq!(prev_balance_pledger_3, token.balance_of(pledger_3)); + assert_eq!(campaign.get_details().total_pledges, 0); + assert!(campaign.get_details().canceled); + + spy + .assert_emitted( + @array![ + ( + campaign.contract_address, + Campaign::Event::RefundedAll(Campaign::RefundedAll { reason: "testing" }) + ), + ( + campaign.contract_address, + Campaign::Event::Canceled(Campaign::Canceled { reason: "testing" }) + ) + ] + ); +} + +#[test] +fn test_refund() { + // setup + let (campaign, token) = deploy_with_token( + declare("Campaign").unwrap(), declare("ERC20Upgradeable").unwrap() + ); + let mut spy = spy_events(SpyOn::One(campaign.contract_address)); + let creator = contract_address_const::<'creator'>(); + let pledger_1 = contract_address_const::<'pledger_1'>(); + let pledger_2 = contract_address_const::<'pledger_2'>(); + let amount_1: u256 = 3000; + let amount_2: u256 = 1500; + let prev_balance_1 = token.balance_of(pledger_1); + let prev_balance_2 = token.balance_of(pledger_2); + + // donate + start_cheat_caller_address(campaign.contract_address, pledger_1); + campaign.pledge(amount_1); + assert_eq!(campaign.get_details().total_pledges, amount_1); + assert_eq!(campaign.get_pledge(pledger_1), amount_1); + assert_eq!(token.balance_of(pledger_1), prev_balance_1 - amount_1); + + start_cheat_caller_address(campaign.contract_address, pledger_2); + campaign.pledge(amount_2); + assert_eq!(campaign.get_details().total_pledges, amount_1 + amount_2); + assert_eq!(campaign.get_pledge(pledger_2), amount_2); + assert_eq!(token.balance_of(pledger_2), prev_balance_2 - amount_2); + + // refund + start_cheat_caller_address(campaign.contract_address, creator); + campaign.refund(pledger_1, "testing"); + assert_eq!(campaign.get_details().total_pledges, amount_2); + assert_eq!(campaign.get_pledge(pledger_2), amount_2); + assert_eq!(token.balance_of(pledger_2), prev_balance_2 - amount_2); + assert_eq!(token.balance_of(pledger_1), prev_balance_1); + + spy + .assert_emitted( + @array![ + ( + campaign.contract_address, + Campaign::Event::Refunded( + Campaign::Refunded { + pledger: pledger_1, amount: amount_1, reason: "testing" + } + ) + ) + ] + ); +} + +#[test] +fn test_unpledge() { + // setup + let (campaign, token) = deploy_with_token( + declare("Campaign").unwrap(), declare("ERC20Upgradeable").unwrap() + ); + let mut spy = spy_events(SpyOn::One(campaign.contract_address)); + let pledger = contract_address_const::<'pledger_1'>(); + let amount: u256 = 3000; + let prev_balance = token.balance_of(pledger); + + // donate + start_cheat_caller_address(campaign.contract_address, pledger); + campaign.pledge(amount); + assert_eq!(campaign.get_details().total_pledges, amount); + assert_eq!(campaign.get_pledge(pledger), amount); + assert_eq!(token.balance_of(pledger), prev_balance - amount); + + // unpledge + campaign.unpledge("testing"); + assert_eq!(campaign.get_details().total_pledges, 0); + assert_eq!(campaign.get_pledge(pledger), 0); + assert_eq!(token.balance_of(pledger), prev_balance); + + spy + .assert_emitted( + @array![ + ( + campaign.contract_address, + Campaign::Event::Unpledged( + Campaign::Unpledged { pledger, amount, reason: "testing" } + ) + ) + ] + ); +} diff --git a/src/SUMMARY.md b/src/SUMMARY.md index d642f9be..07551165 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -60,6 +60,8 @@ Summary - [TimeLock](./applications/timelock.md) - [Staking](./applications/staking.md) - [Simple Storage with Starknet-js](./applications/simple_storage_starknetjs.md) +- [Crowdfunding Campaign](./applications/crowdfunding.md) +- [AdvancedFactory: Crowdfunding](./applications/advanced_factory.md) diff --git a/src/applications/advanced_factory.md b/src/applications/advanced_factory.md new file mode 100644 index 00000000..5d2a27fd --- /dev/null +++ b/src/applications/advanced_factory.md @@ -0,0 +1,13 @@ +# AdvancedFactory: Crowdfunding + +This is an example of an advanced factory contract that manages crowdfunding Campaign contracts created in the ["Crowdfunding" chapter](./crowdfunding.md). The advanced factory allows for a centralized creation and management of `Campaign` contracts on the Starknet blockchain, ensuring that they adhere to a standard interface and can be easily upgraded. + +Key Features +1. **Campaign Creation**: Users can create new crowdfunding campaigns with specific details such as title, description, goal, and duration. +2. **Campaign Management**: The factory contract stores and manages the campaigns, allowing for upgrades and tracking. +3. **Upgrade Mechanism**: The factory owner can update the implementation of the campaign contract, ensuring that all campaigns benefit from improvements and bug fixes. + - the factory only updates it's `Campaign` class hash and emits an event to notify any listeners, but the `Campaign` creators are in the end responsible for actually upgrading their contracts. + +```rust +{{#include ../../listings/applications/advanced_factory/src/contract.cairo:contract}} +``` diff --git a/src/applications/crowdfunding.md b/src/applications/crowdfunding.md new file mode 100644 index 00000000..8d78f05a --- /dev/null +++ b/src/applications/crowdfunding.md @@ -0,0 +1,26 @@ +# Crowdfunding Campaign + +Crowdfunding is a method of raising capital through the collective effort of many individuals. It allows project creators to raise funds from a large number of people, usually through small contributions. + +1. Contract admin creates a campaign in some user's name (i.e. creator). +2. Users can pledge, transferring their token to a campaign. +3. Users can "unpledge", retrieving their tokens. +4. The creator can at any point refund any of the users. +5. Once the total amount pledged is more than the campaign goal, the campaign funds are "locked" in the contract, meaning the users can no longer unpledge; they can still pledge though. +6. After the campaign ends, the campaign creator can claim the funds if the campaign goal is reached. +7. Otherwise, campaign did not reach it's goal, pledgers can retrieve their funds. +8. The creator can at any point cancel the campaign for whatever reason and refund all of the pledgers. +9. The contract admin can upgrade the contract implementation, refunding all of the users and reseting the campaign state (we will use this in the [Advanced Factory chapter](./advanced_factory.md)). + +Because contract upgrades need to be able to refund all of the pledges, we need to be able to iterate over all of the pledgers and their amounts. Since iteration is not supported by `LegacyMap`, we need to create a custom storage type that will encompass pledge management. We use a component for this purpose. + +```rust +{{#include ../../listings/applications/crowdfunding/src/campaign/pledgeable.cairo:component}} +``` + +Now we can create the `Campaign` contract. + + +```rust +{{#include ../../listings/applications/crowdfunding/src/campaign.cairo:contract}} +```