123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599 |
- 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<contributionTarget,"all sold out, cannot withdraw remaining tokens");
- uint256 remainingAmount = totalTokensForSale - totalTokensForSale * totalContributionAmount / contributionTarget;
- IERC20(saleTokenAddress).transfer(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 {
- require(block.timestamp > 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);
- }
- }
|