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 "prb-math/contracts/PRBMath.sol"; import "hardhat/console.sol"; // For debugging purposes, can be removed in production contract Launchpad is ReentrancyGuard { // ================================ // 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 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 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); // ================================ // Constructor // ================================ constructor() { owner = msg.sender; accountsCount = 0; } // ================================ // Modifiers // ================================ modifier onlyOwner() { require(msg.sender == owner, "Only owner can call this function"); _; } modifier saleActive() { require(merkleRoot != bytes32(0), "MerkleRoot not initialized"); require(block.timestamp >= startTime && block.timestamp <= endTime, "Sale is not active"); _; } modifier validClaimTime() { require(claimEnabled, "Claiming tokens is not enabled"); require(block.timestamp >= claimStartTime, "Claiming tokens not started yet"); _; } // ================================ // Owner Functions // ================================ /** * @notice Creates a new token sale with specified parameters. * @param _token The address of the token being sold. * @param _totalTokensForSale The total number of tokens available for sale. * @param _contributionTarget The fundraising target amount (not including fee). * @param _startTime The start time of the sale (timestamp). * @param _endTime The end time of the sale (timestamp). * @param _paymentToken The address of the payment token (or address(0) for ETH). * @param _price The price per token in payment token units. * @param _feeBps The fee in basis points (1/100 of a percent). */ function createSale( address _token, uint256 _totalTokensForSale, uint256 _contributionTarget, uint256 _startTime, uint256 _endTime, address _paymentToken, uint256 _price, uint256 _feeBps ) public onlyOwner { require(_token != address(0), "Invalid token address"); require(_totalTokensForSale > 0, "Total tokens must be greater than 0"); require(_price > 0, "Price must be greater than 0"); require(_contributionTarget > 0, "Contribution target must be greater than 0"); require(_startTime < _endTime, "Invalid time range"); require(_feeBps <= MAX_FEE_BPS, "Fee cannot exceed 100%"); // Validate parameter relationships // contributionTarget = totalTokensForSale × price // Note: _totalTokensForSale and _price both contain 18 decimals, product needs to be divided by 1e18 uint256 expectedContributionTarget = (_totalTokensForSale * _price) / 1e18; require(_contributionTarget == expectedContributionTarget, "Contribution target must equal totalTokensForSale * price"); paymentTokenAddress = _paymentToken; paymentTokenPrice = _price; saleTokenAddress = _token; contributionTarget = _contributionTarget; totalTokensForSale = _totalTokensForSale; startTime = _startTime; endTime = _endTime; totalContributionAmount = 0; feeBps = _feeBps; // Set the fee in basis points merkleRoot = bytes32(0); // Reset Merkle root uint256 currentBalance = IERC20(_token).balanceOf(address(this)); if (currentBalance < _totalTokensForSale) { uint256 needToTransfer = _totalTokensForSale - currentBalance; IERC20(_token).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 { require(block.timestamp < startTime, "Sale already started"); require(saleTokenAddress != address(0), "Sale not initialized"); uint256 balance = IERC20(saleTokenAddress).balanceOf(address(this)); require(balance > 0, "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 _enableClaimTokenTime The timestamp when claiming is enabled. */ function enableClaimTokens(uint256 _enableClaimTokenTime) public onlyOwner { require(_enableClaimTokenTime >= block.timestamp, "Enable claim time must be in the future"); require(_enableClaimTokenTime >= endTime, "Claiming tokens can only be enabled after the sale ends"); claimEnabled = true; claimStartTime = _enableClaimTokenTime; // Set claim start time to now emit EnableClaimToken(claimEnabled, claimStartTime); } /** * @notice Disables claiming of purchased tokens. */ function disableClaimTokens() public onlyOwner { require(claimEnabled, "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 { require(block.timestamp > endTime, "Sale is still active"); require(totalContributionAmount endTime, "Sale is still active"); // Check if oversubscribed if (totalContributionAmount > contributionTarget) { // Oversubscribed: all tokens are sold // Total amount to withdraw = sold tokens * price + fees using PRBMath uint256 soldTokensValue = PRBMath.mulDiv(totalTokensForSale, paymentTokenPrice, 1e18); uint256 feeAmount = PRBMath.mulDiv(soldTokensValue, feeBps, MAX_FEE_BPS); uint256 withdrawAmount = soldTokensValue + feeAmount; 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); } else if (withdrawAmount > 0 && balance > 0) { // Handle small precision differences IERC20(paymentTokenAddress).transfer(owner, balance); } } // Keep in contract: // Refund portion: totalContributionAmount - withdrawAmount } else { // Normal subscription: withdraw all contributions if (paymentTokenAddress == address(0)) { // Native token (ETH) if (address(this).balance > 0) { payable(owner).transfer(address(this).balance); } } else { // ERC20 token uint256 balance = IERC20(paymentTokenAddress).balanceOf(address(this)); if (balance > 0) { IERC20(paymentTokenAddress).transfer(owner, balance); } } } } // ================================ // Configuration Functions // ================================ /** * @notice Transfers contract ownership to a new address. * @param _newOwner The address of the new owner. */ function setOwner(address _newOwner) public onlyOwner { require(_newOwner != address(0), "Invalid owner address"); owner = _newOwner; } /** * @notice Sets the payment token and price for the sale. * @param _paymentToken The address of the payment token. * @param _price The price per token in payment token units. */ function setSalePayment(address _paymentToken, uint256 _price) public onlyOwner { require(_price > 0, "Price must be greater than 0"); paymentTokenAddress = _paymentToken; paymentTokenPrice = _price; } /** * @notice Sets the Merkle root for whitelist verification. * @param _merkleRoot The new Merkle root. */ function setMerkleRoot(bytes32 _merkleRoot) public onlyOwner { merkleRoot = _merkleRoot; } /** * @notice Sets the fee in basis points. * @param _feeBps The fee in basis points (max 10000). */ function setFeeBps(uint256 _feeBps) public onlyOwner { require(_feeBps <= MAX_FEE_BPS, "Fee cannot exceed 100%"); feeBps = _feeBps; // Set the fee in basis points } // ================================ // 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]; } // ================================ // Calculation Functions // ================================ /** * @notice Calculates the maximum contribution amount for a user (including fee). * @param _maxBuyAmount The maximum amount of tokens a user can buy. * @return The maximum contribution amount including fee. */ function calculateMaxContributionAmount(uint256 _maxBuyAmount) public view returns (uint256) { // Calculate buy amount value using PRBMath for high precision uint256 buyAmountValue = PRBMath.mulDiv(_maxBuyAmount, paymentTokenPrice, 1e18); // Calculate fee using PRBMath for high precision uint256 feeAmount = PRBMath.mulDiv(buyAmountValue, feeBps, MAX_FEE_BPS); return buyAmountValue + feeAmount; // Personal hard cap = buy amount + fee } /** * @notice Calculates the breakdown of a contribution amount (buy amount and fee). * @param _contributionAmount The total contribution amount (including fee). * @return buyAmount The amount used to buy tokens. * @return feeAmount The fee amount. */ function calculateContributionBreakdown(uint256 _contributionAmount) public view returns (uint256 buyAmount, uint256 feeAmount) { // Fee is calculated based on buy amount, not total contribution // Total contribution = buy amount + fee // Fee = buy amount * feeBps / MAX_FEE_BPS // So: total contribution = buy amount + (buy amount * feeBps / MAX_FEE_BPS) // Buy amount = total contribution / (1 + feeBps / MAX_FEE_BPS) // Using PRBMath for high precision calculation buyAmount = PRBMath.mulDiv(_contributionAmount, MAX_FEE_BPS, MAX_FEE_BPS + feeBps); feeAmount = _contributionAmount - buyAmount; return (buyAmount, feeAmount); } /** * @notice Calculates the net contribution after deducting the fee. * @param _commitAmount The original contribution amount. * @return The net contribution after fee deduction. */ function calculateNetContribution(uint256 _commitAmount) 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(_commitAmount, MAX_FEE_BPS, MAX_FEE_BPS + feeBps); return buyAmount; } // ================================ // Main Functions // ================================ /** * @notice Contribute to the sale using ETH. Requires whitelist proof. * @param _commitAmount The amount of ETH to contribute. * @param _maxCommitAmount The maximum allowed contribution for the user. * @param _proof The Merkle proof for whitelist verification. * @dev nonReentrant modifier is used to prevent reentrancy attacks. */ function contributeWithETH( uint256 _commitAmount, uint256 _maxCommitAmount, bytes32[] memory _proof ) public payable saleActive nonReentrant { require(paymentTokenAddress == address(0), "Payment token must be ETH for this sale"); require(_commitAmount > 0, "Must buy a positive amount"); require(_commitAmount <= _maxCommitAmount, "Buy amount exceeds max buy amount"); require(msg.value >= _commitAmount, "Must send enough ETH"); require(paymentTokenPrice > 0, "Payment token not accepted for this sale"); // Verify Merkle proof bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, _maxCommitAmount)))); require(MerkleProof.verify(_proof, merkleRoot, leaf), "Invalid proof"); // Calculate breakdown of contribution amount (uint256 buyAmount, uint256 feeAmount) = calculateContributionBreakdown(_commitAmount); // Account count statistics if (userContributionAmount[msg.sender] == 0) { accountsCount++; // Increment accounts count only if this is the first contribution } // Token amount statistics - separate buy amount and fee userContributionAmount[msg.sender] += _commitAmount; // update user's total contribution userBuyAmount[msg.sender] += buyAmount; // update user's buy amount userFeeAmount[msg.sender] += feeAmount; // update user's fee amount require(userContributionAmount[msg.sender] <= _maxCommitAmount, "Buy amount exceeds max buy amount"); totalContributionAmount += _commitAmount; // update total contributed amount if (msg.value > _commitAmount) { payable(msg.sender).transfer(msg.value - _commitAmount); //refund excess ETH } emit Contributed(msg.sender, _commitAmount, address(0)); } /** * @notice Contribute to the sale using ERC20 tokens. Requires whitelist proof. * @param _commitAmount The amount of tokens to contribute. * @param _maxCommitAmount The maximum allowed contribution for the user. * @param _proof The Merkle proof for whitelist verification. * @dev nonReentrant modifier is used to prevent reentrancy attacks. */ function contributeWithERC20( uint256 _commitAmount, uint256 _maxCommitAmount, bytes32[] memory _proof ) public saleActive nonReentrant { require(_commitAmount > 0, "Must send a positive amount"); require(_commitAmount <= _maxCommitAmount, "Buy amount exceeds max buy amount"); bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, _maxCommitAmount)))); require(MerkleProof.verify(_proof, merkleRoot, leaf), "Invalid proof"); require( IERC20(paymentTokenAddress).allowance(msg.sender, address(this)) >= _commitAmount, "Not enough allowance for payment token" ); IERC20(paymentTokenAddress).transferFrom(msg.sender, address(this), _commitAmount); // Transfer payment tokens from user account // Calculate breakdown of contribution amount (uint256 buyAmount, uint256 feeAmount) = calculateContributionBreakdown(_commitAmount); if (userContributionAmount[msg.sender] == 0) { accountsCount++; // Increment accounts count only if this is the first contribution } userContributionAmount[msg.sender] += _commitAmount; // update user's total contribution userBuyAmount[msg.sender] += buyAmount; // update user's buy amount userFeeAmount[msg.sender] += feeAmount; // update user's fee amount require(userContributionAmount[msg.sender] <= _maxCommitAmount, "Buy amount exceeds max buy amount"); totalContributionAmount += _commitAmount; // update total contributed amount emit Contributed(msg.sender, _commitAmount, 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 { require(block.timestamp > endTime, "Sale is still active"); uint256 contributeAmount = userContributionAmount[msg.sender]; uint256 userBuyAmountValue = userBuyAmount[msg.sender]; uint256 userFeeAmountValue = userFeeAmount[msg.sender]; require(contributeAmount > 0, "No tokens to claim"); uint256 refundCost = 0; uint256 boughtToken = 0; if (totalContributionAmount <= 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 // Calculate total buy amount from all contributions using PRBMath uint256 totalBuyAmount = PRBMath.mulDiv(totalContributionAmount, MAX_FEE_BPS, MAX_FEE_BPS + feeBps); // 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; refundCost = refundBuyAmount + refundFeeAmount; // Refund if (paymentTokenAddress == address(0)) { (bool sent, ) = payable(msg.sender).call{ value: refundCost }(""); require(sent, "ETH refund failed"); } else { IERC20(paymentTokenAddress).transfer(msg.sender, refundCost); } // Transfer tokens IERC20(saleTokenAddress).transfer(msg.sender, boughtToken); } // Clear user data userContributionAmount[msg.sender] = 0; userBuyAmount[msg.sender] = 0; userFeeAmount[msg.sender] = 0; emit TokensClaimed(msg.sender, boughtToken, refundCost); } }