#![cfg_attr(not(feature = "std"), no_std)] //! # Pallet Presale - Multi-Presale Launchpad Platform //! //! ## Overview //! //! A comprehensive multi-presale launchpad platform for PezkuwiChain that allows: //! - Multiple simultaneous presales with independent configurations //! - Platform fee collection (2%): 50% treasury, 25% burn, 25% stakers //! - Refund system with grace period (24h low fee, after higher fee) //! - Contribution limits (min/max per wallet, hard cap) //! - Whitelist/KYC support for compliance //! - Vesting schedules for gradual token release //! - Bonus tier system for larger contributors //! - Emergency controls and governance integration //! //! ## Features //! //! - **Multi-Presale**: Unlimited simultaneous presales //! - **Configurable**: Any asset, rate, duration per presale //! - **Platform Fee**: 2% split (50% treasury, 25% burn, 25% stakers) //! - **Refunds**: Grace period with reduced fees //! - **Limits**: Min/max contribution, hard cap //! - **Whitelist**: Optional whitelist/KYC for presales //! - **Vesting**: Linear token release schedules //! - **Bonus Tiers**: Reward larger contributions //! - **Emergency**: Pause, cancel, withdrawal controls pub use pallet::*; #[cfg(test)] mod mock; #[cfg(test)] mod tests; #[cfg(feature = "runtime-benchmarks")] mod benchmarking; pub mod weights; pub use weights::*; extern crate alloc; #[pezframe_support::pallet] pub mod pallet { use super::*; use pezframe_support::{ dispatch::DispatchResult, pezpallet_prelude::*, traits::{ fungibles::{Inspect, Mutate}, tokens::{Fortitude, Precision, Preservation}, }, BoundedVec, PalletId, }; use pezframe_system::pezpallet_prelude::*; use pezsp_runtime::traits::{AtLeast32BitUnsigned, Saturating}; pub type PresaleId = u32; #[derive(Clone, Copy, Encode, Decode, Eq, PartialEq, RuntimeDebug, MaxEncodedLen, TypeInfo)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] pub enum PresaleStatus { Pending, // Not started yet Active, // Ongoing Paused, // Emergency paused (future feature) Successful, // Ended, soft cap reached Failed, // Ended, soft cap NOT reached Cancelled, // Emergency cancelled Finalized, // Tokens distributed (after Successful) } #[derive(Clone, Copy, Encode, Decode, Eq, PartialEq, RuntimeDebug, MaxEncodedLen, TypeInfo)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] #[codec(dumb_trait_bound)] pub enum AccessControl { Public, // Anyone can contribute Whitelist, // Only whitelisted accounts } #[derive(Clone, Copy, Encode, Decode, Eq, PartialEq, RuntimeDebug, MaxEncodedLen, TypeInfo)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] #[codec(dumb_trait_bound)] pub struct BonusTier { /// Minimum contribution to qualify (in payment asset units) pub min_contribution: u128, /// Bonus percentage (0-100) pub bonus_percentage: u8, } #[derive(Clone, Copy, Encode, Decode, Eq, PartialEq, RuntimeDebug, MaxEncodedLen, TypeInfo)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] #[codec(dumb_trait_bound)] pub struct VestingSchedule { /// Percentage released immediately (0-100) pub immediate_release_percent: u8, /// Linear vesting over N blocks pub vesting_duration_blocks: BlockNumber, /// Cliff period before vesting starts pub cliff_blocks: BlockNumber, } #[derive(Clone, Copy, Encode, Decode, Eq, PartialEq, RuntimeDebug, MaxEncodedLen, TypeInfo)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] #[codec(dumb_trait_bound)] pub struct ContributionLimits { /// Minimum contribution per wallet pub min_contribution: u128, /// Maximum contribution per wallet pub max_contribution: u128, /// Minimum funding target (soft cap) - presale succeeds if reached pub soft_cap: u128, /// Maximum funding target (hard cap) - presale stops when reached pub hard_cap: u128, } #[derive(Clone, Copy, Encode, Decode, Eq, PartialEq, RuntimeDebug, MaxEncodedLen, TypeInfo)] #[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))] #[codec(dumb_trait_bound)] pub struct ContributionInfo { /// Total amount contributed pub amount: u128, /// Block number when first contributed (for grace period calculation) pub contributed_at: BlockNumber, /// Whether this contribution was refunded pub refunded: bool, /// Block number when refunded pub refunded_at: Option, /// Fee paid for refund pub refund_fee_paid: u128, } #[derive(Clone, Encode, Decode, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] #[scale_info(skip_type_params(T, MaxBonusTiers))] #[codec(mel_bound(T: Config, MaxBonusTiers: Get))] pub struct PresaleConfig> { /// Presale creator/owner pub owner: T::AccountId, /// Payment asset (wUSDT, wUSDC, etc.) pub payment_asset: T::AssetId, /// Reward token asset pub reward_asset: T::AssetId, /// Total tokens for sale (with decimals) /// Example: 10_000_000 * 10^12 = 10M PEZ with 12 decimals pub tokens_for_sale: u128, /// Presale start block pub start_block: BlockNumberFor, /// Presale duration in blocks pub duration: BlockNumberFor, /// Status pub status: PresaleStatus, /// Access control pub access_control: AccessControl, /// Contribution limits pub limits: ContributionLimits, /// Bonus tiers pub bonus_tiers: BoundedVec, /// Optional vesting schedule pub vesting: Option>>, /// Grace period for refunds (blocks) - low fee pub grace_period_blocks: BlockNumberFor, /// Normal refund fee percentage (0-100) pub refund_fee_percent: u8, /// Grace period refund fee percentage (0-100) pub grace_refund_fee_percent: u8, } #[pallet::pallet] pub struct Pallet(_); #[pallet::config] pub trait Config: pezframe_system::Config { /// Asset ID type type AssetId: Parameter + Member + Copy + MaybeSerializeDeserialize + MaxEncodedLen; /// Balance type type Balance: Parameter + Member + AtLeast32BitUnsigned + Default + Copy + MaybeSerializeDeserialize + MaxEncodedLen + From + Into; /// Assets handling type Assets: Inspect + Mutate; /// The presale pallet id, used for deriving sub-account treasuries #[pallet::constant] type PalletId: Get; /// Platform treasury account (receives 50% of platform fee) #[pallet::constant] type PlatformTreasury: Get; /// Staking reward pool account (receives 25% of platform fee) #[pallet::constant] type StakingRewardPool: Get; /// Platform fee percentage (e.g., 2 for 2%) #[pallet::constant] type PlatformFeePercent: Get; /// Maximum number of contributors per presale #[pallet::constant] type MaxContributors: Get; /// Maximum bonus tiers per presale #[pallet::constant] type MaxBonusTiers: Get; /// Maximum whitelisted accounts per presale #[pallet::constant] type MaxWhitelistedAccounts: Get; /// Origin that can create presales type CreatePresaleOrigin: EnsureOrigin; /// Origin for emergency actions type EmergencyOrigin: EnsureOrigin; /// Weight information type PresaleWeightInfo: crate::weights::WeightInfo; } /// Next presale ID #[pallet::storage] #[pallet::getter(fn next_presale_id)] pub type NextPresaleId = StorageValue<_, PresaleId, ValueQuery>; /// Presale configurations #[pallet::storage] #[pallet::getter(fn presales)] pub type Presales = StorageMap<_, Blake2_128Concat, PresaleId, PresaleConfig, OptionQuery>; /// Contributions: (presale_id, account) => ContributionInfo #[pallet::storage] #[pallet::getter(fn contributions)] pub type Contributions = StorageDoubleMap< _, Blake2_128Concat, PresaleId, Blake2_128Concat, T::AccountId, ContributionInfo>, OptionQuery, >; /// Contributors list per presale #[pallet::storage] #[pallet::getter(fn contributors)] pub type Contributors = StorageMap< _, Blake2_128Concat, PresaleId, BoundedVec, ValueQuery, >; /// Total raised per presale #[pallet::storage] #[pallet::getter(fn total_raised)] pub type TotalRaised = StorageMap<_, Blake2_128Concat, PresaleId, u128, ValueQuery>; /// Whitelist: (presale_id, account) => is_whitelisted #[pallet::storage] #[pallet::getter(fn whitelisted)] pub type WhitelistedAccounts = StorageDoubleMap< _, Blake2_128Concat, PresaleId, Blake2_128Concat, T::AccountId, bool, ValueQuery, >; /// Vesting claims: (presale_id, account) => claimed_amount #[pallet::storage] #[pallet::getter(fn vesting_claimed)] pub type VestingClaimed = StorageDoubleMap< _, Blake2_128Concat, PresaleId, Blake2_128Concat, T::AccountId, u128, ValueQuery, >; /// Platform analytics #[pallet::storage] #[pallet::getter(fn total_platform_volume)] pub type TotalPlatformVolume = StorageValue<_, u128, ValueQuery>; #[pallet::storage] #[pallet::getter(fn total_platform_fees)] pub type TotalPlatformFees = StorageValue<_, u128, ValueQuery>; #[pallet::storage] #[pallet::getter(fn successful_presales)] pub type SuccessfulPresales = StorageValue<_, u32, ValueQuery>; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { /// Presale created [presale_id, owner, payment_asset, reward_asset] PresaleCreated { presale_id: PresaleId, owner: T::AccountId, payment_asset: T::AssetId, reward_asset: T::AssetId, }, /// Contribution made [presale_id, who, amount, bonus_amount] Contributed { presale_id: PresaleId, who: T::AccountId, amount: u128, bonus_amount: u128 }, /// Presale finalized [presale_id, total_raised] PresaleFinalized { presale_id: PresaleId, total_raised: u128 }, /// Tokens distributed [presale_id, who, amount] Distributed { presale_id: PresaleId, who: T::AccountId, amount: u128 }, /// Refund processed [presale_id, who, amount, fee] Refunded { presale_id: PresaleId, who: T::AccountId, amount: u128, fee: u128 }, /// Presale cancelled [presale_id] PresaleCancelled { presale_id: PresaleId }, /// Platform fee distributed [treasury_share, burn_share, staker_share] PlatformFeeDistributed { treasury_share: u128, burn_share: u128, staker_share: u128 }, /// Account whitelisted [presale_id, account] AccountWhitelisted { presale_id: PresaleId, account: T::AccountId }, /// Vesting tokens claimed [presale_id, who, amount] VestingClaimed { presale_id: PresaleId, who: T::AccountId, amount: u128 }, /// Presale succeeded [presale_id, total_raised, soft_cap] PresaleSuccessful { presale_id: PresaleId, total_raised: u128, soft_cap: u128 }, /// Presale failed [presale_id, total_raised, soft_cap] PresaleFailed { presale_id: PresaleId, total_raised: u128, soft_cap: u128 }, /// Batch refund completed [presale_id, refunded_count, total_refunded] BatchRefundCompleted { presale_id: PresaleId, refunded_count: u32, total_refunded: u128 }, /// Presale extended [presale_id, additional_blocks, new_end_block] PresaleExtended { presale_id: PresaleId, additional_blocks: BlockNumberFor, new_end_block: BlockNumberFor, }, } #[pallet::error] pub enum Error { PresaleNotFound, PresaleNotActive, PresaleEnded, PresaleNotEnded, AlreadyFinalized, ZeroContribution, BelowMinContribution, AboveMaxContribution, HardCapReached, NotWhitelisted, TooManyContributors, ArithmeticOverflow, InvalidTokensForSale, InvalidFeePercent, NoContribution, RefundNotAllowed, SoftCapReached, InsufficientBalance, VestingNotEnabled, NothingToClaim, NotPresaleOwner, TooManyBonusTiers, // New errors for soft cap PresaleNotFailed, PresaleNotSuccessful, SoftCapNotReached, InvalidSoftCap, } #[pallet::call] impl Pallet { /// Create a new presale #[pallet::call_index(0)] #[pallet::weight(T::PresaleWeightInfo::create_presale())] pub fn create_presale( origin: OriginFor, payment_asset: T::AssetId, reward_asset: T::AssetId, tokens_for_sale: u128, duration: BlockNumberFor, is_whitelist: bool, min_contribution: u128, max_contribution: u128, soft_cap: u128, hard_cap: u128, enable_vesting: bool, vesting_immediate_percent: u8, vesting_duration_blocks: BlockNumberFor, vesting_cliff_blocks: BlockNumberFor, grace_period_blocks: BlockNumberFor, refund_fee_percent: u8, grace_refund_fee_percent: u8, ) -> DispatchResult { let owner = ensure_signed(origin)?; ensure!(tokens_for_sale > 0, Error::::InvalidTokensForSale); ensure!(soft_cap > 0, Error::::InvalidTokensForSale); ensure!(soft_cap <= hard_cap, Error::::InvalidTokensForSale); ensure!(refund_fee_percent <= 100, Error::::InvalidFeePercent); ensure!(grace_refund_fee_percent <= 100, Error::::InvalidFeePercent); let presale_id = NextPresaleId::::get(); let start_block = >::block_number(); // Start with empty bonus tiers - can be added later let bounded_bonus_tiers = BoundedVec::::default(); let access_control = if is_whitelist { AccessControl::Whitelist } else { AccessControl::Public }; let limits = ContributionLimits { min_contribution, max_contribution, soft_cap, hard_cap }; let vesting = if enable_vesting { Some(VestingSchedule { immediate_release_percent: vesting_immediate_percent, vesting_duration_blocks, cliff_blocks: vesting_cliff_blocks, }) } else { None }; let config = PresaleConfig { owner: owner.clone(), payment_asset: payment_asset.clone(), reward_asset: reward_asset.clone(), tokens_for_sale, start_block, duration, status: PresaleStatus::Active, access_control, limits, bonus_tiers: bounded_bonus_tiers, vesting, grace_period_blocks, refund_fee_percent, grace_refund_fee_percent, }; Presales::::insert(presale_id, config); NextPresaleId::::put(presale_id.saturating_add(1)); Self::deposit_event(Event::PresaleCreated { presale_id, owner, payment_asset, reward_asset, }); Ok(()) } /// Contribute to a presale #[pallet::call_index(1)] #[pallet::weight(T::PresaleWeightInfo::contribute())] pub fn contribute( origin: OriginFor, presale_id: PresaleId, amount: u128, ) -> DispatchResult { let who = ensure_signed(origin)?; let presale = Presales::::get(presale_id).ok_or(Error::::PresaleNotFound)?; // Checks ensure!(presale.status == PresaleStatus::Active, Error::::PresaleNotActive); ensure!(amount > 0, Error::::ZeroContribution); let current_block = >::block_number(); let end_block = presale.start_block + presale.duration; ensure!(current_block < end_block, Error::::PresaleEnded); // Check whitelist if presale.access_control == AccessControl::Whitelist { ensure!( WhitelistedAccounts::::get(presale_id, &who), Error::::NotWhitelisted ); } // Check limits let existing_contribution = Contributions::::get(presale_id, &who); let current_amount = existing_contribution.as_ref().map(|c| c.amount).unwrap_or(0); let new_total = current_amount.saturating_add(amount); ensure!(new_total >= presale.limits.min_contribution, Error::::BelowMinContribution); ensure!(new_total <= presale.limits.max_contribution, Error::::AboveMaxContribution); // Calculate remaining capacity and accept only what fits let total_raised = TotalRaised::::get(presale_id); let remaining_capacity = presale.limits.hard_cap.saturating_sub(total_raised); // Accept only what fits (better UX than failing entire transaction) let accepted_amount = amount.min(remaining_capacity); // Ensure we can accept something ensure!(accepted_amount > 0, Error::::HardCapReached); // Use accepted_amount for the rest of the function let amount = accepted_amount; let new_raised = total_raised.saturating_add(amount); // Calculate platform fee (2%) let platform_fee = amount.saturating_mul(T::PlatformFeePercent::get() as u128) / 100; let net_amount = amount.saturating_sub(platform_fee); // Transfer payment asset from user to presale treasury let treasury = Self::presale_account_id(presale_id); let net_amount_balance: T::Balance = net_amount.try_into().map_err(|_| Error::::ArithmeticOverflow)?; T::Assets::transfer( presale.payment_asset.clone(), &who, &treasury, net_amount_balance, Preservation::Expendable, // Allow user account to die if contributing all funds )?; // Distribute platform fee Self::distribute_platform_fee(presale.payment_asset.clone(), &who, platform_fee)?; // Track contribution with timestamp preservation let contribution = if let Some(existing) = existing_contribution { // Update existing contribution - preserve original timestamp ContributionInfo { amount: existing.amount.saturating_add(amount), contributed_at: existing.contributed_at, // ✅ Keep original timestamp refunded: false, refunded_at: None, refund_fee_paid: 0, } } else { // New contribution - add to contributors list Contributors::::try_mutate(presale_id, |contributors| -> DispatchResult { contributors .try_push(who.clone()) .map_err(|_| Error::::TooManyContributors)?; Ok(()) })?; // Create new contribution with current timestamp ContributionInfo { amount, contributed_at: current_block, // ✅ Set timestamp for first contribution only refunded: false, refunded_at: None, refund_fee_paid: 0, } }; Contributions::::insert(presale_id, &who, contribution); TotalRaised::::insert(presale_id, new_raised); // Update platform analytics TotalPlatformVolume::::mutate(|v| *v = v.saturating_add(amount)); TotalPlatformFees::::mutate(|f| *f = f.saturating_add(platform_fee)); // Note: Bonus amount cannot be accurately calculated until finalization // when total_raised is known. We emit 0 here and calculate during distribution. Self::deposit_event(Event::Contributed { presale_id, who, amount, bonus_amount: 0 }); Ok(()) } /// Finalize presale - checks soft cap and sets status to Successful or Failed #[pallet::call_index(2)] #[pallet::weight(T::PresaleWeightInfo::finalize_presale(Contributors::::get(presale_id).len() as u32))] pub fn finalize_presale(origin: OriginFor, presale_id: PresaleId) -> DispatchResult { ensure_root(origin)?; let mut presale = Presales::::get(presale_id).ok_or(Error::::PresaleNotFound)?; ensure!(presale.status == PresaleStatus::Active, Error::::PresaleNotActive); let current_block = >::block_number(); let end_block = presale.start_block + presale.duration; ensure!(current_block >= end_block, Error::::PresaleNotEnded); let total_raised = TotalRaised::::get(presale_id); // ✅ CHECK SOFT CAP - Set status accordingly if total_raised >= presale.limits.soft_cap { // SUCCESS: Soft cap reached - distribute tokens presale.status = PresaleStatus::Successful; Presales::::insert(presale_id, &presale); Self::deposit_event(Event::PresaleSuccessful { presale_id, total_raised, soft_cap: presale.limits.soft_cap, }); // Now distribute tokens to contributors let treasury = Self::presale_account_id(presale_id); // Distribute rewards to all contributors for contributor in Contributors::::get(presale_id).iter() { let contribution_info = match Contributions::::get(presale_id, contributor) { Some(info) => info, None => continue, }; // Skip if refunded if contribution_info.refunded || contribution_info.amount == 0 { continue; } // Calculate reward tokens using dynamic rate let reward_amount = Self::calculate_reward_dynamic( contribution_info.amount, total_raised, presale.tokens_for_sale, )?; let bonus = Self::calculate_bonus(&presale, contribution_info.amount, reward_amount); let total_reward = reward_amount.saturating_add(bonus); // Handle vesting if let Some(ref vesting) = presale.vesting { let immediate = total_reward .saturating_mul(vesting.immediate_release_percent as u128) / 100; if immediate > 0 { let immediate_balance: T::Balance = immediate.try_into().map_err(|_| Error::::ArithmeticOverflow)?; T::Assets::transfer( presale.reward_asset.clone(), &treasury, contributor, immediate_balance, Preservation::Expendable, )?; } // Store remaining for vesting VestingClaimed::::insert(presale_id, contributor, immediate); } else { // No vesting - transfer all let total_reward_balance: T::Balance = total_reward.try_into().map_err(|_| Error::::ArithmeticOverflow)?; T::Assets::transfer( presale.reward_asset.clone(), &treasury, contributor, total_reward_balance, Preservation::Expendable, )?; } Self::deposit_event(Event::Distributed { presale_id, who: contributor.clone(), amount: total_reward, }); } presale.status = PresaleStatus::Finalized; Presales::::insert(presale_id, presale); SuccessfulPresales::::mutate(|c| *c = c.saturating_add(1)); Self::deposit_event(Event::PresaleFinalized { presale_id, total_raised }); } else { // FAILED: Soft cap NOT reached - enable refunds presale.status = PresaleStatus::Failed; Presales::::insert(presale_id, &presale); Self::deposit_event(Event::PresaleFailed { presale_id, total_raised, soft_cap: presale.limits.soft_cap, }); } Ok(()) } /// Refund contribution (before presale ends) #[pallet::call_index(3)] #[pallet::weight(T::PresaleWeightInfo::refund())] pub fn refund(origin: OriginFor, presale_id: PresaleId) -> DispatchResult { let who = ensure_signed(origin)?; let presale = Presales::::get(presale_id).ok_or(Error::::PresaleNotFound)?; ensure!(presale.status == PresaleStatus::Active, Error::::RefundNotAllowed); let current_block = >::block_number(); let end_block = presale.start_block + presale.duration; ensure!(current_block < end_block, Error::::RefundNotAllowed); let mut contribution_info = Contributions::::get(presale_id, &who).ok_or(Error::::NoContribution)?; ensure!(!contribution_info.refunded, Error::::RefundNotAllowed); ensure!(contribution_info.amount > 0, Error::::NoContribution); // Calculate fee based on grace period using ORIGINAL contribution timestamp let grace_end = contribution_info.contributed_at.saturating_add(presale.grace_period_blocks); let fee_percent = if current_block <= grace_end { presale.grace_refund_fee_percent } else { presale.refund_fee_percent }; // Calculate what the treasury actually received (after 2% platform fee at contribution // time) let platform_fee_at_contribution = contribution_info.amount.saturating_mul(T::PlatformFeePercent::get() as u128) / 100; let net_in_treasury = contribution_info.amount.saturating_sub(platform_fee_at_contribution); // Calculate refund fee on the net amount in treasury (not original contribution) let fee = net_in_treasury.saturating_mul(fee_percent as u128) / 100; let refund_amount = net_in_treasury.saturating_sub(fee); let treasury = Self::presale_account_id(presale_id); // Step 1: Transfer refund amount to user let refund_amount_balance: T::Balance = refund_amount.try_into().map_err(|_| Error::::ArithmeticOverflow)?; T::Assets::transfer( presale.payment_asset.clone(), &treasury, &who, refund_amount_balance, Preservation::Expendable, )?; // Step 2: Distribute fee from remaining treasury balance // Treasury now has exactly 'fee' amount left from this contribution if fee > 0 { Self::distribute_platform_fee(presale.payment_asset.clone(), &treasury, fee)?; } // Update contribution info (mark as refunded instead of removing) contribution_info.refunded = true; contribution_info.refunded_at = Some(current_block); contribution_info.refund_fee_paid = fee; Contributions::::insert(presale_id, &who, contribution_info); TotalRaised::::mutate(presale_id, |r| { *r = r.saturating_sub(contribution_info.amount) }); Self::deposit_event(Event::Refunded { presale_id, who, amount: refund_amount, fee }); Ok(()) } /// Claim vested tokens #[pallet::call_index(4)] #[pallet::weight(T::PresaleWeightInfo::claim_vested())] pub fn claim_vested(origin: OriginFor, presale_id: PresaleId) -> DispatchResult { let who = ensure_signed(origin)?; let presale = Presales::::get(presale_id).ok_or(Error::::PresaleNotFound)?; let vesting = presale.vesting.ok_or(Error::::VestingNotEnabled)?; ensure!(presale.status == PresaleStatus::Finalized, Error::::PresaleNotActive); let contribution_info = Contributions::::get(presale_id, &who).ok_or(Error::::NoContribution)?; ensure!(contribution_info.amount > 0, Error::::NoContribution); ensure!(!contribution_info.refunded, Error::::NoContribution); let current_block = >::block_number(); let end_block = presale.start_block + presale.duration; let vesting_start = end_block + vesting.cliff_blocks; ensure!(current_block >= vesting_start, Error::::NothingToClaim); // Get total raised for dynamic calculation let total_raised = TotalRaised::::get(presale_id); // Calculate total reward using dynamic rate let total_reward = Self::calculate_reward_dynamic( contribution_info.amount, total_raised, presale.tokens_for_sale, )?; let bonus = Self::calculate_bonus(&presale, contribution_info.amount, total_reward); let total_with_bonus = total_reward.saturating_add(bonus); // Calculate vested amount let already_claimed = VestingClaimed::::get(presale_id, &who); let vesting_end = vesting_start + vesting.vesting_duration_blocks; let claimable = if current_block >= vesting_end { // All vested total_with_bonus.saturating_sub(already_claimed) } else { // Linear vesting use pezsp_runtime::traits::SaturatedConversion; let elapsed = current_block.saturating_sub(vesting_start); let elapsed_u128: u128 = elapsed.saturated_into(); let duration_u128: u128 = vesting.vesting_duration_blocks.saturated_into(); let vested_percent = elapsed_u128.saturating_mul(100) / duration_u128; let immediate_percent = vesting.immediate_release_percent as u128; let vesting_percent = 100u128.saturating_sub(immediate_percent); let vested_amount = total_with_bonus .saturating_mul(vesting_percent) .saturating_mul(vested_percent) / 10000; let total_unlocked = vested_amount.saturating_add(already_claimed); total_unlocked.saturating_sub(already_claimed) }; ensure!(claimable > 0, Error::::NothingToClaim); // Transfer tokens let treasury = Self::presale_account_id(presale_id); let claimable_balance: T::Balance = claimable.try_into().map_err(|_| Error::::ArithmeticOverflow)?; T::Assets::transfer( presale.reward_asset, &treasury, &who, claimable_balance, Preservation::Preserve, )?; VestingClaimed::::insert( presale_id, &who, already_claimed.saturating_add(claimable), ); Self::deposit_event(Event::VestingClaimed { presale_id, who, amount: claimable }); Ok(()) } /// Add account to whitelist (presale owner only) #[pallet::call_index(5)] #[pallet::weight(T::PresaleWeightInfo::add_to_whitelist())] pub fn add_to_whitelist( origin: OriginFor, presale_id: PresaleId, account: T::AccountId, ) -> DispatchResult { let who = ensure_signed(origin)?; let presale = Presales::::get(presale_id).ok_or(Error::::PresaleNotFound)?; ensure!(who == presale.owner, Error::::NotPresaleOwner); WhitelistedAccounts::::insert(presale_id, &account, true); Self::deposit_event(Event::AccountWhitelisted { presale_id, account }); Ok(()) } /// Cancel presale (emergency - owner or root) #[pallet::call_index(6)] #[pallet::weight(T::PresaleWeightInfo::cancel_presale())] pub fn cancel_presale(origin: OriginFor, presale_id: PresaleId) -> DispatchResult { // Either EmergencyOrigin or Root can cancel if T::EmergencyOrigin::ensure_origin(origin.clone()).is_err() { ensure_root(origin)?; } let mut presale = Presales::::get(presale_id).ok_or(Error::::PresaleNotFound)?; presale.status = PresaleStatus::Cancelled; Presales::::insert(presale_id, presale); Self::deposit_event(Event::PresaleCancelled { presale_id }); Ok(()) } /// Refund all contributors when presale is cancelled /// Auto-refunds everyone with no fees #[pallet::call_index(7)] #[pallet::weight(T::PresaleWeightInfo::refund_cancelled_presale())] pub fn refund_cancelled_presale( origin: OriginFor, presale_id: PresaleId, ) -> DispatchResult { ensure_signed(origin)?; let presale = Presales::::get(presale_id).ok_or(Error::::PresaleNotFound)?; // Only works on cancelled presales ensure!( matches!(presale.status, PresaleStatus::Cancelled), Error::::PresaleNotFound ); let current_block = >::block_number(); let treasury = Self::presale_account_id(presale_id); // Refund all contributors (treasury fee refunded, burn+stakers portion non-refundable) let contributors = Contributors::::get(presale_id); for contributor in contributors.iter() { if let Some(contribution_info) = Contributions::::get(presale_id, contributor) { if !contribution_info.refunded && contribution_info.amount > 0 { // Calculate non-refundable portion (burn + stakers = 50% of platform fee) let platform_fee = contribution_info .amount .saturating_mul(T::PlatformFeePercent::get() as u128) / 100; let non_refundable = platform_fee.saturating_mul(50) / 100; // 1% (burn 25% + stakers 25%) // Refund = 99% (contribution - non_refundable portion) let refund_amount: T::Balance = contribution_info .amount .saturating_sub(non_refundable) .try_into() .map_err(|_| Error::::ArithmeticOverflow)?; T::Assets::transfer( presale.payment_asset.clone(), &treasury, contributor, refund_amount, Preservation::Preserve, )?; // Mark as refunded let updated_info = ContributionInfo { refunded: true, refunded_at: Some(current_block), refund_fee_paid: 0, // No fee on cancelled presale ..contribution_info }; Contributions::::insert(presale_id, contributor, updated_info); Self::deposit_event(Event::Refunded { presale_id, who: contributor.clone(), amount: contribution_info.amount, fee: 0, }); } } } Ok(()) } /// Batch refund for FAILED presales (soft cap not reached) /// Anyone can call this to help refund contributors /// Processes refunds in batches to avoid gas limits #[pallet::call_index(8)] #[pallet::weight(T::PresaleWeightInfo::batch_refund_failed_presale(*batch_size))] pub fn batch_refund_failed_presale( origin: OriginFor, presale_id: PresaleId, start_index: u32, batch_size: u32, ) -> DispatchResult { ensure_signed(origin)?; // Anyone can trigger let presale = Presales::::get(presale_id).ok_or(Error::::PresaleNotFound)?; // Only works on FAILED presales (soft cap not reached) ensure!(presale.status == PresaleStatus::Failed, Error::::PresaleNotFailed); let current_block = >::block_number(); let treasury = Self::presale_account_id(presale_id); let contributors = Contributors::::get(presale_id); // Calculate end index (don't exceed array length) let end_index = start_index.saturating_add(batch_size).min(contributors.len() as u32); let mut refunded_count = 0u32; let mut total_refunded = 0u128; // Process batch for i in start_index..end_index { let contributor = &contributors[i as usize]; if let Some(contribution_info) = Contributions::::get(presale_id, contributor) { // Skip if already refunded or zero amount if !contribution_info.refunded && contribution_info.amount > 0 { // Calculate non-refundable portion (burn + stakers = 50% of platform fee) let platform_fee = contribution_info .amount .saturating_mul(T::PlatformFeePercent::get() as u128) / 100; let non_refundable = platform_fee.saturating_mul(50) / 100; // 1% (burn 25% + stakers 25%) // Refund = 99% (contribution - non_refundable portion) let refund_amount: T::Balance = contribution_info .amount .saturating_sub(non_refundable) .try_into() .map_err(|_| Error::::ArithmeticOverflow)?; T::Assets::transfer( presale.payment_asset.clone(), &treasury, contributor, refund_amount, Preservation::Preserve, )?; // Mark as refunded Contributions::::try_mutate(presale_id, contributor, |maybe_info| { if let Some(info) = maybe_info { info.refunded = true; info.refunded_at = Some(current_block); info.refund_fee_paid = 0; // No fee! } Ok::<_, Error>(()) })?; refunded_count += 1; total_refunded = total_refunded.saturating_add(contribution_info.amount); Self::deposit_event(Event::Refunded { presale_id, who: contributor.clone(), amount: contribution_info.amount, fee: 0, }); } } } Self::deposit_event(Event::BatchRefundCompleted { presale_id, refunded_count, total_refunded, }); Ok(()) } } impl Pallet { /// Get presale sub-account treasury pub fn presale_account_id(presale_id: PresaleId) -> T::AccountId { use alloc::vec::Vec; use codec::Decode; use pezsp_runtime::traits::{BlakeTwo256, Hash}; // Create a unique account ID for each presale by hashing pezpallet_id + presale_id let pezpallet_id = T::PalletId::get(); let mut buf = Vec::new(); buf.extend_from_slice(&pezpallet_id.0[..]); buf.extend_from_slice(&presale_id.to_le_bytes()); let hash = BlakeTwo256::hash(&buf); // Decode the hash as AccountId T::AccountId::decode(&mut hash.as_ref()) .expect("Hash should always decode to AccountId") } /// Distribute platform fee: 50% treasury, 25% burn, 25% stakers /// IMPORTANT: Operations happen sequentially from the same source account. /// After each operation, the source balance decreases, so we must carefully order /// operations. fn distribute_platform_fee( asset_id: T::AssetId, from: &T::AccountId, total_fee: u128, ) -> DispatchResult { // Calculate exact percentages let to_treasury = total_fee.saturating_mul(50) / 100; // 50% let to_burn = total_fee.saturating_mul(25) / 100; // 25% let to_stakers = total_fee.saturating_mul(25) / 100; // 25% let to_treasury_balance: T::Balance = to_treasury.try_into().map_err(|_| Error::::ArithmeticOverflow)?; let to_burn_balance: T::Balance = to_burn.try_into().map_err(|_| Error::::ArithmeticOverflow)?; let to_stakers_balance: T::Balance = to_stakers.try_into().map_err(|_| Error::::ArithmeticOverflow)?; // Note: Balance check removed - rely on Preservation::Expendable to handle insufficient // balance gracefully The operations below will transfer/burn as much as possible // without failing // 1. Treasury (50%) T::Assets::transfer( asset_id.clone(), from, &T::PlatformTreasury::get(), to_treasury_balance, Preservation::Expendable, )?; // 2. Burn (25%) T::Assets::burn_from( asset_id.clone(), from, to_burn_balance, Preservation::Expendable, Precision::BestEffort, Fortitude::Force, )?; // 3. Stakers (25%) T::Assets::transfer( asset_id, from, &T::StakingRewardPool::get(), to_stakers_balance, Preservation::Expendable, )?; Self::deposit_event(Event::PlatformFeeDistributed { treasury_share: to_treasury, burn_share: to_burn, staker_share: to_stakers, }); Ok(()) } /// Calculate bonus based on tier fn calculate_bonus( presale: &PresaleConfig, contribution: u128, user_reward: u128, ) -> u128 { let mut applicable_bonus = 0u8; for tier in presale.bonus_tiers.iter() { if contribution >= tier.min_contribution { applicable_bonus = tier.bonus_percentage; } } if applicable_bonus == 0 { return 0; } // Bonus calculation based on PEZ reward tokens, not USDT contribution // Returns bonus in PEZ tokens as percentage of user's reward allocation user_reward.saturating_mul(applicable_bonus as u128) / 100 } /// Calculate reward based on user's share of total raised /// Formula: (user_contribution / total_raised) * tokens_for_sale /// /// Example: /// - tokens_for_sale: 10,000,000 PEZ (10M * 10^12 decimals) /// - total_raised: 100,000 wUSDT (100K * 10^6 decimals) /// - user_contribution: 1,000 wUSDT (1K * 10^6 decimals) /// - Result: (1,000 / 100,000) * 10M = 100,000 PEZ per user fn calculate_reward_dynamic( user_contribution: u128, total_raised: u128, tokens_for_sale: u128, ) -> Result> { ensure!(total_raised > 0, Error::::ArithmeticOverflow); // Calculate user's share: (contribution * tokens_for_sale) / total_raised let user_share = user_contribution .saturating_mul(tokens_for_sale) .checked_div(total_raised) .ok_or(Error::::ArithmeticOverflow)?; Ok(user_share) } } }