pragma solidity ^0.8.24; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "@openzeppelin/contracts/utils/Pausable.sol"; import "prb-math/contracts/PRBMath.sol"; import "hardhat/console.sol"; // For debugging purposes, can be removed in production contract Launchpad is ReentrancyGuard, Pausable { // ================================ // Custom Errors // ================================ error Unauthorized(); error InvalidParameter(string reason); error InvalidState(string reason); error InsufficientAmount(uint256 required, uint256 provided); error TransferFailed(); error SaleNotConfigured(); // ================================ // State Variables // ================================ // Core Configuration address public owner; address public saleTokenAddress; // Sale token address uint256 public totalTokensForSale; // Total tokens for sale address public paymentTokenAddress; // Payment tokens accepted for the sale uint256 public paymentTokenPrice; // Payment token price in terms of sale token uint256 public startTime; // Beginning time of the sale uint256 public endTime; // Ending time of the sale bytes32 public merkleRoot; // Merkle root for whitelisting // Sale Status uint256 public contributionTarget; // Target amount to be raised in the sale uint256 public totalContributionAmount; // Total contributed amount so far uint256 public totalContributionAmountWithoutFee; uint256 public totalContributionFee; bool public claimEnabled; // Whether claiming tokens is enabled uint256 public claimStartTime; // Start time for claiming tokens // User Data mapping(address => uint256) public userContributionAmount; // User's total contribution amount (including fee) mapping(address => uint256) public userBuyAmount; // User's buy amount (excluding fee) mapping(address => uint256) public userFeeAmount; // User's fee amount mapping(address => bool) public userClaimed; // Whether user has claimed their tokens uint256 public accountsCount; // Number of accounts that have contributed // Fee Configuration uint256 public feeBps; // Fee in basis points (bps) uint256 public constant MAX_FEE_BPS = 10000; // 100% in basis points uint256 public constant DECIMALS = 1e18; // ================================ // Events // ================================ event SaleCreated(address saleTokenAddress, uint256 totalTokensForSale); event Contributed(address user, uint256 amount, address paymentTokenAddress); event TokensClaimed(address user, uint256 tokensClaimed, uint256 refundAmount); event SaleCancelled(address saleTokenAddress, uint256 refundedAmount); event EnableClaimToken(bool enabled, uint256 claimStartTime); event DisableClaimToken(bool disabled); event OwnerChanged(address oldOwner, address newOwner); event SalePaymentUpdated(address paymentTokenAddress, uint256 tokenPrice); event MerkleRootUpdated(bytes32 newMerkleRoot); event FeeUpdated(uint256 oldFeeBps, uint256 newFeeBps); event RemainingTokensWithdrawn(address owner, uint256 amount); event PaymentsWithdrawn(address owner, uint256 amount, bool isOversubscribed); event EmergencyWithdraw(address owner, uint256 ethAmount, address[] tokenAddresses); event ContractPaused(address account); event ContractUnpaused(address account); // ================================ // Constructor // ================================ constructor() { owner = msg.sender; accountsCount = 0; } // ================================ // Validation Functions // ================================ /** * @notice Validates basic sale parameters */ function _validateSaleParameters( address _saleTokenAddress, uint256 _saleTokenSupply, uint256 _contributionTarget, uint256 _saleStartTime, uint256 _saleEndTime, uint256 _tokenPrice, uint256 _feeBasisPoints ) private pure { if (_saleTokenAddress == address(0)) revert InvalidParameter("Invalid token address"); if (_saleTokenSupply == 0) revert InvalidParameter("Sale token supply must be greater than 0"); if (_tokenPrice == 0) revert InvalidParameter("Price must be greater than 0"); if (_contributionTarget == 0) revert InvalidParameter("Contribution target must be greater than 0"); if (_saleStartTime >= _saleEndTime) revert InvalidParameter("Invalid time range"); if (_feeBasisPoints > MAX_FEE_BPS) revert InvalidParameter("Fee cannot exceed 100%"); } /** * @notice Validates contribution target matches expected calculation */ function _validateContributionTarget( uint256 _saleTokenSupply, uint256 _tokenPrice, uint256 _contributionTarget ) private pure { uint256 expectedContributionTarget = PRBMath.mulDiv(_saleTokenSupply, _tokenPrice, 1e18); if (_contributionTarget != expectedContributionTarget) revert InvalidParameter("Contribution target must equal saleTokenSupply * price"); } /** * @notice Validates sale state for cancellation */ function _validateSaleCancellation() private view { if (block.timestamp >= startTime) revert InvalidState("Sale already started"); if (saleTokenAddress == address(0)) revert SaleNotConfigured(); } /** * @notice Validates claim enablement parameters */ function _validateClaimEnablement(uint256 _claimStartTimestamp) private view { if (_claimStartTimestamp < block.timestamp) revert InvalidParameter("Enable claim time must be in the future"); if (_claimStartTimestamp < endTime) revert InvalidParameter("Claiming tokens can only be enabled after the sale ends"); } /** * @notice Validates withdrawal conditions */ function _validateWithdrawalConditions() private view { if (block.timestamp <= endTime) revert InvalidState("Sale is still active"); } /** * @notice Validates remaining token withdrawal conditions */ function _validateRemainingTokenWithdrawal() private view { _validateWithdrawalConditions(); if (totalContributionAmountWithoutFee >= contributionTarget) revert InvalidState("All sold out, cannot withdraw remaining tokens"); } /** * @notice Validates contribution parameters for ETH */ function _validateETHContribution( uint256 _contributionAmount, uint256 _maxContributionAmount ) private view { if (paymentTokenAddress != address(0)) revert InvalidState("Payment token must be ETH for this sale"); if (_contributionAmount == 0) revert InvalidParameter("Must buy a positive amount"); if (_contributionAmount > _maxContributionAmount) revert InvalidParameter("Buy amount exceeds max buy amount"); if (msg.value < _contributionAmount) revert InsufficientAmount(_contributionAmount, msg.value); if (paymentTokenPrice == 0) revert InvalidState("Payment token not accepted for this sale"); } /** * @notice Validates contribution parameters for ERC20 */ function _validateERC20Contribution( uint256 _contributionAmount, uint256 _maxContributionAmount ) private view { if (_contributionAmount == 0) revert InvalidParameter("Must send a positive amount"); if (_contributionAmount > _maxContributionAmount) revert InvalidParameter("Buy amount exceeds max buy amount"); uint256 allowance = IERC20(paymentTokenAddress).allowance(msg.sender, address(this)); if (allowance < _contributionAmount) revert InsufficientAmount(_contributionAmount, allowance); } /** * @notice Validates Merkle proof */ function _validateMerkleProof( uint256 _maxContributionAmount, bytes32[] memory _merkleProof ) private view { bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, _maxContributionAmount)))); if (!MerkleProof.verify(_merkleProof, merkleRoot, leaf)) revert InvalidParameter("Invalid proof"); } /** * @notice Validates user contribution limits */ function _validateUserContributionLimit(uint256 _maxContributionAmount) private view { if (userContributionAmount[msg.sender] > _maxContributionAmount) revert InvalidParameter("Buy amount exceeds max buy amount"); } /** * @notice Validates claim conditions */ function _validateClaimConditions() private view { if (block.timestamp <= endTime) revert InvalidState("Sale is still active"); if (userContributionAmount[msg.sender] == 0) revert InvalidState("No tokens to claim"); if (userClaimed[msg.sender]) revert InvalidState("Tokens already claimed"); } /** * @notice Validates owner address */ function _validateOwnerAddress(address _newOwnerAddress) private pure { if (_newOwnerAddress == address(0)) revert InvalidParameter("Invalid owner address"); } /** * @notice Validates price parameter */ function _validatePrice(uint256 _tokenPrice) private pure { if (_tokenPrice == 0) revert InvalidParameter("Price must be greater than 0"); } /** * @notice Validates fee basis points */ function _validateFeeBps(uint256 _feeBasisPoints) private pure { if (_feeBasisPoints > MAX_FEE_BPS) revert InvalidParameter("Fee cannot exceed 100%"); } // ================================ // Modifiers // ================================ modifier onlyOwner() { if (msg.sender != owner) revert Unauthorized(); _; } modifier saleActive() { if (merkleRoot == bytes32(0)) revert SaleNotConfigured(); if (block.timestamp < startTime || block.timestamp > endTime) revert InvalidState("Sale is not active"); _; } modifier validClaimTime() { if (!claimEnabled) revert InvalidState("Claiming tokens is not enabled"); if (block.timestamp < claimStartTime) revert InvalidState("Claiming tokens not started yet"); _; } // ================================ // Owner Functions // ================================ /** * @notice Creates a new token sale with specified parameters. * @param _saleTokenAddress The address of the token being sold. * @param _saleTokenSupply The total supply of tokens allocated for this sale. * @param _contributionTarget The fundraising target amount (not including fee). * @param _saleStartTime The start time of the sale (timestamp). * @param _saleEndTime The end time of the sale (timestamp). * @param _paymentTokenAddress The address of the payment token (or address(0) for ETH). * @param _tokenPrice The price per token in payment token units. * @param _feeBasisPoints The fee in basis points (1/100 of a percent). */ function createSale( address _saleTokenAddress, uint256 _saleTokenSupply, uint256 _contributionTarget, uint256 _saleStartTime, uint256 _saleEndTime, address _paymentTokenAddress, uint256 _tokenPrice, uint256 _feeBasisPoints ) public onlyOwner { // Validate all sale parameters _validateSaleParameters(_saleTokenAddress, _saleTokenSupply, _contributionTarget, _saleStartTime, _saleEndTime, _tokenPrice, _feeBasisPoints); // Validate parameter relationships // contributionTarget = saleTokenSupply × price // Note: _saleTokenSupply and _tokenPrice both contain 18 decimals, product needs to be divided by 1e18 _validateContributionTarget(_saleTokenSupply, _tokenPrice, _contributionTarget); paymentTokenAddress = _paymentTokenAddress; paymentTokenPrice = _tokenPrice; saleTokenAddress = _saleTokenAddress; contributionTarget = _contributionTarget; totalTokensForSale = _saleTokenSupply; startTime = _saleStartTime; endTime = _saleEndTime; totalContributionAmount = 0; feeBps = _feeBasisPoints; // Set the fee in basis points merkleRoot = bytes32(0); // Reset Merkle root uint256 currentBalance = IERC20(_saleTokenAddress).balanceOf(address(this)); if (currentBalance < _saleTokenSupply) { uint256 needToTransfer = _saleTokenSupply - currentBalance; IERC20(_saleTokenAddress).transferFrom(msg.sender, address(this), needToTransfer); } emit SaleCreated(saleTokenAddress, totalTokensForSale); } /** * @notice Cancels the sale before it starts and refunds all tokens to the owner. */ function cancelSale() public onlyOwner { _validateSaleCancellation(); uint256 balance = IERC20(saleTokenAddress).balanceOf(address(this)); if (balance == 0) revert InvalidState("No tokens to refund"); IERC20(saleTokenAddress).transfer(owner, balance); // Refund tokens to owner saleTokenAddress = address(0); totalTokensForSale = 0; totalContributionAmount = 0; startTime = 0; endTime = 0; merkleRoot = bytes32(0); // Reset Merkle root emit SaleCancelled(saleTokenAddress, balance); } /** * @notice Enables claiming of purchased tokens after the sale ends. * @param _claimStartTimestamp The timestamp when claiming is enabled. */ function enableClaimTokens(uint256 _claimStartTimestamp) public onlyOwner { _validateClaimEnablement(_claimStartTimestamp); claimEnabled = true; claimStartTime = _claimStartTimestamp; // Set claim start time to now emit EnableClaimToken(claimEnabled, claimStartTime); } /** * @notice Disables claiming of purchased tokens. */ function disableClaimTokens() public onlyOwner { if (!claimEnabled) revert InvalidState("Claiming tokens is already disabled"); claimEnabled = false; emit DisableClaimToken(claimEnabled); } /** * @notice Withdraws any remaining unsold tokens to the owner after the sale ends. */ function withdrawRemainingTokens() public onlyOwner { _validateRemainingTokenWithdrawal(); // Calculate sold tokens using PRBMath to prevent overflow and precision loss uint256 soldTokens = PRBMath.mulDiv(totalTokensForSale, totalContributionAmountWithoutFee, contributionTarget); uint256 remainingAmount = totalTokensForSale - soldTokens; IERC20(saleTokenAddress).transfer(owner, remainingAmount); emit RemainingTokensWithdrawn(owner, remainingAmount); } /** * @notice Withdraws payment tokens from the contract to the owner. * In case of oversubscription, withdraws the total amount for sold tokens plus fees. * In normal subscription, withdraws all contributions. */ function withdrawPayments() public onlyOwner { _validateWithdrawalConditions(); uint256 withdrawAmount = 0; // Use the new state variables to determine oversubscription bool isOversubscribed = totalContributionAmountWithoutFee > contributionTarget; // Check if oversubscribed if (isOversubscribed) { // Oversubscribed: all tokens are sold // Withdraw fixed amount: target amount + target fee uint256 targetFee = PRBMath.mulDiv(contributionTarget, feeBps, MAX_FEE_BPS); withdrawAmount = contributionTarget + targetFee; if (paymentTokenAddress == address(0)) { // Native token (ETH) if (withdrawAmount > 0 && address(this).balance >= withdrawAmount) { payable(owner).transfer(withdrawAmount); } } else { // ERC20 token uint256 balance = IERC20(paymentTokenAddress).balanceOf(address(this)); if (withdrawAmount > 0 && balance >= withdrawAmount) { IERC20(paymentTokenAddress).transfer(owner, withdrawAmount); } } // Keep in contract: // Refund portion: totalContributionAmount - withdrawAmount } else { // Normal subscription: withdraw all contributions if (paymentTokenAddress == address(0)) { // Native token (ETH) withdrawAmount = address(this).balance; if (withdrawAmount > 0) { payable(owner).transfer(withdrawAmount); } } else { // ERC20 token withdrawAmount = IERC20(paymentTokenAddress).balanceOf(address(this)); if (withdrawAmount > 0) { IERC20(paymentTokenAddress).transfer(owner, withdrawAmount); } } } emit PaymentsWithdrawn(owner, withdrawAmount, isOversubscribed); } /** * @notice Emergency function to withdraw all assets from the contract. * This function withdraws all ETH and all ERC20 tokens that the contract holds. * Only callable by the owner in emergency situations. * @param _tokenAddresses Array of ERC20 token addresses to withdraw. */ function emergencyWithdrawAll(address[] memory _tokenAddresses) public onlyOwner { // Withdraw all ETH uint256 ethBalance = address(this).balance; if (ethBalance > 0) { (bool success, ) = payable(owner).call{value: ethBalance}(""); if (!success) revert TransferFailed(); } // Withdraw all specified ERC20 tokens for (uint256 i = 0; i < _tokenAddresses.length; i++) { address tokenAddress = _tokenAddresses[i]; if (tokenAddress != address(0)) { uint256 tokenBalance = IERC20(tokenAddress).balanceOf(address(this)); if (tokenBalance > 0) { IERC20(tokenAddress).transfer(owner, tokenBalance); } } } emit EmergencyWithdraw(owner, ethBalance, _tokenAddresses); } // ================================ // Configuration Functions // ================================ /** * @notice Transfers contract ownership to a new address. * @param _newOwnerAddress The address of the new owner. */ function setOwner(address _newOwnerAddress) public onlyOwner { _validateOwnerAddress(_newOwnerAddress); address oldOwner = owner; owner = _newOwnerAddress; emit OwnerChanged(oldOwner, _newOwnerAddress); } /** * @notice Sets the payment token and price for the sale. * @param _paymentTokenAddress The address of the payment token. * @param _tokenPrice The price per token in payment token units. */ function setSalePayment(address _paymentTokenAddress, uint256 _tokenPrice) public onlyOwner { _validatePrice(_tokenPrice); paymentTokenAddress = _paymentTokenAddress; paymentTokenPrice = _tokenPrice; emit SalePaymentUpdated(_paymentTokenAddress, _tokenPrice); } /** * @notice Sets the Merkle root for whitelist verification. * @param _newMerkleRoot The new Merkle root. */ function setMerkleRoot(bytes32 _newMerkleRoot) public onlyOwner { merkleRoot = _newMerkleRoot; emit MerkleRootUpdated(_newMerkleRoot); } /** * @notice Sets the fee in basis points. * @param _feeBasisPoints The fee in basis points (max 10000). */ function setFeeBps(uint256 _feeBasisPoints) public onlyOwner { _validateFeeBps(_feeBasisPoints); uint256 oldFeeBps = feeBps; feeBps = _feeBasisPoints; // Set the fee in basis points emit FeeUpdated(oldFeeBps, _feeBasisPoints); } /** * @notice Pauses the contract, preventing most operations. * @dev Only the owner can pause the contract. */ function pause() public onlyOwner { _pause(); emit ContractPaused(msg.sender); } /** * @notice Unpauses the contract, allowing normal operations to resume. * @dev Only the owner can unpause the contract. */ function unpause() public onlyOwner { _unpause(); emit ContractUnpaused(msg.sender); } // ================================ // Getter Functions // ================================ /** * @notice Returns the address of the contract owner. */ function getOwner() public view returns (address) { return owner; } /** * @notice Returns the address of the sale token. */ function getSaleToken() public view returns (address) { return saleTokenAddress; } /** * @notice Returns the total number of tokens for sale. */ function getTotalTokensForSale() public view returns (uint256) { return totalTokensForSale; } /** * @notice Returns the total contributed amount so far. */ function getTotalContributeAmount() public view returns (uint256) { return totalContributionAmount; } /** * @notice Returns the sale start time. */ function getStartTime() public view returns (uint256) { return startTime; } /** * @notice Returns the sale end time. */ function getEndTime() public view returns (uint256) { return endTime; } /** * @notice Returns the payment token price. */ function getPaymentTokenPrice() public view returns (uint256) { return paymentTokenPrice; } /** * @notice Returns the payment token address. */ function getPaymentTokens() public view returns (address) { return paymentTokenAddress; } /** * @notice Returns the claim start time. */ function getClaimStartTime() public view returns (uint256) { return claimStartTime; } /** * @notice Returns whether claiming is enabled. */ function isClaimEnabled() public view returns (bool) { return claimEnabled; } /** * @notice Returns all sale details as a tuple. */ function getSaleDetails() public view returns ( address _saleTokenAddress, uint256 _totalTokensForSale, uint256 _totalContributionAmount, uint256 _contributionTarget, uint256 _startTime, uint256 _endTime, bytes32 _merkleRoot, address _paymentTokenAddress, uint256 _paymentTokenPrice, uint256 _feeBps ) { return ( saleTokenAddress, totalTokensForSale, totalContributionAmount, contributionTarget, startTime, endTime, merkleRoot, paymentTokenAddress, paymentTokenPrice, feeBps ); } /** * @notice Returns the claimable tokens for a user. * @param _user The address of the user. */ function getClaimableTokens(address _user) public view returns (uint256) { return userContributionAmount[_user]; } /** * @notice Returns the user's buy amount (excluding fee). * @param _user The address of the user. */ function getUserBuyAmount(address _user) public view returns (uint256) { return userBuyAmount[_user]; } /** * @notice Returns the user's fee amount. * @param _user The address of the user. */ function getUserFeeAmount(address _user) public view returns (uint256) { return userFeeAmount[_user]; } /** * @notice Returns whether the user has claimed their tokens. * @param _user The address of the user. */ function hasUserClaimed(address _user) public view returns (bool) { return userClaimed[_user]; } /** * @notice Returns whether the contract is currently paused. * @return True if the contract is paused, false otherwise. */ function isPaused() public view returns (bool) { return paused(); } // ================================ // Calculation Functions // ================================ /** * @notice Calculates the maximum contribution amount for a user (including fee). * @param _maxTokenBuyAmount The maximum amount of tokens a user can buy. * @return The maximum contribution amount including fee. */ function calculateMaxContributionAmount(uint256 _maxTokenBuyAmount) public view returns (uint256) { // Calculate buy amount value using PRBMath for high precision uint256 buyAmountValue = PRBMath.mulDiv(_maxTokenBuyAmount, paymentTokenPrice, 1e18); // Calculate total contribution amount (buy amount + fee) using PRBMath to prevent overflow // Total = buyAmount * (1 + feeBps/MAX_FEE_BPS) = buyAmount * (MAX_FEE_BPS + feeBps) / MAX_FEE_BPS return PRBMath.mulDiv(buyAmountValue, MAX_FEE_BPS + feeBps, MAX_FEE_BPS); } /** * @notice Calculates the breakdown of a total payment amount into token purchase amount and platform fee. * @param _totalPaymentAmount The total amount paid by user (including platform fee). * @return _tokenPurchaseAmount The amount used to purchase tokens (excluding fee). * @return _platformFeeAmount The platform fee amount. */ function calculateContributionBreakdown(uint256 _totalPaymentAmount) public view returns (uint256 _tokenPurchaseAmount, uint256 _platformFeeAmount) { // Platform fee is calculated based on token purchase amount, not total payment // Total payment = token purchase amount + platform fee // Platform fee = token purchase amount * feeBps / MAX_FEE_BPS // So: total payment = token purchase amount + (token purchase amount * feeBps / MAX_FEE_BPS) // Token purchase amount = total payment / (1 + feeBps / MAX_FEE_BPS) // Using PRBMath for high precision calculation _tokenPurchaseAmount = PRBMath.mulDiv(_totalPaymentAmount, MAX_FEE_BPS, MAX_FEE_BPS + feeBps); _platformFeeAmount = _totalPaymentAmount - _tokenPurchaseAmount; return (_tokenPurchaseAmount, _platformFeeAmount); } /** * @notice Calculates the net contribution after deducting the fee. * @param _contributionAmount The original contribution amount. * @return The net contribution after fee deduction. */ function calculateNetContribution(uint256 _contributionAmount) public view returns (uint256) { // Fee is calculated based on buy amount, not total contribution // Use same logic as calculateContributionBreakdown // Using PRBMath for high precision calculation uint256 buyAmount = PRBMath.mulDiv(_contributionAmount, MAX_FEE_BPS, MAX_FEE_BPS + feeBps); return buyAmount; } // ================================ // Main Functions // ================================ /** * @notice Contribute to the sale using ETH. Requires whitelist proof. * @param _contributionAmount The amount of ETH to contribute. * @param _maxContributionAmount The maximum allowed contribution for the user. * @param _merkleProof The Merkle proof for whitelist verification. * @dev nonReentrant modifier is used to prevent reentrancy attacks. */ function contributeWithETH( uint256 _contributionAmount, uint256 _maxContributionAmount, bytes32[] memory _merkleProof ) public payable saleActive nonReentrant whenNotPaused { // Validate ETH contribution parameters _validateETHContribution(_contributionAmount, _maxContributionAmount); // Verify Merkle proof _validateMerkleProof(_maxContributionAmount, _merkleProof); // Calculate breakdown of total payment amount into token purchase payment and platform fee (uint256 buyAmount, uint256 feeAmount) = calculateContributionBreakdown(_contributionAmount); // Account count statistics if (userContributionAmount[msg.sender] == 0) { accountsCount++; // Increment accounts count only if this is the first contribution } // Payment amount statistics - separate token purchase payment and platform fee userContributionAmount[msg.sender] += _contributionAmount; // update user's total payment amount userBuyAmount[msg.sender] += buyAmount; // update user's payment amount for token purchase (excluding fee) userFeeAmount[msg.sender] += feeAmount; // update user's platform fee payment amount _validateUserContributionLimit(_maxContributionAmount); totalContributionAmount += _contributionAmount; // update total payment amount totalContributionAmountWithoutFee += buyAmount; // update total payment amount for token purchase (excluding fees) totalContributionFee += feeAmount; // update total platform fee amount if (msg.value > _contributionAmount) { payable(msg.sender).transfer(msg.value - _contributionAmount); //refund excess ETH } emit Contributed(msg.sender, _contributionAmount, address(0)); } /** * @notice Contribute to the sale using ERC20 tokens. Requires whitelist proof. * @param _contributionAmount The amount of tokens to contribute. * @param _maxContributionAmount The maximum allowed contribution for the user. * @param _merkleProof The Merkle proof for whitelist verification. * @dev nonReentrant modifier is used to prevent reentrancy attacks. */ function contributeWithERC20( uint256 _contributionAmount, uint256 _maxContributionAmount, bytes32[] memory _merkleProof ) public saleActive nonReentrant whenNotPaused { // Validate ERC20 contribution parameters _validateERC20Contribution(_contributionAmount, _maxContributionAmount); // Verify Merkle proof _validateMerkleProof(_maxContributionAmount, _merkleProof); IERC20(paymentTokenAddress).transferFrom(msg.sender, address(this), _contributionAmount); // Transfer payment tokens from user account // Calculate breakdown of total payment amount into token purchase payment and platform fee (uint256 buyAmount, uint256 feeAmount) = calculateContributionBreakdown(_contributionAmount); if (userContributionAmount[msg.sender] == 0) { accountsCount++; // Increment accounts count only if this is the first contribution } userContributionAmount[msg.sender] += _contributionAmount; // update user's total payment amount userBuyAmount[msg.sender] += buyAmount; // update user's payment amount for token purchase (excluding fee) userFeeAmount[msg.sender] += feeAmount; // update user's platform fee payment amount _validateUserContributionLimit(_maxContributionAmount); totalContributionAmount += _contributionAmount; // update total payment amount totalContributionAmountWithoutFee += buyAmount; // update total payment amount for token purchase (excluding fees) totalContributionFee += feeAmount; // update total platform fee amount emit Contributed(msg.sender, _contributionAmount, paymentTokenAddress); } /** * @notice Claim purchased tokens and receive refund if sale is oversubscribed. * @dev nonReentrant modifier is used to prevent reentrancy attacks. * This function clears the user's contribution after claim to prevent double claim. */ function claimTokens() public validClaimTime whenNotPaused { _validateClaimConditions(); uint256 contributeAmount = userContributionAmount[msg.sender]; uint256 userBuyAmountValue = userBuyAmount[msg.sender]; uint256 userFeeAmountValue = userFeeAmount[msg.sender]; uint256 refundCost = 0; uint256 boughtToken = 0; if (totalContributionAmountWithoutFee <= contributionTarget) { // Normal subscription: use pre-calculated buy amount // Calculate token amount using PRBMath for high precision boughtToken = PRBMath.mulDiv(userBuyAmountValue, 1e18, paymentTokenPrice); IERC20(saleTokenAddress).transfer(msg.sender, boughtToken); } else { // Oversubscribed: all tokens are sold, user gets proportional share based on buy amount // Use the new state variable for total buy amount uint256 totalBuyAmount = totalContributionAmountWithoutFee; // User gets proportional share of all tokens based on their buy amount using PRBMath boughtToken = PRBMath.mulDiv(userBuyAmountValue, totalTokensForSale, totalBuyAmount); // Calculate how much the user actually pays for these tokens using PRBMath uint256 userEffectiveBuyAmount = PRBMath.mulDiv(boughtToken, paymentTokenPrice, 1e18); uint256 userEffectiveFeeAmount = PRBMath.mulDiv(userEffectiveBuyAmount, feeBps, MAX_FEE_BPS); // Calculate refund breakdown: both unused buy amount and unused fee uint256 refundBuyAmount = userBuyAmountValue - userEffectiveBuyAmount; uint256 refundFeeAmount = userFeeAmountValue - userEffectiveFeeAmount; // Use unchecked for addition since both values are guaranteed to be positive unchecked { refundCost = refundBuyAmount + refundFeeAmount; } // Refund if (paymentTokenAddress == address(0)) { (bool sent, ) = payable(msg.sender).call{ value: refundCost }(""); if (!sent) revert TransferFailed(); } else { IERC20(paymentTokenAddress).transfer(msg.sender, refundCost); } // Transfer tokens IERC20(saleTokenAddress).transfer(msg.sender, boughtToken); } // Mark user as claimed and clear user data userClaimed[msg.sender] = true; userContributionAmount[msg.sender] = 0; userBuyAmount[msg.sender] = 0; userFeeAmount[msg.sender] = 0; emit TokensClaimed(msg.sender, boughtToken, refundCost); } }