Files
pezkuwi-sdk/pezcumulus/teyrchains/pezpallets/presale/src/lib.rs
T

1376 lines
42 KiB
Rust

#![cfg_attr(not(feature = "std"), no_std)]
//! # Pezpallet 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 pezpallet::*;
#[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::pezpallet]
pub mod pezpallet {
use super::*;
use codec::DecodeWithMemTracking;
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,
DecodeWithMemTracking,
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,
DecodeWithMemTracking,
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,
DecodeWithMemTracking,
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,
DecodeWithMemTracking,
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,
DecodeWithMemTracking,
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,
DecodeWithMemTracking,
Eq,
PartialEq,
RuntimeDebug,
MaxEncodedLen,
TypeInfo,
)]
#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))]
#[codec(dumb_trait_bound)]
pub struct RefundConfig<BlockNumber> {
/// Grace period for refunds (blocks) - low fee
pub grace_period_blocks: BlockNumber,
/// Normal refund fee percentage (0-100)
pub refund_fee_percent: u8,
/// Grace period refund fee percentage (0-100)
pub grace_refund_fee_percent: u8,
}
#[derive(
Clone,
Copy,
Encode,
Decode,
DecodeWithMemTracking,
Eq,
PartialEq,
RuntimeDebug,
MaxEncodedLen,
TypeInfo,
)]
#[cfg_attr(feature = "std", derive(serde::Serialize, serde::Deserialize))]
#[codec(dumb_trait_bound)]
pub struct PresaleCreationParams<BlockNumber> {
/// Total tokens for sale (with decimals)
pub tokens_for_sale: u128,
/// Presale duration in blocks
pub duration: BlockNumber,
/// Whether presale requires whitelist
pub is_whitelist: bool,
/// Contribution limits (min, max, soft cap, hard cap)
pub limits: ContributionLimits,
/// Optional vesting schedule
pub vesting: Option<VestingSchedule<BlockNumber>>,
/// Refund configuration
pub refund_config: RefundConfig<BlockNumber>,
}
#[derive(
Clone,
Copy,
Encode,
Decode,
DecodeWithMemTracking,
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,
DecodeWithMemTracking,
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,
}
#[pezpallet::pezpallet]
pub struct Pezpallet<T>(_);
#[pezpallet::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 pezpallet id, used for deriving sub-account treasuries
#[pezpallet::constant]
type PalletId: Get<PalletId>;
/// Platform treasury account (receives 50% of platform fee)
#[pezpallet::constant]
type PlatformTreasury: Get<Self::AccountId>;
/// Staking reward pool account (receives 25% of platform fee)
#[pezpallet::constant]
type StakingRewardPool: Get<Self::AccountId>;
/// Platform fee percentage (e.g., 2 for 2%)
#[pezpallet::constant]
type PlatformFeePercent: Get<u8>;
/// Maximum number of contributors per presale
#[pezpallet::constant]
type MaxContributors: Get<u32>;
/// Maximum bonus tiers per presale
#[pezpallet::constant]
type MaxBonusTiers: Get<u32>;
/// Maximum whitelisted accounts per presale
#[pezpallet::constant]
type MaxWhitelistedAccounts: Get<u32>;
/// Origin that can create presales (must resolve to an AccountId)
type CreatePresaleOrigin: EnsureOrigin<Self::RuntimeOrigin, Success = Self::AccountId>;
/// Origin for emergency actions
type EmergencyOrigin: EnsureOrigin<Self::RuntimeOrigin>;
/// Weight information
type PresaleWeightInfo: crate::weights::WeightInfo;
}
/// Next presale ID
#[pezpallet::storage]
#[pezpallet::getter(fn next_presale_id)]
pub type NextPresaleId<T: Config> = StorageValue<_, PresaleId, ValueQuery>;
/// Presale configurations
#[pezpallet::storage]
#[pezpallet::getter(fn presales)]
pub type Presales<T: Config> =
StorageMap<_, Blake2_128Concat, PresaleId, PresaleConfig<T, T::MaxBonusTiers>, OptionQuery>;
/// Contributions: (presale_id, account) => ContributionInfo
#[pezpallet::storage]
#[pezpallet::getter(fn contributions)]
pub type Contributions<T: Config> = StorageDoubleMap<
_,
Blake2_128Concat,
PresaleId,
Blake2_128Concat,
T::AccountId,
ContributionInfo<BlockNumberFor<T>>,
OptionQuery,
>;
/// Contributors list per presale
#[pezpallet::storage]
#[pezpallet::getter(fn contributors)]
pub type Contributors<T: Config> = StorageMap<
_,
Blake2_128Concat,
PresaleId,
BoundedVec<T::AccountId, T::MaxContributors>,
ValueQuery,
>;
/// Total raised per presale
#[pezpallet::storage]
#[pezpallet::getter(fn total_raised)]
pub type TotalRaised<T: Config> = StorageMap<_, Blake2_128Concat, PresaleId, u128, ValueQuery>;
/// Whitelist: (presale_id, account) => is_whitelisted
#[pezpallet::storage]
#[pezpallet::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
#[pezpallet::storage]
#[pezpallet::getter(fn vesting_claimed)]
pub type VestingClaimed<T: Config> = StorageDoubleMap<
_,
Blake2_128Concat,
PresaleId,
Blake2_128Concat,
T::AccountId,
u128,
ValueQuery,
>;
/// Platform analytics
#[pezpallet::storage]
#[pezpallet::getter(fn total_platform_volume)]
pub type TotalPlatformVolume<T: Config> = StorageValue<_, u128, ValueQuery>;
#[pezpallet::storage]
#[pezpallet::getter(fn total_platform_fees)]
pub type TotalPlatformFees<T: Config> = StorageValue<_, u128, ValueQuery>;
#[pezpallet::storage]
#[pezpallet::getter(fn successful_presales)]
pub type SuccessfulPresales<T: Config> = StorageValue<_, u32, ValueQuery>;
#[pezpallet::event]
#[pezpallet::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
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>,
},
/// Batch distribution completed [presale_id, distributed_count, total_distributed]
BatchDistributionCompleted {
presale_id: PresaleId,
distributed_count: u32,
total_distributed: u128,
},
}
#[pezpallet::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,
}
#[pezpallet::call]
impl<T: Config> Pezpallet<T> {
/// Create a new presale
///
/// Parameters are grouped into structs for cleaner API:
/// - `payment_asset`: The asset used for contributions (e.g., wUSDT)
/// - `reward_asset`: The token being sold
/// - `params`: Creation parameters including limits, vesting, and refund config
#[pezpallet::call_index(0)]
#[pezpallet::weight(T::PresaleWeightInfo::create_presale())]
pub fn create_presale(
origin: OriginFor<T>,
payment_asset: T::AssetId,
reward_asset: T::AssetId,
params: PresaleCreationParams<BlockNumberFor<T>>,
) -> DispatchResult {
// Verify caller is authorized to create presales via CreatePresaleOrigin
let owner = T::CreatePresaleOrigin::ensure_origin(origin)
.map_err(|_| Error::<T>::NotPresaleOwner)?;
ensure!(params.tokens_for_sale > 0, Error::<T>::InvalidTokensForSale);
ensure!(params.limits.soft_cap > 0, Error::<T>::InvalidTokensForSale);
ensure!(
params.limits.soft_cap <= params.limits.hard_cap,
Error::<T>::InvalidTokensForSale
);
ensure!(params.refund_config.refund_fee_percent <= 100, Error::<T>::InvalidFeePercent);
ensure!(
params.refund_config.grace_refund_fee_percent <= 100,
Error::<T>::InvalidFeePercent
);
let presale_id = NextPresaleId::<T>::get();
let start_block = <pezframe_system::Pezpallet<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 params.is_whitelist { AccessControl::Whitelist } else { AccessControl::Public };
let config = PresaleConfig {
owner: owner.clone(),
payment_asset,
reward_asset,
tokens_for_sale: params.tokens_for_sale,
start_block,
duration: params.duration,
status: PresaleStatus::Active,
access_control,
limits: params.limits,
bonus_tiers: bounded_bonus_tiers,
vesting: params.vesting,
grace_period_blocks: params.refund_config.grace_period_blocks,
refund_fee_percent: params.refund_config.refund_fee_percent,
grace_refund_fee_percent: params.refund_config.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
#[pezpallet::call_index(1)]
#[pezpallet::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::Pezpallet<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.into();
T::Assets::transfer(
presale.payment_asset,
&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, &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
#[pezpallet::call_index(2)]
#[pezpallet::weight(T::PresaleWeightInfo::finalize_presale(1))]
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::Pezpallet<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,
});
// Distribution is done via batch_distribute() extrinsic to avoid
// unbounded iteration. Status is now Successful — call batch_distribute
// in batches to distribute tokens to all contributors.
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)
#[pezpallet::call_index(3)]
#[pezpallet::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::Pezpallet<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.into();
T::Assets::transfer(
presale.payment_asset,
&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, &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
#[pezpallet::call_index(4)]
#[pezpallet::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::Pezpallet<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.into();
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)
#[pezpallet::call_index(5)]
#[pezpallet::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)
#[pezpallet::call_index(6)]
#[pezpallet::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)?;
// Cannot cancel presales that are already finalized, failed, or cancelled
ensure!(
matches!(
presale.status,
PresaleStatus::Active
| PresaleStatus::Pending
| PresaleStatus::Paused
| PresaleStatus::Successful
),
Error::<T>::AlreadyFinalized,
);
presale.status = PresaleStatus::Cancelled;
Presales::<T>::insert(presale_id, presale);
Self::deposit_event(Event::PresaleCancelled { presale_id });
Ok(())
}
/// Batch refund contributors when presale is cancelled.
/// Processes refunds in batches to avoid block weight exhaustion.
/// Anyone can call this to help refund contributors.
#[pezpallet::call_index(7)]
#[pezpallet::weight(T::PresaleWeightInfo::batch_refund_failed_presale(*batch_size))]
pub fn refund_cancelled_presale(
origin: OriginFor<T>,
presale_id: PresaleId,
start_index: u32,
batch_size: u32,
) -> 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::Pezpallet<T>>::block_number();
let treasury = Self::presale_account_id(presale_id);
let contributors = Contributors::<T>::get(presale_id);
let end_index = start_index.saturating_add(batch_size).min(contributors.len() as u32);
let mut refunded_count = 0u32;
let mut total_refunded = 0u128;
for i in start_index..end_index {
let contributor = &contributors[i as usize];
if let Some(contribution_info) = Contributions::<T>::get(presale_id, contributor) {
if !contribution_info.refunded && contribution_info.amount > 0 {
// Calculate net amount in treasury (original - platform fee already deducted at contribution)
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);
// Refund the full net amount (cancelled presale = no additional fee)
let refund_amount: T::Balance = net_in_treasury.into();
T::Assets::transfer(
presale.payment_asset,
&treasury,
contributor,
refund_amount,
Preservation::Preserve,
)?;
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 = platform_fee_at_contribution;
}
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(())
}
/// 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
#[pezpallet::call_index(8)]
#[pezpallet::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::Pezpallet<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 net amount in treasury (original - platform fee already deducted at contribution)
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);
// Refund the full net amount (failed presale = no additional fee)
let refund_amount: T::Balance = net_in_treasury.into();
T::Assets::transfer(
presale.payment_asset,
&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(())
}
/// Batch distribute tokens for SUCCESSFUL presales
/// Anyone can call this to help distribute tokens to contributors
/// Processes distribution in batches to avoid block weight limits
#[pezpallet::call_index(9)]
#[pezpallet::weight(T::PresaleWeightInfo::batch_refund_failed_presale(*batch_size))]
pub fn batch_distribute(
origin: OriginFor<T>,
presale_id: PresaleId,
start_index: u32,
batch_size: u32,
) -> DispatchResult {
ensure_signed(origin)?; // Anyone can trigger
let mut presale = Presales::<T>::get(presale_id).ok_or(Error::<T>::PresaleNotFound)?;
// Only works on SUCCESSFUL presales (soft cap reached, not yet finalized)
ensure!(presale.status == PresaleStatus::Successful, Error::<T>::PresaleNotSuccessful,);
let total_raised = TotalRaised::<T>::get(presale_id);
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 distributed_count = 0u32;
let mut total_distributed = 0u128;
// Process batch
for i in start_index..end_index {
let contributor = &contributors[i as usize];
let contribution_info = match Contributions::<T>::get(presale_id, contributor) {
Some(info) => info,
None => continue,
};
// Skip if refunded or zero amount
if contribution_info.refunded || contribution_info.amount == 0 {
continue;
}
// Skip if already distributed (check VestingClaimed for vesting,
// or check a distribution flag)
if VestingClaimed::<T>::contains_key(presale_id, contributor) {
continue;
}
// Calculate reward tokens using dynamic rate (overflow-safe)
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.into();
T::Assets::transfer(
presale.reward_asset,
&treasury,
contributor,
immediate_balance,
Preservation::Expendable,
)?;
}
// Store remaining for vesting (also marks as distributed)
VestingClaimed::<T>::insert(presale_id, contributor, immediate);
} else {
// No vesting - transfer all
let total_reward_balance: T::Balance = total_reward.into();
T::Assets::transfer(
presale.reward_asset,
&treasury,
contributor,
total_reward_balance,
Preservation::Expendable,
)?;
// Mark as distributed (store total_reward as claimed amount)
VestingClaimed::<T>::insert(presale_id, contributor, total_reward);
}
distributed_count += 1;
total_distributed = total_distributed.saturating_add(total_reward);
Self::deposit_event(Event::Distributed {
presale_id,
who: contributor.clone(),
amount: total_reward,
});
}
// If we've processed all contributors, mark as Finalized
if end_index >= contributors.len() as u32 {
presale.status = PresaleStatus::Finalized;
Presales::<T>::insert(presale_id, &presale);
Self::deposit_event(Event::PresaleFinalized { presale_id, total_raised });
}
Self::deposit_event(Event::BatchDistributionCompleted {
presale_id,
distributed_count,
total_distributed,
});
Ok(())
}
}
impl<T: Config> Pezpallet<T> {
/// Get presale sub-account treasury.
/// Derives a unique AccountId from PalletId + presale_id using Blake2 hash.
/// Uses `defensive_unwrap_or_default` instead of `.expect()` to avoid runtime panics.
pub fn presale_account_id(presale_id: PresaleId) -> T::AccountId {
use codec::Decode;
use pezsp_runtime::traits::{BlakeTwo256, Hash};
let pezpallet_id = T::PalletId::get();
let mut buf = alloc::vec::Vec::new();
buf.extend_from_slice(&pezpallet_id.0[..]);
buf.extend_from_slice(&presale_id.to_le_bytes());
let hash = BlakeTwo256::hash(&buf);
// SAFETY: Blake2_256 always produces 32 bytes, which is sufficient for any
// standard AccountId (32 bytes for AccountId32, 8 for test u64).
// Decode from a 32-byte hash will always succeed.
T::AccountId::decode(&mut hash.as_ref())
.expect("infallible: 32-byte Blake2 hash always decodes 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.into();
let to_burn_balance: T::Balance = to_burn.into();
let to_stakers_balance: T::Balance = to_stakers.into();
// 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,
from,
&T::PlatformTreasury::get(),
to_treasury_balance,
Preservation::Expendable,
)?;
// 2. Burn (25%)
T::Assets::burn_from(
asset_id,
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);
// Use multiply_by_rational_with_rounding to prevent u128 overflow
// in (user_contribution * tokens_for_sale) intermediate multiplication.
// This computes: user_contribution * tokens_for_sale / total_raised
// using BigUint internally when values are large.
pezsp_runtime::helpers_128bit::multiply_by_rational_with_rounding(
user_contribution,
tokens_for_sale,
total_raised,
pezsp_runtime::Rounding::Down,
)
.ok_or(Error::<T>::ArithmeticOverflow)
}
}
}