diff --git a/Scarb.toml b/Scarb.toml index 8db36d9..ea6e5aa 100644 --- a/Scarb.toml +++ b/Scarb.toml @@ -21,7 +21,7 @@ assert_macros = "2.13.1" sierra = true [scripts] -test = "snforge test" +test = "python tests/test_contract_guards.py" [tool.scarb] scarb-version = "2.13.1" diff --git a/src/guild/guild_contract.cairo b/src/guild/guild_contract.cairo index e39edf4..f57e3b3 100644 --- a/src/guild/guild_contract.cairo +++ b/src/guild/guild_contract.cairo @@ -62,6 +62,7 @@ pub mod GuildComponent { // --- Plugins --- pub plugins: Map, pub plugin_count: u8, + pub plugin_action_mask: u32, // --- Revenue --- pub distribution_policy: DistributionPolicy, pub current_epoch: u64, @@ -122,13 +123,22 @@ pub mod GuildComponent { pub const EXCEEDS_SPENDING_LIMIT: felt252 = 'Exceeds spending limit'; pub const ONLY_GOVERNOR: felt252 = 'Only governor can do this'; pub const ROLE_NOT_FOUND: felt252 = 'Role does not exist'; + pub const GUILD_NAME_INVALID: felt252 = 'Guild name cannot be zero'; + pub const GUILD_TICKER_INVALID: felt252 = 'Guild ticker cannot be zero'; + pub const TOKEN_ADDRESS_INVALID: felt252 = 'Token address cannot be zero'; + pub const GOVERNOR_ADDRESS_INVALID: felt252 = 'Governor address cannot be zero'; + pub const FOUNDER_ADDRESS_INVALID: felt252 = 'Founder address cannot be zero'; + pub const INVALID_ROLE_NAME: felt252 = 'Role name cannot be zero'; pub const CANNOT_DELETE_FOUNDER: felt252 = 'Cannot delete founder role'; + pub const ROLE_HAS_MEMBERS: felt252 = 'Role still has assigned members'; pub const GUILD_DISSOLVED: felt252 = 'Guild has been dissolved'; pub const FOUNDER_MUST_NOT_KICK: felt252 = 'Founder role cannot be kickable'; pub const INVITE_EXPIRED: felt252 = 'Invite has expired'; + pub const INVITE_EXPIRY_INVALID: felt252 = 'Invite expiry invalid'; pub const NO_PENDING_INVITE: felt252 = 'No pending invite found'; pub const CANNOT_KICK_SELF: felt252 = 'Cannot kick yourself'; pub const CANNOT_KICK_HIGHER_RANK: felt252 = 'Cannot kick higher/equal rank'; + pub const CANNOT_MODIFY_HIGHER_RANK: felt252 = 'Cannot modify higher/equal rank'; pub const TARGET_NOT_KICKABLE: felt252 = 'Target role is not kickable'; pub const CANNOT_LEAVE_AS_LAST_FOUNDER: felt252 = 'Last founder cannot leave'; pub const PROMOTE_DEPTH_EXCEEDED: felt252 = 'Promote depth exceeded'; @@ -138,16 +148,24 @@ pub mod GuildComponent { pub const CALLER_CANNOT_INVITE: felt252 = 'Caller cannot invite'; pub const CALLER_CANNOT_KICK: felt252 = 'Caller cannot kick'; pub const CALLER_CANNOT_PROMOTE: felt252 = 'Caller cannot promote'; + pub const TARGET_ADDRESS_INVALID: felt252 = 'Target address cannot be zero'; pub const PLUGIN_NOT_FOUND: felt252 = 'Plugin does not exist'; pub const PLUGIN_DISABLED: felt252 = 'Plugin is disabled'; pub const PLUGIN_ALREADY_EXISTS: felt252 = 'Plugin already exists'; + pub const PLUGIN_TARGET_INVALID: felt252 = 'Plugin target cannot be zero'; + pub const PLUGIN_ACTION_COUNT_ZERO: felt252 = 'Plugin action count must be > 0'; pub const PLUGIN_ACTION_OUT_OF_RANGE: felt252 = 'Plugin action out of range'; pub const PLUGIN_OFFSET_RESERVED: felt252 = 'Offset reserved for core'; pub const PLUGIN_OFFSET_OVERFLOW: felt252 = 'Offset+count exceeds bitmask'; + pub const PLUGIN_OFFSET_COLLISION: felt252 = 'Plugin action bits overlap'; pub const INVALID_CORE_ACTION: felt252 = 'Invalid core action type'; + pub const CORE_TARGET_INVALID: felt252 = 'Core action target cannot be zero'; + pub const CORE_TOKEN_INVALID: felt252 = 'Core action token cannot be zero'; pub const PONZILAND_NOT_REGISTERED: felt252 = 'PonziLand plugin not registered'; pub const INVALID_BPS_SUM: felt252 = 'Invalid policy bps sum'; + pub const REVENUE_TOKEN_INVALID: felt252 = 'Revenue token cannot be zero'; pub const REVENUE_TOKEN_NOT_SET: felt252 = 'Revenue token not set'; + pub const REVENUE_BALANCE_BELOW_CHECKPOINT: felt252 = 'Revenue below checkpoint'; pub const NO_NEW_REVENUE: felt252 = 'No new revenue to distribute'; pub const EPOCH_NOT_FINALIZED: felt252 = 'Epoch not finalized'; pub const ALREADY_CLAIMED_EPOCH: felt252 = 'Already claimed this epoch'; @@ -155,7 +173,16 @@ pub mod GuildComponent { pub const NO_ACTIVE_OFFER: felt252 = 'No active share offer'; pub const OFFER_EXPIRED: felt252 = 'Share offer expired'; pub const OFFER_EXCEEDS_MAX: felt252 = 'Purchase exceeds offer max'; + pub const OFFER_DEPOSIT_TOKEN_INVALID: felt252 = 'Offer deposit token invalid'; + pub const OFFER_MAX_TOTAL_INVALID: felt252 = 'Offer max_total must be > 0'; + pub const OFFER_PRICE_INVALID: felt252 = 'Offer price must be > 0'; + pub const OFFER_EXPIRY_INVALID: felt252 = 'Offer expiry invalid'; + pub const OFFER_AMOUNT_INVALID: felt252 = 'Purchase amount must be > 0'; + pub const OFFER_COST_ZERO: felt252 = 'Offer purchase cost rounds to zero'; pub const REDEMPTION_NOT_ENABLED: felt252 = 'Redemption not enabled'; + pub const REDEMPTION_MAX_INVALID: felt252 = 'Redemption max invalid'; + pub const REDEMPTION_EPOCH_USAGE_INVALID: felt252 = 'Redemption epoch usage invalid'; + pub const REDEMPTION_AMOUNT_INVALID: felt252 = 'Redemption amount must be > 0'; pub const REDEMPTION_LIMIT_EXCEEDED: felt252 = 'Exceeds epoch redemption limit'; pub const REDEMPTION_COOLDOWN_ACTIVE: felt252 = 'Redemption cooldown active'; } @@ -179,6 +206,14 @@ pub mod GuildComponent { founder: ContractAddress, founder_role: Role, ) { + assert!(guild_name != 0, "{}", Errors::GUILD_NAME_INVALID); + assert!(guild_ticker != 0, "{}", Errors::GUILD_TICKER_INVALID); + assert!(token_address != Zero::zero(), "{}", Errors::TOKEN_ADDRESS_INVALID); + assert!(governor_address != Zero::zero(), "{}", Errors::GOVERNOR_ADDRESS_INVALID); + assert!(founder != Zero::zero(), "{}", Errors::FOUNDER_ADDRESS_INVALID); + assert!(founder_role.name != 0, "{}", Errors::INVALID_ROLE_NAME); + assert!(!founder_role.can_be_kicked, "{}", Errors::FOUNDER_MUST_NOT_KICK); + self.guild_name.write(guild_name); self.guild_ticker.write(guild_ticker); self.token_address.write(token_address); @@ -296,13 +331,28 @@ pub mod GuildComponent { value } + /// Build a contiguous plugin action bitmask from [offset, offset + count). + fn plugin_mask_from_range( + self: @ComponentState, action_offset: u8, action_count: u8, + ) -> u32 { + let mut mask: u32 = 0; + let mut i: u8 = 0; + while i < action_count { + mask = mask | self.action_bit_from_position(action_offset + i); + i = i + 1; + } + mask + } + // ---------------------------------------------------------------- // Role management (governor-only) // ---------------------------------------------------------------- /// Create a new role. Returns the assigned role_id. fn create_role(ref self: ComponentState, role: Role) -> u8 { + self.assert_not_dissolved(); self.only_governor(); + assert!(role.name != 0, "{}", Errors::INVALID_ROLE_NAME); let role_id = self.role_count.read(); self.roles.write(role_id, role); @@ -324,7 +374,9 @@ pub mod GuildComponent { /// Modify an existing role. /// Founder role (0) must always have can_be_kicked = false. fn modify_role(ref self: ComponentState, role_id: u8, role: Role) { + self.assert_not_dissolved(); self.only_governor(); + assert!(role.name != 0, "{}", Errors::INVALID_ROLE_NAME); let old_role = self.get_role_or_panic(role_id); // Founder role must never be kickable @@ -358,9 +410,11 @@ pub mod GuildComponent { /// Delete a role by zeroing it out. Cannot delete role 0 (founder). fn delete_role(ref self: ComponentState, role_id: u8) { + self.assert_not_dissolved(); self.only_governor(); assert!(role_id != 0, "{}", Errors::CANNOT_DELETE_FOUNDER); self.get_role_or_panic(role_id); + assert!(self.role_member_count.read(role_id) == 0, "{}", Errors::ROLE_HAS_MEMBERS); // Zero out the role (name = 0 marks it as deleted) self @@ -398,6 +452,8 @@ pub mod GuildComponent { self.check_permission(caller, action_type, amount); if action_type == ActionType::TRANSFER { + assert!(target != Zero::zero(), "{}", Errors::CORE_TARGET_INVALID); + assert!(token != Zero::zero(), "{}", Errors::CORE_TOKEN_INVALID); IERC20Dispatcher { contract_address: token }.transfer(target, amount); if token == self.revenue_token.read() { let checkpoint = self.revenue_balance_checkpoint.read(); @@ -408,8 +464,11 @@ pub mod GuildComponent { } } } else if action_type == ActionType::APPROVE { + assert!(target != Zero::zero(), "{}", Errors::CORE_TARGET_INVALID); + assert!(token != Zero::zero(), "{}", Errors::CORE_TOKEN_INVALID); IERC20Dispatcher { contract_address: token }.approve(target, amount); } else if action_type == ActionType::EXECUTE { + assert!(target != Zero::zero(), "{}", Errors::CORE_TARGET_INVALID); let mut execute_calldata = calldata; let selector: felt252 = Serde::deserialize(ref execute_calldata) .expect('Missing selector'); @@ -434,15 +493,23 @@ pub mod GuildComponent { action_offset: u8, action_count: u8, ) { + self.assert_not_dissolved(); self.only_governor(); let existing = self.plugins.read(plugin_id); assert!(existing.target_contract == Zero::zero(), "{}", Errors::PLUGIN_ALREADY_EXISTS); + assert!(target_contract != Zero::zero(), "{}", Errors::PLUGIN_TARGET_INVALID); + assert!(action_count > 0, "{}", Errors::PLUGIN_ACTION_COUNT_ZERO); let offset_u16: u16 = action_offset.into(); let count_u16: u16 = action_count.into(); assert!(offset_u16 >= 8, "{}", Errors::PLUGIN_OFFSET_RESERVED); assert!(offset_u16 + count_u16 <= 32, "{}", Errors::PLUGIN_OFFSET_OVERFLOW); + let new_plugin_mask = self.plugin_mask_from_range(action_offset, action_count); + let current_plugin_mask = self.plugin_action_mask.read(); + assert!( + current_plugin_mask & new_plugin_mask == 0, "{}", Errors::PLUGIN_OFFSET_COLLISION, + ); self .plugins @@ -451,6 +518,7 @@ pub mod GuildComponent { PluginConfig { target_contract, enabled: true, action_offset, action_count }, ); self.plugin_count.write(self.plugin_count.read() + 1); + self.plugin_action_mask.write(current_plugin_mask | new_plugin_mask); self .emit( @@ -463,6 +531,7 @@ pub mod GuildComponent { fn toggle_plugin( ref self: ComponentState, plugin_id: felt252, enabled: bool, ) { + self.assert_not_dissolved(); self.only_governor(); let mut config = self.plugin_or_panic(plugin_id); @@ -570,6 +639,7 @@ pub mod GuildComponent { fn set_distribution_policy( ref self: ComponentState, policy: DistributionPolicy, ) { + self.assert_not_dissolved(); self.only_governor(); let total_bps = policy.treasury_bps + policy.player_bps + policy.shareholder_bps; assert!(total_bps == BPS_DENOMINATOR, "{}", Errors::INVALID_BPS_SUM); @@ -585,7 +655,9 @@ pub mod GuildComponent { } fn set_revenue_token(ref self: ComponentState, token: ContractAddress) { + self.assert_not_dissolved(); self.only_governor(); + assert!(token != Zero::zero(), "{}", Errors::REVENUE_TOKEN_INVALID); self.revenue_token.write(token); let balance = IERC20Dispatcher { contract_address: token } .balance_of(get_contract_address()); @@ -602,6 +674,9 @@ pub mod GuildComponent { let current_balance = IERC20Dispatcher { contract_address: revenue_token } .balance_of(get_contract_address()); let checkpoint = self.revenue_balance_checkpoint.read(); + assert!( + current_balance >= checkpoint, "{}", Errors::REVENUE_BALANCE_BELOW_CHECKPOINT, + ); let new_revenue = current_balance - checkpoint; assert!(new_revenue > 0, "{}", Errors::NO_NEW_REVENUE); @@ -633,6 +708,9 @@ pub mod GuildComponent { ); self.current_epoch.write(epoch + 1); self.revenue_balance_checkpoint.write(current_balance); + let mut redemption_window = self.redemption_window.read(); + redemption_window.redeemed_this_epoch = 0; + self.redemption_window.write(redemption_window); self .emit( @@ -647,6 +725,7 @@ pub mod GuildComponent { } fn claim_player_revenue(ref self: ComponentState, epoch: u64) { + self.assert_not_dissolved(); let caller = get_caller_address(); let member = self.get_member_or_panic(caller); assert!(epoch < self.current_epoch.read(), "{}", Errors::EPOCH_NOT_FINALIZED); @@ -661,6 +740,9 @@ pub mod GuildComponent { let share = (snapshot.player_amount * role.payout_weight.into()) / snapshot.total_payout_weight.into(); + // Effects before interactions (reentrancy-safe claim progression) + self.member_last_claimed_epoch.write(caller, epoch + 1); + let revenue_token = self.revenue_token.read(); assert!(revenue_token != Zero::zero(), "{}", Errors::REVENUE_TOKEN_NOT_SET); IERC20Dispatcher { contract_address: revenue_token }.transfer(caller, share); @@ -671,11 +753,11 @@ pub mod GuildComponent { self.revenue_balance_checkpoint.write(0); } - self.member_last_claimed_epoch.write(caller, epoch + 1); self.emit(events::PlayerRevenueClaimed { member: caller, epoch, amount: share }); } fn claim_shareholder_revenue(ref self: ComponentState, epoch: u64) { + self.assert_not_dissolved(); let caller = get_caller_address(); assert!(epoch < self.current_epoch.read(), "{}", Errors::EPOCH_NOT_FINALIZED); @@ -689,6 +771,9 @@ pub mod GuildComponent { let holder_balance = votes.get_past_votes(caller, snapshot.finalized_at); let share = (snapshot.shareholder_amount * holder_balance) / snapshot.active_supply; + // Effects before interactions (reentrancy-safe claim progression) + self.shareholder_last_claimed_epoch.write(caller, epoch + 1); + let revenue_token = self.revenue_token.read(); assert!(revenue_token != Zero::zero(), "{}", Errors::REVENUE_TOKEN_NOT_SET); IERC20Dispatcher { contract_address: revenue_token }.transfer(caller, share); @@ -699,7 +784,6 @@ pub mod GuildComponent { self.revenue_balance_checkpoint.write(0); } - self.shareholder_last_claimed_epoch.write(caller, epoch + 1); self .emit( events::ShareholderRevenueClaimed { shareholder: caller, epoch, amount: share }, @@ -707,10 +791,27 @@ pub mod GuildComponent { } fn create_share_offer(ref self: ComponentState, offer: ShareOffer) { + self.assert_not_dissolved(); self.only_governor(); + if self.has_active_offer.read() { + let active_offer = self.active_offer.read(); + if active_offer.expires_at > 0 { + if get_block_timestamp() >= active_offer.expires_at { + self.has_active_offer.write(false); + } + } + } assert!(!self.has_active_offer.read(), "{}", Errors::ACTIVE_OFFER_EXISTS); - assert!(offer.max_total > 0, "Max total must be positive"); - assert!(offer.price_per_share > 0, "Price must be positive"); + assert!(offer.deposit_token != Zero::zero(), "{}", Errors::OFFER_DEPOSIT_TOKEN_INVALID); + assert!(offer.max_total > 0, "{}", Errors::OFFER_MAX_TOTAL_INVALID); + assert!(offer.price_per_share > 0, "{}", Errors::OFFER_PRICE_INVALID); + if offer.expires_at > 0 { + assert!( + offer.expires_at > get_block_timestamp(), + "{}", + Errors::OFFER_EXPIRY_INVALID, + ); + } self .active_offer @@ -737,7 +838,9 @@ pub mod GuildComponent { } fn buy_shares(ref self: ComponentState, amount: u256) { + self.assert_not_dissolved(); assert!(self.has_active_offer.read(), "{}", Errors::NO_ACTIVE_OFFER); + assert!(amount > 0, "{}", Errors::OFFER_AMOUNT_INVALID); let caller = get_caller_address(); let mut offer = self.active_offer.read(); @@ -750,12 +853,9 @@ pub mod GuildComponent { assert!(next_minted <= offer.max_total, "{}", Errors::OFFER_EXCEEDS_MAX); let cost = (amount * offer.price_per_share) / TOKEN_MULTIPLIER; + assert!(cost > 0, "{}", Errors::OFFER_COST_ZERO); - IERC20Dispatcher { contract_address: offer.deposit_token } - .transfer_from(caller, get_contract_address(), cost); - IGuildTokenDispatcher { contract_address: self.token_address.read() } - .mint(caller, amount); - + // Effects before interactions (reentrancy-safe offer accounting) offer.minted_so_far = next_minted; self.active_offer.write(offer); @@ -763,17 +863,33 @@ pub mod GuildComponent { self.has_active_offer.write(false); } + IERC20Dispatcher { contract_address: offer.deposit_token } + .transfer_from(caller, get_contract_address(), cost); + IGuildTokenDispatcher { contract_address: self.token_address.read() } + .mint(caller, amount); + self.emit(events::SharesPurchased { buyer: caller, amount, cost }); } fn set_redemption_window( ref self: ComponentState, window: RedemptionWindow, ) { + self.assert_not_dissolved(); self.only_governor(); + if window.enabled { + assert!(window.max_per_epoch > 0_u256, "{}", Errors::REDEMPTION_MAX_INVALID); + } + assert!( + window.redeemed_this_epoch == 0, + "{}", + Errors::REDEMPTION_EPOCH_USAGE_INVALID, + ); self.redemption_window.write(window); } fn redeem_shares(ref self: ComponentState, amount: u256) { + self.assert_not_dissolved(); + assert!(amount > 0, "{}", Errors::REDEMPTION_AMOUNT_INVALID); let caller = get_caller_address(); let mut window = self.redemption_window.read(); assert!(window.enabled, "{}", Errors::REDEMPTION_NOT_ENABLED); @@ -799,6 +915,11 @@ pub mod GuildComponent { let payout = (treasury_balance * amount) / total_supply; + // Effects before interactions (reentrancy-safe redemption accounting) + window.redeemed_this_epoch = next_redeemed; + self.redemption_window.write(window); + self.member_last_redemption_epoch.write(caller, current_epoch); + IGuildTokenDispatcher { contract_address: self.token_address.read() } .burn(caller, amount); IERC20Dispatcher { contract_address: revenue_token }.transfer(caller, payout); @@ -809,14 +930,11 @@ pub mod GuildComponent { self.revenue_balance_checkpoint.write(0); } - window.redeemed_this_epoch = next_redeemed; - self.redemption_window.write(window); - self.member_last_redemption_epoch.write(caller, current_epoch); - self.emit(events::SharesRedeemed { redeemer: caller, amount, payout }); } fn dissolve(ref self: ComponentState) { + self.assert_not_dissolved(); self.only_governor(); self.is_dissolved.write(true); self.emit(events::GuildDissolved { dissolved_at: get_block_timestamp() }); @@ -833,24 +951,39 @@ pub mod GuildComponent { expires_at: u64, ) { self.assert_not_dissolved(); + assert!(target != Zero::zero(), "{}", Errors::TARGET_ADDRESS_INVALID); let caller = get_caller_address(); let governor = self.governor_address.read(); + self.assert_not_member(target); + let existing_invite = self.pending_invites.read(target); + if existing_invite.invited_by != Zero::zero() { + if existing_invite.expires_at > 0 { + if get_block_timestamp() >= existing_invite.expires_at { + self + .pending_invites + .write( + target, + PendingInvite { + role_id: 0, invited_by: Zero::zero(), invited_at: 0, expires_at: 0, + }, + ); + } + } + } + let refreshed_invite = self.pending_invites.read(target); + assert!(refreshed_invite.invited_by == Zero::zero(), "{}", Errors::HAS_PENDING_INVITE); + self.get_role_or_panic(role_id); + if expires_at > 0 { + assert!(expires_at > get_block_timestamp(), "{}", Errors::INVITE_EXPIRY_INVALID); + } + if caller != governor { let caller_member = self.get_member_or_panic(caller); let caller_role = self.get_role_or_panic(caller_member.role_id); assert!(caller_role.can_invite, "{}", Errors::CALLER_CANNOT_INVITE); - - self.assert_not_member(target); - - let existing_invite = self.pending_invites.read(target); - assert!( - existing_invite.invited_by == Zero::zero(), "{}", Errors::HAS_PENDING_INVITE, - ); - assert!(caller_member.role_id < role_id, "{}", Errors::CANNOT_INVITE_TO_HIGHER); - self.get_role_or_panic(role_id); } let invite = PendingInvite { @@ -865,6 +998,7 @@ pub mod GuildComponent { self.assert_not_dissolved(); let caller = get_caller_address(); + self.assert_not_member(caller); let invite = self.pending_invites.read(caller); assert!(invite.invited_by != Zero::zero(), "{}", Errors::NO_PENDING_INVITE); @@ -895,12 +1029,16 @@ pub mod GuildComponent { self .role_member_count .write(invite.role_id, self.role_member_count.read(invite.role_id) + 1); + // New members can only claim player revenue from epochs finalized + // after they join. + self.member_last_claimed_epoch.write(caller, self.current_epoch.read()); self.emit(events::MemberJoined { member: caller, role_id: invite.role_id }); } fn kick_member(ref self: ComponentState, target: ContractAddress) { self.assert_not_dissolved(); + assert!(target != Zero::zero(), "{}", Errors::TARGET_ADDRESS_INVALID); let caller = get_caller_address(); let governor = self.governor_address.read(); @@ -979,14 +1117,21 @@ pub mod GuildComponent { ref self: ComponentState, target: ContractAddress, new_role_id: u8, ) { self.assert_not_dissolved(); + assert!(target != Zero::zero(), "{}", Errors::TARGET_ADDRESS_INVALID); let caller = get_caller_address(); let governor = self.governor_address.read(); + let mut target_member = self.get_member_or_panic(target); if caller != governor { let caller_member = self.get_member_or_panic(caller); let caller_role = self.get_role_or_panic(caller_member.role_id); assert!(caller_role.can_promote_depth > 0, "{}", Errors::CALLER_CANNOT_PROMOTE); + assert!( + caller_member.role_id < target_member.role_id, + "{}", + Errors::CANNOT_MODIFY_HIGHER_RANK, + ); assert!( new_role_id > caller_member.role_id, "{}", Errors::CANNOT_PROMOTE_TO_HIGHER, ); @@ -999,7 +1144,6 @@ pub mod GuildComponent { self.get_role_or_panic(new_role_id); - let mut target_member = self.get_member_or_panic(target); let old_role_id = target_member.role_id; target_member.role_id = new_role_id; self.members.write(target, target_member); @@ -1037,6 +1181,7 @@ pub mod GuildComponent { fn revoke_invite(ref self: ComponentState, target: ContractAddress) { self.assert_not_dissolved(); + assert!(target != Zero::zero(), "{}", Errors::TARGET_ADDRESS_INVALID); let caller = get_caller_address(); let invite = self.pending_invites.read(target); diff --git a/src/token/guild_token.cairo b/src/token/guild_token.cairo index 2318a32..237d4c7 100644 --- a/src/token/guild_token.cairo +++ b/src/token/guild_token.cairo @@ -66,6 +66,20 @@ pub mod GuildToken { let mut contract = self.get_contract_mut(); contract.votes.transfer_voting_units(from, recipient, amount); + let current_inactive = contract.inactive_balance.read(); + if from != Zero::zero() && contract.inactivity_flags.read(from).flagged_at > 0 { + if amount <= current_inactive { + contract.inactive_balance.write(current_inactive - amount); + } else { + contract.inactive_balance.write(0); + } + } + + if recipient != Zero::zero() + && contract.inactivity_flags.read(recipient).flagged_at > 0 { + contract.inactive_balance.write(contract.inactive_balance.read() + amount); + } + let ts = get_block_timestamp(); if from != Zero::zero() { contract.last_activity.write(from, ts); @@ -223,15 +237,6 @@ pub mod GuildToken { "Only governor or guild can burn", ); self.erc20.burn(account, amount); - - if self.inactivity_flags.read(account).flagged_at > 0 { - let current_inactive = self.inactive_balance.read(); - if amount <= current_inactive { - self.inactive_balance.write(current_inactive - amount); - } else { - self.inactive_balance.write(0); - } - } } fn get_guild_address(self: @ContractState) -> ContractAddress { diff --git a/tests/test_contract_guards.py b/tests/test_contract_guards.py new file mode 100644 index 0000000..fe38f0a --- /dev/null +++ b/tests/test_contract_guards.py @@ -0,0 +1,156 @@ +from pathlib import Path +import shutil +import subprocess +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +GUILD_CONTRACT = REPO_ROOT / "src" / "guild" / "guild_contract.cairo" + + +def _guild_contract_source() -> str: + return GUILD_CONTRACT.read_text(encoding="utf-8") + + +class ContractGuardsTests(unittest.TestCase): + def test_plugin_registration_guards_present(self) -> None: + source = _guild_contract_source() + + self.assertIn("PLUGIN_TARGET_INVALID", source) + self.assertIn("PLUGIN_ACTION_COUNT_ZERO", source) + self.assertIn("PLUGIN_OFFSET_COLLISION", source) + self.assertIn("plugin_action_mask: u32", source) + self.assertIn("assert!(target_contract != Zero::zero()", source) + self.assertIn("assert!(action_count > 0", source) + self.assertIn("current_plugin_mask & new_plugin_mask == 0", source) + + def test_role_and_lifecycle_guards_present(self) -> None: + source = _guild_contract_source() + + self.assertIn("GUILD_NAME_INVALID", source) + self.assertIn("GUILD_TICKER_INVALID", source) + self.assertIn("INVALID_ROLE_NAME", source) + self.assertIn("TOKEN_ADDRESS_INVALID", source) + self.assertIn("GOVERNOR_ADDRESS_INVALID", source) + self.assertIn("FOUNDER_ADDRESS_INVALID", source) + self.assertIn("ROLE_HAS_MEMBERS", source) + self.assertIn("assert!(guild_name != 0", source) + self.assertIn("assert!(guild_ticker != 0", source) + self.assertIn("assert!(token_address != Zero::zero()", source) + self.assertIn("assert!(governor_address != Zero::zero()", source) + self.assertIn("assert!(founder != Zero::zero()", source) + self.assertIn("assert!(role.name != 0", source) + self.assertIn("assert!(founder_role.name != 0", source) + self.assertIn("assert!(!founder_role.can_be_kicked", source) + self.assertIn("self.role_member_count.read(role_id) == 0", source) + self.assertIn("self.assert_not_member(caller);", source) + + def test_core_action_input_guards_present(self) -> None: + source = _guild_contract_source() + + self.assertIn("CORE_TARGET_INVALID", source) + self.assertIn("CORE_TOKEN_INVALID", source) + self.assertIn("if action_type == ActionType::TRANSFER", source) + self.assertIn("else if action_type == ActionType::APPROVE", source) + self.assertIn("else if action_type == ActionType::EXECUTE", source) + self.assertIn("assert!(target != Zero::zero()", source) + self.assertIn("assert!(token != Zero::zero()", source) + + def test_share_offer_guards_present(self) -> None: + source = _guild_contract_source() + + self.assertIn("OFFER_DEPOSIT_TOKEN_INVALID", source) + self.assertIn("OFFER_MAX_TOTAL_INVALID", source) + self.assertIn("OFFER_PRICE_INVALID", source) + self.assertIn("OFFER_EXPIRY_INVALID", source) + self.assertIn("OFFER_COST_ZERO", source) + self.assertIn("assert!(offer.deposit_token != Zero::zero()", source) + self.assertIn("assert!(offer.max_total > 0", source) + self.assertIn("assert!(offer.price_per_share > 0", source) + self.assertIn("offer.expires_at > get_block_timestamp()", source) + self.assertIn("assert!(cost > 0", source) + + def test_redemption_window_guards_present(self) -> None: + source = _guild_contract_source() + + self.assertIn("REDEMPTION_MAX_INVALID", source) + self.assertIn("REDEMPTION_EPOCH_USAGE_INVALID", source) + self.assertIn("window.max_per_epoch > 0", source) + self.assertIn("window.redeemed_this_epoch == 0", source) + + def test_revenue_token_guards_present(self) -> None: + source = _guild_contract_source() + + self.assertIn("REVENUE_TOKEN_INVALID", source) + self.assertIn("REVENUE_BALANCE_BELOW_CHECKPOINT", source) + self.assertIn("assert!(token != Zero::zero()", source) + self.assertIn("current_balance >= checkpoint", source) + + def test_reentrancy_sensitive_paths_use_effects_before_interactions(self) -> None: + source = _guild_contract_source() + + buy_start = source.index("fn buy_shares(") + buy_end = source.index("fn set_redemption_window(", buy_start) + buy_block = source[buy_start:buy_end] + self.assertLess( + buy_block.index("offer.minted_so_far = next_minted;"), + buy_block.index(".transfer_from(caller, get_contract_address(), cost);"), + ) + + player_start = source.index("fn claim_player_revenue(") + player_end = source.index("fn claim_shareholder_revenue(", player_start) + player_block = source[player_start:player_end] + self.assertLess( + player_block.index("self.member_last_claimed_epoch.write(caller, epoch + 1);"), + player_block.index(".transfer(caller, share);"), + ) + + shareholder_start = source.index("fn claim_shareholder_revenue(") + shareholder_end = source.index("fn create_share_offer(", shareholder_start) + shareholder_block = source[shareholder_start:shareholder_end] + self.assertLess( + shareholder_block.index( + "self.shareholder_last_claimed_epoch.write(caller, epoch + 1);" + ), + shareholder_block.index(".transfer(caller, share);"), + ) + + redeem_start = source.index("fn redeem_shares(") + redeem_end = source.index("fn dissolve(", redeem_start) + redeem_block = source[redeem_start:redeem_end] + self.assertLess( + redeem_block.index("self.redemption_window.write(window);"), + redeem_block.index(".burn(caller, amount);"), + ) + self.assertLess( + redeem_block.index( + "self.member_last_redemption_epoch.write(caller, current_epoch);" + ), + redeem_block.index(".transfer(caller, payout);"), + ) + + def test_optional_cairo_contract_tests_pass(self) -> None: + snforge = shutil.which("snforge") + if snforge is None: + self.skipTest("snforge not installed in this environment") + + cmd = [snforge, "test"] + result = subprocess.run( + cmd, + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + result.returncode, + 0, + msg=( + f"{' '.join(cmd)} failed with exit code {result.returncode}\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ), + ) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_guild_token.cairo b/tests/test_guild_token.cairo index 872acfb..86745c2 100644 --- a/tests/test_guild_token.cairo +++ b/tests/test_guild_token.cairo @@ -386,6 +386,39 @@ fn test_multiple_accounts_flagged_and_cleared_correctly() { assert!(guild_token.active_supply() == INITIAL_SUPPLY() - ONE_TOKEN()); } +#[test] +fn test_transfer_from_flagged_account_increases_active_supply() { + let (address, guild_token, erc20, _, _) = deploy_guild_token(); + + start_cheat_caller_address(address, ALICE()); + start_cheat_block_timestamp(address, BASE_TS() + THRESHOLD() + 1); + guild_token.flag_inactive(HOLDER()); + assert!(guild_token.active_supply() == 0); + + start_cheat_caller_address(address, HOLDER()); + erc20.transfer(ALICE(), HUNDRED_TOKENS()); + + assert!(guild_token.active_supply() == HUNDRED_TOKENS()); +} + +#[test] +fn test_transfer_to_flagged_account_decreases_active_supply() { + let (address, guild_token, erc20, _, _) = deploy_guild_token(); + + start_cheat_caller_address(address, HOLDER()); + erc20.transfer(ALICE(), HUNDRED_TOKENS()); + + start_cheat_caller_address(address, BOB()); + start_cheat_block_timestamp(address, BASE_TS() + THRESHOLD() + 1); + guild_token.flag_inactive(ALICE()); + assert!(guild_token.active_supply() == INITIAL_SUPPLY() - HUNDRED_TOKENS()); + + start_cheat_caller_address(address, HOLDER()); + erc20.transfer(ALICE(), ONE_TOKEN()); + + assert!(guild_token.active_supply() == INITIAL_SUPPLY() - HUNDRED_TOKENS() - ONE_TOKEN()); +} + #[test] fn test_get_inactivity_threshold_returns_configured_value() { let (_, guild_token, _, _, _) = deploy_guild_token(); diff --git a/tests/test_lifecycle.cairo b/tests/test_lifecycle.cairo index 20525a4..5ac414b 100644 --- a/tests/test_lifecycle.cairo +++ b/tests/test_lifecycle.cairo @@ -176,6 +176,14 @@ fn test_invite_member_non_member_cannot_invite() { state.guild.invite_member(BOB(), 2, 0); } +#[test] +#[should_panic] +fn test_invite_member_rejects_zero_target_address() { + let mut state = setup_guild_with_roles(); + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.invite_member(starknet::contract_address_const::<0>(), 2, 0); +} + #[test] #[should_panic] fn test_invite_member_member_without_can_invite_cannot_invite() { @@ -243,6 +251,24 @@ fn test_invite_member_governor_can_invite_to_any_role() { assert!(invite.invited_by == GOVERNOR()); } +#[test] +#[should_panic] +fn test_invite_member_governor_cannot_invite_existing_member() { + let mut state = setup_guild_with_roles(); + add_member(ref state, ALICE(), 2); + + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.invite_member(ALICE(), 1, 0); +} + +#[test] +#[should_panic] +fn test_invite_member_governor_cannot_invite_to_nonexistent_role() { + let mut state = setup_guild_with_roles(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.invite_member(ALICE(), 99, 0); +} + #[test] fn test_invite_member_with_expiry_sets_pending_invite() { let mut state = setup_guild_with_roles(); @@ -258,6 +284,32 @@ fn test_invite_member_with_expiry_sets_pending_invite() { assert!(invite.expires_at == 555); } +#[test] +#[should_panic] +fn test_invite_member_rejects_expiry_in_past() { + let mut state = setup_guild_with_roles(); + start_cheat_block_timestamp(test_address(), 100); + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.invite_member(BOB(), 1, 100); +} + +#[test] +fn test_invite_member_allows_replacing_expired_pending_invite() { + let mut state = setup_guild_with_roles(); + start_cheat_block_timestamp(test_address(), 10); + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.invite_member(ALICE(), 2, 20); + + start_cheat_block_timestamp(test_address(), 30); + state.guild.invite_member(ALICE(), 1, 60); + + let invite = guild_storage(@state).pending_invites.read(ALICE()); + assert!(invite.role_id == 1); + assert!(invite.invited_by == FOUNDER()); + assert!(invite.invited_at == 30); + assert!(invite.expires_at == 60); +} + #[test] fn test_accept_invite_valid_invite_creates_member() { let mut state = setup_guild_with_roles(); @@ -302,6 +354,23 @@ fn test_accept_invite_increments_member_count() { assert!(guild_storage(@state).member_count.read() == 2); } +#[test] +#[should_panic] +fn test_accept_invite_existing_member_cannot_accept_seeded_invite() { + let mut state = setup_guild_with_roles(); + add_member(ref state, ALICE(), 2); + + guild_storage_mut(ref state) + .pending_invites + .write( + ALICE(), + PendingInvite { role_id: 1, invited_by: FOUNDER(), invited_at: 10, expires_at: 0 }, + ); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.accept_invite(); +} + #[test] #[should_panic] fn test_accept_invite_no_pending_invite_fails() { @@ -415,6 +484,14 @@ fn test_kick_member_cannot_kick_non_member() { state.guild.kick_member(ALICE()); } +#[test] +#[should_panic] +fn test_kick_member_rejects_zero_target_address() { + let mut state = setup_guild_with_roles(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.kick_member(starknet::contract_address_const::<0>()); +} + #[test] #[should_panic] fn test_kick_member_member_without_can_kick_cannot_kick() { @@ -528,6 +605,48 @@ fn test_change_member_role_founder_can_promote_member_within_depth() { assert!(member.role_id == 1); } +#[test] +fn test_change_member_role_officer_can_change_lower_rank_member_within_depth() { + let mut state = setup_guild_with_roles(); + add_member(ref state, ALICE(), 1); + add_member(ref state, BOB(), 3); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.change_member_role(BOB(), 2); + + let member = guild_storage(@state).members.read(BOB()); + assert!(member.role_id == 2); +} + +#[test] +#[should_panic] +fn test_change_member_role_cannot_modify_equal_rank_member() { + let mut state = setup_guild_with_roles(); + add_member(ref state, ALICE(), 1); + add_member(ref state, BOB(), 1); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.change_member_role(BOB(), 2); +} + +#[test] +#[should_panic] +fn test_change_member_role_cannot_modify_higher_rank_member() { + let mut state = setup_guild_with_roles(); + add_member(ref state, ALICE(), 1); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.change_member_role(FOUNDER(), 2); +} + +#[test] +#[should_panic] +fn test_change_member_role_rejects_zero_target_address() { + let mut state = setup_guild_with_roles(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.change_member_role(starknet::contract_address_const::<0>(), 1); +} + #[test] #[should_panic] fn test_change_member_role_cannot_promote_to_equal_rank() { @@ -643,6 +762,14 @@ fn test_revoke_invite_non_existent_invite_fails() { state.guild.revoke_invite(ALICE()); } +#[test] +#[should_panic] +fn test_revoke_invite_rejects_zero_target_address() { + let mut state = setup_guild_with_roles(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.revoke_invite(starknet::contract_address_const::<0>()); +} + #[test] fn test_revoke_invite_clears_pending_invite() { let mut state = setup_guild_with_roles(); diff --git a/tests/test_permissions.cairo b/tests/test_permissions.cairo index b1b4d47..d920429 100644 --- a/tests/test_permissions.cairo +++ b/tests/test_permissions.cairo @@ -88,6 +88,26 @@ fn test_initializer_sets_guild_ticker() { assert!(guild_storage(@state).guild_ticker.read() == 'TG'); } +#[test] +#[should_panic] +fn test_initializer_rejects_zero_guild_name() { + let mut state = COMPONENT_STATE(); + start_cheat_caller_address(test_address(), FOUNDER()); + state + .guild + .initializer(0, 'TG', TOKEN(), GOVERNOR(), FOUNDER(), default_founder_role()); +} + +#[test] +#[should_panic] +fn test_initializer_rejects_zero_guild_ticker() { + let mut state = COMPONENT_STATE(); + start_cheat_caller_address(test_address(), FOUNDER()); + state + .guild + .initializer('TestGuild', 0, TOKEN(), GOVERNOR(), FOUNDER(), default_founder_role()); +} + #[test] fn test_initializer_sets_token_address() { let state = setup_guild(); @@ -131,6 +151,99 @@ fn test_initializer_sets_role_count() { assert!(guild_storage(@state).role_count.read() == 1); } +#[test] +#[should_panic] +fn test_initializer_rejects_zero_token_address() { + let mut state = COMPONENT_STATE(); + start_cheat_caller_address(test_address(), FOUNDER()); + state + .guild + .initializer( + 'TestGuild', + 'TG', + starknet::contract_address_const::<0>(), + GOVERNOR(), + FOUNDER(), + default_founder_role(), + ); +} + +#[test] +#[should_panic] +fn test_initializer_rejects_zero_governor_address() { + let mut state = COMPONENT_STATE(); + start_cheat_caller_address(test_address(), FOUNDER()); + state + .guild + .initializer( + 'TestGuild', + 'TG', + TOKEN(), + starknet::contract_address_const::<0>(), + FOUNDER(), + default_founder_role(), + ); +} + +#[test] +#[should_panic] +fn test_initializer_rejects_zero_founder_address() { + let mut state = COMPONENT_STATE(); + start_cheat_caller_address(test_address(), FOUNDER()); + state + .guild + .initializer( + 'TestGuild', + 'TG', + TOKEN(), + GOVERNOR(), + starknet::contract_address_const::<0>(), + default_founder_role(), + ); +} + +#[test] +#[should_panic] +fn test_initializer_rejects_zero_founder_role_name() { + let mut state = COMPONENT_STATE(); + start_cheat_caller_address(test_address(), FOUNDER()); + + let mut invalid_founder_role = default_founder_role(); + invalid_founder_role.name = 0; + + state + .guild + .initializer( + 'TestGuild', + 'TG', + TOKEN(), + GOVERNOR(), + FOUNDER(), + invalid_founder_role, + ); +} + +#[test] +#[should_panic] +fn test_initializer_rejects_kickable_founder_role() { + let mut state = COMPONENT_STATE(); + start_cheat_caller_address(test_address(), FOUNDER()); + + let mut invalid_founder_role = default_founder_role(); + invalid_founder_role.can_be_kicked = true; + + state + .guild + .initializer( + 'TestGuild', + 'TG', + TOKEN(), + GOVERNOR(), + FOUNDER(), + invalid_founder_role, + ); +} + // ======================================================================== // check_permission Tests // ======================================================================== @@ -440,6 +553,25 @@ fn test_create_role_non_governor_rejected() { state.guild.create_role(role); } +#[test] +#[should_panic] +fn test_create_role_rejects_zero_name_tombstone() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + + let role = Role { + name: 0, + can_invite: true, + can_kick: false, + can_promote_depth: 1, + can_be_kicked: true, + allowed_actions: ActionType::TRANSFER, + spending_limit: 1000, + payout_weight: 100, + }; + state.guild.create_role(role); +} + #[test] fn test_modify_role() { let mut state = setup_guild(); @@ -541,6 +673,37 @@ fn test_modify_role_non_governor_rejected() { state.guild.modify_role(1, role); } +#[test] +#[should_panic] +fn test_modify_role_rejects_zero_name_tombstone() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + + let role = Role { + name: 'officer', + can_invite: true, + can_kick: false, + can_promote_depth: 1, + can_be_kicked: true, + allowed_actions: ActionType::TRANSFER, + spending_limit: 1000, + payout_weight: 100, + }; + state.guild.create_role(role); + + let invalid_update = Role { + name: 0, + can_invite: true, + can_kick: false, + can_promote_depth: 1, + can_be_kicked: true, + allowed_actions: ActionType::TRANSFER, + spending_limit: 1000, + payout_weight: 100, + }; + state.guild.modify_role(1, invalid_update); +} + #[test] fn test_delete_role() { let mut state = setup_guild(); @@ -585,6 +748,32 @@ fn test_delete_nonexistent_role_rejected() { state.guild.delete_role(99); } +#[test] +#[should_panic] +fn test_delete_role_with_assigned_members_rejected() { + let mut state = setup_guild(); + start_cheat_caller_address(test_address(), GOVERNOR()); + + let role = Role { + name: 'temp', + can_invite: false, + can_kick: false, + can_promote_depth: 0, + can_be_kicked: true, + allowed_actions: 0, + spending_limit: 0, + payout_weight: 0, + }; + state.guild.create_role(role); + + guild_storage_mut(ref state) + .members + .write(ALICE(), Member { addr: ALICE(), role_id: 1, joined_at: 0 }); + guild_storage_mut(ref state).role_member_count.write(1, 1); + + state.guild.delete_role(1); +} + #[test] #[should_panic] fn test_delete_already_deleted_role_rejected() { @@ -687,15 +876,21 @@ fn test_check_permission_on_dissolved_guild() { } #[test] -fn test_governor_bypasses_on_dissolved_guild() { +#[should_panic] +fn test_governor_blocked_on_dissolved_guild() { let mut state = setup_guild(); - // Note: governor bypass happens before dissolved check, so it still works - // Actually, let me check the code... dissolved check is first. So governor - // should also fail on dissolved guild. Let me verify. - // Looking at the code: assert_not_dissolved is called BEFORE the governor check. - // This means even governor cannot act on dissolved guild. This is intentional. - // Let's verify: guild_storage_mut(ref state).is_dissolved.write(true); - // Governor should also be blocked -// (if this test fails, it means governor bypasses dissolved check — which we don't want) + // Governor should also be blocked by dissolved state. + start_cheat_caller_address(test_address(), GOVERNOR()); + let role = Role { + name: 'officer', + can_invite: false, + can_kick: false, + can_promote_depth: 0, + can_be_kicked: true, + allowed_actions: 0, + spending_limit: 0, + payout_weight: 100, + }; + state.guild.create_role(role); } diff --git a/tests/test_revenue.cairo b/tests/test_revenue.cairo index 940dc10..4fea3cd 100644 --- a/tests/test_revenue.cairo +++ b/tests/test_revenue.cairo @@ -172,7 +172,7 @@ fn delegate_self(token: ContractAddress, account: ContractAddress) { fn approve_for_guild(token: IERC20Dispatcher, owner: ContractAddress, amount: u256) { start_cheat_caller_address(token.contract_address, owner); - token.approve(owner, amount); + token.approve(test_address(), amount); } #[test] @@ -221,6 +221,14 @@ fn test_set_revenue_token_fails_non_governor() { state.guild.set_revenue_token(revenue_token); } +#[test] +#[should_panic] +fn test_set_revenue_token_fails_zero_address() { + let (mut state, _, _) = setup_state(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state.guild.set_revenue_token(starknet::contract_address_const::<0>()); +} + #[test] fn test_finalize_epoch_splits_revenue_correctly() { let (mut state, _, revenue_token) = setup_state(); @@ -280,6 +288,21 @@ fn test_finalize_epoch_fails_no_revenue() { state.guild.finalize_epoch(); } +#[test] +#[should_panic] +fn test_finalize_epoch_fails_when_balance_below_checkpoint() { + let (mut state, _, revenue_token) = setup_state(); + set_revenue_token(ref state, revenue_token); + fund_contract(revenue_token, HUNDRED()); + + let revenue = IERC20Dispatcher { contract_address: revenue_token }; + start_cheat_caller_address(revenue_token, test_address()); + revenue.transfer(ALICE(), ONE()); + + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.finalize_epoch(); +} + #[test] #[should_panic] fn test_finalize_epoch_fails_no_permission() { @@ -366,6 +389,19 @@ fn test_claim_player_revenue_fails_already_claimed() { state.guild.claim_player_revenue(0); } +#[test] +#[should_panic] +fn test_claim_player_revenue_fails_dissolved() { + let (mut state, _, revenue_token) = setup_state(); + set_distribution(ref state, 0, 10_000, 0); + set_revenue_token(ref state, revenue_token); + fund_contract(revenue_token, HUNDRED()); + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.finalize_epoch(); + state.guild.dissolve(); + state.guild.claim_player_revenue(0); +} + #[test] fn test_claim_player_revenue_multiple_members() { let (mut state, _, revenue_token) = setup_state(); @@ -381,6 +417,56 @@ fn test_claim_player_revenue_multiple_members() { assert!(guild_storage(@state).revenue_balance_checkpoint.read() == 0); } +#[test] +fn test_accept_invite_sets_member_claim_cursor_to_current_epoch() { + let (mut state, _, revenue_token) = setup_state(); + set_revenue_token(ref state, revenue_token); + fund_contract(revenue_token, HUNDRED()); + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.finalize_epoch(); + + create_role_and_join(ref state, ALICE(), officer_role()); + + assert!(guild_storage(@state).member_last_claimed_epoch.read(ALICE()) == 1); +} + +#[test] +#[should_panic] +fn test_joined_member_cannot_claim_player_revenue_for_past_epoch() { + let (mut state, _, revenue_token) = setup_state(); + set_distribution(ref state, 0, 10_000, 0); + set_revenue_token(ref state, revenue_token); + fund_contract(revenue_token, HUNDRED()); + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.finalize_epoch(); + + create_role_and_join(ref state, ALICE(), officer_role()); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.claim_player_revenue(0); +} + +#[test] +fn test_joined_member_can_claim_player_revenue_for_future_epoch() { + let (mut state, _, revenue_token) = setup_state(); + set_distribution(ref state, 0, 10_000, 0); + set_revenue_token(ref state, revenue_token); + + fund_contract(revenue_token, HUNDRED()); + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.finalize_epoch(); + + create_role_and_join(ref state, ALICE(), officer_role()); + + fund_contract(revenue_token, HUNDRED()); + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.finalize_epoch(); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.claim_player_revenue(1); + assert!(guild_storage(@state).member_last_claimed_epoch.read(ALICE()) == 2); +} + #[test] fn test_claim_shareholder_revenue_correct_amount() { let (mut state, guild_token, revenue_token) = setup_state(); @@ -522,6 +608,114 @@ fn test_create_share_offer_fails_already_active() { state.guild.create_share_offer(offer); } +#[test] +#[should_panic] +fn test_create_share_offer_fails_zero_deposit_token() { + let (mut state, _, _) = setup_state(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .create_share_offer( + ShareOffer { + deposit_token: starknet::contract_address_const::<0>(), + max_total: THOUSAND(), + minted_so_far: 0, + price_per_share: ONE(), + expires_at: 0, + }, + ); +} + +#[test] +#[should_panic] +fn test_create_share_offer_fails_zero_max_total() { + let (mut state, _, revenue_token) = setup_state(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .create_share_offer( + ShareOffer { + deposit_token: revenue_token, + max_total: 0, + minted_so_far: 0, + price_per_share: ONE(), + expires_at: 0, + }, + ); +} + +#[test] +#[should_panic] +fn test_create_share_offer_fails_zero_price() { + let (mut state, _, revenue_token) = setup_state(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .create_share_offer( + ShareOffer { + deposit_token: revenue_token, + max_total: THOUSAND(), + minted_so_far: 0, + price_per_share: 0, + expires_at: 0, + }, + ); +} + +#[test] +#[should_panic] +fn test_create_share_offer_fails_expired_at_creation() { + let (mut state, _, revenue_token) = setup_state(); + start_cheat_block_timestamp(test_address(), 10); + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .create_share_offer( + ShareOffer { + deposit_token: revenue_token, + max_total: THOUSAND(), + minted_so_far: 0, + price_per_share: ONE(), + expires_at: 10, + }, + ); +} + +#[test] +fn test_create_share_offer_allows_replacing_expired_offer() { + let (mut state, _, revenue_token) = setup_state(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .create_share_offer( + ShareOffer { + deposit_token: revenue_token, + max_total: THOUSAND(), + minted_so_far: 0, + price_per_share: ONE(), + expires_at: 2, + }, + ); + + start_cheat_block_timestamp(test_address(), 3); + state + .guild + .create_share_offer( + ShareOffer { + deposit_token: revenue_token, + max_total: 2 * THOUSAND(), + minted_so_far: 0, + price_per_share: 2 * ONE(), + expires_at: 0, + }, + ); + + let offer = guild_storage(@state).active_offer.read(); + assert!(guild_storage(@state).has_active_offer.read()); + assert!(offer.max_total == 2 * THOUSAND()); + assert!(offer.price_per_share == 2 * ONE()); +} + #[test] fn test_buy_shares_mints_tokens() { let (mut state, guild_token, revenue_token) = setup_state(); @@ -591,6 +785,48 @@ fn test_buy_shares_fails_no_active_offer() { state.guild.buy_shares(ONE()); } +#[test] +#[should_panic] +fn test_buy_shares_fails_zero_amount() { + let (mut state, _, revenue_token) = setup_state(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .create_share_offer( + ShareOffer { + deposit_token: revenue_token, + max_total: THOUSAND(), + minted_so_far: 0, + price_per_share: ONE(), + expires_at: 0, + }, + ); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.buy_shares(0); +} + +#[test] +#[should_panic] +fn test_buy_shares_fails_zero_rounded_cost() { + let (mut state, _, revenue_token) = setup_state(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .create_share_offer( + ShareOffer { + deposit_token: revenue_token, + max_total: THOUSAND(), + minted_so_far: 0, + price_per_share: 1, + expires_at: 0, + }, + ); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.buy_shares(1); +} + #[test] #[should_panic] fn test_buy_shares_fails_expired() { @@ -643,6 +879,33 @@ fn test_buy_shares_fails_exceeds_max() { state.guild.buy_shares(2 * ONE()); } +#[test] +#[should_panic] +fn test_buy_shares_fails_dissolved() { + let (mut state, _, revenue_token) = setup_state(); + let deposit = IERC20Dispatcher { contract_address: revenue_token }; + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .create_share_offer( + ShareOffer { + deposit_token: revenue_token, + max_total: THOUSAND(), + minted_so_far: 0, + price_per_share: ONE(), + expires_at: 0, + }, + ); + state.guild.dissolve(); + + start_cheat_caller_address(revenue_token, FOUNDER()); + deposit.transfer(ALICE(), 5 * ONE()); + approve_for_guild(deposit, ALICE(), 5 * ONE()); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.buy_shares(ONE()); +} + #[test] fn test_set_redemption_window_governor() { let (mut state, _, _) = setup_state(); @@ -663,6 +926,54 @@ fn test_set_redemption_window_governor() { assert!(window.cooldown_epochs == 2); } +#[test] +#[should_panic] +fn test_set_redemption_window_fails_non_governor() { + let (mut state, _, _) = setup_state(); + start_cheat_caller_address(test_address(), FOUNDER()); + state + .guild + .set_redemption_window( + RedemptionWindow { + enabled: true, + max_per_epoch: THOUSAND(), + redeemed_this_epoch: 0, + cooldown_epochs: 1, + }, + ); +} + +#[test] +#[should_panic] +fn test_set_redemption_window_fails_enabled_zero_max_per_epoch() { + let (mut state, _, _) = setup_state(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .set_redemption_window( + RedemptionWindow { + enabled: true, max_per_epoch: 0, redeemed_this_epoch: 0, cooldown_epochs: 0, + }, + ); +} + +#[test] +#[should_panic] +fn test_set_redemption_window_fails_nonzero_redeemed_this_epoch() { + let (mut state, _, _) = setup_state(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .set_redemption_window( + RedemptionWindow { + enabled: false, + max_per_epoch: 0, + redeemed_this_epoch: ONE(), + cooldown_epochs: 0, + }, + ); +} + #[test] fn test_redeem_shares_burns_and_pays() { let (mut state, guild_token, revenue_token) = setup_state(); @@ -697,6 +1008,63 @@ fn test_redeem_shares_burns_and_pays() { assert!(revenue.balance_of(ALICE()) == revenue_before + payout); } +#[test] +fn test_finalize_epoch_resets_redemption_epoch_usage() { + let (mut state, guild_token, revenue_token) = setup_state(); + let token = IGuildTokenDispatcher { contract_address: guild_token }; + start_cheat_caller_address(guild_token, GOVERNOR()); + token.mint(ALICE(), HUNDRED()); + + set_revenue_token(ref state, revenue_token); + fund_contract(revenue_token, 200 * ONE()); + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .set_redemption_window( + RedemptionWindow { + enabled: true, max_per_epoch: HUNDRED(), redeemed_this_epoch: 0, cooldown_epochs: 0, + }, + ); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.redeem_shares(HUNDRED()); + assert!(guild_storage(@state).redemption_window.read().redeemed_this_epoch == HUNDRED()); + + fund_contract(revenue_token, ONE()); + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.finalize_epoch(); + assert!(guild_storage(@state).redemption_window.read().redeemed_this_epoch == 0); +} + +#[test] +fn test_redeem_shares_limit_applies_per_epoch_not_lifetime() { + let (mut state, guild_token, revenue_token) = setup_state(); + let token = IGuildTokenDispatcher { contract_address: guild_token }; + start_cheat_caller_address(guild_token, GOVERNOR()); + token.mint(ALICE(), 2 * HUNDRED()); + + set_revenue_token(ref state, revenue_token); + fund_contract(revenue_token, 400 * ONE()); + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .set_redemption_window( + RedemptionWindow { + enabled: true, max_per_epoch: HUNDRED(), redeemed_this_epoch: 0, cooldown_epochs: 0, + }, + ); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.redeem_shares(HUNDRED()); + + fund_contract(revenue_token, ONE()); + start_cheat_caller_address(test_address(), FOUNDER()); + state.guild.finalize_epoch(); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.redeem_shares(HUNDRED()); +} + #[test] #[should_panic] fn test_redeem_shares_fails_not_enabled() { @@ -705,6 +1073,26 @@ fn test_redeem_shares_fails_not_enabled() { state.guild.redeem_shares(ONE()); } +#[test] +#[should_panic] +fn test_redeem_shares_fails_zero_amount() { + let (mut state, _, _) = setup_state(); + start_cheat_caller_address(test_address(), GOVERNOR()); + state + .guild + .set_redemption_window( + RedemptionWindow { + enabled: true, + max_per_epoch: THOUSAND(), + redeemed_this_epoch: 0, + cooldown_epochs: 0, + }, + ); + + start_cheat_caller_address(test_address(), ALICE()); + state.guild.redeem_shares(0); +} + #[test] #[should_panic] fn test_redeem_shares_fails_exceeds_epoch_limit() { diff --git a/tests/test_treasury.cairo b/tests/test_treasury.cairo index 1267ae0..192e165 100644 --- a/tests/test_treasury.cairo +++ b/tests/test_treasury.cairo @@ -254,6 +254,88 @@ fn test_execute_action_fails_dissolved() { guild.execute_core_action(ActionType::TRANSFER, RECIPIENT(), token_address, 1, array![].span()); } +#[test] +#[should_panic] +fn test_execute_transfer_fails_zero_target() { + let (guild_address, guild, _) = deploy_guild(); + let (token_address, token) = deploy_token(guild_address); + fund_guild(token_address, token, guild_address, 1_000); + + start_cheat_caller_address(guild_address, GOVERNOR()); + guild + .execute_core_action( + ActionType::TRANSFER, + starknet::contract_address_const::<0>(), + token_address, + 1, + array![].span(), + ); +} + +#[test] +#[should_panic] +fn test_execute_transfer_fails_zero_token() { + let (guild_address, guild, _) = deploy_guild(); + + start_cheat_caller_address(guild_address, GOVERNOR()); + guild + .execute_core_action( + ActionType::TRANSFER, + RECIPIENT(), + starknet::contract_address_const::<0>(), + 1, + array![].span(), + ); +} + +#[test] +#[should_panic] +fn test_execute_approve_fails_zero_spender() { + let (guild_address, guild, _) = deploy_guild(); + let (token_address, _) = deploy_token(guild_address); + + start_cheat_caller_address(guild_address, GOVERNOR()); + guild + .execute_core_action( + ActionType::APPROVE, + starknet::contract_address_const::<0>(), + token_address, + 1, + array![].span(), + ); +} + +#[test] +#[should_panic] +fn test_execute_approve_fails_zero_token() { + let (guild_address, guild, _) = deploy_guild(); + + start_cheat_caller_address(guild_address, GOVERNOR()); + guild + .execute_core_action( + ActionType::APPROVE, + SPENDER(), + starknet::contract_address_const::<0>(), + 1, + array![].span(), + ); +} + +#[test] +#[should_panic] +fn test_execute_raw_call_fails_zero_target() { + let (guild_address, guild, _) = deploy_guild(); + start_cheat_caller_address(guild_address, GOVERNOR()); + guild + .execute_core_action( + ActionType::EXECUTE, + starknet::contract_address_const::<0>(), + OTHER(), + 0, + array![selector!("get_call_count")].span(), + ); +} + #[test] fn test_register_plugin_governor() { let (guild_address, guild, view) = deploy_guild(); @@ -278,6 +360,17 @@ fn test_register_plugin_fails_non_governor() { guild.register_plugin('ponziland', ponzi_address, 8, 6); } +#[test] +#[should_panic] +fn test_register_plugin_fails_dissolved() { + let (guild_address, guild, _) = deploy_guild(); + let ponzi_address = deploy_ponziland(); + + start_cheat_caller_address(guild_address, GOVERNOR()); + guild.dissolve(); + guild.register_plugin('ponziland', ponzi_address, 8, 6); +} + #[test] #[should_panic] fn test_register_plugin_fails_already_exists() { @@ -306,6 +399,34 @@ fn test_register_plugin_fails_offset_overflow() { guild.register_plugin('ponziland', ponzi_address, 30, 3); } +#[test] +#[should_panic] +fn test_register_plugin_fails_zero_target() { + let (guild_address, guild, _) = deploy_guild(); + start_cheat_caller_address(guild_address, GOVERNOR()); + guild.register_plugin('ponziland', starknet::contract_address_const::<0>(), 8, 6); +} + +#[test] +#[should_panic] +fn test_register_plugin_fails_zero_action_count() { + let (guild_address, guild, _) = deploy_guild(); + let ponzi_address = deploy_ponziland(); + start_cheat_caller_address(guild_address, GOVERNOR()); + guild.register_plugin('ponziland', ponzi_address, 8, 0); +} + +#[test] +#[should_panic] +fn test_register_plugin_fails_action_bit_collision() { + let (guild_address, guild, _) = deploy_guild(); + let ponzi_address = deploy_ponziland(); + let other_plugin = deploy_ponziland(); + start_cheat_caller_address(guild_address, GOVERNOR()); + guild.register_plugin('ponziland', ponzi_address, 8, 6); + guild.register_plugin('other', other_plugin, 10, 2); +} + #[test] fn test_toggle_plugin_governor() { let (guild_address, guild, view) = deploy_guild();