diff --git a/contract_/src/audition/interfaces/iseason_and_audition.cairo b/contract_/src/audition/interfaces/iseason_and_audition.cairo index 9cdd443..4484d97 100644 --- a/contract_/src/audition/interfaces/iseason_and_audition.cairo +++ b/contract_/src/audition/interfaces/iseason_and_audition.cairo @@ -236,7 +236,58 @@ pub trait ISeasonAndAudition { /// @dev Retrieves the contract address associated with a given performer ID. /// @param performer_id The unique identifier of the performer. /// @return ContractAddress The wallet address of the performer. - fn get_performer_address( - self: @TContractState, audition_id: u256, performer_id: u256, - ) -> ContractAddress; + fn get_performer_address(self: @TContractState, performer_id: u256) -> ContractAddress; + + // Unified voting system functions + /// @notice Casts a unified vote that automatically detects voter role + /// @param audition_id The ID of the audition + /// @param artist_id The ID of the artist being voted for + /// @param ipfs_content_hash Pre-validated IPFS hash containing vote commentary + fn cast_vote( + ref self: TContractState, + audition_id: u256, + artist_id: u256, + ipfs_content_hash: felt252, + ); + + /// @notice Sets voting configuration for an audition + /// @param audition_id The ID of the audition + /// @param config Voting configuration parameters + fn set_voting_config(ref self: TContractState, audition_id: u256, config: VotingConfig); + + /// @notice Gets voting configuration for an audition + /// @param audition_id The ID of the audition + /// @return VotingConfig The voting configuration + fn get_voting_config(self: @TContractState, audition_id: u256) -> VotingConfig; + + /// @notice Sets a celebrity judge with higher voting weight + /// @param audition_id The ID of the audition + /// @param celebrity_judge The address of the celebrity judge + /// @param weight_multiplier The weight multiplier for the celebrity judge + fn set_celebrity_judge( + ref self: TContractState, + audition_id: u256, + celebrity_judge: ContractAddress, + weight_multiplier: u256, + ); + + /// @notice Gets the current score for an artist in an audition + /// @param audition_id The ID of the audition + /// @param artist_id The ID of the artist + /// @return ArtistScore The current score information + fn get_artist_score(self: @TContractState, audition_id: u256, artist_id: u256) -> ArtistScore; + + /// @notice Checks if voting is currently active for an audition + /// @param audition_id The ID of the audition + /// @return bool True if voting is active + fn is_voting_active(self: @TContractState, audition_id: u256) -> bool; + + /// @notice Gets a unified vote + /// @param audition_id The ID of the audition + /// @param artist_id The ID of the artist + /// @param voter The address of the voter + /// @return UnifiedVote The vote information + fn get_unified_vote( + self: @TContractState, audition_id: u256, artist_id: u256, voter: ContractAddress, + ) -> UnifiedVote; } diff --git a/contract_/src/audition/season_and_audition.cairo b/contract_/src/audition/season_and_audition.cairo index 6250553..19fb461 100644 --- a/contract_/src/audition/season_and_audition.cairo +++ b/contract_/src/audition/season_and_audition.cairo @@ -1,9 +1,10 @@ #[starknet::contract] pub mod SeasonAndAudition { - use OwnableComponent::{HasComponent, InternalTrait}; + use OwnableComponent::InternalTrait; use contract_::audition::interfaces::iseason_and_audition::ISeasonAndAudition; + use contract_::audition::interfaces::istake_to_vote::{IStakeToVoteDispatcher, IStakeToVoteDispatcherTrait}; use contract_::audition::types::season_and_audition::{ - Appeal, ArtistRegistration, Audition, Evaluation, Genre, RegistrationConfig, Season, Vote, + Appeal, Audition, Evaluation, Genre, Season, Vote, VoteType, UnifiedVote, VotingConfig, ArtistScore, }; use contract_::errors::errors; use core::num::traits::Zero; @@ -16,13 +17,12 @@ pub mod SeasonAndAudition { }; use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_contract_address}; use crate::events::{ - AggregateScoreCalculated, AppealResolved, AppealSubmitted, ArtistRegistered, - AuditionCalculationCompleted, AuditionCreated, AuditionDeleted, AuditionEnded, - AuditionPaused, AuditionResumed, AuditionUpdated, EvaluationSubmitted, EvaluationWeightSet, - JudgeAdded, JudgeRemoved, OracleAdded, OracleRemoved, PausedAll, PriceDeposited, - PriceDistributed, RegistrationConfigSet, ResultSubmitted, ResultsSubmitted, ResumedAll, - SeasonCreated, SeasonDeleted, SeasonEnded, SeasonPaused, SeasonResumed, SeasonUpdated, - VoteRecorded, + AggregateScoreCalculated, AppealResolved, AppealSubmitted, ArtistScoreUpdated, AuditionCalculationCompleted, + AuditionCreated, AuditionDeleted, AuditionEnded, AuditionPaused, AuditionResumed, + AuditionUpdated, CelebrityJudgeSet, EvaluationSubmitted, EvaluationWeightSet, JudgeAdded, JudgeRemoved, + OracleAdded, OracleRemoved, PausedAll, PriceDeposited, PriceDistributed, ResultSubmitted, + ResultsSubmitted, ResumedAll, SeasonCreated, SeasonDeleted, SeasonEnded, SeasonPaused, + SeasonResumed, SeasonUpdated, UnifiedVoteCast, VoteRecorded, VotingConfigSet, }; // Integrates OpenZeppelin ownership component @@ -137,6 +137,18 @@ pub mod SeasonAndAudition { performer_registry: Map<(u256, u256), ContractAddress>, /// @notice a count of performer performers_count: u256, + /// @notice unified voting system storage + unified_votes: Map<(u256, u256, ContractAddress), UnifiedVote>, + /// @notice tracks if a voter has voted for a specific artist in an audition + has_voted: Map<(ContractAddress, u256, u256), bool>, + /// @notice voting configuration for each audition + voting_configs: Map, + /// @notice artist scores for real-time updates + artist_scores: Map<(u256, u256), ArtistScore>, + /// @notice celebrity judges with special weights + celebrity_judges: Map<(u256, ContractAddress), u256>, + /// @notice staking contract address for integration + staking_contract: ContractAddress, } #[event] @@ -175,13 +187,18 @@ pub mod SeasonAndAudition { RegistrationConfigSet: RegistrationConfigSet, ArtistRegistered: ArtistRegistered, ResultSubmitted: ResultSubmitted, + UnifiedVoteCast: UnifiedVoteCast, + VotingConfigSet: VotingConfigSet, + ArtistScoreUpdated: ArtistScoreUpdated, + CelebrityJudgeSet: CelebrityJudgeSet, } #[constructor] - fn constructor(ref self: ContractState, owner: ContractAddress) { + fn constructor(ref self: ContractState, owner: ContractAddress, staking_contract: ContractAddress) { self.ownable.initializer(owner); self.global_paused.write(false); self.judging_paused.write(false); + self.staking_contract.write(staking_contract); } #[abi(embed_v0)] @@ -1238,6 +1255,154 @@ pub mod SeasonAndAudition { ) -> ContractAddress { self.performer_registry.entry((audition_id, performer_id)).read() } + + fn cast_vote( + ref self: ContractState, + audition_id: u256, + artist_id: u256, + ipfs_content_hash: felt252, + ) { + let caller = get_caller_address(); + + // 1. Prevent double voting + assert( + !self.has_voted.read((caller, audition_id, artist_id)), + 'Already voted for this artist' + ); + + // 2. Verify voting is active + assert(self.is_voting_active(audition_id), 'Voting is not active'); + + // 3. Verify audition exists and is not paused + assert(self.audition_exists(audition_id), 'Audition does not exist'); + assert(!self.is_audition_paused(audition_id), 'Audition is paused'); + assert(!self.global_paused.read(), 'Contract is paused'); + + // 4. Auto-detect role (judge or staker) and determine vote weight and type + let (vote_weight, vote_type) = self._determine_voter_role_and_weight(audition_id, caller); + + // 5. Record vote and update scores + let unified_vote = UnifiedVote { + voter: caller, + artist_id, + audition_id, + weight: vote_weight, + vote_type, + ipfs_content_hash, + timestamp: get_block_timestamp(), + }; + + self.unified_votes.write((audition_id, artist_id, caller), unified_vote); + + // 6. Mark as voted + self.has_voted.write((caller, audition_id, artist_id), true); + + // 7. Update real-time scores + self._update_artist_score(audition_id, artist_id, vote_weight, vote_type); + + // 8. Emit voting event + self.emit( + Event::UnifiedVoteCast( + UnifiedVoteCast { + audition_id, + artist_id, + voter: caller, + weight: vote_weight, + vote_type, + ipfs_content_hash, + timestamp: get_block_timestamp(), + } + ) + ); + } + + fn set_voting_config(ref self: ContractState, audition_id: u256, config: VotingConfig) { + self.ownable.assert_only_owner(); + assert(self.audition_exists(audition_id), 'Audition does not exist'); + assert(!self.global_paused.read(), 'Contract is paused'); + + self.voting_configs.write(audition_id, config); + + self.emit( + Event::VotingConfigSet( + VotingConfigSet { + audition_id, + voting_start_time: config.voting_start_time, + voting_end_time: config.voting_end_time, + staker_base_weight: config.staker_base_weight, + judge_base_weight: config.judge_base_weight, + celebrity_weight_multiplier: config.celebrity_weight_multiplier, + } + ) + ); + } + + fn get_voting_config(self: @ContractState, audition_id: u256) -> VotingConfig { + let config = self.voting_configs.read(audition_id); + if config.staker_base_weight == 0 { + // Return default config + VotingConfig { + voting_start_time: 0, + voting_end_time: 0, + staker_base_weight: 50, // 0.5 * 100 for precision + judge_base_weight: 1000, // 10.0 * 100 for precision + celebrity_weight_multiplier: 150, // 1.5x multiplier * 100 for precision + } + } else { + config + } + } + + fn set_celebrity_judge( + ref self: ContractState, + audition_id: u256, + celebrity_judge: ContractAddress, + weight_multiplier: u256, + ) { + self.ownable.assert_only_owner(); + assert(self.audition_exists(audition_id), 'Audition does not exist'); + assert(!celebrity_judge.is_zero(), 'Celebrity judge cannot be zero'); + assert(weight_multiplier > 100, 'Multiplier must be > 100'); // >1.0x + assert(!self.global_paused.read(), 'Contract is paused'); + + // Verify they are already a judge + self.assert_judge_found(audition_id, celebrity_judge); + + self.celebrity_judges.write((audition_id, celebrity_judge), weight_multiplier); + + self.emit( + Event::CelebrityJudgeSet( + CelebrityJudgeSet { + audition_id, + celebrity_judge, + weight_multiplier, + timestamp: get_block_timestamp(), + } + ) + ); + } + + fn get_artist_score(self: @ContractState, audition_id: u256, artist_id: u256) -> ArtistScore { + self.artist_scores.read((audition_id, artist_id)) + } + + fn is_voting_active(self: @ContractState, audition_id: u256) -> bool { + let config = self.get_voting_config(audition_id); + let current_time = get_block_timestamp(); + + // If no specific voting times are set, use audition timing + if config.voting_start_time == 0 && config.voting_end_time == 0 { + return !self.is_audition_ended(audition_id) && !self.is_audition_paused(audition_id); + } + + current_time >= config.voting_start_time && current_time <= config.voting_end_time + } + + fn get_unified_vote( + self: @ContractState, audition_id: u256, artist_id: u256, voter: ContractAddress, + ) -> UnifiedVote { + self.unified_votes.read((audition_id, artist_id, voter)) + } } #[generate_trait] @@ -1467,5 +1632,99 @@ pub mod SeasonAndAudition { + (audition.end_timestamp - audition.start_timestamp) / 2; assert(get_block_timestamp() < halfway_time, 'Audition has gone halfway'); } + + /// @notice Determines the voter's role and calculates their voting weight + /// @param audition_id The ID of the audition + /// @param voter The address of the voter + /// @return (vote_weight, vote_type) Tuple of weight and voter type + fn _determine_voter_role_and_weight( + self: @ContractState, audition_id: u256, voter: ContractAddress, + ) -> (u256, VoteType) { + let config = self.get_voting_config(audition_id); + + // Check if voter is a judge + let judges = self.get_judges(audition_id); + let mut is_judge = false; + for judge in judges { + if judge == voter { + is_judge = true; + break; + } + }; + + if is_judge { + // Check if they are a celebrity judge + let celebrity_multiplier = self.celebrity_judges.read((audition_id, voter)); + if celebrity_multiplier > 0 { + // Celebrity judge gets base weight * multiplier + let celebrity_weight = config.judge_base_weight * celebrity_multiplier / 100; + (celebrity_weight, VoteType::Judge) + } else { + // Regular judge + (config.judge_base_weight, VoteType::Judge) + } + } else { + // Check if they are an eligible staker + let staking_dispatcher = IStakeToVoteDispatcher { + contract_address: self.staking_contract.read(), + }; + + assert( + staking_dispatcher.is_eligible_voter(audition_id, voter), + 'Not eligible to vote' + ); + + (config.staker_base_weight, VoteType::Staker) + } + } + + /// @notice Updates an artist's score in real-time + /// @param audition_id The ID of the audition + /// @param artist_id The ID of the artist + /// @param vote_weight The weight of the vote being added + /// @param vote_type The type of the vote (Judge or Staker) + fn _update_artist_score( + ref self: ContractState, + audition_id: u256, + artist_id: u256, + vote_weight: u256, + vote_type: VoteType, + ) { + let mut score = self.artist_scores.read((audition_id, artist_id)); + + // Initialize if this is the first vote for this artist + if score.artist_id == 0 { + score.artist_id = artist_id; + } + + // Update total score + score.total_score += vote_weight; + + // Update vote counts + match vote_type { + VoteType::Judge => { score.judge_votes += 1; }, + VoteType::Staker => { score.staker_votes += 1; }, + } + + // Update timestamp + score.last_updated = get_block_timestamp(); + + // Save updated score + self.artist_scores.write((audition_id, artist_id), score); + + // Emit score update event + self.emit( + Event::ArtistScoreUpdated( + ArtistScoreUpdated { + audition_id, + artist_id, + total_score: score.total_score, + judge_votes: score.judge_votes, + staker_votes: score.staker_votes, + timestamp: get_block_timestamp(), + } + ) + ); + } } } diff --git a/contract_/src/audition/types/season_and_audition.cairo b/contract_/src/audition/types/season_and_audition.cairo index 6362337..d629478 100644 --- a/contract_/src/audition/types/season_and_audition.cairo +++ b/contract_/src/audition/types/season_and_audition.cairo @@ -87,6 +87,42 @@ pub struct Vote { pub weight: felt252, } +#[derive(Drop, Serde, starknet::Store, PartialEq, Clone, Copy)] +#[allow(starknet::store_no_default_variant)] +pub enum VoteType { + Judge, + Staker, +} + +#[derive(Drop, Serde, Default, starknet::Store)] +pub struct UnifiedVote { + pub voter: ContractAddress, + pub artist_id: u256, + pub audition_id: u256, + pub weight: u256, + pub vote_type: VoteType, + pub ipfs_content_hash: felt252, + pub timestamp: u64, +} + +#[derive(Drop, Serde, Default, starknet::Store, Copy)] +pub struct VotingConfig { + pub voting_start_time: u64, + pub voting_end_time: u64, + pub staker_base_weight: u256, + pub judge_base_weight: u256, + pub celebrity_weight_multiplier: u256, +} + +#[derive(Drop, Serde, Default, starknet::Store, Copy)] +pub struct ArtistScore { + pub artist_id: u256, + pub total_score: u256, + pub judge_votes: u32, + pub staker_votes: u32, + pub last_updated: u64, +} + /// @notice Evaluation struct for storing performer evaluations /// @param audition_id The ID of the audition being evaluated /// @param performer The address of the performer being evaluated @@ -109,6 +145,12 @@ pub struct Appeal { pub resolution_comment: felt252, } +impl DefaultVoteType of Default { + fn default() -> VoteType { + VoteType::Staker + } +} + // Implement default for contract address type impl DefaultImpl of Default { fn default() -> ContractAddress { diff --git a/contract_/src/events.cairo b/contract_/src/events.cairo index fe0e98d..21ac457 100644 --- a/contract_/src/events.cairo +++ b/contract_/src/events.cairo @@ -472,3 +472,51 @@ pub struct StakingConfigUpdated { pub config: contract_::audition::types::stake_to_vote::StakingConfig, pub timestamp: u64, } + +// Unified voting system events +#[derive(Drop, starknet::Event)] +pub struct UnifiedVoteCast { + #[key] + pub audition_id: u256, + #[key] + pub artist_id: u256, + #[key] + pub voter: ContractAddress, + pub weight: u256, + pub vote_type: contract_::audition::types::season_and_audition::VoteType, + pub ipfs_content_hash: felt252, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct VotingConfigSet { + #[key] + pub audition_id: u256, + pub voting_start_time: u64, + pub voting_end_time: u64, + pub staker_base_weight: u256, + pub judge_base_weight: u256, + pub celebrity_weight_multiplier: u256, +} + +#[derive(Drop, starknet::Event)] +pub struct ArtistScoreUpdated { + #[key] + pub audition_id: u256, + #[key] + pub artist_id: u256, + pub total_score: u256, + pub judge_votes: u32, + pub staker_votes: u32, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct CelebrityJudgeSet { + #[key] + pub audition_id: u256, + #[key] + pub celebrity_judge: ContractAddress, + pub weight_multiplier: u256, + pub timestamp: u64, +} diff --git a/contract_/tests/test_unified_voting_system.cairo b/contract_/tests/test_unified_voting_system.cairo new file mode 100644 index 0000000..e374c26 --- /dev/null +++ b/contract_/tests/test_unified_voting_system.cairo @@ -0,0 +1,593 @@ +use contract_::audition::interfaces::iseason_and_audition::{ + ISeasonAndAuditionDispatcher, ISeasonAndAuditionDispatcherTrait, +}; +use contract_::audition::interfaces::istake_to_vote::{ + IStakeToVoteDispatcher, IStakeToVoteDispatcherTrait, +}; +use contract_::audition::types::season_and_audition::{ + Genre, VotingConfig, VoteType, ArtistScore, UnifiedVote, +}; +use contract_::events::{ + UnifiedVoteCast, VotingConfigSet, ArtistScoreUpdated, CelebrityJudgeSet, +}; +use snforge_std::{ + start_cheat_caller_address, stop_cheat_caller_address, start_cheat_block_timestamp, + stop_cheat_block_timestamp, spy_events, EventSpyAssertionsTrait, +}; +use starknet::{ContractAddress, get_block_timestamp, contract_address_const}; +use crate::test_utils::{ + OWNER, USER, NON_OWNER, deploy_contracts_with_staking, default_contract_create_season, + default_contract_create_audition, +}; + +// Test helper functions for addresses +fn JUDGE1() -> ContractAddress { + 'JUDGE1'.try_into().unwrap() +} + +fn JUDGE2() -> ContractAddress { + 'JUDGE2'.try_into().unwrap() +} + +fn CELEBRITY_JUDGE() -> ContractAddress { + 'CELEBRITY_JUDGE'.try_into().unwrap() +} + +fn STAKER1() -> ContractAddress { + 'STAKER1'.try_into().unwrap() +} + +fn STAKER2() -> ContractAddress { + 'STAKER2'.try_into().unwrap() +} + +fn STAKER3() -> ContractAddress { + 'STAKER3'.try_into().unwrap() +} + +// Test helper functions for IPFS hashes +fn IPFS_HASH_1() -> felt252 { + 'QmYjtig7VJQ6anUjqq' +} + +fn IPFS_HASH_2() -> felt252 { + 'QmPK1s3pNYLi9ERsiq3' +} + +fn IPFS_HASH_3() -> felt252 { + 'QmT78zSuBmHaJ56dDQa' +} + +// Helper function to setup audition with participants +fn setup_audition_with_participants() -> (ISeasonAndAuditionDispatcher, IStakeToVoteDispatcher, u256) { + let (audition_dispatcher, staking_dispatcher, _) = deploy_contracts_with_staking(); + + start_cheat_caller_address(audition_dispatcher.contract_address, OWNER()); + start_cheat_caller_address(staking_dispatcher.contract_address, OWNER()); + + // Create season and audition + default_contract_create_season(audition_dispatcher); + default_contract_create_audition(audition_dispatcher); + + let audition_id = 1_u256; + + // Add judges + audition_dispatcher.add_judge(audition_id, JUDGE1()); + audition_dispatcher.add_judge(audition_id, JUDGE2()); + audition_dispatcher.add_judge(audition_id, CELEBRITY_JUDGE()); + + // Set celebrity judge with higher weight + audition_dispatcher.set_celebrity_judge(audition_id, CELEBRITY_JUDGE(), 200); // 2x multiplier + + // Set up staking configuration for the audition + let stake_token_address = contract_address_const::<0x1234>(); + staking_dispatcher.set_staking_config(audition_id, 1000, stake_token_address, 86400); // 1 day withdrawal delay + + // Add eligible stakers by having them stake + start_cheat_caller_address(staking_dispatcher.contract_address, STAKER1()); + staking_dispatcher.stake_to_vote(audition_id); + + start_cheat_caller_address(staking_dispatcher.contract_address, STAKER2()); + staking_dispatcher.stake_to_vote(audition_id); + + start_cheat_caller_address(staking_dispatcher.contract_address, STAKER3()); + staking_dispatcher.stake_to_vote(audition_id); + + stop_cheat_caller_address(audition_dispatcher.contract_address); + stop_cheat_caller_address(staking_dispatcher.contract_address); + + (audition_dispatcher, staking_dispatcher, audition_id) +} + +// TEST 1: Integration test with both judge and staker votes +#[test] +fn test_unified_voting_integration_judge_and_staker_votes() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + let mut spy = spy_events(); + + let artist_id = 1_u256; + + // Test 1: Judge vote + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_1()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Verify judge vote was recorded + let judge_vote = audition_dispatcher.get_unified_vote(audition_id, artist_id, JUDGE1()); + assert(judge_vote.voter == JUDGE1(), 'Wrong judge voter'); + assert(judge_vote.artist_id == artist_id, 'Wrong artist ID'); + assert(judge_vote.audition_id == audition_id, 'Wrong audition ID'); + assert(judge_vote.weight == 1000, 'Wrong judge weight'); // Default judge weight + assert(judge_vote.vote_type == VoteType::Judge, 'Wrong vote type'); + assert(judge_vote.ipfs_content_hash == IPFS_HASH_1(), 'Wrong IPFS hash'); + + // Test 2: Staker vote + start_cheat_caller_address(audition_dispatcher.contract_address, STAKER1()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_2()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Verify staker vote was recorded + let staker_vote = audition_dispatcher.get_unified_vote(audition_id, artist_id, STAKER1()); + assert(staker_vote.voter == STAKER1(), 'Wrong staker voter'); + assert(staker_vote.artist_id == artist_id, 'Wrong artist ID'); + assert(staker_vote.weight == 50, 'Wrong staker weight'); // Default staker weight + assert(staker_vote.vote_type == VoteType::Staker, 'Wrong vote type'); + assert(staker_vote.ipfs_content_hash == IPFS_HASH_2(), 'Wrong IPFS hash'); + + // Test 3: Celebrity judge vote with higher weight + let artist_id_2 = 2_u256; + start_cheat_caller_address(audition_dispatcher.contract_address, CELEBRITY_JUDGE()); + audition_dispatcher.cast_vote(audition_id, artist_id_2, IPFS_HASH_3()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Verify celebrity judge vote + let celebrity_vote = audition_dispatcher.get_unified_vote(audition_id, artist_id_2, CELEBRITY_JUDGE()); + assert(celebrity_vote.weight == 2000, 'Wrong celebrity weight'); // 1000 * 2.0 multiplier + assert(celebrity_vote.vote_type == VoteType::Judge, 'Wrong celebrity vote type'); + + // Verify events were emitted + spy.assert_emitted(@array![ + (audition_dispatcher.contract_address, UnifiedVoteCast { + audition_id, + artist_id, + voter: JUDGE1(), + weight: 1000, + vote_type: VoteType::Judge, + ipfs_content_hash: IPFS_HASH_1(), + timestamp: get_block_timestamp(), + }) + ]); +} + +// TEST 2: Automatic role detection accuracy +#[test] +fn test_automatic_role_detection_accuracy() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + let artist_id = 1_u256; + + // Test 1: Regular judge detection + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_1()); + let vote = audition_dispatcher.get_unified_vote(audition_id, artist_id, JUDGE1()); + assert(vote.vote_type == VoteType::Judge, 'Should detect judge'); + assert(vote.weight == 1000, 'Wrong judge weight'); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Test 2: Celebrity judge detection (higher weight) + start_cheat_caller_address(audition_dispatcher.contract_address, CELEBRITY_JUDGE()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_2()); + let celebrity_vote = audition_dispatcher.get_unified_vote(audition_id, artist_id, CELEBRITY_JUDGE()); + assert(celebrity_vote.vote_type == VoteType::Judge, 'Should detect celebrity judge'); + assert(celebrity_vote.weight == 2000, 'Wrong celebrity weight'); // 1000 * 2.0 + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Test 3: Staker detection + start_cheat_caller_address(audition_dispatcher.contract_address, STAKER1()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_3()); + let staker_vote = audition_dispatcher.get_unified_vote(audition_id, artist_id, STAKER1()); + assert(staker_vote.vote_type == VoteType::Staker, 'Should detect staker'); + assert(staker_vote.weight == 50, 'Wrong staker weight'); + stop_cheat_caller_address(audition_dispatcher.contract_address); +} + +// TEST 3: Test ineligible user cannot vote +#[test] +#[should_panic(expected: ('Not eligible to vote',))] +fn test_automatic_role_detection_ineligible_user() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + // Try to vote as a user who is neither judge nor staker + start_cheat_caller_address(audition_dispatcher.contract_address, NON_OWNER()); + audition_dispatcher.cast_vote(audition_id, 1_u256, IPFS_HASH_1()); +} + +// TEST 4: Double voting prevention +#[test] +#[should_panic(expected: ('Already voted for this artist',))] +fn test_double_voting_prevention() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + let artist_id = 1_u256; + + // First vote should succeed + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_1()); + + // Second vote for same artist should fail + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_2()); +} + +// TEST 5: Double voting prevention - different artists allowed +#[test] +fn test_double_voting_prevention_different_artists() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + + // Should be able to vote for different artists + audition_dispatcher.cast_vote(audition_id, 1_u256, IPFS_HASH_1()); + audition_dispatcher.cast_vote(audition_id, 2_u256, IPFS_HASH_2()); + audition_dispatcher.cast_vote(audition_id, 3_u256, IPFS_HASH_3()); + + // Verify all votes were recorded + let vote1 = audition_dispatcher.get_unified_vote(audition_id, 1_u256, JUDGE1()); + let vote2 = audition_dispatcher.get_unified_vote(audition_id, 2_u256, JUDGE1()); + let vote3 = audition_dispatcher.get_unified_vote(audition_id, 3_u256, JUDGE1()); + + assert(vote1.artist_id == 1_u256, 'Vote 1 not recorded'); + assert(vote2.artist_id == 2_u256, 'Vote 2 not recorded'); + assert(vote3.artist_id == 3_u256, 'Vote 3 not recorded'); + + stop_cheat_caller_address(audition_dispatcher.contract_address); +} + +// TEST 6: Real-time score updates +#[test] +fn test_real_time_score_updates() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + let mut spy = spy_events(); + + let artist_id = 1_u256; + + // Initial score should be zero + let initial_score = audition_dispatcher.get_artist_score(audition_id, artist_id); + assert(initial_score.total_score == 0, 'Initial score should be 0'); + assert(initial_score.judge_votes == 0, 'Initial judge votes = 0'); + assert(initial_score.staker_votes == 0, 'Initial staker votes = 0'); + + // Judge vote should update score + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_1()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + let score_after_judge = audition_dispatcher.get_artist_score(audition_id, artist_id); + assert(score_after_judge.total_score == 1000, 'Score should be 1000'); + assert(score_after_judge.judge_votes == 1, 'Judge votes should be 1'); + assert(score_after_judge.staker_votes == 0, 'Staker votes should be 0'); + assert(score_after_judge.artist_id == artist_id, 'Wrong artist ID'); + + // Staker vote should add to score + start_cheat_caller_address(audition_dispatcher.contract_address, STAKER1()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_2()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + let score_after_staker = audition_dispatcher.get_artist_score(audition_id, artist_id); + assert(score_after_staker.total_score == 1050, 'Score should be 1050'); // 1000 + 50 + assert(score_after_staker.judge_votes == 1, 'Judge votes should be 1'); + assert(score_after_staker.staker_votes == 1, 'Staker votes should be 1'); + + // Celebrity judge vote should add higher weight + start_cheat_caller_address(audition_dispatcher.contract_address, CELEBRITY_JUDGE()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_3()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + let score_after_celebrity = audition_dispatcher.get_artist_score(audition_id, artist_id); + assert(score_after_celebrity.total_score == 3050, 'Score should be 3050'); // 1050 + 2000 + assert(score_after_celebrity.judge_votes == 2, 'Judge votes should be 2'); + assert(score_after_celebrity.staker_votes == 1, 'Staker votes should be 1'); + + // Verify score timestamp was updated + assert(score_after_celebrity.last_updated > 0, 'Score timestamp should be set'); +} + +// TEST 7: IPFS hash integration +#[test] +fn test_ipfs_hash_integration() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + let artist_id = 1_u256; + + // Test different IPFS hashes are properly stored + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_1()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE2()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_2()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + start_cheat_caller_address(audition_dispatcher.contract_address, STAKER1()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_3()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Verify IPFS hashes are correctly stored + let vote1 = audition_dispatcher.get_unified_vote(audition_id, artist_id, JUDGE1()); + let vote2 = audition_dispatcher.get_unified_vote(audition_id, artist_id, JUDGE2()); + let vote3 = audition_dispatcher.get_unified_vote(audition_id, artist_id, STAKER1()); + + assert(vote1.ipfs_content_hash == IPFS_HASH_1(), 'Wrong IPFS hash 1'); + assert(vote2.ipfs_content_hash == IPFS_HASH_2(), 'Wrong IPFS hash 2'); + assert(vote3.ipfs_content_hash == IPFS_HASH_3(), 'Wrong IPFS hash 3'); +} + +// TEST 8: Voting window enforcement with custom config +#[test] +fn test_voting_window_enforcement_custom_config() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + start_cheat_caller_address(audition_dispatcher.contract_address, OWNER()); + + let current_time = get_block_timestamp(); + let voting_start = current_time + 100; + let voting_end = current_time + 200; + + // Set custom voting window + let voting_config = VotingConfig { + voting_start_time: voting_start, + voting_end_time: voting_end, + staker_base_weight: 75, + judge_base_weight: 1500, + celebrity_weight_multiplier: 250, + }; + + audition_dispatcher.set_voting_config(audition_id, voting_config); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Test voting before window opens + assert(!audition_dispatcher.is_voting_active(audition_id), 'Voting should not be active'); + + // Move to voting window + start_cheat_block_timestamp(audition_dispatcher.contract_address, voting_start + 10); + assert(audition_dispatcher.is_voting_active(audition_id), 'Voting should be active'); + + // Test successful vote during window + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, 1_u256, IPFS_HASH_1()); + + // Verify custom weights are applied + let vote = audition_dispatcher.get_unified_vote(audition_id, 1_u256, JUDGE1()); + assert(vote.weight == 1500, 'Wrong custom judge weight'); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Move past voting window + start_cheat_block_timestamp(audition_dispatcher.contract_address, voting_end + 10); + assert(!audition_dispatcher.is_voting_active(audition_id), 'Voting should not be active'); + + stop_cheat_block_timestamp(audition_dispatcher.contract_address); +} + +// TEST 9: Voting before window opens (should fail) +#[test] +#[should_panic(expected: ('Voting is not active',))] +fn test_voting_window_enforcement_before_window() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + start_cheat_caller_address(audition_dispatcher.contract_address, OWNER()); + + let current_time = get_block_timestamp(); + let voting_start = current_time + 100; + let voting_end = current_time + 200; + + let voting_config = VotingConfig { + voting_start_time: voting_start, + voting_end_time: voting_end, + staker_base_weight: 50, + judge_base_weight: 1000, + celebrity_weight_multiplier: 150, + }; + + audition_dispatcher.set_voting_config(audition_id, voting_config); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Try to vote before window opens (should fail) + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, 1_u256, IPFS_HASH_1()); +} + +// TEST 10: Staking contract integration +#[test] +fn test_staking_contract_integration() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + // Test that stakers can vote + start_cheat_caller_address(audition_dispatcher.contract_address, STAKER1()); + audition_dispatcher.cast_vote(audition_id, 1_u256, IPFS_HASH_1()); + + let vote = audition_dispatcher.get_unified_vote(audition_id, 1_u256, STAKER1()); + assert(vote.vote_type == VoteType::Staker, 'Should be staker vote'); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Test that multiple stakers can vote + start_cheat_caller_address(audition_dispatcher.contract_address, STAKER2()); + audition_dispatcher.cast_vote(audition_id, 2_u256, IPFS_HASH_2()); + + let vote2 = audition_dispatcher.get_unified_vote(audition_id, 2_u256, STAKER2()); + assert(vote2.vote_type == VoteType::Staker, 'Should be staker vote 2'); + stop_cheat_caller_address(audition_dispatcher.contract_address); +} + +// TEST 11: Comprehensive event emission +#[test] +fn test_comprehensive_event_emission() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + let mut spy = spy_events(); + + let current_time = get_block_timestamp(); + let artist_id = 1_u256; + + // Test UnifiedVoteCast event + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, artist_id, IPFS_HASH_1()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Verify at least the voting event was emitted + spy.assert_emitted(@array![ + (audition_dispatcher.contract_address, UnifiedVoteCast { + audition_id, + artist_id, + voter: JUDGE1(), + weight: 1000, + vote_type: VoteType::Judge, + ipfs_content_hash: IPFS_HASH_1(), + timestamp: current_time, + }) + ]); +} + +// TEST 12: Edge case - nonexistent audition +#[test] +#[should_panic(expected: ('Audition does not exist',))] +fn test_edge_case_nonexistent_audition() { + let (audition_dispatcher, _staking_dispatcher, _audition_id) = setup_audition_with_participants(); + + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(999_u256, 1_u256, IPFS_HASH_1()); // Nonexistent audition +} + +// TEST 13: Edge case - paused audition +#[test] +#[should_panic(expected: ('Audition is paused',))] +fn test_edge_case_paused_audition() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + // Pause the audition + start_cheat_caller_address(audition_dispatcher.contract_address, OWNER()); + audition_dispatcher.pause_audition(audition_id); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Try to vote on paused audition + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, 1_u256, IPFS_HASH_1()); +} + +// TEST 14: Edge case - global pause +#[test] +#[should_panic(expected: ('Contract is paused',))] +fn test_edge_case_global_pause() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + // Pause the entire contract + start_cheat_caller_address(audition_dispatcher.contract_address, OWNER()); + audition_dispatcher.pause_all(); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Try to vote when globally paused + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, 1_u256, IPFS_HASH_1()); +} + +// TEST 15: Edge case - ended audition +#[test] +#[should_panic(expected: ('Voting is not active',))] +fn test_edge_case_ended_audition() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + // End the audition + start_cheat_caller_address(audition_dispatcher.contract_address, OWNER()); + audition_dispatcher.end_audition(audition_id); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Try to vote on ended audition + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, 1_u256, IPFS_HASH_1()); +} + +// TEST 16: Edge case - zero artist ID (should work) +#[test] +fn test_edge_case_zero_artist_id() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + // Should be able to vote for artist with ID 0 + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, 0_u256, IPFS_HASH_1()); + + let vote = audition_dispatcher.get_unified_vote(audition_id, 0_u256, JUDGE1()); + assert(vote.artist_id == 0_u256, 'Should accept zero artist ID'); + stop_cheat_caller_address(audition_dispatcher.contract_address); +} + +// TEST 17: Complex scenario with multiple artists and voters +#[test] +fn test_complex_scenario_multiple_artists_and_voters() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + // Artist 1: Gets votes from 1 judge, 1 celebrity judge, 2 stakers + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE1()); + audition_dispatcher.cast_vote(audition_id, 1_u256, IPFS_HASH_1()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + start_cheat_caller_address(audition_dispatcher.contract_address, CELEBRITY_JUDGE()); + audition_dispatcher.cast_vote(audition_id, 1_u256, IPFS_HASH_2()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + start_cheat_caller_address(audition_dispatcher.contract_address, STAKER1()); + audition_dispatcher.cast_vote(audition_id, 1_u256, IPFS_HASH_3()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + start_cheat_caller_address(audition_dispatcher.contract_address, STAKER2()); + audition_dispatcher.cast_vote(audition_id, 1_u256, IPFS_HASH_1()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Artist 2: Gets votes from 1 judge, 1 staker + start_cheat_caller_address(audition_dispatcher.contract_address, JUDGE2()); + audition_dispatcher.cast_vote(audition_id, 2_u256, IPFS_HASH_2()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + start_cheat_caller_address(audition_dispatcher.contract_address, STAKER3()); + audition_dispatcher.cast_vote(audition_id, 2_u256, IPFS_HASH_3()); + stop_cheat_caller_address(audition_dispatcher.contract_address); + + // Verify scores + let score1 = audition_dispatcher.get_artist_score(audition_id, 1_u256); + let score2 = audition_dispatcher.get_artist_score(audition_id, 2_u256); + + // Artist 1: 1000 (judge) + 2000 (celebrity) + 50 (staker1) + 50 (staker2) = 3100 + assert(score1.total_score == 3100, 'Wrong Artist1 total score'); + assert(score1.judge_votes == 2, 'Wrong Artist1 judge votes'); + assert(score1.staker_votes == 2, 'Wrong Artist1 staker votes'); + + // Artist 2: 1000 (judge) + 50 (staker) = 1050 + assert(score2.total_score == 1050, 'Wrong Artist2 total score'); + assert(score2.judge_votes == 1, 'Wrong Artist2 judge votes'); + assert(score2.staker_votes == 1, 'Wrong Artist2 staker votes'); +} + +// TEST 18: Voting config persistence +#[test] +fn test_voting_config_persistence() { + let (audition_dispatcher, _staking_dispatcher, audition_id) = setup_audition_with_participants(); + + start_cheat_caller_address(audition_dispatcher.contract_address, OWNER()); + + // Set custom config + let custom_config = VotingConfig { + voting_start_time: 1000, + voting_end_time: 2000, + staker_base_weight: 75, + judge_base_weight: 1500, + celebrity_weight_multiplier: 250, + }; + + audition_dispatcher.set_voting_config(audition_id, custom_config); + + // Retrieve and verify config persisted + let retrieved_config = audition_dispatcher.get_voting_config(audition_id); + assert(retrieved_config.voting_start_time == 1000, 'Wrong start time'); + assert(retrieved_config.voting_end_time == 2000, 'Wrong end time'); + assert(retrieved_config.staker_base_weight == 75, 'Wrong staker weight'); + assert(retrieved_config.judge_base_weight == 1500, 'Wrong judge weight'); + assert(retrieved_config.celebrity_weight_multiplier == 250, 'Wrong celebrity multiplier'); + + stop_cheat_caller_address(audition_dispatcher.contract_address); +} \ No newline at end of file diff --git a/contract_/tests/test_utils.cairo b/contract_/tests/test_utils.cairo index c6577ad..ced7062 100644 --- a/contract_/tests/test_utils.cairo +++ b/contract_/tests/test_utils.cairo @@ -2,6 +2,9 @@ use contract_::audition::interfaces::iseason_and_audition::{ ISeasonAndAuditionDispatcher, ISeasonAndAuditionDispatcherTrait, ISeasonAndAuditionSafeDispatcher, }; +use contract_::audition::interfaces::istake_to_vote::{ + IStakeToVoteDispatcher, IStakeToVoteDispatcherTrait, +}; use contract_::audition::types::season_and_audition::{ Appeal, Audition, Evaluation, Genre, Season, Vote, }; @@ -58,19 +61,41 @@ pub fn VOTER2() -> ContractAddress { 'VOTER2'.try_into().unwrap() } +// Helper function to deploy staking contract +pub fn deploy_staking_contract() -> IStakeToVoteDispatcher { + // Deploy staking contract + let staking_class = declare("StakeToVote") + .expect('Failed to declare StakeToVote') + .contract_class(); + + let mut staking_calldata: Array = array![]; + OWNER().serialize(ref staking_calldata); + zero().serialize(ref staking_calldata); // temporary audition address, will be updated + + let (staking_address, _) = staking_class + .deploy(@staking_calldata) + .expect('Failed to deploy StakeToVote'); + + IStakeToVoteDispatcher { contract_address: staking_address } +} + // Helper function to deploy the contract pub fn deploy_contract() -> ( ISeasonAndAuditionDispatcher, IOwnableDispatcher, ISeasonAndAuditionSafeDispatcher, ) { - // declare the contract + // Deploy real staking contract first + let staking_dispatcher = deploy_staking_contract(); + + // declare the audition contract let contract_class = declare("SeasonAndAudition") - .expect('Failed to declare counter') + .expect('Failed to declare SAudition') .contract_class(); // serialize constructor let mut calldata: Array = array![]; OWNER().serialize(ref calldata); + staking_dispatcher.contract_address.serialize(ref calldata); // deploy the contract let (contract_address, _) = contract_class @@ -84,6 +109,35 @@ pub fn deploy_contract() -> ( (contract, ownable, safe_dispatcher) } +// Helper function to deploy both contracts and return both +pub fn deploy_contracts_with_staking() -> ( + ISeasonAndAuditionDispatcher, IStakeToVoteDispatcher, IOwnableDispatcher, +) { + // Deploy real staking contract first + let staking_dispatcher = deploy_staking_contract(); + + // declare the audition contract + let contract_class = declare("SeasonAndAudition") + .expect('Failed to declare SAudition') + .contract_class(); + + // serialize constructor + let mut calldata: Array = array![]; + + OWNER().serialize(ref calldata); + staking_dispatcher.contract_address.serialize(ref calldata); + + // deploy the contract + let (contract_address, _) = contract_class + .deploy(@calldata) + .expect('Failed to deploy contract'); + + let contract = ISeasonAndAuditionDispatcher { contract_address }; + let ownable = IOwnableDispatcher { contract_address }; + + (contract, staking_dispatcher, ownable) +} + // Helper function to create a default Season struct pub fn create_default_season(season_id: u256) -> Season { Season {