123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826 |
- 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);
- }
- }
|