diff --git a/src/contract.cairo b/src/contract.cairo index 5be3afee..1d836386 100644 --- a/src/contract.cairo +++ b/src/contract.cairo @@ -35,6 +35,7 @@ mod Governance { use konoha::upgrades::upgrades as upgrades_component; use konoha::airdrop::airdrop as airdrop_component; use konoha::vesting::vesting as vesting_component; + use konoha::discussion::discussion as discussion_component; use starknet::ContractAddress; @@ -43,6 +44,7 @@ mod Governance { component!(path: vesting_component, storage: vesting, event: VestingEvent); component!(path: proposals_component, storage: proposals, event: ProposalsEvent); component!(path: upgrades_component, storage: upgrades, event: UpgradesEvent); + component!(path: discussion_component, storage: discussions, event: DiscussionEvent); #[abi(embed_v0)] impl Airdrop = airdrop_component::AirdropImpl; @@ -55,6 +57,9 @@ mod Governance { #[abi(embed_v0)] impl Upgrades = upgrades_component::UpgradesImpl; + #[abi(embed_v0)] + impl Discussions = discussion_component::DiscussionImpl; + #[storage] struct Storage { proposal_initializer_run: LegacyMap::, @@ -66,7 +71,9 @@ mod Governance { #[substorage(v0)] proposals: proposals_component::Storage, #[substorage(v0)] - upgrades: upgrades_component::Storage + upgrades: upgrades_component::Storage, + #[substorage(v0)] + discussions: discussion_component::Storage } // PROPOSALS @@ -93,7 +100,8 @@ mod Governance { AirdropEvent: airdrop_component::Event, VestingEvent: vesting_component::Event, ProposalsEvent: proposals_component::Event, - UpgradesEvent: upgrades_component::Event + UpgradesEvent: upgrades_component::Event, + DiscussionEvent: discussion_component::Event, } #[constructor] diff --git a/src/discussion.cairo b/src/discussion.cairo new file mode 100644 index 00000000..ce592a52 --- /dev/null +++ b/src/discussion.cairo @@ -0,0 +1,125 @@ +use starknet::ContractAddress; +use konoha::types::Comment; + +#[starknet::interface] +trait IDiscussion { + fn add_comment(ref self: TContractState, prop_id: u32, ipfs_hash: ByteArray); + fn get_comments(self: @TContractState, prop_id: u32) -> Array; +} + +#[starknet::component] +mod discussion { + use array::ArrayTrait; + use core::box::Box; + use core::serde::Serde; + + use starknet::get_caller_address; + use starknet::ContractAddress; + + use konoha::proposals::proposals as proposals_component; + use konoha::proposals::proposals::ProposalsImpl; + use konoha::traits::IERC20Dispatcher; + use konoha::traits::IERC20DispatcherTrait; + use konoha::traits::get_governance_token_address_self; + use konoha::types::Comment; + + #[storage] + struct Storage { + // mapping of (proposal id, index) to comment + comments: LegacyMap::<(u32, u64), Comment>, + // mapping of proposal id to number of comments + comment_count: LegacyMap:: + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event {} + + #[embeddable_as(DiscussionImpl)] + impl Discussions< + TContractState, + +HasComponent, + +Drop, + impl Proposals: proposals_component::HasComponent, + > of super::IDiscussion> { + fn add_comment( + ref self: ComponentState, prop_id: u32, ipfs_hash: ByteArray + ) { + //Check if proposal is live + let is_live = self.is_proposal_live(prop_id); + assert(is_live, 'Proposal is not live!'); + + //Check if caller is a governance token holder + let user_address = get_caller_address(); + let govtoken_addr = get_governance_token_address_self(); + let caller_balance: u256 = IERC20Dispatcher { contract_address: govtoken_addr } + .balance_of(user_address); + assert(caller_balance.high != 0 || caller_balance.low != 0, 'Govtoken balance is zero'); + + //get current comment count + let count: u64 = self.comment_count.read(prop_id); + + //store new comment/ipfs_hash at next index + let new_comment = Comment { user: user_address, ipfs_hash: ipfs_hash }; + self.comments.write((prop_id, count), new_comment); + + //Increment comment count for proposal by one + self.comment_count.write(prop_id, count + 1); + } + + fn get_comments(self: @ComponentState, prop_id: u32) -> Array { + //Get comment counts + let count: u64 = self.comment_count.read(prop_id); + + //Initialize an array of comments + let mut arr = ArrayTrait::::new(); + + // loop over comment count and collect comments + let mut i: u64 = 0; + loop { + if i >= count { + break; + } + + arr.append(self.comments.read((prop_id, i))); + i += 1; + }; + + // return array of comments + arr + } + } + + #[generate_trait] + impl InternalImpl< + TContractState, + +HasComponent, + +Drop, + impl Proposals: proposals_component::HasComponent, + > of InternalTrait { + fn is_proposal_live(ref self: ComponentState, prop_id: u32) -> bool { + let proposals_comp = get_dep_component!(@self, Proposals); + + let live_proposals = proposals_comp.get_live_proposals(); + + let mut is_live = false; + + //loop over the array to check if prop_id is in the array + let mut i = 0; + loop { + if i >= live_proposals.len() { + break; + } + + match live_proposals.get(i) { + Option::Some(_prop_id) => { + is_live = true; + break; + }, + Option::None => i += 1 + } + }; + is_live + } + } +} diff --git a/src/lib.cairo b/src/lib.cairo index ae86eda8..ca208c28 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -7,6 +7,7 @@ mod treasury_types { } mod constants; mod contract; +mod discussion; mod merkle_tree; mod proposals; mod token; diff --git a/src/traits.cairo b/src/traits.cairo index 4d3798ff..f92359dc 100644 --- a/src/traits.cairo +++ b/src/traits.cairo @@ -30,6 +30,7 @@ trait IERC20 { fn decimals(self: @TContractState) -> u8; fn totalSupply(self: @TContractState) -> u256; fn balanceOf(self: @TContractState, account: ContractAddress) -> u256; + fn balance_of(self: @TContractState, account: ContractAddress) -> u256; fn allowance(self: @TContractState, owner: ContractAddress, spender: ContractAddress) -> u256; fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; fn transferFrom( diff --git a/src/types.cairo b/src/types.cairo index a4e28bdb..41b79151 100644 --- a/src/types.cairo +++ b/src/types.cairo @@ -1,6 +1,7 @@ use starknet::SyscallResult; use starknet::syscalls::{storage_read_syscall, storage_write_syscall, ClassHash}; use starknet::storage_address_from_base_and_offset; +use starknet::ContractAddress; use core::serde::Serde; #[derive(Copy, Drop, Serde, starknet::Store)] @@ -25,3 +26,9 @@ struct CustomProposalConfig { selector: felt252, library_call: bool } + +#[derive(Drop, Serde, starknet::Store)] +struct Comment { + user: ContractAddress, + ipfs_hash: ByteArray, +} diff --git a/tests/lib.cairo b/tests/lib.cairo index 9662d4ff..95128e20 100644 --- a/tests/lib.cairo +++ b/tests/lib.cairo @@ -1,6 +1,6 @@ +mod airdrop_tests; mod basic; -mod test_treasury; mod proposals_tests; -mod airdrop_tests; -mod upgrades_tests; mod setup; +mod test_treasury; +mod upgrades_tests; diff --git a/tests/proposals_tests.cairo b/tests/proposals_tests.cairo index 1c6c7e35..17804290 100644 --- a/tests/proposals_tests.cairo +++ b/tests/proposals_tests.cairo @@ -1,5 +1,6 @@ use array::ArrayTrait; use core::traits::TryInto; +use core::traits::Into; use debug::PrintTrait; use starknet::ContractAddress; use openzeppelin::token::erc20::interface::{ @@ -22,6 +23,9 @@ use konoha::proposals::IProposalsDispatcherTrait; use konoha::upgrades::IUpgradesDispatcher; use konoha::upgrades::IUpgradesDispatcherTrait; use konoha::constants; +use konoha::discussion::IDiscussionDispatcher; +use konoha::discussion::IDiscussionDispatcherTrait; + use starknet::get_block_timestamp; @@ -418,3 +422,142 @@ fn test_successful_proposal_submission() { assert!(prop_details_1.payload == 42, "wrong payload first proposal"); assert!(prop_details_2.payload == 43, "wrong payload second proposal"); } + + +#[test] +#[should_panic(expected: ('Proposal is not live!',))] +fn test_add_comment_on_non_live_proposal() { + let token_contract = deploy_and_distribute_gov_tokens(admin_addr.try_into().unwrap()); + let gov_contract = deploy_governance(token_contract.contract_address); + let gov_contract_addr = gov_contract.contract_address; + let ipfs_hash: ByteArray = "QmTFMPrNQiJ6o5dfyMn4PPjbQhDrJ6Mu93qe2yMvgnJYM6"; + + let dispatcher = IProposalsDispatcher { contract_address: gov_contract_addr }; + + prank( + CheatTarget::One(gov_contract_addr), + admin_addr.try_into().unwrap(), + CheatSpan::TargetCalls(3) + ); + let prop_id = dispatcher.submit_proposal(42, 1); + + //simulate passage of time + let current_timestamp = get_block_timestamp(); + let end_timestamp = current_timestamp + constants::PROPOSAL_VOTING_SECONDS; + start_warp(CheatTarget::One(gov_contract_addr), end_timestamp + 1); + + IDiscussionDispatcher { contract_address: gov_contract_addr } + .add_comment(prop_id.try_into().unwrap(), ipfs_hash); +} + +#[test] +#[should_panic(expected: ('Govtoken balance is zero',))] +fn test_add_comment_when_token_balance_is_zero() { + let token_contract = deploy_and_distribute_gov_tokens(admin_addr.try_into().unwrap()); + let gov_contract = deploy_governance(token_contract.contract_address); + let gov_contract_addr = gov_contract.contract_address; + let ipfs_hash: ByteArray = "QmTFMPrNQiJ6o5dfyMn4PPjbQhDrJ6Mu93qe2yMvgnJYM6"; + + let dispatcher = IProposalsDispatcher { contract_address: gov_contract_addr }; + + prank( + CheatTarget::One(gov_contract_addr), + admin_addr.try_into().unwrap(), + CheatSpan::TargetCalls(1) + ); + let prop_id = dispatcher.submit_proposal(42, 1); + + prank( + CheatTarget::One(gov_contract_addr), + first_address.try_into().unwrap(), + CheatSpan::TargetCalls(1) + ); + + IDiscussionDispatcher { contract_address: gov_contract_addr } + .add_comment(prop_id.try_into().unwrap(), ipfs_hash); +} + +#[test] +fn test_add_comment() { + let token_contract = deploy_and_distribute_gov_tokens(admin_addr.try_into().unwrap()); + let gov_contract = deploy_governance(token_contract.contract_address); + let gov_contract_addr = gov_contract.contract_address; + let ipfs_hash: ByteArray = "QmTFMPrNQiJ6o5dfyMn4PPjbQhDrJ6Mu93qe2yMvgnJYM6"; + + let dispatcher = IProposalsDispatcher { contract_address: gov_contract_addr }; + + prank( + CheatTarget::One(gov_contract_addr), + admin_addr.try_into().unwrap(), + CheatSpan::TargetCalls(1) + ); + let prop_id = dispatcher.submit_proposal(42, 1); + + prank( + CheatTarget::One(token_contract.contract_address), + admin_addr.try_into().unwrap(), + CheatSpan::TargetCalls(1) + ); + token_contract.transfer(first_address.try_into().unwrap(), 100000.try_into().unwrap()); + + prank( + CheatTarget::One(gov_contract_addr), + first_address.try_into().unwrap(), + CheatSpan::TargetCalls(1) + ); + + IDiscussionDispatcher { contract_address: gov_contract_addr } + .add_comment(prop_id.try_into().unwrap(), ipfs_hash); +} + +#[test] +fn test_get_comments() { + let token_contract = deploy_and_distribute_gov_tokens(admin_addr.try_into().unwrap()); + let gov_contract = deploy_governance(token_contract.contract_address); + let gov_contract_addr = gov_contract.contract_address; + let ipfs_hash_1: ByteArray = "QmTFMPrNQiJ6o5dfyMn4PPjbQhDrJ6Mu93qe2yMvgnJYM6"; + let ipfs_hash_2: ByteArray = "Uinienu2G54J6o5dfyMn4PPjbQhDrJ6Mu93qbhwjni2ijnf"; + let ipfs_hash_3: ByteArray = "MPrNQiJbdik6o5dfyMn4Pjnislnenoen7hHSU8Ii82jdB56"; + + let dispatcher = IProposalsDispatcher { contract_address: gov_contract_addr }; + + prank( + CheatTarget::One(gov_contract_addr), + admin_addr.try_into().unwrap(), + CheatSpan::TargetCalls(1) + ); + let prop_id = dispatcher.submit_proposal(42, 1); + + prank( + CheatTarget::One(token_contract.contract_address), + admin_addr.try_into().unwrap(), + CheatSpan::TargetCalls(1) + ); + token_contract.transfer(first_address.try_into().unwrap(), 100000.try_into().unwrap()); + + prank( + CheatTarget::One(gov_contract_addr), + first_address.try_into().unwrap(), + CheatSpan::TargetCalls(7) + ); + + let discussion_dispatcher = IDiscussionDispatcher { contract_address: gov_contract_addr }; + + discussion_dispatcher.add_comment(prop_id.try_into().unwrap(), ipfs_hash_1.clone()); + discussion_dispatcher.add_comment(prop_id.try_into().unwrap(), ipfs_hash_2.clone()); + discussion_dispatcher.add_comment(prop_id.try_into().unwrap(), ipfs_hash_3.clone()); + + let res = discussion_dispatcher.get_comments(prop_id.try_into().unwrap()); + + let res_span = res.span(); + + assert_eq!(*res_span.at(0).user, first_address.try_into().unwrap()); + assert_eq!(res_span.at(0).ipfs_hash.clone(), ipfs_hash_1); + + assert_eq!(*res_span.at(1).user, first_address.try_into().unwrap()); + assert_eq!(res_span.at(1).ipfs_hash.clone(), ipfs_hash_2); + + assert_eq!(*res_span.at(2).user, first_address.try_into().unwrap()); + assert_eq!(res_span.at(2).ipfs_hash.clone(), ipfs_hash_3); +} +