From 8a45afeda5fc5cac86d358e54e5ed68901d03312 Mon Sep 17 00:00:00 2001 From: heymide Date: Sun, 28 Jun 2026 20:54:06 +0100 Subject: [PATCH 1/2] feat(contracts): add manual and merit-based winner selection --- contracts/geev-core/src/giveaway.rs | 284 ++++++++++++++++++++++------ contracts/geev-core/src/test.rs | 256 ++++++++++++++++++++++++- contracts/geev-core/src/types.rs | 9 + 3 files changed, 494 insertions(+), 55 deletions(-) diff --git a/contracts/geev-core/src/giveaway.rs b/contracts/geev-core/src/giveaway.rs index 75eb136..5b89e04 100644 --- a/contracts/geev-core/src/giveaway.rs +++ b/contracts/geev-core/src/giveaway.rs @@ -1,5 +1,5 @@ use crate::profile::ProfileContract; -use crate::types::{DataKey, Error, Giveaway, GiveawayStatus, ParticipantVerification}; +use crate::types::{DataKey, Error, Giveaway, GiveawayStatus, ParticipantVerification, SelectionMethod}; use crate::utils::with_reentrancy_guard; use soroban_sdk::{ contract, contractevent, contractimpl, panic_with_error, token, Address, Env, String, Vec, @@ -42,6 +42,31 @@ impl GiveawayContract { duration_seconds: u64, winner_count: u32, verification: Option, + ) -> u64 { + Self::create_giveaway_with_selection( + env, + creator, + token, + amount, + title, + duration_seconds, + winner_count, + verification, + SelectionMethod::Random, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn create_giveaway_with_selection( + env: Env, + creator: Address, + token: Address, + amount: i128, + title: String, + duration_seconds: u64, + winner_count: u32, + verification: Option, + selection_method: SelectionMethod, ) -> u64 { creator.require_auth(); @@ -83,6 +108,7 @@ impl GiveawayContract { winners: Vec::new(&env), verification_type, min_reputation, + selection_method, }; if let Some(verification) = &verification { @@ -170,24 +196,17 @@ impl GiveawayContract { pub fn pick_winner(env: Env, giveaway_id: u64) -> Address { let giveaway_key = DataKey::Giveaway(giveaway_id); - let mut giveaway: Giveaway = env + let giveaway: Giveaway = env .storage() .persistent() .get(&giveaway_key) .unwrap_or_else(|| panic_with_error!(&env, Error::GiveawayNotFound)); - if giveaway.status != GiveawayStatus::Active { + if giveaway.selection_method != SelectionMethod::Random { panic_with_error!(&env, Error::InvalidStatus); } - if env.ledger().timestamp() <= giveaway.end_time { - panic_with_error!(&env, Error::GiveawayStillActive); - } - if giveaway.participant_count == 0 { - panic_with_error!(&env, Error::NoParticipants); - } - if giveaway.participant_count < giveaway.winner_count { - panic_with_error!(&env, Error::InsufficientParticipants); - } + + ensure_ready_for_selection(&env, &giveaway); let random_seed = env.prng().gen::(); let mut selected_indexes: Vec = Vec::new(&env); @@ -220,50 +239,9 @@ impl GiveawayContract { .get(&participant_key) .unwrap_or_else(|| panic_with_error!(&env, Error::InvalidIndex)); winners.push_back(winner_address.clone()); - - // Publish the approximate prize share for each winner. - let fee_key = DataKey::Fee; - let fee_bps: u32 = env.storage().instance().get(&fee_key).unwrap_or(100); - let fee_amount = giveaway - .amount - .checked_mul(fee_bps as i128) - .and_then(|v| v.checked_div(10_000)) - .unwrap_or_else(|| panic_with_error!(&env, Error::ArithmeticOverflow)); - let net_prize = giveaway - .amount - .checked_sub(fee_amount) - .unwrap_or_else(|| panic_with_error!(&env, Error::ArithmeticOverflow)); - let winner_count = target_count as i128; - let base_prize = net_prize - .checked_div(winner_count) - .unwrap_or_else(|| panic_with_error!(&env, Error::ArithmeticOverflow)); - let prize_amount = if i == 0 { - net_prize - .checked_sub( - base_prize - .checked_mul(winner_count - 1) - .unwrap_or_else(|| panic_with_error!(&env, Error::ArithmeticOverflow)), - ) - .unwrap_or_else(|| panic_with_error!(&env, Error::ArithmeticOverflow)) - } else { - base_prize - }; - - GiveawayWinnerSelected { - winner: winner_address.clone(), - giveaway_id, - prize_amount, - } - .publish(&env); } - giveaway.winners = winners.clone(); - giveaway.status = GiveawayStatus::Claimable; - env.storage().persistent().set(&giveaway_key, &giveaway); - - winners - .first() - .unwrap_or_else(|| panic_with_error!(&env, Error::NoParticipants)) + finalize_winners(&env, &giveaway_key, giveaway, winners) } pub fn distribute_prize(env: Env, giveaway_id: u64) { @@ -414,4 +392,202 @@ impl GiveawayContract { env.storage().persistent().set(&collected_fees_key, &0i128); } } + + // Helper function to ensure the giveaway is ready for selection + fn ensure_ready_for_selection(env: &Env, giveaway: &Giveaway) { + if giveaway.status != GiveawayStatus::Active { + panic_with_error!(env, Error::InvalidStatus); + } + if env.ledger().timestamp() <= giveaway.end_time { + panic_with_error!(env, Error::GiveawayStillActive); + } + if giveaway.participant_count == 0 { + panic_with_error!(env, Error::NoParticipants); + } + if giveaway.participant_count < giveaway.winner_count { + panic_with_error!(env, Error::InsufficientParticipants); + } + } + + // Helper function to check if caller is creator or admin + fn ensure_creator_or_admin(env: &Env, caller: &Address, giveaway: &Giveaway) { + if *caller == giveaway.creator { + return; + } + let admin_key = DataKey::Admin; + let admin: Option
= env.storage().instance().get(&admin_key); + if let Some(admin) = admin { + if *caller == admin { + return; + } + } + panic_with_error!(env, Error::NotCreator); + } + + // Helper function to validate manual winners are all valid participants + fn validate_manual_winners(env: &Env, giveaway_id: u64, winners: &Vec
) { + for winner in winners.iter() { + let has_entered_key = DataKey::HasEntered(giveaway_id, winner.clone()); + let has_entered: bool = env.storage().persistent().get(&has_entered_key).unwrap_or(false); + if !has_entered { + panic_with_error!(env, Error::InvalidIndex); + } + } + // Check for duplicates + for i in 0..winners.len() { + for j in i+1..winners.len() { + if winners.get_unchecked(i) == winners.get_unchecked(j) { + panic_with_error!(env, Error::InvalidIndex); + } + } + } + } + + // Helper function to select winners by merit (reputation) + fn select_merit_winners(env: &Env, giveaway_id: u64, winner_count: u32, participant_count: u32) -> Vec
{ + let mut participants_with_reputation = Vec::new(env); + for i in 0..participant_count { + let participant_key = DataKey::ParticipantIndex(giveaway_id, i); + let participant: Address = env.storage().persistent().get(&participant_key).unwrap(); + let reputation = ProfileContract::get_reputation(env.clone(), participant.clone()); + participants_with_reputation.push_back((participant, reputation)); + } + // Sort by reputation descending (very simple sort) + let mut sorted = Vec::new(env); + for pr in participants_with_reputation.iter() { + let mut inserted = false; + for i in 0..sorted.len() { + let existing = sorted.get_unchecked(i); + if pr.1 > existing.1 { + let mut temp = Vec::new(env); + for j in 0..i { + temp.push_back(sorted.get_unchecked(j)); + } + temp.push_back(pr); + for j in i..sorted.len() { + temp.push_back(sorted.get_unchecked(j)); + } + sorted = temp; + inserted = true; + break; + } + } + if !inserted { + sorted.push_back(pr); + } + } + // Take top N winners + let mut winners = Vec::new(env); + for i in 0..winner_count { + winners.push_back(sorted.get_unchecked(i as usize).0.clone()); + } + winners + } + + // Helper function to finalize winners and emit events + fn finalize_winners(env: &Env, giveaway_key: &DataKey, mut giveaway: Giveaway, winners: Vec
) -> Address { + // Emit winner events + let fee_key = DataKey::Fee; + let fee_bps: u32 = env.storage().instance().get(&fee_key).unwrap_or(100); + let fee_amount = giveaway + .amount + .checked_mul(fee_bps as i128) + .and_then(|v| v.checked_div(10_000)) + .unwrap_or_else(|| panic_with_error!(env, Error::ArithmeticOverflow)); + let net_prize = giveaway + .amount + .checked_sub(fee_amount) + .unwrap_or_else(|| panic_with_error!(env, Error::ArithmeticOverflow)); + let winner_count = giveaway.winner_count as i128; + let base_prize = net_prize + .checked_div(winner_count) + .unwrap_or_else(|| panic_with_error!(env, Error::ArithmeticOverflow)); + + for (index, winner) in winners.iter().enumerate() { + let prize_amount = if index == 0 { + net_prize + .checked_sub( + base_prize + .checked_mul(winner_count - 1) + .unwrap_or_else(|| panic_with_error!(env, Error::ArithmeticOverflow)), + ) + .unwrap_or_else(|| panic_with_error!(env, Error::ArithmeticOverflow)) + } else { + base_prize + }; + GiveawayWinnerSelected { + winner: winner.clone(), + giveaway_id: giveaway.id, + prize_amount, + }.publish(env); + } + + giveaway.winners = winners.clone(); + giveaway.status = GiveawayStatus::Claimable; + env.storage().persistent().set(giveaway_key, &giveaway); + + winners.first().unwrap_or_else(|| panic_with_error!(env, Error::NoParticipants)) + } + + /// Finalize a giveaway with manually selected winners + /// + /// # Arguments + /// * `env` - The contract environment + /// * `caller` - The address calling this function (must be creator or admin) + /// * `giveaway_id` - The ID of the giveaway to finalize + /// * `winners` - The list of winner addresses + pub fn finalize_manual_winners(env: Env, caller: Address, giveaway_id: u64, winners: Vec
) -> Address { + caller.require_auth(); + + let giveaway_key = DataKey::Giveaway(giveaway_id); + let giveaway: Giveaway = env + .storage() + .persistent() + .get(&giveaway_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::GiveawayNotFound)); + + // Validate + ensure_creator_or_admin(&env, &caller, &giveaway); + ensure_ready_for_selection(&env, &giveaway); + + if giveaway.selection_method != SelectionMethod::Manual { + panic_with_error!(&env, Error::InvalidStatus); + } + + if winners.len() != giveaway.winner_count as usize { + panic_with_error!(&env, Error::InvalidWinnerCount); + } + + validate_manual_winners(&env, giveaway_id, &winners); + + finalize_winners(&env, &giveaway_key, giveaway, winners) + } + + /// Finalize a giveaway with merit-based selected winners (by reputation) + /// + /// # Arguments + /// * `env` - The contract environment + /// * `caller` - The address calling this function (must be creator or admin) + /// * `giveaway_id` - The ID of the giveaway to finalize + pub fn finalize_merit_winners(env: Env, caller: Address, giveaway_id: u64) -> Address { + caller.require_auth(); + + let giveaway_key = DataKey::Giveaway(giveaway_id); + let giveaway: Giveaway = env + .storage() + .persistent() + .get(&giveaway_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::GiveawayNotFound)); + + // Validate + ensure_creator_or_admin(&env, &caller, &giveaway); + ensure_ready_for_selection(&env, &giveaway); + + if giveaway.selection_method != SelectionMethod::Merit { + panic_with_error!(&env, Error::InvalidStatus); + } + + let winners = select_merit_winners(&env, giveaway_id, giveaway.winner_count, giveaway.participant_count); + finalize_winners(&env, &giveaway_key, giveaway, winners) + } } diff --git a/contracts/geev-core/src/test.rs b/contracts/geev-core/src/test.rs index 4e09f86..df00c32 100644 --- a/contracts/geev-core/src/test.rs +++ b/contracts/geev-core/src/test.rs @@ -1593,7 +1593,7 @@ fn test_flag_counts_are_independent_per_id() { // ── auto-suspension tests ───────────────────────────────────────────────────── use crate::governance::FLAG_THRESHOLD; -use crate::types::GiveawayStatus; +use crate::types::{GiveawayStatus, SelectionMethod}; /// Seed a minimal active Giveaway directly into contract storage. fn seed_active_giveaway(env: &Env, contract_id: &Address, giveaway_id: u64, token: &Address) { @@ -1611,6 +1611,7 @@ fn seed_active_giveaway(env: &Env, contract_id: &Address, giveaway_id: u64, toke winners: Vec::new(env), verification_type: 0, min_reputation: 0, + selection_method: SelectionMethod::Random, }; env.as_contract(contract_id, || { env.storage() @@ -1945,3 +1946,256 @@ fn test_reputation_accumulates_across_giveaways() { assert_eq!(score, 2); }); } + +#[test] +fn test_manual_winner_selection() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(GiveawayContract, ()); + let client = GiveawayContractClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let mock_token = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_admin_client = token::StellarAssetClient::new(&env, &mock_token); + + let creator = Address::generate(&env); + let participant1 = Address::generate(&env); + let participant2 = Address::generate(&env); + let participant3 = Address::generate(&env); + token_admin_client.mint(&creator, &1000); + + env.as_contract(&contract_id, || { + env.storage() + .instance() + .set(&DataKey::AllowedToken(mock_token.clone()), &true); + }); + + let giveaway_id = client.create_giveaway_with_selection( + &creator, + &mock_token, + &500, + &String::from_str(&env, "Manual Test"), + &60, + &2, + &None, + SelectionMethod::Manual, + ); + + client.enter_giveaway(&participant1, &giveaway_id); + client.enter_giveaway(&participant2, &giveaway_id); + client.enter_giveaway(&participant3, &giveaway_id); + + env.ledger().with_mut(|li| li.timestamp += 100); + + let mut winners = Vec::new(&env); + winners.push_back(participant2.clone()); + winners.push_back(participant3.clone()); + + let winner = client.finalize_manual_winners(&creator, &giveaway_id, winners); + + assert!(winner == participant2 || winner == participant3); + + let stored_winners: Vec
= env.as_contract(&contract_id, || { + let g: Giveaway = env.storage().persistent().get(&DataKey::Giveaway(giveaway_id)).unwrap(); + g.winners + }); + + assert_eq!(stored_winners.len(), 2); + assert!(stored_winners.contains(&participant2)); + assert!(stored_winners.contains(&participant3)); +} + +#[test] +#[should_panic] +fn test_manual_winner_selection_fails_non_creator() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(GiveawayContract, ()); + let client = GiveawayContractClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let mock_token = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_admin_client = token::StellarAssetClient::new(&env, &mock_token); + + let creator = Address::generate(&env); + let random_user = Address::generate(&env); + let participant = Address::generate(&env); + token_admin_client.mint(&creator, &1000); + + env.as_contract(&contract_id, || { + env.storage() + .instance() + .set(&DataKey::AllowedToken(mock_token.clone()), &true); + }); + + let giveaway_id = client.create_giveaway_with_selection( + &creator, + &mock_token, + &500, + &String::from_str(&env, "Manual Test"), + &60, + &1, + &None, + SelectionMethod::Manual, + ); + + client.enter_giveaway(&participant, &giveaway_id); + env.ledger().with_mut(|li| li.timestamp += 100); + + let mut winners = Vec::new(&env); + winners.push_back(participant.clone()); + client.finalize_manual_winners(&random_user, &giveaway_id, winners); +} + +#[test] +fn test_merit_winner_selection() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(GiveawayContract, ()); + let client = GiveawayContractClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let mock_token = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_admin_client = token::StellarAssetClient::new(&env, &mock_token); + + let creator = Address::generate(&env); + let participant1 = Address::generate(&env); // rep 10 + let participant2 = Address::generate(&env); // rep 50 + let participant3 = Address::generate(&env); // rep 30 + token_admin_client.mint(&creator, &1000); + + env.as_contract(&contract_id, || { + env.storage() + .instance() + .set(&DataKey::AllowedToken(mock_token.clone()), &true); + env.storage().persistent().set(&DataKey::Reputation(participant1.clone()), &10u64); + env.storage().persistent().set(&DataKey::Reputation(participant2.clone()), &50u64); + env.storage().persistent().set(&DataKey::Reputation(participant3.clone()), &30u64); + }); + + let giveaway_id = client.create_giveaway_with_selection( + &creator, + &mock_token, + &500, + &String::from_str(&env, "Merit Test"), + &60, + &2, + &None, + SelectionMethod::Merit, + ); + + client.enter_giveaway(&participant1, &giveaway_id); + client.enter_giveaway(&participant2, &giveaway_id); + client.enter_giveaway(&participant3, &giveaway_id); + + env.ledger().with_mut(|li| li.timestamp += 100); + + let winner = client.finalize_merit_winners(&creator, &giveaway_id); + assert_eq!(winner, participant2); + + let stored_winners: Vec
= env.as_contract(&contract_id, || { + let g: Giveaway = env.storage().persistent().get(&DataKey::Giveaway(giveaway_id)).unwrap(); + g.winners + }); + + assert_eq!(stored_winners.len(), 2); + assert_eq!(stored_winners.get(0).unwrap(), participant2); + assert_eq!(stored_winners.get(1).unwrap(), participant3); +} + +#[test] +#[should_panic] +fn test_pick_winner_fails_on_manual_giveaway() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(GiveawayContract, ()); + let client = GiveawayContractClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let mock_token = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_admin_client = token::StellarAssetClient::new(&env, &mock_token); + + let creator = Address::generate(&env); + let participant = Address::generate(&env); + token_admin_client.mint(&creator, &1000); + + env.as_contract(&contract_id, || { + env.storage() + .instance() + .set(&DataKey::AllowedToken(mock_token.clone()), &true); + }); + + let giveaway_id = client.create_giveaway_with_selection( + &creator, + &mock_token, + &500, + &String::from_str(&env, "Test"), + &60, + &1, + &None, + SelectionMethod::Manual, + ); + + client.enter_giveaway(&participant, &giveaway_id); + env.ledger().with_mut(|li| li.timestamp += 100); + client.pick_winner(&giveaway_id); +} + +#[test] +fn test_admin_can_finalize_manual_winners() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(GiveawayContract, ()); + let client = GiveawayContractClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let admin = Address::generate(&env); + let mock_token = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + let token_admin_client = token::StellarAssetClient::new(&env, &mock_token); + + let creator = Address::generate(&env); + let participant = Address::generate(&env); + token_admin_client.mint(&creator, &1000); + + client.init(&admin, &100); + + env.as_contract(&contract_id, || { + env.storage() + .instance() + .set(&DataKey::AllowedToken(mock_token.clone()), &true); + }); + + let giveaway_id = client.create_giveaway_with_selection( + &creator, + &mock_token, + &500, + &String::from_str(&env, "Admin Test"), + &60, + &1, + &None, + SelectionMethod::Manual, + ); + + client.enter_giveaway(&participant, &giveaway_id); + env.ledger().with_mut(|li| li.timestamp += 100); + + let mut winners = Vec::new(&env); + winners.push_back(participant.clone()); + + client.finalize_manual_winners(&admin, &giveaway_id, winners); +} diff --git a/contracts/geev-core/src/types.rs b/contracts/geev-core/src/types.rs index d7ba44c..b3bb698 100644 --- a/contracts/geev-core/src/types.rs +++ b/contracts/geev-core/src/types.rs @@ -45,6 +45,14 @@ pub struct ParticipantVerification { pub uses_reputation: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[contracttype] +pub enum SelectionMethod { + Random = 0, + Manual = 1, + Merit = 2, +} + #[derive(Clone)] #[contracttype] pub struct Giveaway { @@ -60,6 +68,7 @@ pub struct Giveaway { pub winners: Vec
, pub verification_type: u32, pub min_reputation: u64, + pub selection_method: SelectionMethod, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] From 978072b36906fa4e80d4bf802df98ba66d9d7d3e Mon Sep 17 00:00:00 2001 From: heymide Date: Sun, 28 Jun 2026 20:55:49 +0100 Subject: [PATCH 2/2] quick fix [ci skip]