From 3da60f8b70c1269c46f56fa73249e3544563bb6e Mon Sep 17 00:00:00 2001 From: Alin Cruceat Date: Mon, 16 Dec 2024 12:57:02 +0200 Subject: [PATCH] reorgazing --- .../lottery-esdt/src/basics/constants.rs | 4 + .../examples/lottery-esdt/src/basics/mod.rs | 4 + .../lottery-esdt/src/basics/storage.rs | 42 ++ .../examples/lottery-esdt/src/basics/utils.rs | 25 + .../examples/lottery-esdt/src/basics/views.rs | 31 + .../examples/lottery-esdt/src/lottery.rs | 570 +----------------- .../lottery-esdt/src/specific/award.rs | 194 ++++++ .../src/{ => specific}/awarding_status.rs | 0 .../examples/lottery-esdt/src/specific/buy.rs | 63 ++ .../lottery-esdt/src/specific/claim.rs | 101 ++++ .../src/{ => specific}/lottery_info.rs | 0 .../examples/lottery-esdt/src/specific/mod.rs | 7 + .../lottery-esdt/src/specific/setup.rs | 138 +++++ .../lottery-esdt/src/{ => specific}/status.rs | 0 .../examples/lottery-esdt/wasm/src/lib.rs | 6 +- 15 files changed, 625 insertions(+), 560 deletions(-) create mode 100644 contracts/examples/lottery-esdt/src/basics/constants.rs create mode 100644 contracts/examples/lottery-esdt/src/basics/mod.rs create mode 100644 contracts/examples/lottery-esdt/src/basics/storage.rs create mode 100644 contracts/examples/lottery-esdt/src/basics/utils.rs create mode 100644 contracts/examples/lottery-esdt/src/basics/views.rs create mode 100644 contracts/examples/lottery-esdt/src/specific/award.rs rename contracts/examples/lottery-esdt/src/{ => specific}/awarding_status.rs (100%) create mode 100644 contracts/examples/lottery-esdt/src/specific/buy.rs create mode 100644 contracts/examples/lottery-esdt/src/specific/claim.rs rename contracts/examples/lottery-esdt/src/{ => specific}/lottery_info.rs (100%) create mode 100644 contracts/examples/lottery-esdt/src/specific/mod.rs create mode 100644 contracts/examples/lottery-esdt/src/specific/setup.rs rename contracts/examples/lottery-esdt/src/{ => specific}/status.rs (100%) diff --git a/contracts/examples/lottery-esdt/src/basics/constants.rs b/contracts/examples/lottery-esdt/src/basics/constants.rs new file mode 100644 index 0000000000..0bd62754d9 --- /dev/null +++ b/contracts/examples/lottery-esdt/src/basics/constants.rs @@ -0,0 +1,4 @@ +pub const PERCENTAGE_TOTAL: u32 = 100; +pub const THIRTY_DAYS_IN_SECONDS: u64 = 60 * 60 * 24 * 30; +pub const MAX_TICKETS: usize = 800; +pub const MAX_OPERATIONS: usize = 50; diff --git a/contracts/examples/lottery-esdt/src/basics/mod.rs b/contracts/examples/lottery-esdt/src/basics/mod.rs new file mode 100644 index 0000000000..9efc59385f --- /dev/null +++ b/contracts/examples/lottery-esdt/src/basics/mod.rs @@ -0,0 +1,4 @@ +pub mod constants; +pub mod storage; +pub mod utils; +pub mod views; diff --git a/contracts/examples/lottery-esdt/src/basics/storage.rs b/contracts/examples/lottery-esdt/src/basics/storage.rs new file mode 100644 index 0000000000..aff1483a2d --- /dev/null +++ b/contracts/examples/lottery-esdt/src/basics/storage.rs @@ -0,0 +1,42 @@ +use multiversx_sc::imports::*; + +#[multiversx_sc::module] +pub trait StorageModule { + #[storage_mapper("ticketHolder")] + fn ticket_holders(&self, lottery_name: &ManagedBuffer) -> VecMapper; + + #[storage_mapper("accumulatedRewards")] + fn accumulated_rewards( + &self, + token_id: &EgldOrEsdtTokenIdentifier, + user_id: &u64, + ) -> SingleValueMapper; + + #[storage_mapper("totalWinning_tickets")] + fn total_winning_tickets(&self, lottery_name: &ManagedBuffer) -> SingleValueMapper; + + #[storage_mapper("indexLastWinner")] + fn index_last_winner(&self, lottery_name: &ManagedBuffer) -> SingleValueMapper; + + #[storage_mapper("accumulatedRewards")] + fn user_accumulated_token_rewards( + &self, + user_id: &u64, + ) -> UnorderedSetMapper; + + #[storage_mapper("numberOfEntriesForUser")] + fn number_of_entries_for_user( + &self, + lottery_name: &ManagedBuffer, + user_id: &u64, + ) -> SingleValueMapper; + + #[storage_mapper("addressToIdMapper")] + fn addres_to_id_mapper(&self) -> AddressToIdMapper; + + #[storage_mapper("burnPercentageForLottery")] + fn burn_percentage_for_lottery( + &self, + lottery_name: &ManagedBuffer, + ) -> SingleValueMapper; +} diff --git a/contracts/examples/lottery-esdt/src/basics/utils.rs b/contracts/examples/lottery-esdt/src/basics/utils.rs new file mode 100644 index 0000000000..d944c56c0d --- /dev/null +++ b/contracts/examples/lottery-esdt/src/basics/utils.rs @@ -0,0 +1,25 @@ +use multiversx_sc::imports::*; + +use crate::constants::PERCENTAGE_TOTAL; + +#[multiversx_sc::module] +pub trait UtilsModule { + fn sum_array(&self, array: &ManagedVec) -> u32 { + let mut sum = 0; + + for item in array { + sum += item as u32; + } + + sum + } + + /// does not check if max - min >= amount, that is the caller's job + fn get_distinct_random(&self, min: usize, max: usize) -> usize { + let mut rand = RandomnessSource::new(); + rand.next_usize_in_range(min, max) + } + fn calculate_percentage_of(&self, value: &BigUint, percentage: &BigUint) -> BigUint { + value * percentage / PERCENTAGE_TOTAL + } +} diff --git a/contracts/examples/lottery-esdt/src/basics/views.rs b/contracts/examples/lottery-esdt/src/basics/views.rs new file mode 100644 index 0000000000..1dbd73cda3 --- /dev/null +++ b/contracts/examples/lottery-esdt/src/basics/views.rs @@ -0,0 +1,31 @@ +use crate::{LotteryInfo, Status}; +use multiversx_sc::imports::*; + +#[multiversx_sc::module] +pub trait ViewsModule { + #[view] + fn status(&self, lottery_name: &ManagedBuffer) -> Status { + if self.lottery_info(lottery_name).is_empty() { + return Status::Inactive; + } + + let info = self.lottery_info(lottery_name).get(); + let current_time = self.blockchain().get_block_timestamp(); + if current_time > info.deadline || info.tickets_left == 0 { + return Status::Ended; + } + + Status::Running + } + + #[view(getLotteryInfo)] + #[storage_mapper("lotteryInfo")] + fn lottery_info( + &self, + lottery_name: &ManagedBuffer, + ) -> SingleValueMapper>; + + #[view(getLotteryWhitelist)] + #[storage_mapper("lotteryWhitelist")] + fn lottery_whitelist(&self, lottery_name: &ManagedBuffer) -> UnorderedSetMapper; +} diff --git a/contracts/examples/lottery-esdt/src/lottery.rs b/contracts/examples/lottery-esdt/src/lottery.rs index c1e801c72b..c0542a03e8 100644 --- a/contracts/examples/lottery-esdt/src/lottery.rs +++ b/contracts/examples/lottery-esdt/src/lottery.rs @@ -1,570 +1,26 @@ #![no_std] +use basics::{constants, storage, utils, views}; use multiversx_sc::imports::*; +use specific::{award, awarding_status, buy, claim, lottery_info, setup, status}; -mod awarding_status; -mod lottery_info; -mod status; +mod basics; +mod specific; use awarding_status::AwardingStatus; use lottery_info::LotteryInfo; use status::Status; -const PERCENTAGE_TOTAL: u32 = 100; -const THIRTY_DAYS_IN_SECONDS: u64 = 60 * 60 * 24 * 30; -const MAX_TICKETS: usize = 800; -const MAX_OPERATIONS: usize = 50; - #[multiversx_sc::contract] -pub trait Lottery { +pub trait Lottery: + award::AwardingModule + + views::ViewsModule + + storage::StorageModule + + utils::UtilsModule + + claim::ClaimModule + + buy::BuyTicketModule + + setup::SetupModule +{ #[init] fn init(&self) {} - - #[allow_multiple_var_args] - #[endpoint(createLotteryPool)] - fn create_lottery_pool( - &self, - lottery_name: ManagedBuffer, - token_identifier: EgldOrEsdtTokenIdentifier, - ticket_price: BigUint, - opt_total_tickets: Option, - opt_deadline: Option, - opt_max_entries_per_user: Option, - opt_prize_distribution: ManagedOption>, - opt_whitelist: ManagedOption>, - opt_burn_percentage: OptionalValue, - ) { - self.start_lottery( - lottery_name, - token_identifier, - ticket_price, - opt_total_tickets, - opt_deadline, - opt_max_entries_per_user, - opt_prize_distribution, - opt_whitelist, - opt_burn_percentage, - ); - } - - #[allow_multiple_var_args] - #[allow(clippy::too_many_arguments)] - fn start_lottery( - &self, - lottery_name: ManagedBuffer, - token_identifier: EgldOrEsdtTokenIdentifier, - ticket_price: BigUint, - opt_total_tickets: Option, - opt_deadline: Option, - opt_max_entries_per_user: Option, - opt_prize_distribution: ManagedOption>, - opt_whitelist: ManagedOption>, - opt_burn_percentage: OptionalValue, - ) { - require!(!lottery_name.is_empty(), "Name can't be empty!"); - - let timestamp = self.blockchain().get_block_timestamp(); - let total_tickets = opt_total_tickets.unwrap_or(MAX_TICKETS); - let deadline = opt_deadline.unwrap_or(timestamp + THIRTY_DAYS_IN_SECONDS); - let max_entries_per_user = opt_max_entries_per_user.unwrap_or(MAX_TICKETS); - let prize_distribution = opt_prize_distribution - .unwrap_or_else(|| ManagedVec::from_single_item(PERCENTAGE_TOTAL as u8)); - - require!( - total_tickets > prize_distribution.len(), - "Number of winners should be smaller than the number of available tickets" - ); - require!( - self.status(&lottery_name) == Status::Inactive, - "Lottery is already active!" - ); - require!(token_identifier.is_valid(), "Invalid token name provided!"); - require!(ticket_price > 0, "Ticket price must be higher than 0!"); - require!( - total_tickets > 0, - "Must have more than 0 tickets available!" - ); - require!( - total_tickets <= MAX_TICKETS, - "Only 800 or less total tickets per lottery are allowed!" - ); - require!(deadline > timestamp, "Deadline can't be in the past!"); - require!( - deadline <= timestamp + THIRTY_DAYS_IN_SECONDS, - "Deadline can't be later than 30 days from now!" - ); - require!( - max_entries_per_user > 0, - "Must have more than 0 max entries per user!" - ); - require!( - self.sum_array(&prize_distribution) == PERCENTAGE_TOTAL, - "Prize distribution must add up to exactly 100(%)!" - ); - - match opt_burn_percentage { - OptionalValue::Some(burn_percentage) => { - require!(!token_identifier.is_egld(), "EGLD can't be burned!"); - - let roles = self - .blockchain() - .get_esdt_local_roles(&token_identifier.clone().unwrap_esdt()); - require!( - roles.has_role(&EsdtLocalRole::Burn), - "The contract can't burn the selected token!" - ); - - require!( - burn_percentage < PERCENTAGE_TOTAL, - "Invalid burn percentage!" - ); - self.burn_percentage_for_lottery(&lottery_name) - .set(burn_percentage); - }, - OptionalValue::None => {}, - } - - if let Some(whitelist) = opt_whitelist.as_option() { - let mut mapper = self.lottery_whitelist(&lottery_name); - for addr in &*whitelist { - let addr_id = self.addres_to_id_mapper().get_id_or_insert(&addr); - mapper.insert(addr_id); - } - } - - let info = LotteryInfo { - token_identifier, - ticket_price, - tickets_left: total_tickets, - deadline, - max_entries_per_user, - prize_distribution, - prize_pool: BigUint::zero(), - unawarded_amount: BigUint::zero(), - }; - - self.lottery_info(&lottery_name).set(&info); - } - - #[endpoint] - #[payable("*")] - fn buy_ticket(&self, lottery_name: ManagedBuffer) { - let (token_identifier, payment) = self.call_value().egld_or_single_fungible_esdt(); - - match self.status(&lottery_name) { - Status::Inactive => sc_panic!("Lottery is currently inactive."), - Status::Running => { - self.update_after_buy_ticket(&lottery_name, &token_identifier, &payment) - }, - Status::Ended => { - sc_panic!("Lottery entry period has ended! Awaiting winner announcement.") - }, - }; - } - - #[endpoint] - fn determine_winner(&self, lottery_name: ManagedBuffer) -> AwardingStatus { - let sc_address = self.blockchain().get_sc_address(); - let sc_address_shard = self.blockchain().get_shard_of_address(&sc_address); - let caller = self.blockchain().get_caller(); - let caller_shard = self.blockchain().get_shard_of_address(&caller); - require!( - sc_address_shard != caller_shard, - "Caller needs to be on a remote shard" - ); - - match self.status(&lottery_name) { - Status::Inactive => sc_panic!("Lottery is inactive!"), - Status::Running => sc_panic!("Lottery is still running!"), - Status::Ended => self.handle_awarding(&lottery_name), - } - } - - fn handle_awarding(&self, lottery_name: &ManagedBuffer) -> AwardingStatus { - if self.total_winning_tickets(lottery_name).is_empty() { - self.prepare_awarding(lottery_name); - } - self.distribute_prizes(lottery_name) - } - - #[view] - fn status(&self, lottery_name: &ManagedBuffer) -> Status { - if self.lottery_info(lottery_name).is_empty() { - return Status::Inactive; - } - - let info = self.lottery_info(lottery_name).get(); - let current_time = self.blockchain().get_block_timestamp(); - if current_time > info.deadline || info.tickets_left == 0 { - return Status::Ended; - } - - Status::Running - } - - fn update_after_buy_ticket( - &self, - lottery_name: &ManagedBuffer, - token_identifier: &EgldOrEsdtTokenIdentifier, - payment: &BigUint, - ) { - let info_mapper = self.lottery_info(lottery_name); - let mut info = info_mapper.get(); - let caller = self.blockchain().get_caller(); - let caller_id = self.addres_to_id_mapper().get_id_or_insert(&caller); - let whitelist = self.lottery_whitelist(lottery_name); - - require!( - whitelist.is_empty() || whitelist.contains(&caller_id), - "You are not allowed to participate in this lottery!" - ); - require!( - token_identifier == &info.token_identifier && payment == &info.ticket_price, - "Wrong ticket fee!" - ); - - let entries_mapper = self.number_of_entries_for_user(lottery_name, &caller_id); - let mut entries = entries_mapper.get(); - require!( - entries < info.max_entries_per_user, - "Ticket limit exceeded for this lottery!" - ); - - self.ticket_holders(lottery_name).push(&caller_id); - - entries += 1; - info.tickets_left -= 1; - info.prize_pool += &info.ticket_price; - info.unawarded_amount += &info.ticket_price; - - entries_mapper.set(entries); - info_mapper.set(&info); - } - - fn prepare_awarding(&self, lottery_name: &ManagedBuffer) { - let mut info = self.lottery_info(lottery_name).get(); - let ticket_holders_mapper = self.ticket_holders(lottery_name); - let total_tickets = ticket_holders_mapper.len(); - - if total_tickets == 0 { - return; - } - - self.burn_prize_percentage(lottery_name, &mut info); - - // if there are less tickets than the distributed prize pool, - // the 1st place gets the leftover, maybe could split between the remaining - // but this is a rare case anyway and it's not worth the overhead - let total_winning_tickets = if total_tickets < info.prize_distribution.len() { - total_tickets - } else { - info.prize_distribution.len() - }; - - self.total_winning_tickets(lottery_name) - .set(total_winning_tickets); - self.index_last_winner(lottery_name).set(1); - - self.lottery_info(lottery_name).set(info); - } - - fn burn_prize_percentage( - &self, - lottery_name: &ManagedBuffer, - info: &mut LotteryInfo, - ) { - let burn_percentage = self.burn_percentage_for_lottery(lottery_name).get(); - if burn_percentage == 0 { - return; - } - - let burn_amount = self.calculate_percentage_of(&info.prize_pool, &burn_percentage); - - // Prevent crashing if the role was unset while the lottery was running - // The tokens will simply remain locked forever - let esdt_token_id = info.token_identifier.clone().unwrap_esdt(); - let roles = self.blockchain().get_esdt_local_roles(&esdt_token_id); - if roles.has_role(&EsdtLocalRole::Burn) { - self.send().esdt_local_burn(&esdt_token_id, 0, &burn_amount); - } - - info.prize_pool -= &burn_amount; - info.unawarded_amount -= burn_amount; - } - - fn distribute_prizes(&self, lottery_name: &ManagedBuffer) -> AwardingStatus { - let mut info = self.lottery_info(lottery_name).get(); - let ticket_holders_mapper = self.ticket_holders(lottery_name); - let total_tickets = ticket_holders_mapper.len(); - - let mut index_last_winner = self.index_last_winner(lottery_name).get(); - let total_winning_tickets = self.total_winning_tickets(lottery_name).get(); - require!( - index_last_winner <= total_winning_tickets, - "Awarding has ended" - ); - - let mut iterations = 0; - while index_last_winner <= total_winning_tickets && iterations < MAX_OPERATIONS { - self.award_winner( - lottery_name, - &index_last_winner, - total_tickets, - total_winning_tickets, - &mut info, - ); - index_last_winner += 1; - iterations += 1; - } - self.lottery_info(lottery_name).set(info); - self.index_last_winner(lottery_name).set(index_last_winner); - if index_last_winner > total_winning_tickets { - self.clear_storage(lottery_name); - return AwardingStatus::Finished; - } - AwardingStatus::Ongoing - } - - fn award_winner( - &self, - lottery_name: &ManagedBuffer, - index_last_winner: &usize, - total_tickets: usize, - total_winning_tickets: usize, - info: &mut LotteryInfo, - ) { - let rand_index = self.get_distinct_random(*index_last_winner, total_tickets); - let ticket_holders_mapper = self.ticket_holders(lottery_name); - - // swap indexes of the winner addresses - we are basically bringing the winners in the first indexes of the mapper - let winner_address = self.ticket_holders(lottery_name).get(rand_index); - let last_index_winner_address = self.ticket_holders(lottery_name).get(*index_last_winner); - - self.ticket_holders(lottery_name) - .set(rand_index, &last_index_winner_address); - self.ticket_holders(lottery_name) - .set(*index_last_winner, &winner_address); - - // distribute to the first place last. Laws of probability say that order doesn't matter. - // this is done to mitigate the effects of BigUint division leading to "spare" prize money being left out at times - // 1st place will get the spare money instead. - if *index_last_winner <= total_winning_tickets { - let prize = self.calculate_percentage_of( - &info.prize_pool, - &BigUint::from( - info.prize_distribution - .get(total_winning_tickets - *index_last_winner), - ), - ); - if prize > 0 { - self.assign_prize_to_winner(info.token_identifier.clone(), &prize, &winner_address); - - info.unawarded_amount -= prize; - } - } else { - // insert token in accumulated rewards first place - let first_place_winner = ticket_holders_mapper.get(*index_last_winner); - - self.assign_prize_to_winner( - info.token_identifier.clone(), - &info.unawarded_amount, - &first_place_winner, - ); - } - } - - fn assign_prize_to_winner( - &self, - token_id: EgldOrEsdtTokenIdentifier, - amount: &BigUint, - winner_id: &u64, - ) { - self.accumulated_rewards(&token_id, winner_id) - .update(|value| *value += amount); - self.user_accumulated_token_rewards(winner_id) - .insert(token_id); - } - - #[endpoint] - fn claim_rewards(&self, tokens: MultiValueEncoded) { - let caller = self.blockchain().get_caller(); - let caller_id = self.addres_to_id_mapper().get_id_or_insert(&caller); - require!( - !self.user_accumulated_token_rewards(&caller_id).is_empty(), - "You have no rewards to claim" - ); - - let mut accumulated_egld_rewards = BigUint::zero(); - let mut accumulated_esdt_rewards = ManagedVec::::new(); - - // to save reviewers time, these 2 iterators have different generics, so it was not possible to make just 1 for loop - - if tokens.is_empty() { - // if wanted tokens were not specified claim all, and clear user_accumulated_token_rewards storage mapper - - let mut all_tokens: ManagedVec = - ManagedVec::new(); - - for token_id in self.user_accumulated_token_rewards(&caller_id).iter() { - require!( - !self.accumulated_rewards(&token_id, &caller_id).is_empty(), - "Token requested not available for claim" - ); - all_tokens.push(token_id); - } - - self.claim_rewards_user( - all_tokens, - &caller_id, - &mut accumulated_egld_rewards, - &mut accumulated_esdt_rewards, - ) - } else { - // otherwise claim just what was requested and remove those tokens from the user_accumulated_token_rewards storage mapper - - self.claim_rewards_user( - tokens.to_vec(), - &caller_id, - &mut accumulated_egld_rewards, - &mut accumulated_esdt_rewards, - ) - }; - if !accumulated_esdt_rewards.is_empty() { - self.tx() - .to(&caller) - .multi_esdt(accumulated_esdt_rewards) - .transfer(); - } - - if accumulated_egld_rewards > 0u64 { - self.tx() - .to(&caller) - .egld(accumulated_egld_rewards) - .transfer(); - } - } - - fn claim_rewards_user( - &self, - tokens: ManagedVec, - caller_id: &u64, - accumulated_egld_rewards: &mut BigUint, - accumulated_esdt_rewards: &mut ManagedVec, - ) { - for token_id in tokens.iter().rev() { - let _ = &self - .user_accumulated_token_rewards(caller_id) - .swap_remove(&token_id); - - self.prepare_token_for_claim( - token_id, - caller_id, - accumulated_egld_rewards, - accumulated_esdt_rewards, - ); - } - } - - fn prepare_token_for_claim( - &self, - token_id: EgldOrEsdtTokenIdentifier, - caller_id: &u64, - accumulated_egld_rewards: &mut BigUint, - accumulated_esdt_rewards: &mut ManagedVec, - ) { - let value = self.accumulated_rewards(&token_id, caller_id).take(); - if token_id.is_egld() { - *accumulated_egld_rewards += value; - } else { - accumulated_esdt_rewards.push(EsdtTokenPayment::new(token_id.unwrap_esdt(), 0, value)); - } - } - - fn clear_storage(&self, lottery_name: &ManagedBuffer) { - let mut ticket_holders_mapper = self.ticket_holders(lottery_name); - let current_ticket_number = ticket_holders_mapper.len(); - - for i in 1..=current_ticket_number { - let addr = ticket_holders_mapper.get(i); - self.number_of_entries_for_user(lottery_name, &addr).clear(); - } - - ticket_holders_mapper.clear(); - self.lottery_info(lottery_name).clear(); - self.lottery_whitelist(lottery_name).clear(); - self.total_winning_tickets(lottery_name).clear(); - self.index_last_winner(lottery_name).clear(); - self.burn_percentage_for_lottery(lottery_name).clear(); - } - - fn sum_array(&self, array: &ManagedVec) -> u32 { - let mut sum = 0; - - for item in array { - sum += item as u32; - } - - sum - } - - /// does not check if max - min >= amount, that is the caller's job - fn get_distinct_random(&self, min: usize, max: usize) -> usize { - let mut rand = RandomnessSource::new(); - rand.next_usize_in_range(min, max) - } - - fn calculate_percentage_of(&self, value: &BigUint, percentage: &BigUint) -> BigUint { - value * percentage / PERCENTAGE_TOTAL - } - - // storage - - #[view(getLotteryInfo)] - #[storage_mapper("lotteryInfo")] - fn lottery_info( - &self, - lottery_name: &ManagedBuffer, - ) -> SingleValueMapper>; - - #[view(getLotteryWhitelist)] - #[storage_mapper("lotteryWhitelist")] - fn lottery_whitelist(&self, lottery_name: &ManagedBuffer) -> UnorderedSetMapper; - - #[storage_mapper("ticketHolder")] - fn ticket_holders(&self, lottery_name: &ManagedBuffer) -> VecMapper; - - #[storage_mapper("accumulatedRewards")] - fn accumulated_rewards( - &self, - token_id: &EgldOrEsdtTokenIdentifier, - user_id: &u64, - ) -> SingleValueMapper; - - #[storage_mapper("totalWinning_tickets")] - fn total_winning_tickets(&self, lottery_name: &ManagedBuffer) -> SingleValueMapper; - - #[storage_mapper("indexLastWinner")] - fn index_last_winner(&self, lottery_name: &ManagedBuffer) -> SingleValueMapper; - - #[storage_mapper("accumulatedRewards")] - fn user_accumulated_token_rewards( - &self, - user_id: &u64, - ) -> UnorderedSetMapper; - - #[storage_mapper("numberOfEntriesForUser")] - fn number_of_entries_for_user( - &self, - lottery_name: &ManagedBuffer, - user_id: &u64, - ) -> SingleValueMapper; - - #[storage_mapper("addressToIdMapper")] - fn addres_to_id_mapper(&self) -> AddressToIdMapper; - - #[storage_mapper("burnPercentageForLottery")] - fn burn_percentage_for_lottery( - &self, - lottery_name: &ManagedBuffer, - ) -> SingleValueMapper; } diff --git a/contracts/examples/lottery-esdt/src/specific/award.rs b/contracts/examples/lottery-esdt/src/specific/award.rs new file mode 100644 index 0000000000..f95771a446 --- /dev/null +++ b/contracts/examples/lottery-esdt/src/specific/award.rs @@ -0,0 +1,194 @@ +use crate::{ + constants::MAX_OPERATIONS, lottery_info::LotteryInfo, storage, utils, views, AwardingStatus, + Status, +}; +use multiversx_sc::imports::*; + +#[multiversx_sc::module] +pub trait AwardingModule: views::ViewsModule + storage::StorageModule + utils::UtilsModule { + #[endpoint] + fn determine_winner(&self, lottery_name: ManagedBuffer) -> AwardingStatus { + let sc_address = self.blockchain().get_sc_address(); + let sc_address_shard = self.blockchain().get_shard_of_address(&sc_address); + let caller = self.blockchain().get_caller(); + let caller_shard = self.blockchain().get_shard_of_address(&caller); + require!( + sc_address_shard != caller_shard, + "Caller needs to be on a remote shard" + ); + + match self.status(&lottery_name) { + Status::Inactive => sc_panic!("Lottery is inactive!"), + Status::Running => sc_panic!("Lottery is still running!"), + Status::Ended => self.handle_awarding(&lottery_name), + } + } + + fn handle_awarding(&self, lottery_name: &ManagedBuffer) -> AwardingStatus { + if self.total_winning_tickets(lottery_name).is_empty() { + self.prepare_awarding(lottery_name); + } + self.distribute_prizes(lottery_name) + } + + fn prepare_awarding(&self, lottery_name: &ManagedBuffer) { + let mut info = self.lottery_info(lottery_name).get(); + let ticket_holders_mapper = self.ticket_holders(lottery_name); + let total_tickets = ticket_holders_mapper.len(); + + if total_tickets == 0 { + return; + } + + self.burn_prize_percentage(lottery_name, &mut info); + + // if there are less tickets than the distributed prize pool, + // the 1st place gets the leftover, maybe could split between the remaining + // but this is a rare case anyway and it's not worth the overhead + let total_winning_tickets = if total_tickets < info.prize_distribution.len() { + total_tickets + } else { + info.prize_distribution.len() + }; + + self.total_winning_tickets(lottery_name) + .set(total_winning_tickets); + self.index_last_winner(lottery_name).set(1); + + self.lottery_info(lottery_name).set(info); + } + + fn burn_prize_percentage( + &self, + lottery_name: &ManagedBuffer, + info: &mut LotteryInfo, + ) { + let burn_percentage = self.burn_percentage_for_lottery(lottery_name).get(); + if burn_percentage == 0 { + return; + } + + let burn_amount = self.calculate_percentage_of(&info.prize_pool, &burn_percentage); + + // Prevent crashing if the role was unset while the lottery was running + // The tokens will simply remain locked forever + let esdt_token_id = info.token_identifier.clone().unwrap_esdt(); + let roles = self.blockchain().get_esdt_local_roles(&esdt_token_id); + if roles.has_role(&EsdtLocalRole::Burn) { + self.send().esdt_local_burn(&esdt_token_id, 0, &burn_amount); + } + + info.prize_pool -= &burn_amount; + info.unawarded_amount -= burn_amount; + } + + fn distribute_prizes(&self, lottery_name: &ManagedBuffer) -> AwardingStatus { + let mut info = self.lottery_info(lottery_name).get(); + let ticket_holders_mapper = self.ticket_holders(lottery_name); + let total_tickets = ticket_holders_mapper.len(); + + let mut index_last_winner = self.index_last_winner(lottery_name).get(); + let total_winning_tickets = self.total_winning_tickets(lottery_name).get(); + require!( + index_last_winner <= total_winning_tickets, + "Awarding has ended" + ); + + let mut iterations = 0; + while index_last_winner <= total_winning_tickets && iterations < MAX_OPERATIONS { + self.award_winner( + lottery_name, + &index_last_winner, + total_tickets, + total_winning_tickets, + &mut info, + ); + index_last_winner += 1; + iterations += 1; + } + self.lottery_info(lottery_name).set(info); + self.index_last_winner(lottery_name).set(index_last_winner); + if index_last_winner > total_winning_tickets { + self.clear_storage(lottery_name); + return AwardingStatus::Finished; + } + AwardingStatus::Ongoing + } + + fn clear_storage(&self, lottery_name: &ManagedBuffer) { + let mut ticket_holders_mapper = self.ticket_holders(lottery_name); + let current_ticket_number = ticket_holders_mapper.len(); + + for i in 1..=current_ticket_number { + let addr = ticket_holders_mapper.get(i); + self.number_of_entries_for_user(lottery_name, &addr).clear(); + } + + ticket_holders_mapper.clear(); + self.lottery_info(lottery_name).clear(); + self.lottery_whitelist(lottery_name).clear(); + self.total_winning_tickets(lottery_name).clear(); + self.index_last_winner(lottery_name).clear(); + self.burn_percentage_for_lottery(lottery_name).clear(); + } + + fn award_winner( + &self, + lottery_name: &ManagedBuffer, + index_last_winner: &usize, + total_tickets: usize, + total_winning_tickets: usize, + info: &mut LotteryInfo, + ) { + let rand_index = self.get_distinct_random(*index_last_winner, total_tickets); + let ticket_holders_mapper = self.ticket_holders(lottery_name); + + // swap indexes of the winner addresses - we are basically bringing the winners in the first indexes of the mapper + let winner_address = self.ticket_holders(lottery_name).get(rand_index); + let last_index_winner_address = self.ticket_holders(lottery_name).get(*index_last_winner); + + self.ticket_holders(lottery_name) + .set(rand_index, &last_index_winner_address); + self.ticket_holders(lottery_name) + .set(*index_last_winner, &winner_address); + + // distribute to the first place last. Laws of probability say that order doesn't matter. + // this is done to mitigate the effects of BigUint division leading to "spare" prize money being left out at times + // 1st place will get the spare money instead. + if *index_last_winner <= total_winning_tickets { + let prize = self.calculate_percentage_of( + &info.prize_pool, + &BigUint::from( + info.prize_distribution + .get(total_winning_tickets - *index_last_winner), + ), + ); + if prize > 0 { + self.assign_prize_to_winner(info.token_identifier.clone(), &prize, &winner_address); + + info.unawarded_amount -= prize; + } + } else { + // insert token in accumulated rewards first place + let first_place_winner = ticket_holders_mapper.get(*index_last_winner); + + self.assign_prize_to_winner( + info.token_identifier.clone(), + &info.unawarded_amount, + &first_place_winner, + ); + } + } + + fn assign_prize_to_winner( + &self, + token_id: EgldOrEsdtTokenIdentifier, + amount: &BigUint, + winner_id: &u64, + ) { + self.accumulated_rewards(&token_id, winner_id) + .update(|value| *value += amount); + self.user_accumulated_token_rewards(winner_id) + .insert(token_id); + } +} diff --git a/contracts/examples/lottery-esdt/src/awarding_status.rs b/contracts/examples/lottery-esdt/src/specific/awarding_status.rs similarity index 100% rename from contracts/examples/lottery-esdt/src/awarding_status.rs rename to contracts/examples/lottery-esdt/src/specific/awarding_status.rs diff --git a/contracts/examples/lottery-esdt/src/specific/buy.rs b/contracts/examples/lottery-esdt/src/specific/buy.rs new file mode 100644 index 0000000000..5321b8e25c --- /dev/null +++ b/contracts/examples/lottery-esdt/src/specific/buy.rs @@ -0,0 +1,63 @@ +use multiversx_sc::imports::*; + +use crate::basics::{storage, views}; + +use super::status::Status; + +#[multiversx_sc::module] +pub trait BuyTicketModule: storage::StorageModule + views::ViewsModule { + #[endpoint] + #[payable("*")] + fn buy_ticket(&self, lottery_name: ManagedBuffer) { + let (token_identifier, payment) = self.call_value().egld_or_single_fungible_esdt(); + + match self.status(&lottery_name) { + Status::Inactive => sc_panic!("Lottery is currently inactive."), + Status::Running => { + self.update_after_buy_ticket(&lottery_name, &token_identifier, &payment) + }, + Status::Ended => { + sc_panic!("Lottery entry period has ended! Awaiting winner announcement.") + }, + }; + } + + fn update_after_buy_ticket( + &self, + lottery_name: &ManagedBuffer, + token_identifier: &EgldOrEsdtTokenIdentifier, + payment: &BigUint, + ) { + let info_mapper = self.lottery_info(lottery_name); + let mut info = info_mapper.get(); + let caller = self.blockchain().get_caller(); + let caller_id = self.addres_to_id_mapper().get_id_or_insert(&caller); + let whitelist = self.lottery_whitelist(lottery_name); + + require!( + whitelist.is_empty() || whitelist.contains(&caller_id), + "You are not allowed to participate in this lottery!" + ); + require!( + token_identifier == &info.token_identifier && payment == &info.ticket_price, + "Wrong ticket fee!" + ); + + let entries_mapper = self.number_of_entries_for_user(lottery_name, &caller_id); + let mut entries = entries_mapper.get(); + require!( + entries < info.max_entries_per_user, + "Ticket limit exceeded for this lottery!" + ); + + self.ticket_holders(lottery_name).push(&caller_id); + + entries += 1; + info.tickets_left -= 1; + info.prize_pool += &info.ticket_price; + info.unawarded_amount += &info.ticket_price; + + entries_mapper.set(entries); + info_mapper.set(&info); + } +} diff --git a/contracts/examples/lottery-esdt/src/specific/claim.rs b/contracts/examples/lottery-esdt/src/specific/claim.rs new file mode 100644 index 0000000000..9034e37182 --- /dev/null +++ b/contracts/examples/lottery-esdt/src/specific/claim.rs @@ -0,0 +1,101 @@ +use multiversx_sc::imports::*; + +use crate::basics::storage; + +#[multiversx_sc::module] +pub trait ClaimModule: storage::StorageModule { + #[endpoint] + fn claim_rewards(&self, tokens: MultiValueEncoded) { + let caller = self.blockchain().get_caller(); + let caller_id = self.addres_to_id_mapper().get_id_or_insert(&caller); + require!( + !self.user_accumulated_token_rewards(&caller_id).is_empty(), + "You have no rewards to claim" + ); + + let mut accumulated_egld_rewards = BigUint::zero(); + let mut accumulated_esdt_rewards = ManagedVec::::new(); + + // to save reviewers time, these 2 iterators have different generics, so it was not possible to make just 1 for loop + + if tokens.is_empty() { + // if wanted tokens were not specified claim all, and clear user_accumulated_token_rewards storage mapper + + let mut all_tokens: ManagedVec = + ManagedVec::new(); + + for token_id in self.user_accumulated_token_rewards(&caller_id).iter() { + require!( + !self.accumulated_rewards(&token_id, &caller_id).is_empty(), + "Token requested not available for claim" + ); + all_tokens.push(token_id); + } + + self.claim_rewards_user( + all_tokens, + &caller_id, + &mut accumulated_egld_rewards, + &mut accumulated_esdt_rewards, + ) + } else { + // otherwise claim just what was requested and remove those tokens from the user_accumulated_token_rewards storage mapper + + self.claim_rewards_user( + tokens.to_vec(), + &caller_id, + &mut accumulated_egld_rewards, + &mut accumulated_esdt_rewards, + ) + }; + if !accumulated_esdt_rewards.is_empty() { + self.tx() + .to(&caller) + .multi_esdt(accumulated_esdt_rewards) + .transfer(); + } + + if accumulated_egld_rewards > 0u64 { + self.tx() + .to(&caller) + .egld(accumulated_egld_rewards) + .transfer(); + } + } + + fn claim_rewards_user( + &self, + tokens: ManagedVec, + caller_id: &u64, + accumulated_egld_rewards: &mut BigUint, + accumulated_esdt_rewards: &mut ManagedVec, + ) { + for token_id in tokens.iter().rev() { + let _ = &self + .user_accumulated_token_rewards(caller_id) + .swap_remove(&token_id); + + self.prepare_token_for_claim( + token_id, + caller_id, + accumulated_egld_rewards, + accumulated_esdt_rewards, + ); + } + } + + fn prepare_token_for_claim( + &self, + token_id: EgldOrEsdtTokenIdentifier, + caller_id: &u64, + accumulated_egld_rewards: &mut BigUint, + accumulated_esdt_rewards: &mut ManagedVec, + ) { + let value = self.accumulated_rewards(&token_id, caller_id).take(); + if token_id.is_egld() { + *accumulated_egld_rewards += value; + } else { + accumulated_esdt_rewards.push(EsdtTokenPayment::new(token_id.unwrap_esdt(), 0, value)); + } + } +} diff --git a/contracts/examples/lottery-esdt/src/lottery_info.rs b/contracts/examples/lottery-esdt/src/specific/lottery_info.rs similarity index 100% rename from contracts/examples/lottery-esdt/src/lottery_info.rs rename to contracts/examples/lottery-esdt/src/specific/lottery_info.rs diff --git a/contracts/examples/lottery-esdt/src/specific/mod.rs b/contracts/examples/lottery-esdt/src/specific/mod.rs new file mode 100644 index 0000000000..1405caa87b --- /dev/null +++ b/contracts/examples/lottery-esdt/src/specific/mod.rs @@ -0,0 +1,7 @@ +pub mod award; +pub mod awarding_status; +pub mod buy; +pub mod claim; +pub mod lottery_info; +pub mod setup; +pub mod status; diff --git a/contracts/examples/lottery-esdt/src/specific/setup.rs b/contracts/examples/lottery-esdt/src/specific/setup.rs new file mode 100644 index 0000000000..55b1483c89 --- /dev/null +++ b/contracts/examples/lottery-esdt/src/specific/setup.rs @@ -0,0 +1,138 @@ +use multiversx_sc::imports::*; + +use crate::{ + basics::{ + constants::{MAX_TICKETS, PERCENTAGE_TOTAL, THIRTY_DAYS_IN_SECONDS}, + storage, utils, views, + }, + specific::{lottery_info::LotteryInfo, status::Status}, +}; + +#[multiversx_sc::module] +pub trait SetupModule: storage::StorageModule + views::ViewsModule + utils::UtilsModule { + #[allow_multiple_var_args] + #[endpoint(createLotteryPool)] + fn create_lottery_pool( + &self, + lottery_name: ManagedBuffer, + token_identifier: EgldOrEsdtTokenIdentifier, + ticket_price: BigUint, + opt_total_tickets: Option, + opt_deadline: Option, + opt_max_entries_per_user: Option, + opt_prize_distribution: ManagedOption>, + opt_whitelist: ManagedOption>, + opt_burn_percentage: OptionalValue, + ) { + self.start_lottery( + lottery_name, + token_identifier, + ticket_price, + opt_total_tickets, + opt_deadline, + opt_max_entries_per_user, + opt_prize_distribution, + opt_whitelist, + opt_burn_percentage, + ); + } + + #[allow_multiple_var_args] + #[allow(clippy::too_many_arguments)] + fn start_lottery( + &self, + lottery_name: ManagedBuffer, + token_identifier: EgldOrEsdtTokenIdentifier, + ticket_price: BigUint, + opt_total_tickets: Option, + opt_deadline: Option, + opt_max_entries_per_user: Option, + opt_prize_distribution: ManagedOption>, + opt_whitelist: ManagedOption>, + opt_burn_percentage: OptionalValue, + ) { + require!(!lottery_name.is_empty(), "Name can't be empty!"); + + let timestamp = self.blockchain().get_block_timestamp(); + let total_tickets = opt_total_tickets.unwrap_or(MAX_TICKETS); + let deadline = opt_deadline.unwrap_or(timestamp + THIRTY_DAYS_IN_SECONDS); + let max_entries_per_user = opt_max_entries_per_user.unwrap_or(MAX_TICKETS); + let prize_distribution = opt_prize_distribution + .unwrap_or_else(|| ManagedVec::from_single_item(PERCENTAGE_TOTAL as u8)); + + require!( + total_tickets > prize_distribution.len(), + "Number of winners should be smaller than the number of available tickets" + ); + require!( + self.status(&lottery_name) == Status::Inactive, + "Lottery is already active!" + ); + require!(token_identifier.is_valid(), "Invalid token name provided!"); + require!(ticket_price > 0, "Ticket price must be higher than 0!"); + require!( + total_tickets > 0, + "Must have more than 0 tickets available!" + ); + require!( + total_tickets <= MAX_TICKETS, + "Only 800 or less total tickets per lottery are allowed!" + ); + require!(deadline > timestamp, "Deadline can't be in the past!"); + require!( + deadline <= timestamp + THIRTY_DAYS_IN_SECONDS, + "Deadline can't be later than 30 days from now!" + ); + require!( + max_entries_per_user > 0, + "Must have more than 0 max entries per user!" + ); + require!( + self.sum_array(&prize_distribution) == PERCENTAGE_TOTAL, + "Prize distribution must add up to exactly 100(%)!" + ); + + match opt_burn_percentage { + OptionalValue::Some(burn_percentage) => { + require!(!token_identifier.is_egld(), "EGLD can't be burned!"); + + let roles = self + .blockchain() + .get_esdt_local_roles(&token_identifier.clone().unwrap_esdt()); + require!( + roles.has_role(&EsdtLocalRole::Burn), + "The contract can't burn the selected token!" + ); + + require!( + burn_percentage < PERCENTAGE_TOTAL, + "Invalid burn percentage!" + ); + self.burn_percentage_for_lottery(&lottery_name) + .set(burn_percentage); + }, + OptionalValue::None => {}, + } + + if let Some(whitelist) = opt_whitelist.as_option() { + let mut mapper = self.lottery_whitelist(&lottery_name); + for addr in &*whitelist { + let addr_id = self.addres_to_id_mapper().get_id_or_insert(&addr); + mapper.insert(addr_id); + } + } + + let info = LotteryInfo { + token_identifier, + ticket_price, + tickets_left: total_tickets, + deadline, + max_entries_per_user, + prize_distribution, + prize_pool: BigUint::zero(), + unawarded_amount: BigUint::zero(), + }; + + self.lottery_info(&lottery_name).set(&info); + } +} diff --git a/contracts/examples/lottery-esdt/src/status.rs b/contracts/examples/lottery-esdt/src/specific/status.rs similarity index 100% rename from contracts/examples/lottery-esdt/src/status.rs rename to contracts/examples/lottery-esdt/src/specific/status.rs diff --git a/contracts/examples/lottery-esdt/wasm/src/lib.rs b/contracts/examples/lottery-esdt/wasm/src/lib.rs index b5f5528b54..468182c673 100644 --- a/contracts/examples/lottery-esdt/wasm/src/lib.rs +++ b/contracts/examples/lottery-esdt/wasm/src/lib.rs @@ -18,13 +18,13 @@ multiversx_sc_wasm_adapter::endpoints! { lottery_esdt ( init => init - createLotteryPool => create_lottery_pool - buy_ticket => buy_ticket determine_winner => determine_winner status => status - claim_rewards => claim_rewards getLotteryInfo => lottery_info getLotteryWhitelist => lottery_whitelist + claim_rewards => claim_rewards + buy_ticket => buy_ticket + createLotteryPool => create_lottery_pool ) }