helium3@sina.com 1 tydzień temu
rodzic
commit
c3be4276fc

+ 254 - 67
contracts/Launchpad.sol

@@ -3,11 +3,17 @@ pragma solidity ^0.8.24;
 import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
 import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
 import "@openzeppelin/contracts/utils/ReentrancyGuard.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
 import "hardhat/console.sol"; // For debugging purposes, can be removed in production
 
 
 contract Launchpad is ReentrancyGuard {
 contract Launchpad is ReentrancyGuard {
+	
+	// ================================
+	// State Variables
+	// ================================
+	
+	// Core Configuration
 	address public owner;
 	address public owner;
-
 	address public saleTokenAddress; // Sale token address
 	address public saleTokenAddress; // Sale token address
 	uint256 public totalTokensForSale; // Total tokens for sale
 	uint256 public totalTokensForSale; // Total tokens for sale
 	address public paymentTokenAddress; // Payment tokens accepted for the sale
 	address public paymentTokenAddress; // Payment tokens accepted for the sale
@@ -16,17 +22,26 @@ contract Launchpad is ReentrancyGuard {
 	uint256 public endTime; // Ending time of the sale
 	uint256 public endTime; // Ending time of the sale
 	bytes32 public merkleRoot; // Merkle root for whitelisting
 	bytes32 public merkleRoot; // Merkle root for whitelisting
 
 
+	// Sale Status
 	uint256 public contributionTarget; // Target amount to be raised in the sale
 	uint256 public contributionTarget; // Target amount to be raised in the sale
 	uint256 public totalContributionAmount; // Total contributed amount so far
 	uint256 public totalContributionAmount; // Total contributed amount so far
 	bool public claimEnabled; // Whether claiming tokens is enabled
 	bool public claimEnabled; // Whether claiming tokens is enabled
 	uint256 public claimStartTime; // Start time for claiming tokens
 	uint256 public claimStartTime; // Start time for claiming tokens
 
 
-	mapping(address => uint256) public userContributionAmount; // User's contribution amount 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
 	uint256 public accountsCount; // Number of accounts that have contributed
+	
+	// Fee Configuration
 	uint256 public feeBps; // Fee in basis points (bps)
 	uint256 public feeBps; // Fee in basis points (bps)
 	uint256 public constant MAX_FEE_BPS = 10000; // 100% in basis points
 	uint256 public constant MAX_FEE_BPS = 10000; // 100% in basis points
 	uint256 public constant DECIMALS = 1e18;
 	uint256 public constant DECIMALS = 1e18;
 
 
+	// ================================
+	// Events
+	// ================================
 	event SaleCreated(address saleTokenAddress, uint256 totalTokensForSale);
 	event SaleCreated(address saleTokenAddress, uint256 totalTokensForSale);
 	event Contributed(address user, uint256 amount, address paymentTokenAddress);
 	event Contributed(address user, uint256 amount, address paymentTokenAddress);
 	event TokensClaimed(address user, uint256 tokensClaimed, uint256 refundAmount);
 	event TokensClaimed(address user, uint256 tokensClaimed, uint256 refundAmount);
@@ -34,12 +49,17 @@ contract Launchpad is ReentrancyGuard {
 	event EnableClaimToken(bool enabled, uint256 claimStartTime);
 	event EnableClaimToken(bool enabled, uint256 claimStartTime);
 	event DisableClaimToken(bool disabled);
 	event DisableClaimToken(bool disabled);
 
 
+	// ================================
+	// Constructor
+	// ================================
 	constructor() {
 	constructor() {
 		owner = msg.sender;
 		owner = msg.sender;
 		accountsCount = 0;
 		accountsCount = 0;
 	}
 	}
 
 
+	// ================================
 	// Modifiers
 	// Modifiers
+	// ================================
 	modifier onlyOwner() {
 	modifier onlyOwner() {
 		require(msg.sender == owner, "Only owner can call this function");
 		require(msg.sender == owner, "Only owner can call this function");
 		_;
 		_;
@@ -50,18 +70,22 @@ contract Launchpad is ReentrancyGuard {
 		require(block.timestamp >= startTime && block.timestamp <= endTime, "Sale is not active");
 		require(block.timestamp >= startTime && block.timestamp <= endTime, "Sale is not active");
 		_;
 		_;
 	}
 	}
+	
 	modifier validClaimTime() {
 	modifier validClaimTime() {
 		require(claimEnabled, "Claiming tokens is not enabled");
 		require(claimEnabled, "Claiming tokens is not enabled");
 		require(block.timestamp >= claimStartTime, "Claiming tokens not started yet");
 		require(block.timestamp >= claimStartTime, "Claiming tokens not started yet");
 		_;
 		_;
 	}
 	}
 
 
+	// ================================
 	// Owner Functions
 	// Owner Functions
+	// ================================
+
 	/**
 	/**
 	 * @notice Creates a new token sale with specified parameters.
 	 * @notice Creates a new token sale with specified parameters.
 	 * @param _token The address of the token being sold.
 	 * @param _token The address of the token being sold.
 	 * @param _totalTokensForSale The total number of tokens available for sale.
 	 * @param _totalTokensForSale The total number of tokens available for sale.
-	 * @param _contributionTarget The fundraising target amount.
+	 * @param _contributionTarget The fundraising target amount (not including fee).
 	 * @param _startTime The start time of the sale (timestamp).
 	 * @param _startTime The start time of the sale (timestamp).
 	 * @param _endTime The end 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 _paymentToken The address of the payment token (or address(0) for ETH).
@@ -80,7 +104,17 @@ contract Launchpad is ReentrancyGuard {
 	) public onlyOwner {
 	) public onlyOwner {
 		require(_token != address(0), "Invalid token address");
 		require(_token != address(0), "Invalid token address");
 		require(_totalTokensForSale > 0, "Total tokens must be greater than 0");
 		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(_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;
 		paymentTokenAddress = _paymentToken;
 		paymentTokenPrice = _price;
 		paymentTokenPrice = _price;
@@ -92,32 +126,14 @@ contract Launchpad is ReentrancyGuard {
 		totalContributionAmount = 0;
 		totalContributionAmount = 0;
 		feeBps = _feeBps; // Set the fee in basis points
 		feeBps = _feeBps; // Set the fee in basis points
 		merkleRoot = bytes32(0); // Reset Merkle root
 		merkleRoot = bytes32(0); // Reset Merkle root
-		IERC20(_token).transferFrom(msg.sender, address(this), _totalTokensForSale); // Transfer tokens to the contracts
-
-		emit SaleCreated(saleTokenAddress, totalTokensForSale);
-	}
-
-	/**
-	 * @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 all collected payments (ETH or ERC20) to the owner after the sale ends.
-	 */
-	function withdrawPayments() public onlyOwner {
-		require(block.timestamp > endTime, "Sale is still active");
-		if (paymentTokenAddress == address(0)) {
-			payable(owner).transfer(address(this).balance);
-		} else {
-			uint256 balance = IERC20(paymentTokenAddress).balanceOf(address(this));
-			IERC20(paymentTokenAddress).transfer(owner, balance);
+		
+		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);
 	}
 	}
 
 
 	/**
 	/**
@@ -159,7 +175,71 @@ contract Launchpad is ReentrancyGuard {
 		emit DisableClaimToken(claimEnabled);
 		emit DisableClaimToken(claimEnabled);
 	}
 	}
 
 
-	// Setter
+	/**
+	 * @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.
 	 * @notice Transfers contract ownership to a new address.
 	 * @param _newOwner The address of the new owner.
 	 * @param _newOwner The address of the new owner.
@@ -197,7 +277,9 @@ contract Launchpad is ReentrancyGuard {
 		feeBps = _feeBps; // Set the fee in basis points
 		feeBps = _feeBps; // Set the fee in basis points
 	}
 	}
 
 
-	//Getter
+	// ================================
+	// Getter Functions
+	// ================================
 
 
 	/**
 	/**
 	 * @notice Returns the address of the contract owner.
 	 * @notice Returns the address of the contract owner.
@@ -205,36 +287,70 @@ contract Launchpad is ReentrancyGuard {
 	function getOwner() public view returns (address) {
 	function getOwner() public view returns (address) {
 		return owner;
 		return owner;
 	}
 	}
+
 	/**
 	/**
 	 * @notice Returns the address of the sale token.
 	 * @notice Returns the address of the sale token.
 	 */
 	 */
 	function getSaleToken() public view returns (address) {
 	function getSaleToken() public view returns (address) {
 		return saleTokenAddress;
 		return saleTokenAddress;
 	}
 	}
+
 	/**
 	/**
 	 * @notice Returns the total number of tokens for sale.
 	 * @notice Returns the total number of tokens for sale.
 	 */
 	 */
 	function getTotalTokensForSale() public view returns (uint256) {
 	function getTotalTokensForSale() public view returns (uint256) {
 		return totalTokensForSale;
 		return totalTokensForSale;
 	}
 	}
+
 	/**
 	/**
 	 * @notice Returns the total contributed amount so far.
 	 * @notice Returns the total contributed amount so far.
 	 */
 	 */
 	function getTotalContributeAmount() public view returns (uint256) {
 	function getTotalContributeAmount() public view returns (uint256) {
 		return totalContributionAmount;
 		return totalContributionAmount;
 	}
 	}
+
 	/**
 	/**
 	 * @notice Returns the sale start time.
 	 * @notice Returns the sale start time.
 	 */
 	 */
 	function getStartTime() public view returns (uint256) {
 	function getStartTime() public view returns (uint256) {
 		return startTime;
 		return startTime;
 	}
 	}
+
 	/**
 	/**
 	 * @notice Returns the sale end time.
 	 * @notice Returns the sale end time.
 	 */
 	 */
 	function getEndTime() public view returns (uint256) {
 	function getEndTime() public view returns (uint256) {
 		return endTime;
 		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.
 	 * @notice Returns all sale details as a tuple.
 	 */
 	 */
@@ -242,25 +358,29 @@ contract Launchpad is ReentrancyGuard {
 		public
 		public
 		view
 		view
 		returns (
 		returns (
-			address saleTokenAddress,
-			uint256 totalTokensForSale,
-			uint256 totalContributeAmount,
-			uint256 startTime,
-			uint256 endTime,
-			bytes32 merkleRoot,
-			address paymentTokenAddress,
-			uint256 salePaymentPrice
+			address _saleTokenAddress,
+			uint256 _totalTokensForSale,
+			uint256 _totalContributionAmount,
+			uint256 _contributionTarget,
+			uint256 _startTime,
+			uint256 _endTime,
+			bytes32 _merkleRoot,
+			address _paymentTokenAddress,
+			uint256 _paymentTokenPrice,
+			uint256 _feeBps
 		)
 		)
 	{
 	{
 		return (
 		return (
 			saleTokenAddress,
 			saleTokenAddress,
 			totalTokensForSale,
 			totalTokensForSale,
 			totalContributionAmount,
 			totalContributionAmount,
+			contributionTarget,
 			startTime,
 			startTime,
 			endTime,
 			endTime,
 			merkleRoot,
 			merkleRoot,
 			paymentTokenAddress,
 			paymentTokenAddress,
-			paymentTokenPrice
+			paymentTokenPrice,
+			feeBps
 		);
 		);
 	}
 	}
 
 
@@ -273,42 +393,75 @@ contract Launchpad is ReentrancyGuard {
 	}
 	}
 
 
 	/**
 	/**
-	 * @notice Returns the payment token price.
+	 * @notice Returns the user's buy amount (excluding fee).
+	 * @param _user The address of the user.
 	 */
 	 */
-	function getPaymentTokenPrice() public view returns (uint256) {
-		return paymentTokenPrice;
+	function getUserBuyAmount(address _user) public view returns (uint256) {
+		return userBuyAmount[_user];
 	}
 	}
+
 	/**
 	/**
-	 * @notice Returns the payment token address.
+	 * @notice Returns the user's fee amount.
+	 * @param _user The address of the user.
 	 */
 	 */
-	function getPaymentTokens() public view returns (address) {
-		return paymentTokenAddress;
+	function getUserFeeAmount(address _user) public view returns (uint256) {
+		return userFeeAmount[_user];
 	}
 	}
+
+	// ================================
+	// Calculation Functions
+	// ================================
+
 	/**
 	/**
-	 * @notice Returns the claim start time.
+	 * @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 getClaimStartTime() public view returns (uint256) {
-		return claimStartTime;
+	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 Returns whether claiming is enabled.
+	 * @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 isClaimEnabled() public view returns (bool) {
-		return claimEnabled;
+	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);
 	}
 	}
 
 
-	//Main Function
 	/**
 	/**
 	 * @notice Calculates the net contribution after deducting the fee.
 	 * @notice Calculates the net contribution after deducting the fee.
 	 * @param _commitAmount The original contribution amount.
 	 * @param _commitAmount The original contribution amount.
 	 * @return The net contribution after fee deduction.
 	 * @return The net contribution after fee deduction.
 	 */
 	 */
 	function calculateNetContribution(uint256 _commitAmount) public view returns (uint256) {
 	function calculateNetContribution(uint256 _commitAmount) public view returns (uint256) {
-		// Calculate net contribution after fee
-		uint256 net = (_commitAmount * (MAX_FEE_BPS - feeBps)) / MAX_FEE_BPS;
-		return net;
+		// 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.
 	 * @notice Contribute to the sale using ETH. Requires whitelist proof.
 	 * @param _commitAmount The amount of ETH to contribute.
 	 * @param _commitAmount The amount of ETH to contribute.
@@ -327,17 +480,22 @@ contract Launchpad is ReentrancyGuard {
 		require(msg.value >= _commitAmount, "Must send enough ETH");
 		require(msg.value >= _commitAmount, "Must send enough ETH");
 		require(paymentTokenPrice > 0, "Payment token not accepted for this sale");
 		require(paymentTokenPrice > 0, "Payment token not accepted for this sale");
 
 
-		// 验证 Merkle 证明
+		// Verify Merkle proof
 		bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, _maxCommitAmount))));
 		bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, _maxCommitAmount))));
 		require(MerkleProof.verify(_proof, merkleRoot, leaf), "Invalid proof");
 		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) {
 		if (userContributionAmount[msg.sender] == 0) {
 			accountsCount++; // Increment accounts count only if this is the first contribution
 			accountsCount++; // Increment accounts count only if this is the first contribution
 		}
 		}
 
 
-		//币数统计
-		userContributionAmount[msg.sender] += _commitAmount; // update user's commit tokens
+		// 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");
 		require(userContributionAmount[msg.sender] <= _maxCommitAmount, "Buy amount exceeds max buy amount");
 		totalContributionAmount += _commitAmount; // update total contributed amount
 		totalContributionAmount += _commitAmount; // update total contributed amount
 		if (msg.value > _commitAmount) {
 		if (msg.value > _commitAmount) {
@@ -368,13 +526,19 @@ contract Launchpad is ReentrancyGuard {
 			IERC20(paymentTokenAddress).allowance(msg.sender, address(this)) >= _commitAmount,
 			IERC20(paymentTokenAddress).allowance(msg.sender, address(this)) >= _commitAmount,
 			"Not enough allowance for payment token"
 			"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) {
 		if (userContributionAmount[msg.sender] == 0) {
 			accountsCount++; // Increment accounts count only if this is the first contribution
 			accountsCount++; // Increment accounts count only if this is the first contribution
 		}
 		}
-		userContributionAmount[msg.sender] += _commitAmount; // update user's claimable tokens
+		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");
 		require(userContributionAmount[msg.sender] <= _maxCommitAmount, "Buy amount exceeds max buy amount");
 		totalContributionAmount += _commitAmount; // update total contributed amount
 		totalContributionAmount += _commitAmount; // update total contributed amount
-		IERC20(paymentTokenAddress).transferFrom(msg.sender, address(this), _commitAmount); // 从用户账户转移支付代币
 		emit Contributed(msg.sender, _commitAmount, paymentTokenAddress);
 		emit Contributed(msg.sender, _commitAmount, paymentTokenAddress);
 	}
 	}
 
 
@@ -386,27 +550,50 @@ contract Launchpad is ReentrancyGuard {
 	function claimTokens() public validClaimTime {
 	function claimTokens() public validClaimTime {
 		require(block.timestamp > endTime, "Sale is still active");
 		require(block.timestamp > endTime, "Sale is still active");
 		uint256 contributeAmount = userContributionAmount[msg.sender];
 		uint256 contributeAmount = userContributionAmount[msg.sender];
+		uint256 userBuyAmountValue = userBuyAmount[msg.sender];
+		uint256 userFeeAmountValue = userFeeAmount[msg.sender];
 		require(contributeAmount > 0, "No tokens to claim");
 		require(contributeAmount > 0, "No tokens to claim");
 		uint256 refundCost = 0;
 		uint256 refundCost = 0;
 		uint256 boughtToken = 0;
 		uint256 boughtToken = 0;
+		
 		if (totalContributionAmount <= contributionTarget) {
 		if (totalContributionAmount <= contributionTarget) {
-			uint256 netContribution = calculateNetContribution(contributeAmount);
-			boughtToken = (netContribution * DECIMALS) / paymentTokenPrice;
+			// 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);
 			IERC20(saleTokenAddress).transfer(msg.sender, boughtToken);
 		} else {
 		} else {
-			uint256 userValidContribution = (contributeAmount * contributionTarget) / totalContributionAmount;
-			refundCost = contributeAmount - userValidContribution;
-			uint256 netContribution = calculateNetContribution(userValidContribution);
-			boughtToken = (netContribution * DECIMALS) / paymentTokenPrice;
+			// 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)) {
 			if (paymentTokenAddress == address(0)) {
 				(bool sent, ) = payable(msg.sender).call{ value: refundCost }("");
 				(bool sent, ) = payable(msg.sender).call{ value: refundCost }("");
 				require(sent, "ETH refund failed");
 				require(sent, "ETH refund failed");
 			} else {
 			} else {
 				IERC20(paymentTokenAddress).transfer(msg.sender, refundCost);
 				IERC20(paymentTokenAddress).transfer(msg.sender, refundCost);
 			}
 			}
+			
+			// Transfer tokens
 			IERC20(saleTokenAddress).transfer(msg.sender, boughtToken);
 			IERC20(saleTokenAddress).transfer(msg.sender, boughtToken);
 		}
 		}
+		
+		// Clear user data
 		userContributionAmount[msg.sender] = 0;
 		userContributionAmount[msg.sender] = 0;
+		userBuyAmount[msg.sender] = 0;
+		userFeeAmount[msg.sender] = 0;
 		emit TokensClaimed(msg.sender, boughtToken, refundCost);
 		emit TokensClaimed(msg.sender, boughtToken, refundCost);
 	}
 	}
 }
 }

+ 39 - 0
contracts/MockUSD1.sol

@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+import "@openzeppelin/contracts/access/Ownable.sol";
+
+/**
+ * @title MockUSD1
+ * @dev Mock USD1 token for testing purposes
+ */
+contract MockUSD1 is ERC20, Ownable {
+	uint8 private _decimals = 18; // Standard ERC20 has 18 decimals
+
+	constructor() ERC20("Mock USD1", "mUSD1") Ownable(msg.sender) {
+		// Mint initial supply to deployer
+		_mint(msg.sender, 1000000 * 10**18); // 1M USD1
+	}
+
+    /**
+     * @dev Returns the number of decimals used to get its user representation.
+     */
+    function decimals() public view virtual override returns (uint8) {
+        return _decimals;
+    }
+
+    /**
+     * @dev Mint tokens to a specific address (only owner)
+     */
+    function mint(address to, uint256 amount) public onlyOwner {
+        _mint(to, amount);
+    }
+
+    /**
+     * @dev Burn tokens from caller
+     */
+    function burn(uint256 amount) public {
+        _burn(msg.sender, amount);
+    }
+}

+ 39 - 0
contracts/MockUSDT.sol

@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+import "@openzeppelin/contracts/access/Ownable.sol";
+
+/**
+ * @title MockUSD1
+ * @dev Mock USD1 token for testing purposes
+ */
+contract MockUSD1 is ERC20, Ownable {
+	uint8 private _decimals = 18; // Standard ERC20 has 18 decimals
+
+	constructor() ERC20("Mock USD1", "mUSD1") Ownable(msg.sender) {
+		// Mint initial supply to deployer
+		_mint(msg.sender, 1000000 * 10**18); // 1M USD1
+	}
+
+    /**
+     * @dev Returns the number of decimals used to get its user representation.
+     */
+    function decimals() public view virtual override returns (uint8) {
+        return _decimals;
+    }
+
+    /**
+     * @dev Mint tokens to a specific address (only owner)
+     */
+    function mint(address to, uint256 amount) public onlyOwner {
+        _mint(to, amount);
+    }
+
+    /**
+     * @dev Burn tokens from caller
+     */
+    function burn(uint256 amount) public {
+        _burn(msg.sender, amount);
+    }
+}

+ 26 - 0
hardhat.config.ts

@@ -13,9 +13,11 @@ const MAINNET_RPC_URL = process.env.MAINNET_RPC_URL || "https://eth-mainnet.g.al
 const SEPOLIA_RPC_URL = process.env.SEPOLIA_RPC_URL || "https://eth-sepolia.g.alchemy.com/v2/your-api-key"
 const SEPOLIA_RPC_URL = process.env.SEPOLIA_RPC_URL || "https://eth-sepolia.g.alchemy.com/v2/your-api-key"
 const MATIC_RPC_URL = process.env.MATIC_RPC_URL || "https://polygon-mainnet.g.alchemy.com/v2/your-api-key"
 const MATIC_RPC_URL = process.env.MATIC_RPC_URL || "https://polygon-mainnet.g.alchemy.com/v2/your-api-key"
 const MUMBAI_RPC_URL = process.env.MUMBAI_RPC_URL || "https://polygon-mumbai.g.alchemy.com/v2/v3/your-api-key"
 const MUMBAI_RPC_URL = process.env.MUMBAI_RPC_URL || "https://polygon-mumbai.g.alchemy.com/v2/v3/your-api-key"
+const BSC_MAINNET_RPC_URL = process.env.BSC_MAINNET_RPC_URL || "https://bsc-dataseed1.binance.org/"
 
 
 const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY || "api-key"
 const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY || "api-key"
 const POLYGONSCAN_API_KEY = process.env.POLYGONSCAN_API_KEY || "api-key"
 const POLYGONSCAN_API_KEY = process.env.POLYGONSCAN_API_KEY || "api-key"
+const BSCSCAN_API_KEY = process.env.BSCSCAN_API_KEY || "api-key"
 
 
 // Import MNEMONIC or single private key
 // Import MNEMONIC or single private key
 const MNEMONIC = process.env.MNEMONIC || "your mnemonic"
 const MNEMONIC = process.env.MNEMONIC || "your mnemonic"
@@ -49,6 +51,20 @@ const config: HardhatUserConfig = {
 			url: MUMBAI_RPC_URL,
 			url: MUMBAI_RPC_URL,
 			accounts: PRIVATE_KEY ? [PRIVATE_KEY] : { mnemonic: MNEMONIC },
 			accounts: PRIVATE_KEY ? [PRIVATE_KEY] : { mnemonic: MNEMONIC },
 		},
 		},
+		bscTestnet: {
+			url: "https://data-seed-prebsc-1-s1.binance.org:8545/",
+			chainId: 97,
+			accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
+			gasPrice: 10000000000, // 10 gwei
+			gas: 8000000,
+		},
+		bscMainnet: {
+			url: BSC_MAINNET_RPC_URL,
+			chainId: 56,
+			accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
+			gasPrice: 5000000000, // 5 gwei
+			gas: 8000000,
+		},
 	},
 	},
 	etherscan: {
 	etherscan: {
 		// Your API key for Etherscan
 		// Your API key for Etherscan
@@ -59,12 +75,22 @@ const config: HardhatUserConfig = {
 			// Polygon
 			// Polygon
 			polygon: POLYGONSCAN_API_KEY,
 			polygon: POLYGONSCAN_API_KEY,
 			polygonMumbai: POLYGONSCAN_API_KEY,
 			polygonMumbai: POLYGONSCAN_API_KEY,
+			// BSC
+			bsc: BSCSCAN_API_KEY,
+			bscTestnet: BSCSCAN_API_KEY,
 		},
 		},
 	},
 	},
 	solidity: {
 	solidity: {
 		compilers: [
 		compilers: [
 			{
 			{
 				version: "0.8.28",
 				version: "0.8.28",
+				settings: {
+					viaIR: true,
+					optimizer: {
+						enabled: true,
+						runs: 200,
+					},
+				},
 			},
 			},
 		],
 		],
 	},
 	},

+ 142 - 9
package-lock.json

@@ -10,7 +10,10 @@
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
 				"@openzeppelin/contracts": "^5.3.0",
 				"@openzeppelin/contracts": "^5.3.0",
-				"@openzeppelin/merkle-tree": "^1.0.8"
+				"@openzeppelin/merkle-tree": "^1.0.8",
+				"@prb/math": "^4.1.0",
+				"abdk-libraries-solidity": "^3.2.0",
+				"prb-math": "^2.4.3"
 			},
 			},
 			"devDependencies": {
 			"devDependencies": {
 				"@eslint/eslintrc": "^3.3.1",
 				"@eslint/eslintrc": "^3.3.1",
@@ -79,7 +82,6 @@
 			"version": "7.23.2",
 			"version": "7.23.2",
 			"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
 			"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
 			"integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
 			"integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
-			"dev": true,
 			"dependencies": {
 			"dependencies": {
 				"regenerator-runtime": "^0.14.0"
 				"regenerator-runtime": "^0.14.0"
 			},
 			},
@@ -810,7 +812,6 @@
 			"version": "5.7.0",
 			"version": "5.7.0",
 			"resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.7.0.tgz",
 			"resolved": "https://registry.npmjs.org/@ethersproject/bignumber/-/bignumber-5.7.0.tgz",
 			"integrity": "sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==",
 			"integrity": "sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==",
-			"dev": true,
 			"funding": [
 			"funding": [
 				{
 				{
 					"type": "individual",
 					"type": "individual",
@@ -831,7 +832,6 @@
 			"version": "5.7.0",
 			"version": "5.7.0",
 			"resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz",
 			"resolved": "https://registry.npmjs.org/@ethersproject/bytes/-/bytes-5.7.0.tgz",
 			"integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==",
 			"integrity": "sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==",
-			"dev": true,
 			"funding": [
 			"funding": [
 				{
 				{
 					"type": "individual",
 					"type": "individual",
@@ -1011,7 +1011,6 @@
 			"version": "5.7.0",
 			"version": "5.7.0",
 			"resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.7.0.tgz",
 			"resolved": "https://registry.npmjs.org/@ethersproject/logger/-/logger-5.7.0.tgz",
 			"integrity": "sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==",
 			"integrity": "sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==",
-			"dev": true,
 			"funding": [
 			"funding": [
 				{
 				{
 					"type": "individual",
 					"type": "individual",
@@ -2962,6 +2961,12 @@
 				"node": ">=12"
 				"node": ">=12"
 			}
 			}
 		},
 		},
+		"node_modules/@prb/math": {
+			"version": "4.1.0",
+			"resolved": "https://registry.npmjs.org/@prb/math/-/math-4.1.0.tgz",
+			"integrity": "sha512-ef5Xrlh3BeX4xT5/Wi810dpEPq2bYPndRxgFIaKSU1F/Op/s8af03kyom+mfU7gEpvfIZ46xu8W0duiHplbBMg==",
+			"license": "MIT"
+		},
 		"node_modules/@scure/base": {
 		"node_modules/@scure/base": {
 			"version": "1.1.9",
 			"version": "1.1.9",
 			"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz",
 			"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz",
@@ -5090,6 +5095,12 @@
 			"integrity": "sha512-LEyx4aLEC3x6T0UguF6YILf+ntvmOaWsVfENmIW0E9H09vKlLDGelMjjSm0jkDHALj8A8quZ/HapKNigzwge+Q==",
 			"integrity": "sha512-LEyx4aLEC3x6T0UguF6YILf+ntvmOaWsVfENmIW0E9H09vKlLDGelMjjSm0jkDHALj8A8quZ/HapKNigzwge+Q==",
 			"dev": true
 			"dev": true
 		},
 		},
+		"node_modules/abdk-libraries-solidity": {
+			"version": "3.2.0",
+			"resolved": "https://registry.npmjs.org/abdk-libraries-solidity/-/abdk-libraries-solidity-3.2.0.tgz",
+			"integrity": "sha512-JyedKxyqsszb9qCTX+YCOQHA4m0YAq9p77Qvumiy8LJLLrm1FfA9D3pdSAZN5wrmi3PSv0y3Svy8c/C+8S9K8g==",
+			"license": "BSD-4-Clause"
+		},
 		"node_modules/abitype": {
 		"node_modules/abitype": {
 			"version": "1.0.8",
 			"version": "1.0.8",
 			"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz",
 			"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.0.8.tgz",
@@ -5651,8 +5662,7 @@
 		"node_modules/bn.js": {
 		"node_modules/bn.js": {
 			"version": "5.2.1",
 			"version": "5.2.1",
 			"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
 			"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz",
-			"integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==",
-			"dev": true
+			"integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ=="
 		},
 		},
 		"node_modules/body-parser": {
 		"node_modules/body-parser": {
 			"version": "1.20.2",
 			"version": "1.20.2",
@@ -6482,6 +6492,19 @@
 				"node": ">= 12"
 				"node": ">= 12"
 			}
 			}
 		},
 		},
+		"node_modules/complex.js": {
+			"version": "2.4.2",
+			"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz",
+			"integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==",
+			"license": "MIT",
+			"engines": {
+				"node": "*"
+			},
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/rawify"
+			}
+		},
 		"node_modules/concat-map": {
 		"node_modules/concat-map": {
 			"version": "0.0.1",
 			"version": "0.0.1",
 			"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
 			"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -6798,6 +6821,12 @@
 				"url": "https://github.com/sponsors/sindresorhus"
 				"url": "https://github.com/sponsors/sindresorhus"
 			}
 			}
 		},
 		},
+		"node_modules/decimal.js": {
+			"version": "10.6.0",
+			"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+			"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+			"license": "MIT"
+		},
 		"node_modules/decode-uri-component": {
 		"node_modules/decode-uri-component": {
 			"version": "0.2.2",
 			"version": "0.2.2",
 			"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
 			"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
@@ -7206,6 +7235,12 @@
 			"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
 			"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
 			"dev": true
 			"dev": true
 		},
 		},
+		"node_modules/escape-latex": {
+			"version": "1.2.0",
+			"resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz",
+			"integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==",
+			"license": "MIT"
+		},
 		"node_modules/escape-string-regexp": {
 		"node_modules/escape-string-regexp": {
 			"version": "1.0.5",
 			"version": "1.0.5",
 			"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
 			"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -7857,6 +7892,19 @@
 			"integrity": "sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ==",
 			"integrity": "sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ==",
 			"dev": true
 			"dev": true
 		},
 		},
+		"node_modules/evm-bn": {
+			"version": "1.1.2",
+			"resolved": "https://registry.npmjs.org/evm-bn/-/evm-bn-1.1.2.tgz",
+			"integrity": "sha512-Lq8CT1EAjSeN+Yk0h1hpSwnZyMA4Xir6fQD4vlStljAuW2xr7qLOEGDLGsTa9sU2e40EYIumA4wYhMC/e+lyKw==",
+			"license": "MIT",
+			"dependencies": {
+				"@ethersproject/bignumber": "^5.5.0",
+				"from-exponential": "^1.1.1"
+			},
+			"peerDependencies": {
+				"@ethersproject/bignumber": "5.x"
+			}
+		},
 		"node_modules/evp_bytestokey": {
 		"node_modules/evp_bytestokey": {
 			"version": "1.0.3",
 			"version": "1.0.3",
 			"resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
 			"resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
@@ -8296,6 +8344,19 @@
 			"integrity": "sha512-H5KQDspykdHuztLTg+ajGN0Z2qUjcEf3Ybxc6hLt0k7/zPkn29XnKnxlBPyW2XIddWrGaJBzBl4VLYOtk39yZg==",
 			"integrity": "sha512-H5KQDspykdHuztLTg+ajGN0Z2qUjcEf3Ybxc6hLt0k7/zPkn29XnKnxlBPyW2XIddWrGaJBzBl4VLYOtk39yZg==",
 			"dev": true
 			"dev": true
 		},
 		},
+		"node_modules/fraction.js": {
+			"version": "4.3.7",
+			"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
+			"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
+			"license": "MIT",
+			"engines": {
+				"node": "*"
+			},
+			"funding": {
+				"type": "patreon",
+				"url": "https://github.com/sponsors/rawify"
+			}
+		},
 		"node_modules/fresh": {
 		"node_modules/fresh": {
 			"version": "0.5.2",
 			"version": "0.5.2",
 			"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
 			"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -8305,6 +8366,12 @@
 				"node": ">= 0.6"
 				"node": ">= 0.6"
 			}
 			}
 		},
 		},
+		"node_modules/from-exponential": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/from-exponential/-/from-exponential-1.1.1.tgz",
+			"integrity": "sha512-VBE7f5OVnYwdgB3LHa+Qo29h8qVpxhVO9Trlc+AWm+/XNAgks1tAwMFHb33mjeiof77GglsJzeYF7OqXrROP/A==",
+			"license": "MIT"
+		},
 		"node_modules/fs-extra": {
 		"node_modules/fs-extra": {
 			"version": "9.1.0",
 			"version": "9.1.0",
 			"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
 			"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -9849,6 +9916,12 @@
 				"@pkgjs/parseargs": "^0.11.0"
 				"@pkgjs/parseargs": "^0.11.0"
 			}
 			}
 		},
 		},
+		"node_modules/javascript-natural-sort": {
+			"version": "0.7.1",
+			"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
+			"integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==",
+			"license": "MIT"
+		},
 		"node_modules/js-sha3": {
 		"node_modules/js-sha3": {
 			"version": "0.8.0",
 			"version": "0.8.0",
 			"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
 			"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
@@ -10342,6 +10415,29 @@
 				"url": "https://github.com/sponsors/wooorm"
 				"url": "https://github.com/sponsors/wooorm"
 			}
 			}
 		},
 		},
+		"node_modules/mathjs": {
+			"version": "10.6.4",
+			"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-10.6.4.tgz",
+			"integrity": "sha512-omQyvRE1jIy+3k2qsqkWASOcd45aZguXZDckr3HtnTYyXk5+2xpVfC3kATgbO2Srjxlqww3TVdhD0oUdZ/hiFA==",
+			"license": "Apache-2.0",
+			"dependencies": {
+				"@babel/runtime": "^7.18.6",
+				"complex.js": "^2.1.1",
+				"decimal.js": "^10.3.1",
+				"escape-latex": "^1.2.0",
+				"fraction.js": "^4.2.0",
+				"javascript-natural-sort": "^0.7.1",
+				"seedrandom": "^3.0.5",
+				"tiny-emitter": "^2.1.0",
+				"typed-function": "^2.1.0"
+			},
+			"bin": {
+				"mathjs": "bin/cli.js"
+			},
+			"engines": {
+				"node": ">= 14"
+			}
+		},
 		"node_modules/md5.js": {
 		"node_modules/md5.js": {
 			"version": "1.3.5",
 			"version": "1.3.5",
 			"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
 			"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@@ -11682,6 +11778,24 @@
 				"node": ">=12.0.0"
 				"node": ">=12.0.0"
 			}
 			}
 		},
 		},
+		"node_modules/prb-math": {
+			"version": "2.4.3",
+			"resolved": "https://registry.npmjs.org/prb-math/-/prb-math-2.4.3.tgz",
+			"integrity": "sha512-t5jncEscKmWg7COkMkmbXXxSeDI6Xl21rxx5e+oxUCqskNvsu6Q9RqdVfGY6y+sMwyMQE8DJ/OOyQ9ExH1yfbw==",
+			"deprecated": "package no longer maintained, you should install @prb/math instead",
+			"license": "Unlicense",
+			"dependencies": {
+				"@ethersproject/bignumber": "^5.5.0",
+				"decimal.js": "^10.3.1",
+				"evm-bn": "^1.1.1",
+				"mathjs": "^10.1.1"
+			},
+			"peerDependencies": {
+				"@ethersproject/bignumber": "5.x",
+				"evm-bn": "1.x",
+				"mathjs": "10.x"
+			}
+		},
 		"node_modules/prelude-ls": {
 		"node_modules/prelude-ls": {
 			"version": "1.1.2",
 			"version": "1.1.2",
 			"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
 			"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
@@ -12082,8 +12196,7 @@
 		"node_modules/regenerator-runtime": {
 		"node_modules/regenerator-runtime": {
 			"version": "0.14.0",
 			"version": "0.14.0",
 			"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
 			"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
-			"integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==",
-			"dev": true
+			"integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
 		},
 		},
 		"node_modules/registry-auth-token": {
 		"node_modules/registry-auth-token": {
 			"version": "5.0.2",
 			"version": "5.0.2",
@@ -12471,6 +12584,12 @@
 				"node": ">=10.0.0"
 				"node": ">=10.0.0"
 			}
 			}
 		},
 		},
+		"node_modules/seedrandom": {
+			"version": "3.0.5",
+			"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
+			"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
+			"license": "MIT"
+		},
 		"node_modules/semver": {
 		"node_modules/semver": {
 			"version": "6.3.1",
 			"version": "6.3.1",
 			"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
 			"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -13833,6 +13952,12 @@
 				"node": ">=0.10.0"
 				"node": ">=0.10.0"
 			}
 			}
 		},
 		},
+		"node_modules/tiny-emitter": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
+			"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
+			"license": "MIT"
+		},
 		"node_modules/tinyglobby": {
 		"node_modules/tinyglobby": {
 			"version": "0.2.14",
 			"version": "0.2.14",
 			"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
 			"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@@ -14297,6 +14422,14 @@
 				"node": ">= 4.0.0"
 				"node": ">= 4.0.0"
 			}
 			}
 		},
 		},
+		"node_modules/typed-function": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/typed-function/-/typed-function-2.1.0.tgz",
+			"integrity": "sha512-bctQIOqx2iVbWGDGPWwIm18QScpu2XRmkC19D8rQGFsjKSgteq/o1hTZvIG/wuDq8fanpBDrLkLq+aEN/6y5XQ==",
+			"engines": {
+				"node": ">= 10"
+			}
+		},
 		"node_modules/typedarray-to-buffer": {
 		"node_modules/typedarray-to-buffer": {
 			"version": "3.1.5",
 			"version": "3.1.5",
 			"resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
 			"resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",

+ 46 - 9
package.json

@@ -3,26 +3,63 @@
 	"version": "1.0.0",
 	"version": "1.0.0",
 	"description": "Basic Hardhat template to get you started with Solidity and TypeScript",
 	"description": "Basic Hardhat template to get you started with Solidity and TypeScript",
 	"scripts": {
 	"scripts": {
+		"# ===== 开发工具 ===== #": "",
 		"compile": "hardhat compile",
 		"compile": "hardhat compile",
 		"test": "hardhat test",
 		"test": "hardhat test",
 		"coverage": "hardhat coverage",
 		"coverage": "hardhat coverage",
-		"deploy": "hardhat ignition deploy",
 		"node": "hardhat node",
 		"node": "hardhat node",
-		"deployLaunchpad:local": "npx hardhat ignition deploy ignition/modules/LaunchpadModule.ts --network localhost --reset",
-		"visualize": "npx hardhat ignition visualize ignition/modules/LaunchpadModule.ts",
-		"deployLaunchpad:sep": "npx hardhat ignition deploy ignition/modules/LaunchpadModule.ts --network sepolia",
-		"sol:format:check": "prettier --check --plugin=prettier-plugin-solidity 'contracts/**/*.sol'",
-		"sol:format:write": "prettier --write --plugin=prettier-plugin-solidity 'contracts/**/*.sol'",
-		"solhint": "solhint -f table 'contracts/**/*.sol'",
 		"format:check": "prettier --check .",
 		"format:check": "prettier --check .",
 		"format:write": "prettier --write .",
 		"format:write": "prettier --write .",
 		"lint:check": "eslint .",
 		"lint:check": "eslint .",
-		"lint:fix": "eslint --fix ."
+		"lint:fix": "eslint --fix .",
+		"sol:format:check": "prettier --check --plugin=prettier-plugin-solidity 'contracts/**/*.sol'",
+		"sol:format:write": "prettier --write --plugin=prettier-plugin-solidity 'contracts/**/*.sol'",
+		"solhint": "solhint -f table 'contracts/**/*.sol'",
+		"": "",
+		"# ===== Ignition 部署 ===== #": "",
+		"deploy": "hardhat ignition deploy",
+		"deploy:local": "npx hardhat ignition deploy ignition/modules/LaunchpadModule.ts --network localhost --reset",
+		"deploy:sepolia": "npx hardhat ignition deploy ignition/modules/LaunchpadModule.ts --network sepolia",
+		"visualize": "npx hardhat ignition visualize ignition/modules/LaunchpadModule.ts",
+		"": "",
+		"# ===== 多环境统一调用 ===== #": "",
+		"env": "npx ts-node scripts/run-with-env.ts",
+		"env:help": "npx ts-node scripts/run-with-env.ts --help",
+		"env:quick-help": "echo '多环境调用方式: npm run env <environment> <script>' && echo '例如: npm run env hardhat 01:deploy' && echo '例如: npm run env bsctest 03:init' && echo '例如: npm run env bscmainnet 01:deploy'",
+		"": "",
+		"# ===== 传统调用方式 (保留兼容性) ===== #": "",
+		"01:deploy": "npx ts-node scripts/01-deploy-contracts.ts",
+		"02:distribute": "npx ts-node scripts/02-distribute-tokens.ts",
+		"03:init": "npx ts-node scripts/03-init-sale.ts",
+		"04:contribute-undersubscribed": "npx ts-node scripts/04-test-contributions.ts",
+		"04:contribute-oversubscribed": "SCENARIO=1 npx ts-node scripts/04-test-contributions.ts",
+		"05:claim": "npx ts-node scripts/05-test-claiming.ts",
+		"": "",
+		"# ===== 快捷流程 ===== #": "",
+		"test:hardhat": "npm run env hardhat 01:deploy && npm run env hardhat 02:distribute && npm run env hardhat 03:init && npm run env hardhat 04:contribute && npm run env hardhat 05:claim",
+		"test:bsctest": "npm run env bsctest 01:deploy && npm run env bsctest 02:distribute && npm run env bsctest 03:init && npm run env bsctest 04:contribute && npm run env bsctest 05:claim",
+		"test:bscmainnet": "npm run env bscmainnet 01:deploy && npm run env bscmainnet 02:distribute && npm run env bscmainnet 03:init && npm run env bscmainnet 04:contribute && npm run env bscmainnet 05:claim",
+		"test:undersubscribed": "npm run 01:deploy && npm run 02:distribute && npm run 03:init && npm run 04:contribute-undersubscribed && npm run 05:claim",
+		"test:oversubscribed": "npm run 01:deploy && npm run 02:distribute && npm run 03:init && npm run 04:contribute-oversubscribed && npm run 05:claim",
+		"": "",
+		"# ===== 简化命令 ===== #": "",
+		"04:contribute": "npm run 04:contribute-undersubscribed",
+		"04:contribute:over": "npm run 04:contribute-oversubscribed",
+		"": "",
+		"# ===== 独立工具 ===== #": "",
+		"deploy:legacy": "npx ts-node scripts/deploy-bsctest.ts",
+		"set-merkle": "npx ts-node scripts/set-merkle-root.ts",
+		"": "",
+		"# ===== 帮助信息 ===== #": "",
+		"help": "echo '🚀 多环境统一调用方式:' && echo 'npm run env <environment> <script>' && echo '' && echo '🌍 可用环境:' && echo '  hardhat    - Hardhat本地环境' && echo '  bsctest    - BSC测试网环境' && echo '  bscmainnet - BNB主网环境' && echo '' && echo '📜 可用脚本:' && echo '  01:deploy   - 部署合约' && echo '  02:distribute - 分发代币' && echo '  03:init     - 初始化销售' && echo '  04:contribute - 测试贡献' && echo '  05:claim    - 测试领取' && echo '' && echo '💡 示例:' && echo '  npm run env hardhat 01:deploy' && echo '  npm run env bsctest 03:init' && echo '  npm run env bscmainnet 01:deploy' && echo '' && echo '🚀 快捷流程:' && echo '  npm run test:hardhat    - Hardhat环境完整测试' && echo '  npm run test:bsctest    - BSC测试网完整测试' && echo '  npm run test:bscmainnet - BNB主网完整测试'"
 	},
 	},
 	"license": "MIT",
 	"license": "MIT",
 	"dependencies": {
 	"dependencies": {
 		"@openzeppelin/contracts": "^5.3.0",
 		"@openzeppelin/contracts": "^5.3.0",
-		"@openzeppelin/merkle-tree": "^1.0.8"
+		"@openzeppelin/merkle-tree": "^1.0.8",
+		"@prb/math": "^4.1.0",
+		"abdk-libraries-solidity": "^3.2.0",
+		"prb-math": "^2.4.3"
 	},
 	},
 	"devDependencies": {
 	"devDependencies": {
 		"@eslint/eslintrc": "^3.3.1",
 		"@eslint/eslintrc": "^3.3.1",

+ 155 - 0
scripts/01-deploy-contracts.ts

@@ -0,0 +1,155 @@
+import "dotenv/config"
+import { ethers } from "ethers"
+import * as fs from "fs"
+import * as path from "path"
+import { execSync } from "child_process"
+import { BasicERC20__factory, Launchpad__factory, MockUSD1__factory } from "../typechain-types"
+
+async function main() {
+	// 获取环境配置
+	const environment = process.env.ENVIRONMENT || "bsctest"
+	const rpcUrl = process.env.RPC_URL || "https://data-seed-prebsc-1-s1.binance.org:8545/"
+	const outputDir = process.env.OUTPUT_DIR || "./output/bsctest"
+	const chainId = process.env.CHAIN_ID || "97"
+	
+	console.log(`🚀 第1步:部署合约到${environment === "hardhat" ? "Hardhat本地环境" : "BSC测试网"}...`)
+	console.log(`🌍 环境: ${environment}`)
+	console.log(`🔗 RPC: ${rpcUrl}`)
+	console.log(`📁 输出目录: ${outputDir}`)
+
+	// 确保合约已编译
+	console.log("\n🔨 编译合约...")
+	try {
+		execSync("npx hardhat compile", { stdio: "inherit" })
+		console.log("✅ 合约编译完成")
+	} catch (error) {
+		console.error("❌ 合约编译失败:", error)
+		process.exit(1)
+	}
+
+	// 检查环境变量
+	if (!process.env.PRIVATE_KEY) {
+		console.error("❌ 需要设置 PRIVATE_KEY 环境变量")
+		process.exit(1)
+	}
+
+	// 设置提供者和部署者
+	const provider = new ethers.JsonRpcProvider(rpcUrl)
+	const deployer = new ethers.Wallet(process.env.PRIVATE_KEY, provider)
+	
+	// 获取当前nonce
+	const currentNonce = await provider.getTransactionCount(deployer.address)
+	console.log("📋 当前nonce:", currentNonce)
+
+	console.log("📋 部署者地址:", deployer.address)
+
+	// 检查部署者余额
+	const balance = await provider.getBalance(deployer.address)
+	console.log("💰 部署者余额:", ethers.formatEther(balance), "BNB")
+
+	if (balance < ethers.parseEther("0.01")) {
+		console.error("❌ 余额不足。部署需要至少0.01 BNB")
+		process.exit(1)
+	}
+
+	// 检查是否已有合约地址文件
+	let existingAddresses: any = {}
+	let mockUSD1Address: string
+	let saleTokenAddress: string
+	const contractAddressesPath = path.join(outputDir, "contract-addresses.json")
+
+	if (fs.existsSync(contractAddressesPath)) {
+		console.log("\n📋 检测到现有合约地址文件,检查合约状态...")
+		existingAddresses = JSON.parse(fs.readFileSync(contractAddressesPath, "utf8"))
+
+		// 检查MockUSD1合约是否存在
+		try {
+			const mockUSD1 = MockUSD1__factory.connect(existingAddresses.mockUSD1, provider)
+			await mockUSD1.name() // 尝试调用合约方法验证合约存在
+			mockUSD1Address = existingAddresses.mockUSD1
+			console.log("✅ MockUSD1合约已存在:", mockUSD1Address)
+		} catch (error) {
+			console.log("❌ MockUSD1合约不存在或无效,需要重新部署")
+			mockUSD1Address = ""
+		}
+
+		// 检查BasicERC20合约是否存在
+		try {
+			const saleToken = BasicERC20__factory.connect(existingAddresses.saleToken, provider)
+			await saleToken.name() // 尝试调用合约方法验证合约存在
+			saleTokenAddress = existingAddresses.saleToken
+			console.log("✅ 销售代币合约已存在:", saleTokenAddress)
+		} catch (error) {
+			console.log("❌ 销售代币合约不存在或无效,需要重新部署")
+			saleTokenAddress = ""
+		}
+	} else {
+		console.log("\n📋 未检测到现有合约地址文件,将部署所有合约")
+		mockUSD1Address = ""
+		saleTokenAddress = ""
+	}
+
+	// 部署MockUSD1代币(如果需要)
+	let mockUSD1: any
+	let nonce = currentNonce
+	if (!mockUSD1Address) {
+		console.log("\n📦 部署MockUSD1代币...")
+		mockUSD1 = await new MockUSD1__factory(deployer).deploy({ nonce: nonce++ })
+		await mockUSD1.waitForDeployment()
+		mockUSD1Address = await mockUSD1.getAddress()
+		console.log("✅ MockUSD1已部署到:", mockUSD1Address)
+	} else {
+		mockUSD1 = MockUSD1__factory.connect(mockUSD1Address, deployer)
+	}
+
+	// 部署BasicERC20代币(如果需要)
+	let saleToken: any
+	if (!saleTokenAddress) {
+		console.log("\n📦 部署BasicERC20代币...")
+		saleToken = await new BasicERC20__factory(deployer).deploy("Test Token", "TEST", deployer.address, { nonce: nonce++ })
+		await saleToken.waitForDeployment()
+		saleTokenAddress = await saleToken.getAddress()
+		console.log("✅ 销售代币已部署到:", saleTokenAddress)
+	} else {
+		saleToken = BasicERC20__factory.connect(saleTokenAddress, deployer)
+	}
+
+	// 总是部署新的启动板合约
+	console.log("\n📦 部署新的启动板合约...")
+	const launchpad = await new Launchpad__factory(deployer).deploy({ nonce: nonce++ })
+	await launchpad.waitForDeployment()
+	const launchpadAddress = await launchpad.getAddress()
+	console.log("✅ 启动板已部署到:", launchpadAddress)
+
+	// 保存合约地址到文件供后续步骤使用
+	const contractAddresses = {
+		mockUSD1: mockUSD1Address,
+		saleToken: saleTokenAddress,
+		launchpad: launchpadAddress,
+		deployer: deployer.address,
+	}
+
+	fs.writeFileSync(contractAddressesPath, JSON.stringify(contractAddresses, null, 2))
+
+	console.log("\n🎉 合约部署成功完成!")
+	console.log(`\n📋 合约地址已保存到 ${contractAddressesPath}:`)
+	console.log("- MockUSD1:", contractAddresses.mockUSD1)
+	console.log("- 销售代币:", contractAddresses.saleToken)
+	console.log("- 启动板:", contractAddresses.launchpad)
+	console.log("- 部署者:", contractAddresses.deployer)
+
+	console.log("\n📋 下一步:运行 'npm run 02:distribute' 来分发测试代币")
+}
+
+// 导出main函数供多环境脚本调用
+export { main }
+
+// 如果直接运行此脚本
+if (require.main === module) {
+	main()
+		.then(() => process.exit(0))
+		.catch((error) => {
+			console.error("❌ Error:", error)
+			process.exit(1)
+		})
+}

+ 173 - 0
scripts/02-distribute-tokens.ts

@@ -0,0 +1,173 @@
+import "dotenv/config"
+import { ethers } from "ethers"
+import * as fs from "fs"
+import * as path from "path"
+import { MockUSD1__factory, BasicERC20__factory } from "../typechain-types/factories/contracts"
+import type { MockUSD1, BasicERC20 } from "../typechain-types/contracts"
+
+// NonceManager类用于管理nonce
+class NonceManager {
+	private nonce: number
+	private provider: ethers.Provider
+	private address: string
+
+	constructor(provider: ethers.Provider, address: string, initialNonce?: number) {
+		this.provider = provider
+		this.address = address
+		this.nonce = initialNonce || 0
+	}
+
+	async getNextNonce(): Promise<number> {
+		if (this.nonce === 0) {
+			// 从网络获取当前nonce
+			const currentNonce = await this.provider.getTransactionCount(this.address)
+			this.nonce = currentNonce
+		}
+		return this.nonce++
+	}
+
+	async resetNonce(): Promise<void> {
+		const currentNonce = await this.provider.getTransactionCount(this.address)
+		this.nonce = currentNonce
+	}
+}
+
+async function main() {
+	// 获取环境配置
+	const environment = process.env.ENVIRONMENT || "bsctest"
+	const rpcUrl = process.env.RPC_URL || "https://data-seed-prebsc-1-s1.binance.org:8545/"
+	const outputDir = process.env.OUTPUT_DIR || "./output/bsctest"
+
+	console.log(`🚀 第2步:向所有账户分发测试代币...`)
+	console.log(`🌍 环境: ${environment}`)
+	console.log(`🔗 RPC: ${rpcUrl}`)
+	console.log(`📁 输出目录: ${outputDir}`)
+
+	// 检查环境变量
+	if (!process.env.PRIVATE_KEY) {
+		console.error("❌ 需要设置 PRIVATE_KEY 环境变量")
+		process.exit(1)
+	}
+
+	// 从之前的步骤加载合约地址
+	const contractAddressesPath = path.join(outputDir, "contract-addresses.json")
+	if (!fs.existsSync(contractAddressesPath)) {
+		console.error(`❌ 未找到 ${contractAddressesPath} 文件。请先运行第1步。`)
+		process.exit(1)
+	}
+
+	const contractAddresses = JSON.parse(fs.readFileSync(contractAddressesPath, "utf8"))
+	console.log("📋 已从之前的步骤加载合约地址")
+
+	// 设置提供者和部署者
+	const provider = new ethers.JsonRpcProvider(rpcUrl)
+	const deployer = new ethers.Wallet(process.env.PRIVATE_KEY, provider)
+
+	// 为部署者初始化NonceManager
+	const nonceManager = new NonceManager(provider, deployer.address)
+
+	// 连接到已部署的合约
+	const mockUSD1 = MockUSD1__factory.connect(contractAddresses.mockUSD1, deployer)
+
+	// 从JSON文件加载测试账户
+	const testAccountsPath = "./scripts/testAccount/BLaunchpadTest.json"
+	if (!fs.existsSync(testAccountsPath)) {
+		console.error("❌ 未找到测试账户文件:", testAccountsPath)
+		process.exit(1)
+	}
+
+	const testAccounts = JSON.parse(fs.readFileSync(testAccountsPath, "utf8"))
+	console.log("📋 已从", testAccountsPath, "加载", testAccounts.length, "个测试账户")
+
+	// 向所有测试账户分发MockUSD1
+	console.log("\n💰 向所有测试账户分发MockUSD1...")
+	const usd1Amount = ethers.parseEther("20000") // 每个账户20K USD1
+
+	for (let i = 0; i < testAccounts.length; i++) {
+		const account = testAccounts[i]
+		const userWallet = new ethers.Wallet(account.privateKey, provider)
+		const tokenBalance = await mockUSD1.balanceOf(userWallet.address)
+
+		// 如果余额不足20K,需要补充
+		if (tokenBalance < ethers.parseEther("20000")) {
+			const needToMint = ethers.parseEther("20000") - tokenBalance
+			try {
+				const nonce = await nonceManager.getNextNonce()
+				const tx = await mockUSD1.connect(deployer).mint(userWallet.address, needToMint, { nonce })
+				console.log(`✅ 账户 ${i + 1}: ${userWallet.address} - 补充 ${ethers.formatEther(needToMint)} USD1 (总计: ${ethers.formatEther(usd1Amount)})`)
+			} catch (error) {
+				console.error(`❌ 为账户 ${i + 1} 铸造USD1失败:`, error)
+				// 出错时重置nonce
+				await nonceManager.resetNonce()
+			}
+		} else {
+			console.log(`✅ 账户 ${i + 1}: ${userWallet.address} - 已有足够余额 ${ethers.formatEther(tokenBalance)} USD1`)
+		}
+	}
+
+	// 向所有测试账户分发真实BNB作为gas费用
+	console.log("\n💰 向所有测试账户分发BNB作为gas费用...")
+	const bnbGasAmount = ethers.parseEther("0.01") // 每个账户0.01 BNB用于gas
+
+	for (let i = 0; i < testAccounts.length; i++) {
+		const account = testAccounts[i]
+		const userWallet = new ethers.Wallet(account.privateKey, provider)
+		const balance = await provider.getBalance(userWallet.address)
+		if (balance < bnbGasAmount) {
+			try {
+				const nonce = await nonceManager.getNextNonce()
+				const tx = await deployer.sendTransaction({
+					to: userWallet.address,
+					value: bnbGasAmount,
+					nonce: nonce,
+				})
+				await tx.wait()
+				console.log(`✅ 账户 ${i + 1}: ${userWallet.address} - ${ethers.formatEther(bnbGasAmount)} BNB用于gas`)
+			} catch (error) {
+				console.error(`❌ 向账户 ${i + 1} 发送BNB失败:`, error)
+				// 出错时重置nonce
+				await nonceManager.resetNonce()
+			}
+		} else {
+			console.log(`✅ 账户 ${i + 1}: ${userWallet.address} - ${ethers.formatEther(balance)} BNB`)
+		}
+	}
+
+	// 分发TestBNB(使用BasicERC20作为TestBNB)给所有测试账户
+	console.log("\n💰 向所有测试账户分发TestBNB...")
+
+
+	// 检查前3个账户的余额(主要测试账户)
+	console.log("\n📊 主要测试账户余额检查:")
+	for (let i = 0; i < Math.max(3, testAccounts.length); i++) {
+		const account = testAccounts[i]
+		const userWallet = new ethers.Wallet(account.privateKey, provider)
+
+		const usd1Balance = await mockUSD1.balanceOf(userWallet.address)
+		const realBnbBalance = await provider.getBalance(userWallet.address)
+
+		console.log(`账户 ${i + 1} (${userWallet.address}):`)
+		console.log(`  - USD1: ${ethers.formatEther(usd1Balance)}`)
+		console.log(`  - 真实BNB: ${ethers.formatEther(realBnbBalance)}`)
+	}
+
+	console.log("\n🎉 代币分发成功完成!")
+	console.log("\n📋 总结:")
+	console.log("- 总账户数:", testAccounts.length)
+	console.log("- 每个账户USD1:", ethers.formatEther(usd1Amount))
+	console.log("- 每个账户真实BNB:", ethers.formatEther(bnbGasAmount))
+	console.log("- MockUSD1地址:", contractAddresses.mockUSD1)
+}
+
+// 导出main函数供多环境脚本调用
+export { main }
+
+// 如果直接运行此脚本
+if (require.main === module) {
+	main()
+		.then(() => process.exit(0))
+		.catch((error) => {
+			console.error("❌ Error:", error)
+			process.exit(1)
+		})
+}

+ 464 - 0
scripts/03-init-sale.ts

@@ -0,0 +1,464 @@
+import "dotenv/config"
+import { ethers } from "ethers"
+import * as fs from "fs"
+import * as path from "path"
+import { StandardMerkleTree } from "@openzeppelin/merkle-tree"
+import { MockUSD1__factory, BasicERC20__factory, Launchpad__factory } from "../typechain-types/factories/contracts"
+import type { MockUSD1, BasicERC20, Launchpad } from "../typechain-types/contracts"
+
+async function main() {
+	// 获取环境配置
+	const environment = process.env.ENVIRONMENT || "bsctest"
+	const rpcUrl = process.env.RPC_URL || "https://data-seed-prebsc-1-s1.binance.org:8545/"
+	const outputDir = process.env.OUTPUT_DIR || "./output/bsctest"
+	
+	console.log(`🚀 第3步:初始化销售并铸造代币...`)
+	console.log(`🌍 环境: ${environment}`)
+	console.log(`🔗 RPC: ${rpcUrl}`)
+	console.log(`📁 输出目录: ${outputDir}`)
+
+	// 检查环境变量
+	if (!process.env.PRIVATE_KEY) {
+		console.error("❌ 需要设置 PRIVATE_KEY 环境变量")
+		process.exit(1)
+	}
+
+	// 从之前的步骤加载合约地址
+	const contractAddressesPath = path.join(outputDir, "contract-addresses.json")
+	if (!fs.existsSync(contractAddressesPath)) {
+		console.error(`❌ 未找到 ${contractAddressesPath} 文件。请先运行第1步。`)
+		process.exit(1)
+	}
+
+	const contractAddresses = JSON.parse(fs.readFileSync(contractAddressesPath, "utf8"))
+	console.log("📋 已从之前的步骤加载合约地址")
+
+	// 设置提供者和部署者
+	const provider = new ethers.JsonRpcProvider(rpcUrl)
+	const deployer = new ethers.Wallet(process.env.PRIVATE_KEY, provider)
+	
+	// 获取当前nonce
+	const currentNonce = await provider.getTransactionCount(deployer.address)
+	console.log("📋 当前nonce:", currentNonce)
+
+	// 连接到已部署的合约
+	const mockUSD1 = MockUSD1__factory.connect(contractAddresses.mockUSD1, deployer)
+
+	const saleToken = BasicERC20__factory.connect(contractAddresses.saleToken, deployer)
+
+	const launchpad = Launchpad__factory.connect(contractAddresses.launchpad, deployer)
+
+	// 检查并确保有足够的销售代币(幂等操作)
+	console.log("\n💰 检查销售代币余额...")
+	const totalTokensForSale = ethers.parseEther("50000") // 5万个代币(对应50K USDT目标)
+
+	// 检查部署者余额
+	const deployerBalance = await saleToken.balanceOf(deployer.address)
+	console.log("📊 部署者当前余额:", ethers.formatEther(deployerBalance), "代币")
+
+	// 检查Launchpad合约余额
+	const launchpadBalance = await saleToken.balanceOf(contractAddresses.launchpad)
+	console.log("📊 Launchpad合约当前余额:", ethers.formatEther(launchpadBalance), "代币")
+
+	// 检查部署者对Launchpad的授权
+	const allowance = await saleToken.allowance(deployer.address, contractAddresses.launchpad)
+	console.log("📊 当前授权额度:", ethers.formatEther(allowance), "代币")
+
+	// 计算需要的代币数量
+	const totalNeeded = totalTokensForSale
+	const currentTotal = deployerBalance + launchpadBalance
+
+	console.log("📊 需要总代币数:", ethers.formatEther(totalNeeded), "代币")
+	console.log("📊 当前总代币数:", ethers.formatEther(currentTotal), "代币")
+
+	// 如果部署者余额不足,需要铸造
+	let nonce = currentNonce
+	if (deployerBalance < totalNeeded) {
+		const needToMint = totalNeeded - deployerBalance
+		console.log("🔄 需要向部署者铸造", ethers.formatEther(needToMint), "个代币")
+		await saleToken.connect(deployer).mint(deployer.address, needToMint, { nonce: nonce++ })
+		console.log("✅ 已向部署者铸造", ethers.formatEther(needToMint), "个销售代币")
+	} else {
+		console.log("✅ 部署者已有足够的代币余额")
+	}
+
+	// 如果授权不足,需要批准
+	if (allowance < totalNeeded) {
+		const needToApprove = totalNeeded - allowance
+		console.log("🔄 需要批准", ethers.formatEther(needToApprove), "个代币给Launchpad")
+		await saleToken.connect(deployer).approve(contractAddresses.launchpad, needToApprove, { nonce: nonce++ })
+		console.log("✅ 已批准", ethers.formatEther(needToApprove), "个代币给Launchpad")
+	} else {
+		console.log("✅ Launchpad已有足够的代币授权")
+	}
+
+	// 从JSON文件加载测试账户
+	const testAccountsPath = "./scripts/testAccount/BLaunchpadTest.json"
+	if (!fs.existsSync(testAccountsPath)) {
+		console.error("❌ 未找到测试账户文件:", testAccountsPath)
+		process.exit(1)
+	}
+
+	const testAccounts = JSON.parse(fs.readFileSync(testAccountsPath, "utf8"))
+	console.log("📋 已从", testAccountsPath, "加载", testAccounts.length, "个测试账户")
+
+	// 从加载的账户创建测试用户(所有20个账户)
+	const testUsers = testAccounts.map((account: any) => new ethers.Wallet(account.privateKey, provider))
+
+	// 基于第2步的USD1分发情况创建销售参数
+	const now = Math.floor(Date.now() / 1000)
+	const startTime = now + 60 // 1分钟后开始
+	const endTime = now + 3600 // 1小时后结束(更短的时间用于测试)
+	const paymentTokenPrice = ethers.parseEther("1") // 1 USDT 每代币
+	const feeBps = 500 // 5% 手续费
+
+	// 设置一个固定目标,如果有5个以上用户参与就会超过
+	// 每个用户可以贡献最多10,000 USD1(他们的全部USD1余额)
+	// 5个用户 × 10,000 USD1 = 50,000 USD1 = 50,000 USD1 目标(刚好达到)
+	// 6个用户 × 10,000 USD1 = 60,000 USD1 > 50,000 USD1 目标(超募)
+	
+	// 直接设置目标为 50,000 USD1(带 18 位小数)
+	const contributionTarget = ethers.parseEther("50000") // 50K USDT 目标
+	console.log("\n🎯 目标设置为 50K USDT")
+	console.log("📊 如果有5个以上用户参与 (5 × 10K = 50K),销售将达到目标")
+	console.log("📊 如果有6个以上用户参与 (6 × 10K = 60K),销售将超额认购")
+	console.log("📊 如果少于5个用户参与,销售将认购不足")
+
+	console.log("\n📋 销售参数:")
+	console.log("- 开始时间:", new Date(startTime * 1000).toLocaleString())
+	console.log("- 结束时间:", new Date(endTime * 1000).toLocaleString())
+	console.log("- 持续时间:", Math.floor((endTime - startTime) / 3600), "小时")
+	console.log("- 目标:", ethers.formatEther(contributionTarget), "USDT")
+	console.log("- 价格:", ethers.formatEther(paymentTokenPrice), "USDT 每代币")
+	console.log("- 手续费:", feeBps / 100, "%")
+	console.log("- 销售代币总数:", ethers.formatEther(totalTokensForSale))
+
+	// 验证销售参数
+	if (startTime >= endTime) {
+		console.error("❌ 错误:开始时间必须在结束时间之前")
+		process.exit(1)
+	}
+
+	if (contributionTarget <= 0) {
+		console.error("❌ 错误:贡献目标必须大于0")
+		process.exit(1)
+	}
+
+	if (paymentTokenPrice <= 0) {
+		console.error("❌ 错误:支付代币价格必须大于0")
+		process.exit(1)
+	}
+
+	// 创建销售(幂等操作)
+	console.log("\n🎯 检查并创建销售...")
+	try {
+		// 尝试获取销售详情,如果成功说明销售已存在
+		const saleDetails = await launchpad.getSaleDetails()
+		const currentSaleToken = saleDetails[0]
+		const currentTotalTokens = saleDetails[1]
+		const currentContribution = saleDetails[2] // totalContributionAmount
+
+		// 检查是否已经调用过createSale(销售代币地址不为0)
+		if (currentSaleToken === ethers.ZeroAddress || currentTotalTokens === BigInt(0)) {
+			console.log("📋 检测到未初始化的销售,直接创建...")
+			console.log("📦 创建新销售...")
+			console.log("📋 参数详情:")
+			console.log(`  - 销售代币: ${contractAddresses.saleToken}`)
+			console.log(`  - 总代币数: ${ethers.formatEther(totalTokensForSale)}`)
+			console.log(`  - 贡献目标: ${ethers.formatEther(contributionTarget)}`)
+			console.log(`  - 开始时间: ${startTime} (${new Date(startTime * 1000).toLocaleString()})`)
+			console.log(`  - 结束时间: ${endTime} (${new Date(endTime * 1000).toLocaleString()})`)
+			console.log(`  - 支付代币: ${contractAddresses.mockUSD1}`)
+			console.log(`  - 价格: ${ethers.formatEther(paymentTokenPrice)}`)
+			console.log(`  - 手续费: ${feeBps} bps`)
+			
+			try {
+				const tx = await launchpad.connect(deployer).createSale(
+					contractAddresses.saleToken, // _token
+					totalTokensForSale, // _totalTokensForSale
+					contributionTarget, // _contributionTarget
+					startTime, // _startTime
+					endTime, // _endTime
+					contractAddresses.mockUSD1, // _paymentToken (USD1)
+					paymentTokenPrice, // _price
+					feeBps, // _feeBps
+					{ nonce: nonce++ }
+				)
+				console.log("📋 交易哈希:", tx.hash)
+				const receipt = await tx.wait()
+				console.log("📋 交易状态:", receipt?.status === 1 ? "✅ 成功" : "❌ 失败")
+				console.log("✅ 销售创建成功")
+			} catch (error) {
+				console.error("❌ 创建销售失败:", error)
+				throw error
+			}
+		} else {
+			// 销售已存在,检查参数一致性
+			console.log("📋 销售已存在,检查参数...")
+			console.log("- 销售代币地址:", currentSaleToken)
+			console.log("- 总代币数量:", ethers.formatEther(currentTotalTokens))
+			console.log("- 当前贡献:", ethers.formatEther(currentContribution))
+
+			// 检查销售参数是否与预期一致
+			const expectedSaleToken = contractAddresses.saleToken
+			const expectedTotalTokens = totalTokensForSale
+			const expectedTarget = contributionTarget
+			const expectedStartTime = BigInt(startTime)
+			const expectedEndTime = BigInt(endTime)
+			const expectedPaymentToken = contractAddresses.mockUSD1
+			const expectedPrice = paymentTokenPrice
+			const expectedFeeBps = BigInt(feeBps)
+
+					// 获取当前销售参数
+		const currentTarget = BigInt(saleDetails[3]) // contributionTarget
+		const currentStartTime = BigInt(saleDetails[4]) // startTime
+		const currentEndTime = BigInt(saleDetails[5]) // endTime
+		const currentMerkleRoot = saleDetails[6] // merkleRoot
+		const currentPaymentToken = saleDetails[7] // paymentTokenAddress
+		const currentPrice = BigInt(saleDetails[8]) // paymentTokenPrice
+		const currentFeeBps = BigInt(saleDetails[9]) // feeBps
+
+			console.log("\n📊 参数对比:")
+			console.log("- 销售代币地址:", currentSaleToken === expectedSaleToken ? "✅ 一致" : "❌ 不一致")
+			console.log("- 总代币数量:", currentTotalTokens === expectedTotalTokens ? "✅ 一致" : "❌ 不一致")
+			console.log("- 贡献目标:", currentTarget === expectedTarget ? "✅ 一致" : "❌ 不一致")
+			// 时间容差检查(1小时 = 3600秒)
+			const timeTolerance = BigInt(3600)
+			const startTimeDiff =
+				currentStartTime > expectedStartTime
+					? currentStartTime - expectedStartTime
+					: expectedStartTime - currentStartTime
+			const endTimeDiff =
+				currentEndTime > expectedEndTime ? currentEndTime - expectedEndTime : expectedEndTime - currentEndTime
+
+			const startTimeConsistent = startTimeDiff <= timeTolerance
+			const endTimeConsistent = endTimeDiff <= timeTolerance
+
+			console.log("- 开始时间:", startTimeConsistent ? "✅ 一致" : "❌ 不一致")
+			if (!startTimeConsistent) {
+				console.log(`  当前: ${new Date(Number(currentStartTime) * 1000).toLocaleString()}`)
+				console.log(`  预期: ${new Date(Number(expectedStartTime) * 1000).toLocaleString()}`)
+				console.log(`  差异: ${Number(startTimeDiff)} 秒`)
+			}
+			console.log("- 结束时间:", endTimeConsistent ? "✅ 一致" : "❌ 不一致")
+			if (!endTimeConsistent) {
+				console.log(`  当前: ${new Date(Number(currentEndTime) * 1000).toLocaleString()}`)
+				console.log(`  预期: ${new Date(Number(expectedEndTime) * 1000).toLocaleString()}`)
+				console.log(`  差异: ${Number(endTimeDiff)} 秒`)
+			}
+			console.log("- 支付代币:", currentPaymentToken === expectedPaymentToken ? "✅ 一致" : "❌ 不一致")
+			console.log("- 价格:", currentPrice === expectedPrice ? "✅ 一致" : "❌ 不一致")
+			console.log("- 手续费:", currentFeeBps === expectedFeeBps ? "✅ 一致" : "❌ 不一致")
+
+			// 检查是否有任何参数不一致
+			const isConsistent =
+				currentSaleToken === expectedSaleToken &&
+				currentTotalTokens === expectedTotalTokens &&
+				currentTarget === expectedTarget &&
+				startTimeConsistent &&
+				endTimeConsistent &&
+				currentPaymentToken === expectedPaymentToken &&
+				currentPrice === expectedPrice &&
+				currentFeeBps === expectedFeeBps
+
+			if (isConsistent) {
+				console.log("\n✅ 销售参数与预期一致,无需重置")
+			} else {
+				console.log("\n⚠️  销售参数与预期不一致")
+
+				// 检查是否有贡献,如果有贡献则不能重置
+				if (currentContribution > 0) {
+					console.error("❌ 错误:销售已有贡献,无法重置。请手动处理或等待销售结束")
+					console.error("当前贡献:", ethers.formatEther(currentContribution), "USD1")
+					process.exit(1)
+				}
+
+				// 检查是否是严重不一致(销售代币地址为0)
+				const isSeverelyInconsistent =
+					currentSaleToken === ethers.ZeroAddress ||
+					currentTotalTokens === BigInt(0) ||
+					currentPaymentToken === ethers.ZeroAddress
+
+				if (isSeverelyInconsistent) {
+					console.log("🔄 检测到严重不一致,尝试重新创建销售...")
+
+					// 尝试重新创建销售(这可能会失败,但值得尝试)
+					try {
+						console.log("📦 重新创建销售...")
+						await launchpad.connect(deployer).createSale(
+							contractAddresses.saleToken, // _token
+							totalTokensForSale, // _totalTokensForSale
+							contributionTarget, // _contributionTarget
+							startTime, // _startTime
+							endTime, // _endTime
+							contractAddresses.mockUSD1, // _paymentToken (USD1)
+							paymentTokenPrice, // _price
+							feeBps, // _feeBps
+							{
+								gasLimit: 10000000,
+								gasPrice: ethers.parseUnits("0.1", "gwei"),
+								nonce: nonce++
+							}
+						)
+						console.log("✅ 销售重新创建成功")
+					} catch (createError) {
+						console.error("❌ 重新创建销售失败:", createError)
+						console.log("💡 提示:请手动处理不一致的参数,或重新部署合约")
+						console.log("💡 当前销售将继续使用现有参数")
+					}
+				} else {
+					console.log("💡 提示:参数轻微不一致,合约不支持自动重置销售")
+					console.log("💡 请手动处理不一致的参数,或等待销售结束后重新部署")
+					console.log("💡 当前销售将继续使用现有参数")
+				}
+			}
+		}
+	} catch (error) {
+		// 销售不存在,创建新销售
+		console.log("📦 创建新销售...")
+		await launchpad.connect(deployer).createSale(
+			contractAddresses.saleToken, // _token
+			totalTokensForSale, // _totalTokensForSale
+			contributionTarget, // _contributionTarget
+			startTime, // _startTime
+			endTime, // _endTime
+			contractAddresses.mockUSD1, // _paymentToken (USD1)
+			paymentTokenPrice, // _price
+			feeBps, // _feeBps
+			{ nonce: nonce++ }
+		)
+		console.log("✅ 销售创建成功")
+	}
+
+	console.log("\n📋 测试用户已加载:")
+	console.log(`- 总用户数: ${testUsers.length}`)
+	for (let i = 0; i < Math.min(5, testUsers.length); i++) {
+		console.log(`- 用户${i + 1}: ${testUsers[i].address}`)
+	}
+	if (testUsers.length > 5) {
+		console.log(`- ... 还有 ${testUsers.length - 5} 个用户`)
+	}
+
+	// 检查第2步分发的USD1余额(前5个用户)
+	console.log("\n💰 检查第2步分发的USD1余额...")
+	let totalUsd1Balance = ethers.parseEther("0")
+	for (let i = 0; i < Math.min(5, testUsers.length); i++) {
+		const balance = await mockUSD1.balanceOf(testUsers[i].address)
+		totalUsd1Balance += balance
+		console.log(`✅ 用户${i + 1} USD1余额: ${ethers.formatEther(balance)}`)
+	}
+	if (testUsers.length > 5) {
+		console.log(`- ... 还有 ${testUsers.length - 5} 个用户有类似余额`)
+	}
+
+	// 验证用户是否有足够的USD1进行销售
+	const expectedBalancePerUser = ethers.parseEther("10000")
+	const avgBalance = totalUsd1Balance / BigInt(Math.min(5, testUsers.length))
+	if (avgBalance < expectedBalancePerUser) {
+		console.warn(
+			`⚠️  警告:平均USD1余额 (${ethers.formatEther(avgBalance)}) 少于预期 (${ethers.formatEther(expectedBalancePerUser)})`
+		)
+		console.warn("   请确保第2步(分发代币)已成功运行")
+	}
+
+	// 为白名单设置Merkle根(幂等操作)
+	console.log("\n🌳 检查并设置白名单...")
+	let whitelistData: [string, bigint][]
+
+	// 每个用户可以贡献最多10,000 USD1(使用他们的全部USD1余额)
+	const maxContributionPerUser = ethers.parseEther("10000")
+
+	// 将所有测试用户添加到白名单,设置大的贡献限额
+	whitelistData = testUsers.map((user: ethers.Wallet) => [user.address, maxContributionPerUser])
+
+	const totalMaxContribution = maxContributionPerUser * BigInt(testUsers.length)
+	console.log(
+		`📋 白名单:所有 ${testUsers.length} 个用户每人最多可贡献 ${ethers.formatEther(maxContributionPerUser)} USDT`
+	)
+	console.log(`📋 最大总贡献:${ethers.formatEther(totalMaxContribution)} USDT`)
+	console.log(`📋 目标:${ethers.formatEther(contributionTarget)} USDT`)
+	console.log(`📋 超额认购阈值:5个用户 (5 × 10K = 50K = 50K 目标)`)
+
+	const tree = StandardMerkleTree.of(whitelistData, ["address", "uint256"])
+
+	// 检查当前Merkle根
+	console.log("🔍 检查当前Merkle根...")
+	try {
+		const currentMerkleRoot = await launchpad.merkleRoot()
+		console.log("📊 当前Merkle根:", currentMerkleRoot)
+		console.log("📊 新计算的Merkle根:", tree.root)
+
+		if (currentMerkleRoot === tree.root) {
+			console.log("✅ Merkle根未变化,无需更新")
+		} else if (currentMerkleRoot === ethers.ZeroHash) {
+			console.log("📝 当前Merkle根为空,设置新的Merkle根...")
+			await launchpad.connect(deployer).setMerkleRoot(tree.root, { nonce: nonce++ })
+			console.log("✅ Merkle根已设置:", tree.root)
+		} else {
+			console.log("📝 Merkle根已变化,更新Merkle根...")
+			console.log("   - 旧Merkle根:", currentMerkleRoot)
+			console.log("   - 新Merkle根:", tree.root)
+			await launchpad.connect(deployer).setMerkleRoot(tree.root, { nonce: nonce++ })
+			console.log("✅ Merkle根已更新:", tree.root)
+		}
+	} catch (error) {
+		// 如果获取Merkle根失败,说明还没有设置
+		console.log("📝 无法获取当前Merkle根,设置新的Merkle根...")
+		await launchpad.connect(deployer).setMerkleRoot(tree.root, { nonce: nonce++ })
+		console.log("✅ Merkle根已设置:", tree.root)
+	}
+
+	// 为后续步骤保存额外数据
+	const step2Data = {
+		...contractAddresses,
+		testUsers: testUsers.map((user: ethers.Wallet) => user.address),
+		merkleRoot: tree.root,
+		whitelistData: whitelistData,
+		saleParams: {
+			startTime,
+			endTime,
+			contributionTarget: contributionTarget.toString(),
+			paymentTokenPrice: paymentTokenPrice.toString(),
+			feeBps,
+		},
+		// 第4步的控制参数
+		oversubscriptionThreshold: 5, // 5个以上用户 = 超额认购
+		maxContributionPerUser: maxContributionPerUser.toString(),
+		totalMaxContribution: totalMaxContribution.toString(),
+	}
+
+	const step2DataPath = path.join(outputDir, "step2-data.json")
+	fs.writeFileSync(
+		step2DataPath,
+		JSON.stringify(step2Data, (key, value) => (typeof value === "bigint" ? value.toString() : value), 2)
+	)
+
+	console.log("\n🎉 销售初始化成功完成!")
+	console.log("\n📋 总结:")
+	console.log("- 创建了50K USDT目标的销售")
+	console.log("- 所有20个测试用户已添加到白名单")
+	console.log("- 每个用户最多可贡献10K USDT")
+	console.log("- 超额认购阈值:5个以上用户")
+	console.log("- 销售持续时间:24小时")
+	console.log(`- 数据已保存到 ${step2DataPath}`)
+
+	console.log("\n📋 下一步:运行 'npm run 04:contribute' 测试贡献")
+	console.log("   - 使用少于5个用户进行认购不足场景")
+	console.log("   - 使用5个以上用户进行超额认购场景")
+	console.log("\n📋 控制场景:")
+	console.log("   - 认购不足:npm run 04:contribute (4个用户)")
+	console.log("   - 超额认购:SCENARIO=1 npm run 04:contribute (6个用户)")
+}
+
+// 导出main函数供多环境脚本调用
+export { main }
+
+// 如果直接运行此脚本
+if (require.main === module) {
+	main()
+		.then(() => process.exit(0))
+		.catch((error) => {
+			console.error("❌ 错误:", error)
+			process.exit(1)
+		})
+}

+ 210 - 0
scripts/04-test-contributions.ts

@@ -0,0 +1,210 @@
+import "dotenv/config"
+import { ethers } from "ethers"
+import * as fs from "fs"
+import * as path from "path"
+import { StandardMerkleTree } from "@openzeppelin/merkle-tree"
+import { MockUSD1__factory, Launchpad__factory } from "../typechain-types/factories/contracts"
+import type { MockUSD1, Launchpad } from "../typechain-types/contracts"
+
+async function main() {
+	// 获取环境配置
+	const environment = process.env.ENVIRONMENT || "bsctest"
+	const rpcUrl = process.env.RPC_URL || "https://data-seed-prebsc-1-s1.binance.org:8545/"
+	const outputDir = process.env.OUTPUT_DIR || "./output/bsctest"
+	
+	console.log(`🚀 第4步:测试用户贡献...`)
+	console.log(`🌍 环境: ${environment}`)
+	console.log(`🔗 RPC: ${rpcUrl}`)
+	console.log(`📁 输出目录: ${outputDir}`)
+
+	// 检查环境变量
+	if (!process.env.PRIVATE_KEY) {
+		console.error("❌ 需要设置 PRIVATE_KEY 环境变量")
+		process.exit(1)
+	}
+
+	// 从之前的步骤加载数据
+	const step2DataPath = path.join(outputDir, "step2-data.json")
+	if (!fs.existsSync(step2DataPath)) {
+		console.error(`❌ 未找到 ${step2DataPath} 文件。请先运行第3步。`)
+		process.exit(1)
+	}
+
+	const step2Data = JSON.parse(fs.readFileSync(step2DataPath, "utf8"))
+	console.log("📋 已从之前的步骤加载数据")
+
+	// 设置提供者
+	const provider = new ethers.JsonRpcProvider(rpcUrl)
+	const deployer = new ethers.Wallet(process.env.PRIVATE_KEY, provider)
+
+	// 连接到合约
+	const mockUSD1 = MockUSD1__factory.connect(step2Data.mockUSD1, deployer)
+
+	const launchpad = Launchpad__factory.connect(step2Data.launchpad, deployer)
+
+	// 从JSON文件加载测试账户
+	const testAccountsPath = "./scripts/testAccount/BLaunchpadTest.json"
+	const testAccounts = JSON.parse(fs.readFileSync(testAccountsPath, "utf8"))
+
+	// 从加载的账户创建测试用户(所有用户)
+	const testUsers = testAccounts.map((account: any) => new ethers.Wallet(account.privateKey, provider))
+
+	// 根据场景确定参与用户数量
+	const scenario = process.env.SCENARIO || "0" // 默认为认购不足
+	const target = BigInt(step2Data.saleParams.contributionTarget) // contributionTarget 已经是 wei 值
+	const maxContributionPerUser = ethers.parseEther("10000") // 每个用户最大10K USD1
+	const usersNeededForTarget = Number(target) / Number(maxContributionPerUser) // 达到目标需要的用户数
+	console.log(`📊 目标: ${ethers.formatEther(target)} USD1`)
+	console.log(`📊 每个用户最大: ${ethers.formatEther(maxContributionPerUser)} USD1`)
+	console.log(`📊 需要用户数: ${usersNeededForTarget}`)
+
+	let participatingUsers: ethers.Wallet[]
+	if (scenario === "1") {
+		// 超额认购:使用超过目标所需的用户数量
+		participatingUsers = testUsers.slice(0, Math.ceil(usersNeededForTarget) + 1) // 6个用户 (5 + 1)
+		console.log(`\n🎯 场景:超额认购 (${participatingUsers.length} 个用户参与,目标需要: ${usersNeededForTarget} 个用户)`)
+	} else {
+		// 认购不足:使用少于目标所需的用户数量
+		participatingUsers = testUsers.slice(0, Math.floor(usersNeededForTarget) - 1) // 4个用户 (5 - 1)
+		console.log(`\n🎯 场景:认购不足 (${participatingUsers.length} 个用户参与,目标需要: ${usersNeededForTarget} 个用户)`)
+	}
+
+	console.log(`📋 ${participatingUsers.length} 个用户将参与销售`)
+
+	// 重新创建Merkle树
+	const tree = StandardMerkleTree.of(step2Data.whitelistData, ["address", "uint256"])
+
+	// 检查销售是否已开始
+	// 在 Hardhat 环境下使用区块链时间,在真实网络下使用系统时间
+	const isHardhat = rpcUrl.includes("127.0.0.1") || rpcUrl.includes("localhost") || rpcUrl.includes("8545")
+	let currentTime: number
+	
+	if (isHardhat) {
+		// 在 Hardhat 环境下使用区块链时间
+		const blockNumber = await provider.getBlockNumber()
+		const block = await provider.getBlock(blockNumber)
+		currentTime = block?.timestamp || Math.floor(Date.now() / 1000)
+	} else {
+		// 在真实网络下使用系统时间
+		currentTime = Math.floor(Date.now() / 1000)
+	}
+	
+	const saleStartTime = step2Data.saleParams.startTime
+
+	if (currentTime < saleStartTime) {
+		console.log(`\n⏰ 销售尚未开始。当前时间: ${new Date(currentTime * 1000).toLocaleString()}`)
+		console.log(`⏰ 销售开始时间: ${new Date(saleStartTime * 1000).toLocaleString()}`)
+		
+		// 在 Hardhat 环境下自动快进时间
+		if (isHardhat) {
+			const timeToAdvance = saleStartTime - currentTime + 60 // 快进到开始时间后1分钟
+			console.log(`⏰ 在 Hardhat 环境下快进时间 ${timeToAdvance} 秒...`)
+			await provider.send("evm_increaseTime", [timeToAdvance])
+			await provider.send("evm_mine", [])
+			
+			// 获取新的当前时间
+			const newBlockNumber = await provider.getBlockNumber()
+			const newBlock = await provider.getBlock(newBlockNumber)
+			const newCurrentTime = newBlock?.timestamp || Math.floor(Date.now() / 1000)
+			console.log(`✅ 时间已快进到: ${new Date(newCurrentTime * 1000).toLocaleString()}`)
+		} else {
+			console.log(`⏰ 等待 ${saleStartTime - currentTime} 秒...`)
+			console.log("⏰ 您可以修改 step2-data.json 中的 startTime 为当前时间以立即测试")
+			return
+		}
+	}
+
+	console.log("\n💰 测试用户贡献...")
+
+	// 每个用户贡献他们的最大允许金额
+	const contributionPerUser = ethers.parseEther("10000") // 每个用户10K USD1
+
+	for (let i = 0; i < participatingUsers.length; i++) {
+		const user = participatingUsers[i]
+		console.log(`\n👤 用户${i + 1} (${user.address}) 贡献 ${ethers.formatEther(contributionPerUser)} USD1...`)
+
+		// 在白名单中找到用户的索引
+		const userIndex = step2Data.testUsers.indexOf(user.address)
+		if (userIndex === -1) {
+			console.error(`❌ 用户 ${user.address} 在白名单中未找到`)
+			continue
+		}
+
+		const userProof = tree.getProof(userIndex)
+
+		try {
+			// 获取用户当前nonce
+			const userNonce = await provider.getTransactionCount(user.address)
+			console.log(`📋 用户${i + 1} 当前nonce: ${userNonce}`)
+
+			// 批准USD1支出
+			await mockUSD1.connect(user).approve(step2Data.launchpad, contributionPerUser, { nonce: userNonce })
+			console.log(`✅ 用户${i + 1} 已批准USD1支出`)
+
+			// 贡献
+			await launchpad.connect(user).contributeWithERC20(
+				contributionPerUser,
+				contributionPerUser, // 最大金额(与贡献相同)
+				userProof,
+				{ nonce: userNonce + 1 }
+			)
+			console.log(`✅ 用户${i + 1} 贡献了 ${ethers.formatEther(contributionPerUser)} USD1`)
+		} catch (error) {
+			console.error(`❌ 用户${i + 1} 贡献失败:`, error)
+		}
+	}
+
+	// 检查总贡献
+	const totalContributed = await launchpad.totalContributionAmount()
+	console.log("\n📊 总贡献:", ethers.formatEther(totalContributed), "USD1")
+
+	// 检查销售是否超额认购
+	const isOversubscribed = totalContributed > target
+
+	console.log("\n📋 销售状态:")
+	console.log("- 目标:", ethers.formatEther(target), "USD1")
+	console.log("- 总贡献:", ethers.formatEther(totalContributed), "USD1")
+	console.log("- 状态:", isOversubscribed ? "超额认购" : "认购不足")
+
+	// 检查个别用户贡献
+	console.log("\n📋 个别贡献:")
+	for (let i = 0; i < Math.min(5, participatingUsers.length); i++) {
+		const user = participatingUsers[i]
+		const userContributed = await launchpad.userContributionAmount(user.address)
+		console.log(`- 用户${i + 1}: ${ethers.formatEther(userContributed)} USD1`)
+	}
+	if (participatingUsers.length > 5) {
+		console.log(`- ... 还有 ${participatingUsers.length - 5} 个用户`)
+	}
+
+	// 保存贡献数据
+	const contributionData = {
+		...step2Data,
+		contributions: {
+			total: totalContributed.toString(),
+			participatingUsers: participatingUsers.map(user => user.address),
+			participatingUsersCount: participatingUsers.length,
+			isOversubscribed: isOversubscribed,
+		},
+	}
+
+	const step3DataPath = path.join(outputDir, "step3-data.json")
+	fs.writeFileSync(step3DataPath, JSON.stringify(contributionData, null, 2))
+
+	console.log("\n🎉 贡献测试成功完成!")
+	console.log(`\n📋 场景: ${isOversubscribed ? "超额认购" : "认购不足"}`)
+	console.log("📋 下一步:运行 'npm run 05:claim' 在销售结束后测试领取")
+}
+
+// 导出main函数供多环境脚本调用
+export { main }
+
+// 如果直接运行此脚本
+if (require.main === module) {
+	main()
+		.then(() => process.exit(0))
+		.catch((error) => {
+			console.error("❌ Error:", error)
+			process.exit(1)
+		})
+}

+ 305 - 0
scripts/05-test-claiming.ts

@@ -0,0 +1,305 @@
+import "dotenv/config"
+import { ethers } from "ethers"
+import * as fs from "fs"
+import * as path from "path"
+import { MockUSD1__factory, Launchpad__factory, BasicERC20__factory } from "../typechain-types/factories/contracts"
+import type { MockUSD1, Launchpad, BasicERC20 } from "../typechain-types/contracts"
+
+async function main() {
+	// 获取环境配置
+	const environment = process.env.ENVIRONMENT || "bsctest"
+	const rpcUrl = process.env.RPC_URL || "https://data-seed-prebsc-1-s1.binance.org:8545/"
+	const outputDir = process.env.OUTPUT_DIR || "./output/bsctest"
+	
+	console.log(`🚀 第5步:测试代币领取和提款...`)
+	console.log(`🌍 环境: ${environment}`)
+	console.log(`🔗 RPC: ${rpcUrl}`)
+	console.log(`📁 输出目录: ${outputDir}`)
+
+	// 检查环境变量
+	if (!process.env.PRIVATE_KEY) {
+		console.error("❌ 需要设置 PRIVATE_KEY 环境变量")
+		process.exit(1)
+	}
+
+	// 从之前的步骤加载数据
+	const step3DataPath = path.join(outputDir, "step3-data.json")
+	if (!fs.existsSync(step3DataPath)) {
+		console.error(`❌ 未找到 ${step3DataPath} 文件。请先运行第4步。`)
+		process.exit(1)
+	}
+
+	const step3Data = JSON.parse(fs.readFileSync(step3DataPath, "utf8"))
+	console.log("📋 已从之前的步骤加载数据")
+
+	// 设置提供者
+	const provider = new ethers.JsonRpcProvider(rpcUrl)
+	const deployer = new ethers.Wallet(process.env.PRIVATE_KEY, provider)
+
+	// 连接到合约
+	const mockUSD1 = MockUSD1__factory.connect(step3Data.mockUSD1, deployer)
+
+	const launchpad = Launchpad__factory.connect(step3Data.launchpad, deployer)
+
+	const saleToken = BasicERC20__factory.connect(step3Data.saleToken, deployer)
+
+	// 从JSON文件加载测试账户
+	const testAccountsPath = "./scripts/testAccount/BLaunchpadTest.json"
+	const testAccounts = JSON.parse(fs.readFileSync(testAccountsPath, "utf8"))
+
+	// 从加载的账户创建测试用户(保留用于兼容性)
+	const user1 = new ethers.Wallet(testAccounts[0].privateKey, provider)
+	const user2 = new ethers.Wallet(testAccounts[1].privateKey, provider)
+	const user3 = new ethers.Wallet(testAccounts[2].privateKey, provider)
+
+	// 获取当前时间和销售结束时间
+	const currentTime = Math.floor(Date.now() / 1000)
+	const saleEndTime = step3Data.saleParams.endTime
+
+	console.log(`\n⏰ 当前时间: ${new Date(currentTime * 1000).toLocaleString()}`)
+	console.log(`⏰ 销售结束时间: ${new Date(saleEndTime * 1000).toLocaleString()}`)
+
+	// 检测是否为Hardhat环境
+	const isHardhat = rpcUrl.includes("127.0.0.1") || rpcUrl.includes("localhost") || rpcUrl.includes("8545")
+	
+	if (isHardhat) {
+		console.log("🔧 检测到Hardhat环境,使用时间快进功能...")
+		
+		// 使用Hardhat快进时间到销售结束后
+		const timeToAdvance = saleEndTime - currentTime + 3600 // 快进到销售结束后1小时
+		
+		console.log(`⏰ 快进时间 ${timeToAdvance} 秒到销售结束后...`)
+		await provider.send("evm_increaseTime", [timeToAdvance])
+		await provider.send("evm_mine", [])
+		
+		// 获取区块链当前时间
+		const blockNumber = await provider.getBlockNumber()
+		const block = await provider.getBlock(blockNumber)
+		const newCurrentTime = block?.timestamp || Math.floor(Date.now() / 1000)
+		console.log("✅ 时间已快进到:", new Date(newCurrentTime * 1000).toLocaleString())
+		
+		console.log("\n🔓 销售已结束,启用领取...")
+
+		// 启用领取
+		const claimStartTime = newCurrentTime + 1 // 从现在起1秒后启用领取
+		await launchpad.connect(deployer).enableClaimTokens(claimStartTime)
+		console.log("✅ 领取已启用,时间:", new Date(claimStartTime * 1000).toLocaleString())
+
+		// 快进时间到领取开始后
+		if (newCurrentTime < claimStartTime) {
+			const waitTime = claimStartTime - newCurrentTime + 1
+			console.log(`⏰ 快进 ${waitTime} 秒到领取开始后...`)
+			await provider.send("evm_increaseTime", [waitTime])
+			await provider.send("evm_mine", [])
+			
+			const finalBlockNumber = await provider.getBlockNumber()
+			const finalBlock = await provider.getBlock(finalBlockNumber)
+			const finalTime = finalBlock?.timestamp || newCurrentTime
+			console.log("✅ 时间已快进到:", new Date(finalTime * 1000).toLocaleString())
+		}
+		
+		// 再次检查当前时间,确保销售已结束
+		const finalCheckBlock = await provider.getBlock(await provider.getBlockNumber())
+		const finalCheckTime = finalCheckBlock?.timestamp || Math.floor(Date.now() / 1000)
+		console.log("🔍 最终时间检查:", new Date(finalCheckTime * 1000).toLocaleString())
+		console.log("🔍 销售结束时间:", new Date(saleEndTime * 1000).toLocaleString())
+		console.log("🔍 销售是否已结束:", finalCheckTime > saleEndTime)
+	} else {
+		console.log("🌐 检测到真实网络环境,检查销售是否已结束...")
+		
+		// 检查销售是否已经结束
+		if (currentTime < saleEndTime) {
+			console.log("❌ 销售尚未结束,无法进行领取测试")
+			console.log("💡 提示:在真实网络中,需要等待销售自然结束才能进行领取测试")
+			console.log("💡 或者可以修改合约的endTime参数来提前结束销售")
+			return
+		}
+		
+		console.log("✅ 销售已结束,检查是否已启用领取...")
+		
+		// 检查是否已经启用领取
+		try {
+			// 尝试启用领取(如果已经启用会失败,这是正常的)
+			const claimStartTime = currentTime + 60 // 1分钟后启用
+			await launchpad.connect(deployer).enableClaimTokens(claimStartTime)
+			console.log("✅ 领取已启用,时间:", new Date(claimStartTime * 1000).toLocaleString())
+		} catch (error) {
+			console.log("✅ 领取功能已经启用或正在启用中...")
+		}
+		
+		// 等待一段时间确保领取功能完全启用
+		console.log("⏳ 等待领取功能完全启用...")
+		await new Promise(resolve => setTimeout(resolve, 5000)) // 等待5秒
+	}
+
+	console.log("\n🎁 测试代币领取...")
+
+	// 获取所有测试用户
+	const allTestUsers = testAccounts.map((account: any) => new ethers.Wallet(account.privateKey, provider))
+	
+	// 记录所有用户的代币领取情况
+	const userTokensReceived: { [key: string]: bigint } = {}
+	let totalTokensClaimed = BigInt(0)
+
+	// 检查是否需要快进时间(在Hardhat环境中)
+	if (isHardhat) {
+		// 获取当前区块链时间
+		const currentBlockNumber = await provider.getBlockNumber()
+		const currentBlock = await provider.getBlock(currentBlockNumber)
+		const currentBlockTime = currentBlock?.timestamp || Math.floor(Date.now() / 1000)
+		
+		// 检查是否已经启用了领取功能
+		try {
+			const isClaimEnabled = await launchpad.isClaimEnabled()
+			const claimStartTime = await launchpad.getClaimStartTime()
+			console.log("🔍 检查领取功能状态...")
+			console.log(`- 领取功能已启用: ${isClaimEnabled}`)
+			console.log(`- 领取开始时间: ${new Date(Number(claimStartTime) * 1000).toLocaleString()}`)
+			
+			// 如果领取功能未启用,尝试启用
+			if (!isClaimEnabled) {
+				console.log("🔓 领取功能未启用,正在启用...")
+				const newClaimStartTime = currentBlockTime + 1
+				await launchpad.connect(deployer).enableClaimTokens(newClaimStartTime)
+				console.log("✅ 领取功能已启用")
+				
+				// 快进时间到领取开始后
+				const waitTime = 2
+				console.log(`⏰ 快进 ${waitTime} 秒到领取开始后...`)
+				await provider.send("evm_increaseTime", [waitTime])
+				await provider.send("evm_mine", [])
+			}
+		} catch (error) {
+			console.log("⚠️ 无法检测领取功能状态,继续执行...")
+		}
+	}
+
+	// 让所有用户领取代币
+	for (let i = 0; i < allTestUsers.length; i++) {
+		const user = allTestUsers[i]
+		console.log(`\n👤 用户${i + 1} (${user.address}) 领取代币...`)
+		
+		// 检查用户是否参与过贡献
+		let userContributed = false
+		let userContributionAmount = BigInt(0)
+		try {
+			// 从合约获取用户的贡献信息
+			userContributionAmount = await launchpad.userContributionAmount(user.address)
+			userContributed = userContributionAmount > 0
+			console.log(`📊 用户${i + 1} 贡献金额: ${ethers.formatEther(userContributionAmount)} USD1`)
+		} catch (error) {
+			const errorMessage = error instanceof Error ? error.message : String(error)
+			console.log(`⚠️ 无法检测用户${i + 1}的贡献状态: ${errorMessage}`)
+		}
+		
+		if (!userContributed) {
+			console.log(`❌ 用户${i + 1} 没有参与贡献,跳过领取`)
+			userTokensReceived[user.address] = BigInt(0)
+			continue
+		}
+		
+		const userBalanceBefore = await saleToken.balanceOf(user.address)
+		let userTokensReceivedAmount = BigInt(0)
+		
+		try {
+			// 获取用户当前nonce
+			const userNonce = await provider.getTransactionCount(user.address)
+			await launchpad.connect(user).claimTokens({ nonce: userNonce })
+			const userBalanceAfter = await saleToken.balanceOf(user.address)
+			userTokensReceivedAmount = userBalanceAfter - userBalanceBefore
+			totalTokensClaimed += userTokensReceivedAmount
+			console.log(`✅ 用户${i + 1} 领取了 ${ethers.formatEther(userTokensReceivedAmount)} 个代币`)
+		} catch (error) {
+			const errorMessage = error instanceof Error ? error.message : String(error)
+			if (errorMessage.includes("No tokens to claim")) {
+				console.log(`✅ 用户${i + 1} 没有代币可领取(可能已经领取过了)`)
+			} else if (errorMessage.includes("Claiming not enabled")) {
+				console.log(`❌ 用户${i + 1} 领取功能尚未启用`)
+			} else if (errorMessage.includes("Sale has not ended")) {
+				console.log(`❌ 用户${i + 1} 销售尚未结束,无法领取`)
+			} else {
+				console.log(`❌ 用户${i + 1} 领取失败: ${errorMessage}`)
+			}
+		}
+		
+		userTokensReceived[user.address] = userTokensReceivedAmount
+	}
+
+	// 统计实际参与贡献的用户数量
+	let participatingUsersCount = 0
+	for (const user of allTestUsers) {
+		const userContribution = await launchpad.userContributionAmount(user.address)
+		if (userContribution > 0) {
+			participatingUsersCount++
+		}
+	}
+
+	console.log(`\n📊 代币领取总结:`)
+	console.log(`- 总领取代币数量: ${totalTokensClaimed.toString()} tokens`)
+	console.log(`- 参与用户数量: ${participatingUsersCount} 个用户`)
+
+	// 所有者提款支付
+	console.log("\n💸 所有者提款支付...")
+	const ownerBalanceBefore = await mockUSD1.balanceOf(deployer.address)
+	const ownerNonce = await provider.getTransactionCount(deployer.address)
+	await launchpad.connect(deployer).withdrawPayments({ nonce: ownerNonce })
+	const ownerBalanceAfter = await mockUSD1.balanceOf(deployer.address)
+	const ownerReceived = ownerBalanceAfter - ownerBalanceBefore
+	console.log("✅ 所有者收到了", ethers.formatEther(ownerReceived), "USD1")
+
+	// 所有者提款剩余代币
+	console.log("\n🏦 所有者提款剩余代币...")
+	const remainingTokens = await saleToken.balanceOf(step3Data.launchpad)
+	console.log("📊 合约中剩余代币:", ethers.formatEther(remainingTokens))
+	
+	if (remainingTokens > 0) {
+		try {
+			await launchpad.connect(deployer).withdrawRemainingTokens()
+			console.log("✅ 所有者提款了", ethers.formatEther(remainingTokens), "个剩余代币")
+		} catch (error) {
+			console.log("⚠️ 无法提款剩余代币,可能合约逻辑认为所有代币已售出")
+			console.log("💡 这通常发生在超额认购时,合约认为所有代币都已分配给用户")
+		}
+	} else {
+		console.log("✅ 没有剩余代币可提款(所有代币已售出)")
+	}
+
+	// 最终总结
+	console.log("\n🎉 领取和提款测试成功完成!")
+	console.log("\n📋 最终总结:")
+	console.log("- 总领取代币数量:", ethers.formatEther(totalTokensClaimed), "个代币")
+	console.log("- 参与用户数量:", participatingUsersCount, "个用户")
+	console.log("- 所有者收到了:", ethers.formatEther(ownerReceived), "USD1")
+	console.log("- 所有者提款了:", ethers.formatEther(remainingTokens), "个剩余代币")
+
+	// Save final data
+	const finalData = {
+		...step3Data,
+		claiming: {
+			totalTokensClaimed: totalTokensClaimed.toString(),
+			participatingUsersCount: participatingUsersCount,
+			userTokensReceived: Object.fromEntries(
+				Object.entries(userTokensReceived).map(([key, value]) => [key, value.toString()])
+			),
+			ownerReceivedUSD1: ownerReceived.toString(),
+			remainingTokens: remainingTokens.toString(),
+		},
+	}
+
+	const finalDataPath = path.join(outputDir, "final-data.json")
+	fs.writeFileSync(finalDataPath, JSON.stringify(finalData, null, 2))
+	console.log(`\n📋 All data saved to ${finalDataPath}`)
+}
+
+// 导出main函数供多环境脚本调用
+export { main }
+
+// 如果直接运行此脚本
+if (require.main === module) {
+	main()
+		.then(() => process.exit(0))
+		.catch((error) => {
+			console.error("❌ Error:", error)
+			process.exit(1)
+		})
+}

+ 243 - 0
scripts/README.md

@@ -0,0 +1,243 @@
+# Launchpad 测试脚本文档
+
+## 📁 脚本文件结构
+
+```
+scripts/
+├── 01-deploy-contracts.ts      # 部署合约
+├── 02-distribute-tokens.ts     # 分发测试代币
+├── 03-init-sale.ts            # 初始化销售
+├── 04-test-contributions.ts   # 测试参与
+├── 05-test-claiming.ts        # 测试领取
+├── deploy-bsctest.ts          # 完整部署脚本(旧版本)
+├── set-merkle-root.ts         # 设置默克尔树
+├── testAccount/
+│   └── BLaunchpadTest.json    # 测试账户文件
+└── README.md                  # 本文档
+```
+
+## 🎯 脚本功能说明
+
+### 1. `01-deploy-contracts.ts` - 部署合约
+**功能:** 部署所有必需的智能合约到 BSC 测试网
+- 部署 MockUSD1 代币(支付代币)
+- 部署 BasicERC20 代币(销售代币)
+- 部署 Launchpad 合约(众筹平台)
+- 保存合约地址到 `contract-addresses.json`
+
+**输出文件:** `contract-addresses.json`
+
+### 2. `02-distribute-tokens.ts` - 分发测试代币
+**功能:** 给所有测试账户分发测试代币和 gas 费
+- 从 `BLaunchpadTest.json` 读取测试账户
+- 给每个账户分发 100K MockUSD1
+- 给每个账户分发 0.1 BNB 作为 gas 费
+- 部署 TestBNB 代币并分发 1000 TestBNB
+- 显示前3个账户的余额
+
+**依赖:** `contract-addresses.json`, `BLaunchpadTest.json`
+
+### 3. `03-init-sale.ts` - 初始化销售
+**功能:** 创建众筹销售并设置白名单
+- 支持两种场景:未满募和超募
+- 设置销售参数(时间、目标、价格、手续费)
+- 创建默克尔树白名单
+- 保存销售数据到 `step2-data.json`
+
+**环境变量:**
+- `SCENARIO=0` - 未满募场景(目标50K,用户限额30K)
+- `SCENARIO=1` - 超募场景(目标5K,用户限额6K)
+
+**输出文件:** `step2-data.json`
+
+### 4. `04-test-contributions.ts` - 测试参与
+**功能:** 测试用户参与众筹
+- 验证销售是否已开始
+- 测试3个用户参与众筹
+- 验证白名单和贡献金额
+- 保存贡献数据到 `step3-data.json`
+
+**依赖:** `step2-data.json`
+
+**输出文件:** `step3-data.json`
+
+### 5. `05-test-claiming.ts` - 测试领取
+**功能:** 测试用户领取代币和退款
+- 验证销售是否已结束
+- 启用 claim 功能
+- 测试用户领取代币
+- 测试 owner 提款
+- 保存最终数据到 `final-data.json`
+
+**依赖:** `step3-data.json`
+
+**输出文件:** `final-data.json`
+
+## 🚀 使用方法
+
+### 环境设置
+```bash
+# 设置私钥环境变量
+export PRIVATE_KEY=your_private_key_here
+```
+
+### 完整测试流程
+
+#### 测试未满募场景
+```bash
+# 方式1: 逐步执行
+npm run 01:deploy
+npm run 02:distribute
+npm run 03:init-undersubscribed
+npm run 04:contribute
+npm run 05:claim
+
+# 方式2: 一键执行
+npm run test:undersubscribed
+```
+
+#### 测试超募场景
+```bash
+# 方式1: 逐步执行
+npm run 01:deploy
+npm run 02:distribute
+npm run 03:init-oversubscribed
+npm run 04:contribute
+npm run 05:claim
+
+# 方式2: 一键执行
+npm run test:oversubscribed
+```
+
+### 单独执行脚本
+```bash
+# 部署合约
+npm run 01:deploy
+
+# 分发代币
+npm run 02:distribute
+
+# 初始化销售(指定场景)
+npm run 03:init-undersubscribed  # 未满募
+npm run 03:init-oversubscribed   # 超募
+
+# 测试参与
+npm run 04:contribute
+
+# 测试领取
+npm run 05:claim
+```
+
+## 📊 场景参数对比
+
+| 参数 | 未满募场景 | 超募场景 |
+|------|------------|----------|
+| 目标金额 | 50K USD1 | 5K USD1 |
+| 用户1限额 | 15K USD1 | 3K USD1 |
+| 用户2限额 | 10K USD1 | 2K USD1 |
+| 用户3限额 | 5K USD1 | 1K USD1 |
+| 总限额 | 30K USD1 | 6K USD1 |
+| 结果 | 未达到目标 | 超过目标 |
+
+## 📁 输出文件说明
+
+### `contract-addresses.json`
+```json
+{
+  	"mockUSD1": "0x...",
+	"saleToken": "0x...",
+	"launchpad": "0x...",
+	"deployer": "0x...",
+  "testBNB": "0x..."
+}
+```
+
+### `step2-data.json`
+```json
+{
+  "mockUSDT": "0x...",
+  "saleToken": "0x...",
+  "launchpad": "0x...",
+  "testUsers": {
+    "user1": "0x...",
+    "user2": "0x...",
+    "user3": "0x..."
+  },
+  "merkleRoot": "0x...",
+  "whitelistData": [...],
+  "saleParams": {...}
+}
+```
+
+### `step3-data.json`
+```json
+{
+  // 包含 step2 的所有数据
+  "contributions": {
+    "total": "45000",
+    "user1": "15000",
+    "user2": "10000",
+    "user3": "5000"
+  }
+}
+```
+
+### `final-data.json`
+```json
+{
+  // 包含 step3 的所有数据
+  "claiming": {
+    "user1Tokens": "14250",
+    "user2Tokens": "9500",
+    "user3Tokens": "4750",
+    	"ownerReceivedUSD1": "42750",
+    "remainingTokens": "800000"
+  }
+}
+```
+
+## 🔧 故障排除
+
+### 常见错误
+1. **PRIVATE_KEY 未设置**
+   ```bash
+   export PRIVATE_KEY=your_private_key_here
+   ```
+
+2. **余额不足**
+   - 确保账户有足够的 BNB 支付 gas 费
+   - 可以从 BSC 测试网水龙头获取测试 BNB
+
+3. **文件不存在**
+   - 确保按顺序执行脚本
+   - 检查输出文件是否生成
+
+4. **时间问题**
+   - 销售时间基于当前时间设置
+   - 可以修改脚本中的时间参数
+
+### 调试技巧
+- 查看控制台输出的详细日志
+- 检查生成的 JSON 文件内容
+- 使用 BSCScan 测试网查看交易状态
+
+## 📝 注意事项
+
+1. **私钥安全**:不要在代码中硬编码私钥,使用环境变量
+2. **网络选择**:脚本默认使用 BSC 测试网
+3. **时间设置**:销售时间基于脚本执行时间
+4. **数据持久化**:每个脚本都会保存数据供下一步使用
+5. **错误处理**:脚本包含基本的错误检查和提示
+
+## 🔄 重新开始
+
+如果需要重新开始测试:
+```bash
+# 删除所有输出文件
+rm -f contract-addresses.json step2-data.json step3-data.json final-data.json
+
+# 重新执行流程
+npm run 01:deploy
+npm run 02:distribute
+# ... 继续其他步骤
+```

+ 220 - 0
scripts/run-with-env.ts

@@ -0,0 +1,220 @@
+import { ethers } from "ethers"
+import * as fs from "fs"
+import * as path from "path"
+import { config } from "dotenv"
+
+// 环境配置接口
+interface EnvConfig {
+	name: string
+	rpcUrl: string
+	chainId: number
+	envFile: string
+	outputDir: string
+	description: string
+}
+
+// 预定义的环境配置
+const ENVIRONMENTS: { [key: string]: EnvConfig } = {
+	hardhat: {
+		name: "hardhat",
+		rpcUrl: "http://127.0.0.1:8545",
+		chainId: 31337,
+		envFile: ".env.hardhat",
+		outputDir: "./output/hardhat",
+		description: "Hardhat本地环境"
+	},
+	bsctest: {
+		name: "bsctest",
+		rpcUrl: "https://data-seed-prebsc-1-s1.binance.org:8545/",
+		chainId: 97,
+		envFile: ".env.bsctest",
+		outputDir: "./output/bsctest",
+		description: "BSC测试网环境"
+	},
+	bscmainnet: {
+		name: "bscmainnet",
+		rpcUrl: "https://bsc-dataseed1.binance.org/",
+		chainId: 56,
+		envFile: ".env.bscmainnet",
+		outputDir: "./output/bscmainnet",
+		description: "BNB主网环境"
+	}
+}
+
+// 脚本配置接口
+interface ScriptConfig {
+	name: string
+	script: string
+	description: string
+}
+
+// 可用的脚本列表
+const AVAILABLE_SCRIPTS: ScriptConfig[] = [
+	{ name: "01:deploy", script: "01-deploy-contracts.ts", description: "部署合约" },
+	{ name: "02:distribute", script: "02-distribute-tokens.ts", description: "分发代币" },
+	{ name: "03:init", script: "03-init-sale.ts", description: "初始化销售" },
+	{ name: "04:contribute", script: "04-test-contributions.ts", description: "测试贡献" },
+	{ name: "05:claim", script: "05-test-claiming.ts", description: "测试领取" },
+	{ name: "test", script: "test-env.ts", description: "测试环境配置" }
+]
+
+function printUsage() {
+	console.log("🚀 多环境脚本执行器")
+	console.log("\n📋 使用方法:")
+	console.log("  npm run env <environment> <script>")
+	console.log("  npm run env <environment> <script> [options]")
+	
+	console.log("\n🌍 可用环境:")
+	Object.entries(ENVIRONMENTS).forEach(([key, env]) => {
+		console.log(`  ${key.padEnd(10)} - ${env.description}`)
+	})
+	
+	console.log("\n📜 可用脚本:")
+	AVAILABLE_SCRIPTS.forEach(script => {
+		console.log(`  ${script.name.padEnd(12)} - ${script.description}`)
+	})
+	
+	console.log("\n💡 示例:")
+	console.log("  npm run env hardhat 01:deploy")
+	console.log("  npm run env bsctest 03:init")
+	console.log("  npm run env bscmainnet 01:deploy")
+	console.log("  npm run env hardhat 04:contribute SCENARIO=1")
+	
+	console.log("\n📁 输出目录:")
+	Object.entries(ENVIRONMENTS).forEach(([key, env]) => {
+		console.log(`  ${key.padEnd(10)} - ${env.outputDir}`)
+	})
+}
+
+function validateEnvironment(envName: string): EnvConfig {
+	const env = ENVIRONMENTS[envName]
+	if (!env) {
+		console.error(`❌ 错误:未知环境 '${envName}'`)
+		console.log("\n可用环境:")
+		Object.keys(ENVIRONMENTS).forEach(key => console.log(`  - ${key}`))
+		process.exit(1)
+	}
+	return env
+}
+
+function validateScript(scriptName: string): ScriptConfig {
+	const script = AVAILABLE_SCRIPTS.find(s => s.name === scriptName)
+	if (!script) {
+		console.error(`❌ 错误:未知脚本 '${scriptName}'`)
+		console.log("\n可用脚本:")
+		AVAILABLE_SCRIPTS.forEach(s => console.log(`  - ${s.name}`))
+		process.exit(1)
+	}
+	return script
+}
+
+function setupEnvironment(env: EnvConfig) {
+	console.log(`\n🌍 切换到环境: ${env.description}`)
+	console.log(`📁 输出目录: ${env.outputDir}`)
+	console.log(`🔗 RPC地址: ${env.rpcUrl}`)
+	
+	// 创建输出目录
+	if (!fs.existsSync(env.outputDir)) {
+		fs.mkdirSync(env.outputDir, { recursive: true })
+		console.log(`✅ 创建输出目录: ${env.outputDir}`)
+	}
+	
+	// 加载环境变量
+	const envPath = path.resolve(env.envFile)
+	if (fs.existsSync(envPath)) {
+		// 清除之前的环境变量,然后加载指定的环境文件
+		Object.keys(process.env).forEach(key => {
+			if (key.startsWith('PRIVATE_KEY') || key.startsWith('RPC_URL') || key.startsWith('CHAIN_ID')) {
+				delete process.env[key]
+			}
+		})
+		config({ path: envPath, override: true })
+		console.log(`✅ 加载环境文件: ${env.envFile}`)
+	} else {
+		console.warn(`⚠️  环境文件不存在: ${env.envFile}`)
+		console.log("💡 请创建环境文件或使用默认.env文件")
+	}
+	
+	// 设置环境变量
+	process.env.ENVIRONMENT = env.name
+	process.env.OUTPUT_DIR = env.outputDir
+	process.env.RPC_URL = env.rpcUrl
+	process.env.CHAIN_ID = env.chainId.toString()
+	
+	console.log(`✅ 环境设置完成`)
+}
+
+async function runScript(script: ScriptConfig, args: string[]) {
+	console.log(`\n📜 执行脚本: ${script.description}`)
+	console.log(`📄 脚本文件: ${script.script}`)
+	
+	// 构建脚本路径
+	const scriptPath = path.resolve(`./scripts/${script.script}`)
+	
+	if (!fs.existsSync(scriptPath)) {
+		console.error(`❌ 脚本文件不存在: ${scriptPath}`)
+		process.exit(1)
+	}
+	
+	// 设置脚本参数
+	process.argv = [process.argv[0], process.argv[1], ...args]
+	
+	try {
+		// 动态导入并执行脚本
+		const scriptModule = await import(scriptPath)
+		if (scriptModule.main) {
+			await scriptModule.main()
+		} else {
+			console.error(`❌ 脚本 ${script.script} 没有导出 main 函数`)
+			process.exit(1)
+		}
+	} catch (error) {
+		console.error(`❌ 执行脚本失败:`, error)
+		process.exit(1)
+	}
+}
+
+async function main() {
+	const args = process.argv.slice(2)
+	
+	// 显示帮助信息
+	if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
+		printUsage()
+		return
+	}
+	
+	// 解析参数
+	const envName = args[0]
+	const scriptName = args[1]
+	const scriptArgs = args.slice(2)
+	
+	if (!envName || !scriptName) {
+		console.error("❌ 错误:缺少环境或脚本参数")
+		printUsage()
+		process.exit(1)
+	}
+	
+	// 验证环境
+	const env = validateEnvironment(envName)
+	
+	// 验证脚本
+	const script = validateScript(scriptName)
+	
+	// 设置环境
+	setupEnvironment(env)
+	
+	// 执行脚本
+	await runScript(script, scriptArgs)
+	
+	console.log("\n🎉 脚本执行完成!")
+}
+
+// 如果直接运行此脚本
+if (require.main === module) {
+	main().catch(error => {
+		console.error("❌ 执行失败:", error)
+		process.exit(1)
+	})
+}
+
+export { main, ENVIRONMENTS, AVAILABLE_SCRIPTS }