Files
pezkuwi-sdk/pezcumulus/teyrchains/pallets/presale/src/lib.rs
T
pezkuwichain 1c0e57d984 feat: Rebrand Polkadot/Substrate references to PezkuwiChain
This commit systematically rebrands various references from Parity Technologies'
Polkadot/Substrate ecosystem to PezkuwiChain within the kurdistan-sdk.

Key changes include:
- Updated external repository URLs (zombienet-sdk, parity-db, parity-scale-codec, wasm-instrument) to point to pezkuwichain forks.
- Modified internal documentation and code comments to reflect PezkuwiChain naming and structure.
- Replaced direct references to  with  or specific paths within the  for XCM, Pezkuwi, and other modules.
- Cleaned up deprecated  issue and PR references in various  and  files, particularly in  and  modules.
- Adjusted image and logo URLs in documentation to point to PezkuwiChain assets.
- Removed or rephrased comments related to external Polkadot/Substrate PRs and issues.

This is a significant step towards fully customizing the SDK for the PezkuwiChain ecosystem.
2025-12-14 00:04:10 +03:00

1176 lines
38 KiB
Rust

#![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<BlockNumber> {
/// 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<BlockNumber> {
/// 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<BlockNumber>,
/// 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<u32>))]
pub struct PresaleConfig<T: Config, MaxBonusTiers: Get<u32>> {
/// 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<T>,
/// Presale duration in blocks
pub duration: BlockNumberFor<T>,
/// Status
pub status: PresaleStatus,
/// Access control
pub access_control: AccessControl,
/// Contribution limits
pub limits: ContributionLimits,
/// Bonus tiers
pub bonus_tiers: BoundedVec<BonusTier, MaxBonusTiers>,
/// Optional vesting schedule
pub vesting: Option<VestingSchedule<BlockNumberFor<T>>>,
/// Grace period for refunds (blocks) - low fee
pub grace_period_blocks: BlockNumberFor<T>,
/// 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<T>(_);
#[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<u128>
+ Into<u128>;
/// Assets handling
type Assets: Inspect<Self::AccountId, AssetId = Self::AssetId, Balance = Self::Balance>
+ Mutate<Self::AccountId>;
/// The presale pallet id, used for deriving sub-account treasuries
#[pallet::constant]
type PalletId: Get<PalletId>;
/// Platform treasury account (receives 50% of platform fee)
#[pallet::constant]
type PlatformTreasury: Get<Self::AccountId>;
/// Staking reward pool account (receives 25% of platform fee)
#[pallet::constant]
type StakingRewardPool: Get<Self::AccountId>;
/// Platform fee percentage (e.g., 2 for 2%)
#[pallet::constant]
type PlatformFeePercent: Get<u8>;
/// Maximum number of contributors per presale
#[pallet::constant]
type MaxContributors: Get<u32>;
/// Maximum bonus tiers per presale
#[pallet::constant]
type MaxBonusTiers: Get<u32>;
/// Maximum whitelisted accounts per presale
#[pallet::constant]
type MaxWhitelistedAccounts: Get<u32>;
/// Origin that can create presales
type CreatePresaleOrigin: EnsureOrigin<Self::RuntimeOrigin>;
/// Origin for emergency actions
type EmergencyOrigin: EnsureOrigin<Self::RuntimeOrigin>;
/// Weight information
type PresaleWeightInfo: crate::weights::WeightInfo;
}
/// Next presale ID
#[pallet::storage]
#[pallet::getter(fn next_presale_id)]
pub type NextPresaleId<T: Config> = StorageValue<_, PresaleId, ValueQuery>;
/// Presale configurations
#[pallet::storage]
#[pallet::getter(fn presales)]
pub type Presales<T: Config> =
StorageMap<_, Blake2_128Concat, PresaleId, PresaleConfig<T, T::MaxBonusTiers>, OptionQuery>;
/// Contributions: (presale_id, account) => ContributionInfo
#[pallet::storage]
#[pallet::getter(fn contributions)]
pub type Contributions<T: Config> = StorageDoubleMap<
_,
Blake2_128Concat,
PresaleId,
Blake2_128Concat,
T::AccountId,
ContributionInfo<BlockNumberFor<T>>,
OptionQuery,
>;
/// Contributors list per presale
#[pallet::storage]
#[pallet::getter(fn contributors)]
pub type Contributors<T: Config> = StorageMap<
_,
Blake2_128Concat,
PresaleId,
BoundedVec<T::AccountId, T::MaxContributors>,
ValueQuery,
>;
/// Total raised per presale
#[pallet::storage]
#[pallet::getter(fn total_raised)]
pub type TotalRaised<T: Config> = StorageMap<_, Blake2_128Concat, PresaleId, u128, ValueQuery>;
/// Whitelist: (presale_id, account) => is_whitelisted
#[pallet::storage]
#[pallet::getter(fn whitelisted)]
pub type WhitelistedAccounts<T: Config> = 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<T: Config> = StorageDoubleMap<
_,
Blake2_128Concat,
PresaleId,
Blake2_128Concat,
T::AccountId,
u128,
ValueQuery,
>;
/// Platform analytics
#[pallet::storage]
#[pallet::getter(fn total_platform_volume)]
pub type TotalPlatformVolume<T: Config> = StorageValue<_, u128, ValueQuery>;
#[pallet::storage]
#[pallet::getter(fn total_platform_fees)]
pub type TotalPlatformFees<T: Config> = StorageValue<_, u128, ValueQuery>;
#[pallet::storage]
#[pallet::getter(fn successful_presales)]
pub type SuccessfulPresales<T: Config> = StorageValue<_, u32, ValueQuery>;
#[pallet::event]
#[pallet::generate_deposit(pub(super) fn deposit_event)]
pub enum Event<T: Config> {
/// 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<T>,
new_end_block: BlockNumberFor<T>,
},
}
#[pallet::error]
pub enum Error<T> {
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<T: Config> Pallet<T> {
/// Create a new presale
#[pallet::call_index(0)]
#[pallet::weight(T::PresaleWeightInfo::create_presale())]
pub fn create_presale(
origin: OriginFor<T>,
payment_asset: T::AssetId,
reward_asset: T::AssetId,
tokens_for_sale: u128,
duration: BlockNumberFor<T>,
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<T>,
vesting_cliff_blocks: BlockNumberFor<T>,
grace_period_blocks: BlockNumberFor<T>,
refund_fee_percent: u8,
grace_refund_fee_percent: u8,
) -> DispatchResult {
let owner = ensure_signed(origin)?;
ensure!(tokens_for_sale > 0, Error::<T>::InvalidTokensForSale);
ensure!(soft_cap > 0, Error::<T>::InvalidTokensForSale);
ensure!(soft_cap <= hard_cap, Error::<T>::InvalidTokensForSale);
ensure!(refund_fee_percent <= 100, Error::<T>::InvalidFeePercent);
ensure!(grace_refund_fee_percent <= 100, Error::<T>::InvalidFeePercent);
let presale_id = NextPresaleId::<T>::get();
let start_block = <pezframe_system::Pallet<T>>::block_number();
// Start with empty bonus tiers - can be added later
let bounded_bonus_tiers = BoundedVec::<BonusTier, T::MaxBonusTiers>::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::<T>::insert(presale_id, config);
NextPresaleId::<T>::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<T>,
presale_id: PresaleId,
amount: u128,
) -> DispatchResult {
let who = ensure_signed(origin)?;
let presale = Presales::<T>::get(presale_id).ok_or(Error::<T>::PresaleNotFound)?;
// Checks
ensure!(presale.status == PresaleStatus::Active, Error::<T>::PresaleNotActive);
ensure!(amount > 0, Error::<T>::ZeroContribution);
let current_block = <pezframe_system::Pallet<T>>::block_number();
let end_block = presale.start_block + presale.duration;
ensure!(current_block < end_block, Error::<T>::PresaleEnded);
// Check whitelist
if presale.access_control == AccessControl::Whitelist {
ensure!(
WhitelistedAccounts::<T>::get(presale_id, &who),
Error::<T>::NotWhitelisted
);
}
// Check limits
let existing_contribution = Contributions::<T>::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::<T>::BelowMinContribution);
ensure!(new_total <= presale.limits.max_contribution, Error::<T>::AboveMaxContribution);
// Calculate remaining capacity and accept only what fits
let total_raised = TotalRaised::<T>::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::<T>::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::<T>::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::<T>::try_mutate(presale_id, |contributors| -> DispatchResult {
contributors
.try_push(who.clone())
.map_err(|_| Error::<T>::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::<T>::insert(presale_id, &who, contribution);
TotalRaised::<T>::insert(presale_id, new_raised);
// Update platform analytics
TotalPlatformVolume::<T>::mutate(|v| *v = v.saturating_add(amount));
TotalPlatformFees::<T>::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::<T>::get(presale_id).len() as u32))]
pub fn finalize_presale(origin: OriginFor<T>, presale_id: PresaleId) -> DispatchResult {
ensure_root(origin)?;
let mut presale = Presales::<T>::get(presale_id).ok_or(Error::<T>::PresaleNotFound)?;
ensure!(presale.status == PresaleStatus::Active, Error::<T>::PresaleNotActive);
let current_block = <pezframe_system::Pallet<T>>::block_number();
let end_block = presale.start_block + presale.duration;
ensure!(current_block >= end_block, Error::<T>::PresaleNotEnded);
let total_raised = TotalRaised::<T>::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::<T>::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::<T>::get(presale_id).iter() {
let contribution_info = match Contributions::<T>::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::<T>::ArithmeticOverflow)?;
T::Assets::transfer(
presale.reward_asset.clone(),
&treasury,
contributor,
immediate_balance,
Preservation::Expendable,
)?;
}
// Store remaining for vesting
VestingClaimed::<T>::insert(presale_id, contributor, immediate);
} else {
// No vesting - transfer all
let total_reward_balance: T::Balance =
total_reward.try_into().map_err(|_| Error::<T>::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::<T>::insert(presale_id, presale);
SuccessfulPresales::<T>::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::<T>::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<T>, presale_id: PresaleId) -> DispatchResult {
let who = ensure_signed(origin)?;
let presale = Presales::<T>::get(presale_id).ok_or(Error::<T>::PresaleNotFound)?;
ensure!(presale.status == PresaleStatus::Active, Error::<T>::RefundNotAllowed);
let current_block = <pezframe_system::Pallet<T>>::block_number();
let end_block = presale.start_block + presale.duration;
ensure!(current_block < end_block, Error::<T>::RefundNotAllowed);
let mut contribution_info =
Contributions::<T>::get(presale_id, &who).ok_or(Error::<T>::NoContribution)?;
ensure!(!contribution_info.refunded, Error::<T>::RefundNotAllowed);
ensure!(contribution_info.amount > 0, Error::<T>::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::<T>::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::<T>::insert(presale_id, &who, contribution_info);
TotalRaised::<T>::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<T>, presale_id: PresaleId) -> DispatchResult {
let who = ensure_signed(origin)?;
let presale = Presales::<T>::get(presale_id).ok_or(Error::<T>::PresaleNotFound)?;
let vesting = presale.vesting.ok_or(Error::<T>::VestingNotEnabled)?;
ensure!(presale.status == PresaleStatus::Finalized, Error::<T>::PresaleNotActive);
let contribution_info =
Contributions::<T>::get(presale_id, &who).ok_or(Error::<T>::NoContribution)?;
ensure!(contribution_info.amount > 0, Error::<T>::NoContribution);
ensure!(!contribution_info.refunded, Error::<T>::NoContribution);
let current_block = <pezframe_system::Pallet<T>>::block_number();
let end_block = presale.start_block + presale.duration;
let vesting_start = end_block + vesting.cliff_blocks;
ensure!(current_block >= vesting_start, Error::<T>::NothingToClaim);
// Get total raised for dynamic calculation
let total_raised = TotalRaised::<T>::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::<T>::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::<T>::NothingToClaim);
// Transfer tokens
let treasury = Self::presale_account_id(presale_id);
let claimable_balance: T::Balance =
claimable.try_into().map_err(|_| Error::<T>::ArithmeticOverflow)?;
T::Assets::transfer(
presale.reward_asset,
&treasury,
&who,
claimable_balance,
Preservation::Preserve,
)?;
VestingClaimed::<T>::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<T>,
presale_id: PresaleId,
account: T::AccountId,
) -> DispatchResult {
let who = ensure_signed(origin)?;
let presale = Presales::<T>::get(presale_id).ok_or(Error::<T>::PresaleNotFound)?;
ensure!(who == presale.owner, Error::<T>::NotPresaleOwner);
WhitelistedAccounts::<T>::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<T>, 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::<T>::get(presale_id).ok_or(Error::<T>::PresaleNotFound)?;
presale.status = PresaleStatus::Cancelled;
Presales::<T>::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<T>,
presale_id: PresaleId,
) -> DispatchResult {
ensure_signed(origin)?;
let presale = Presales::<T>::get(presale_id).ok_or(Error::<T>::PresaleNotFound)?;
// Only works on cancelled presales
ensure!(
matches!(presale.status, PresaleStatus::Cancelled),
Error::<T>::PresaleNotFound
);
let current_block = <pezframe_system::Pallet<T>>::block_number();
let treasury = Self::presale_account_id(presale_id);
// Refund all contributors (treasury fee refunded, burn+stakers portion non-refundable)
let contributors = Contributors::<T>::get(presale_id);
for contributor in contributors.iter() {
if let Some(contribution_info) = Contributions::<T>::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::<T>::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::<T>::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<T>,
presale_id: PresaleId,
start_index: u32,
batch_size: u32,
) -> DispatchResult {
ensure_signed(origin)?; // Anyone can trigger
let presale = Presales::<T>::get(presale_id).ok_or(Error::<T>::PresaleNotFound)?;
// Only works on FAILED presales (soft cap not reached)
ensure!(presale.status == PresaleStatus::Failed, Error::<T>::PresaleNotFailed);
let current_block = <pezframe_system::Pallet<T>>::block_number();
let treasury = Self::presale_account_id(presale_id);
let contributors = Contributors::<T>::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::<T>::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::<T>::ArithmeticOverflow)?;
T::Assets::transfer(
presale.payment_asset.clone(),
&treasury,
contributor,
refund_amount,
Preservation::Preserve,
)?;
// Mark as refunded
Contributions::<T>::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<T>>(())
})?;
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<T: Config> Pallet<T> {
/// 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::<T>::ArithmeticOverflow)?;
let to_burn_balance: T::Balance =
to_burn.try_into().map_err(|_| Error::<T>::ArithmeticOverflow)?;
let to_stakers_balance: T::Balance =
to_stakers.try_into().map_err(|_| Error::<T>::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<T, T::MaxBonusTiers>,
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<u128, Error<T>> {
ensure!(total_raised > 0, Error::<T>::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::<T>::ArithmeticOverflow)?;
Ok(user_share)
}
}
}