|
@@ -3,10 +3,22 @@ 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 {
|
|
|
+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();
|
|
|
+ error ContractPaused();
|
|
|
|
|
|
// ================================
|
|
|
// State Variables
|
|
@@ -25,6 +37,8 @@ contract Launchpad is ReentrancyGuard {
|
|
|
// 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
|
|
|
|
|
@@ -32,6 +46,7 @@ contract Launchpad is ReentrancyGuard {
|
|
|
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
|
|
@@ -48,6 +63,15 @@ contract Launchpad is ReentrancyGuard {
|
|
|
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
|
|
@@ -57,23 +81,170 @@ contract Launchpad is ReentrancyGuard {
|
|
|
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() {
|
|
|
- require(msg.sender == owner, "Only owner can call this function");
|
|
|
+ if (msg.sender != owner) revert Unauthorized();
|
|
|
_;
|
|
|
}
|
|
|
|
|
|
modifier saleActive() {
|
|
|
- require(merkleRoot != bytes32(0), "MerkleRoot not initialized");
|
|
|
- require(block.timestamp >= startTime && block.timestamp <= endTime, "Sale is not active");
|
|
|
+ if (merkleRoot == bytes32(0)) revert SaleNotConfigured();
|
|
|
+ if (block.timestamp < startTime || block.timestamp > endTime) revert InvalidState("Sale is not active");
|
|
|
_;
|
|
|
}
|
|
|
|
|
|
modifier validClaimTime() {
|
|
|
- require(claimEnabled, "Claiming tokens is not enabled");
|
|
|
- require(block.timestamp >= claimStartTime, "Claiming tokens not started yet");
|
|
|
+ if (!claimEnabled) revert InvalidState("Claiming tokens is not enabled");
|
|
|
+ if (block.timestamp < claimStartTime) revert InvalidState("Claiming tokens not started yet");
|
|
|
+ _;
|
|
|
+ }
|
|
|
+
|
|
|
+ modifier whenNotPaused() {
|
|
|
+ if (paused()) revert ContractPaused();
|
|
|
_;
|
|
|
}
|
|
|
|
|
@@ -83,54 +254,48 @@ contract Launchpad is ReentrancyGuard {
|
|
|
|
|
|
/**
|
|
|
* @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 _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 _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).
|
|
|
+ * @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 _token,
|
|
|
- uint256 _totalTokensForSale,
|
|
|
+ address _saleTokenAddress,
|
|
|
+ uint256 _saleTokenSupply,
|
|
|
uint256 _contributionTarget,
|
|
|
- uint256 _startTime,
|
|
|
- uint256 _endTime,
|
|
|
- address _paymentToken,
|
|
|
- uint256 _price,
|
|
|
- uint256 _feeBps
|
|
|
+ uint256 _saleStartTime,
|
|
|
+ uint256 _saleEndTime,
|
|
|
+ address _paymentTokenAddress,
|
|
|
+ uint256 _tokenPrice,
|
|
|
+ uint256 _feeBasisPoints
|
|
|
) 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 all sale parameters
|
|
|
+ _validateSaleParameters(_saleTokenAddress, _saleTokenSupply, _contributionTarget, _saleStartTime, _saleEndTime, _tokenPrice, _feeBasisPoints);
|
|
|
+
|
|
|
// 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 = 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 = _totalTokensForSale;
|
|
|
- startTime = _startTime;
|
|
|
- endTime = _endTime;
|
|
|
+ totalTokensForSale = _saleTokenSupply;
|
|
|
+ startTime = _saleStartTime;
|
|
|
+ endTime = _saleEndTime;
|
|
|
totalContributionAmount = 0;
|
|
|
- feeBps = _feeBps; // Set the fee in basis points
|
|
|
+ feeBps = _feeBasisPoints; // 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);
|
|
|
+ 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);
|
|
@@ -140,10 +305,9 @@ contract Launchpad is ReentrancyGuard {
|
|
|
* @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");
|
|
|
+ _validateSaleCancellation();
|
|
|
uint256 balance = IERC20(saleTokenAddress).balanceOf(address(this));
|
|
|
- require(balance > 0, "No tokens to refund");
|
|
|
+ if (balance == 0) revert InvalidState("No tokens to refund");
|
|
|
IERC20(saleTokenAddress).transfer(owner, balance); // Refund tokens to owner
|
|
|
saleTokenAddress = address(0);
|
|
|
totalTokensForSale = 0;
|
|
@@ -156,13 +320,12 @@ contract Launchpad is ReentrancyGuard {
|
|
|
|
|
|
/**
|
|
|
* @notice Enables claiming of purchased tokens after the sale ends.
|
|
|
- * @param _enableClaimTokenTime The timestamp when claiming is enabled.
|
|
|
+ * @param _claimStartTimestamp 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");
|
|
|
+ function enableClaimTokens(uint256 _claimStartTimestamp) public onlyOwner {
|
|
|
+ _validateClaimEnablement(_claimStartTimestamp);
|
|
|
claimEnabled = true;
|
|
|
- claimStartTime = _enableClaimTokenTime; // Set claim start time to now
|
|
|
+ claimStartTime = _claimStartTimestamp; // Set claim start time to now
|
|
|
emit EnableClaimToken(claimEnabled, claimStartTime);
|
|
|
}
|
|
|
|
|
@@ -170,7 +333,7 @@ contract Launchpad is ReentrancyGuard {
|
|
|
* @notice Disables claiming of purchased tokens.
|
|
|
*/
|
|
|
function disableClaimTokens() public onlyOwner {
|
|
|
- require(claimEnabled, "Claiming tokens is already disabled");
|
|
|
+ if (!claimEnabled) revert InvalidState("Claiming tokens is already disabled");
|
|
|
claimEnabled = false;
|
|
|
emit DisableClaimToken(claimEnabled);
|
|
|
}
|
|
@@ -179,10 +342,12 @@ contract Launchpad is ReentrancyGuard {
|
|
|
* @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;
|
|
|
+ _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);
|
|
|
}
|
|
|
|
|
|
/**
|
|
@@ -191,15 +356,18 @@ contract Launchpad is ReentrancyGuard {
|
|
|
* In normal subscription, withdraws all contributions.
|
|
|
*/
|
|
|
function withdrawPayments() public onlyOwner {
|
|
|
- require(block.timestamp > endTime, "Sale is still active");
|
|
|
+ _validateWithdrawalConditions();
|
|
|
+
|
|
|
+ uint256 withdrawAmount = 0;
|
|
|
+ // Use the new state variables to determine oversubscription
|
|
|
+ bool isOversubscribed = totalContributionAmountWithoutFee > contributionTarget;
|
|
|
|
|
|
// Check if oversubscribed
|
|
|
- if (totalContributionAmount > contributionTarget) {
|
|
|
+ if (isOversubscribed) {
|
|
|
// 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;
|
|
|
+ // 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)
|
|
@@ -211,9 +379,6 @@ contract Launchpad is ReentrancyGuard {
|
|
|
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);
|
|
|
}
|
|
|
}
|
|
|
|
|
@@ -223,17 +388,48 @@ contract Launchpad is ReentrancyGuard {
|
|
|
// Normal subscription: withdraw all contributions
|
|
|
if (paymentTokenAddress == address(0)) {
|
|
|
// Native token (ETH)
|
|
|
- if (address(this).balance > 0) {
|
|
|
- payable(owner).transfer(address(this).balance);
|
|
|
+ withdrawAmount = address(this).balance;
|
|
|
+ if (withdrawAmount > 0) {
|
|
|
+ payable(owner).transfer(withdrawAmount);
|
|
|
}
|
|
|
} else {
|
|
|
// ERC20 token
|
|
|
- uint256 balance = IERC20(paymentTokenAddress).balanceOf(address(this));
|
|
|
- if (balance > 0) {
|
|
|
- IERC20(paymentTokenAddress).transfer(owner, balance);
|
|
|
+ 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);
|
|
|
}
|
|
|
|
|
|
// ================================
|
|
@@ -242,39 +438,63 @@ contract Launchpad is ReentrancyGuard {
|
|
|
|
|
|
/**
|
|
|
* @notice Transfers contract ownership to a new address.
|
|
|
- * @param _newOwner The address of the new owner.
|
|
|
+ * @param _newOwnerAddress The address of the new owner.
|
|
|
*/
|
|
|
- function setOwner(address _newOwner) public onlyOwner {
|
|
|
- require(_newOwner != address(0), "Invalid owner address");
|
|
|
- owner = _newOwner;
|
|
|
+ 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 _paymentToken The address of the payment token.
|
|
|
- * @param _price The price per token in payment token units.
|
|
|
+ * @param _paymentTokenAddress The address of the payment token.
|
|
|
+ * @param _tokenPrice 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;
|
|
|
+ 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 _merkleRoot The new Merkle root.
|
|
|
+ * @param _newMerkleRoot The new Merkle root.
|
|
|
*/
|
|
|
- function setMerkleRoot(bytes32 _merkleRoot) public onlyOwner {
|
|
|
- merkleRoot = _merkleRoot;
|
|
|
+ function setMerkleRoot(bytes32 _newMerkleRoot) public onlyOwner {
|
|
|
+ merkleRoot = _newMerkleRoot;
|
|
|
+ emit MerkleRootUpdated(_newMerkleRoot);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @notice Sets the fee in basis points.
|
|
|
- * @param _feeBps The fee in basis points (max 10000).
|
|
|
+ * @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 setFeeBps(uint256 _feeBps) public onlyOwner {
|
|
|
- require(_feeBps <= MAX_FEE_BPS, "Fee cannot exceed 100%");
|
|
|
- feeBps = _feeBps; // Set the fee in basis points
|
|
|
+ function unpause() public onlyOwner {
|
|
|
+ _unpause();
|
|
|
+ emit ContractUnpaused(msg.sender);
|
|
|
}
|
|
|
|
|
|
// ================================
|
|
@@ -408,53 +628,68 @@ contract Launchpad is ReentrancyGuard {
|
|
|
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 _maxBuyAmount The maximum amount of tokens a user can buy.
|
|
|
+ * @param _maxTokenBuyAmount The maximum amount of tokens a user can buy.
|
|
|
* @return The maximum contribution amount including fee.
|
|
|
*/
|
|
|
- function calculateMaxContributionAmount(uint256 _maxBuyAmount) public view returns (uint256) {
|
|
|
+ function calculateMaxContributionAmount(uint256 _maxTokenBuyAmount) 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);
|
|
|
+ uint256 buyAmountValue = PRBMath.mulDiv(_maxTokenBuyAmount, paymentTokenPrice, 1e18);
|
|
|
|
|
|
- return buyAmountValue + feeAmount; // Personal hard cap = buy amount + fee
|
|
|
+ // 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 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.
|
|
|
+ * @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 _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)
|
|
|
+ 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
|
|
|
- buyAmount = PRBMath.mulDiv(_contributionAmount, MAX_FEE_BPS, MAX_FEE_BPS + feeBps);
|
|
|
- feeAmount = _contributionAmount - buyAmount;
|
|
|
- return (buyAmount, feeAmount);
|
|
|
+ _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 _commitAmount The original contribution amount.
|
|
|
+ * @param _contributionAmount The original contribution amount.
|
|
|
* @return The net contribution after fee deduction.
|
|
|
*/
|
|
|
- function calculateNetContribution(uint256 _commitAmount) public view returns (uint256) {
|
|
|
+ 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(_commitAmount, MAX_FEE_BPS, MAX_FEE_BPS + feeBps);
|
|
|
+ uint256 buyAmount = PRBMath.mulDiv(_contributionAmount, MAX_FEE_BPS, MAX_FEE_BPS + feeBps);
|
|
|
return buyAmount;
|
|
|
}
|
|
|
|
|
@@ -464,82 +699,77 @@ contract Launchpad is ReentrancyGuard {
|
|
|
|
|
|
/**
|
|
|
* @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.
|
|
|
+ * @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 _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");
|
|
|
-
|
|
|
+ uint256 _contributionAmount,
|
|
|
+ uint256 _maxContributionAmount,
|
|
|
+ bytes32[] memory _merkleProof
|
|
|
+ ) public payable saleActive nonReentrant whenNotPaused {
|
|
|
+ // Validate ETH contribution parameters
|
|
|
+ _validateETHContribution(_contributionAmount, _maxContributionAmount);
|
|
|
+
|
|
|
// Verify Merkle proof
|
|
|
- bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, _maxCommitAmount))));
|
|
|
- require(MerkleProof.verify(_proof, merkleRoot, leaf), "Invalid proof");
|
|
|
+ _validateMerkleProof(_maxContributionAmount, _merkleProof);
|
|
|
|
|
|
- // Calculate breakdown of contribution amount
|
|
|
- (uint256 buyAmount, uint256 feeAmount) = calculateContributionBreakdown(_commitAmount);
|
|
|
+ // 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
|
|
|
}
|
|
|
|
|
|
- // 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
|
|
|
+ // 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, _commitAmount, address(0));
|
|
|
+ emit Contributed(msg.sender, _contributionAmount, 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.
|
|
|
+ * @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 _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
|
|
|
+ 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 contribution amount
|
|
|
- (uint256 buyAmount, uint256 feeAmount) = calculateContributionBreakdown(_commitAmount);
|
|
|
+ // 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] += _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);
|
|
|
+ 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);
|
|
|
}
|
|
|
|
|
|
/**
|
|
@@ -547,24 +777,23 @@ contract Launchpad is ReentrancyGuard {
|
|
|
* @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");
|
|
|
+ function claimTokens() public validClaimTime whenNotPaused {
|
|
|
+ _validateClaimConditions();
|
|
|
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) {
|
|
|
+ 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
|
|
|
- // Calculate total buy amount from all contributions using PRBMath
|
|
|
- uint256 totalBuyAmount = PRBMath.mulDiv(totalContributionAmount, MAX_FEE_BPS, MAX_FEE_BPS + feeBps);
|
|
|
+ // 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);
|
|
@@ -576,12 +805,15 @@ contract Launchpad is ReentrancyGuard {
|
|
|
// Calculate refund breakdown: both unused buy amount and unused fee
|
|
|
uint256 refundBuyAmount = userBuyAmountValue - userEffectiveBuyAmount;
|
|
|
uint256 refundFeeAmount = userFeeAmountValue - userEffectiveFeeAmount;
|
|
|
- refundCost = refundBuyAmount + refundFeeAmount;
|
|
|
+ // 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 }("");
|
|
|
- require(sent, "ETH refund failed");
|
|
|
+ if (!sent) revert TransferFailed();
|
|
|
} else {
|
|
|
IERC20(paymentTokenAddress).transfer(msg.sender, refundCost);
|
|
|
}
|
|
@@ -590,7 +822,8 @@ contract Launchpad is ReentrancyGuard {
|
|
|
IERC20(saleTokenAddress).transfer(msg.sender, boughtToken);
|
|
|
}
|
|
|
|
|
|
- // Clear user data
|
|
|
+ // Mark user as claimed and clear user data
|
|
|
+ userClaimed[msg.sender] = true;
|
|
|
userContributionAmount[msg.sender] = 0;
|
|
|
userBuyAmount[msg.sender] = 0;
|
|
|
userFeeAmount[msg.sender] = 0;
|