helium3@sina.com 1 ماه پیش
والد
کامیت
71526d1daf
2فایلهای تغییر یافته به همراه676 افزوده شده و 159 حذف شده
  1. 249 111
      contracts/Launchpad.sol
  2. 427 48
      test/LaunchpadOwner.ts

+ 249 - 111
contracts/Launchpad.sol

@@ -7,24 +7,33 @@ import "hardhat/console.sol"; // For debugging purposes, can be removed in produ
 
 contract Launchpad is ReentrancyGuard {
 	address public owner;
-	address public token; // sale token address
-	uint256 public totalTokens; // total tokens for sale
-	uint256 public tokensSold; // tokens sold so far
-	uint256 public startTime; // beginning time of the sale
-	uint256 public endTime; // ending time of the sale
+
+	address public saleTokenAddress; // Sale token address
+	uint256 public totalTokensForSale; // Total tokens for sale
+	address public paymentTokenAddress; // Payment tokens accepted for the sale
+	uint256 public paymentTokenPrice; // Payment token price in terms of sale token
+	uint256 public startTime; // Beginning time of the sale
+	uint256 public endTime; // Ending time of the sale
 	bytes32 public merkleRoot; // Merkle root for whitelisting
-	bool public enableClaimToken; // enable claim token
-	uint256 public claimStartTime; // start time for claiming tokens
-	address public paymentToken; // payment tokens accepted for the sale
-	uint256 public salePaymentPrice; //  payment token price in terms of sale token
-	mapping(address => uint256) public claimableTokensAmount; // user contributions for claiming tokens
-	uint256 public accountsCount; // number of accounts that have contributed
-	event SaleCreated(address token, uint256 totalTokens);
-	event Contributed(address user, uint256 amount, address paymentToken);
-	event TokensClaimed(address user, uint256 amount);
-	event SaleCancelled(address token, uint256 refundedAmount);
+
+	uint256 public contributionTarget; // Target amount to be raised in the sale
+	uint256 public totalContributionAmount; // Total contributed amount so far
+	bool public claimEnabled; // Whether claiming tokens is enabled
+	uint256 public claimStartTime; // Start time for claiming tokens
+
+	mapping(address => uint256) public userContributionAmount; // User's contribution amount for claiming tokens
+	uint256 public accountsCount; // Number of accounts that have contributed
+	uint256 public feeBps; // Fee in basis points (bps)
+	uint256 public constant MAX_FEE_BPS = 10000; // 100% in basis points
+	uint256 public constant DECIMALS = 1e18;
+
+	event SaleCreated(address saleTokenAddress, uint256 totalTokensForSale);
+	event Contributed(address user, uint256 amount, address paymentTokenAddress);
+	event TokensClaimed(address user, uint256 tokensClaimed, uint256 refundAmount);
+	event SaleCancelled(address saleTokenAddress, uint256 refundedAmount);
 	event EnableClaimToken(bool enabled, uint256 claimStartTime);
 	event DisableClaimToken(bool disabled);
+
 	constructor() {
 		owner = msg.sender;
 		accountsCount = 0;
@@ -38,237 +47,366 @@ contract Launchpad is ReentrancyGuard {
 
 	modifier saleActive() {
 		require(merkleRoot != bytes32(0), "MerkleRoot not initialized");
-		console.log("Claim start time:", startTime);
-		console.log("Current time:", block.timestamp);
-		console.log("Claiming tokens enabled:", endTime);
 		require(block.timestamp >= startTime && block.timestamp <= endTime, "Sale is not active");
 		_;
 	}
 	modifier validClaimTime() {
-		require(enableClaimToken, "Claiming tokens is not enabled");
+		require(claimEnabled, "Claiming tokens is not enabled");
 		require(block.timestamp >= claimStartTime, "Claiming tokens not started yet");
 		_;
 	}
 
 	// Owner Functions
+	/**
+	 * @notice Creates a new token sale with specified parameters.
+	 * @param _token The address of the token being sold.
+	 * @param _totalTokensForSale The total number of tokens available for sale.
+	 * @param _contributionTarget The fundraising target amount.
+	 * @param _startTime The start time of the sale (timestamp).
+	 * @param _endTime The end time of the sale (timestamp).
+	 * @param _paymentToken The address of the payment token (or address(0) for ETH).
+	 * @param _price The price per token in payment token units.
+	 * @param _feeBps The fee in basis points (1/100 of a percent).
+	 */
 	function createSale(
 		address _token,
-		uint256 _totalTokens,
+		uint256 _totalTokensForSale,
+		uint256 _contributionTarget,
 		uint256 _startTime,
 		uint256 _endTime,
 		address _paymentToken,
-		uint256 _price
+		uint256 _price,
+		uint256 _feeBps
 	) public onlyOwner {
 		require(_token != address(0), "Invalid token address");
-		require(_totalTokens > 0, "Total tokens must be greater than 0");
+		require(_totalTokensForSale > 0, "Total tokens must be greater than 0");
 		require(_startTime < _endTime, "Invalid time range");
 
-		paymentToken = _paymentToken;
-		salePaymentPrice = _price;
-		token = _token;
-		totalTokens = _totalTokens;
+		paymentTokenAddress = _paymentToken;
+		paymentTokenPrice = _price;
+		saleTokenAddress = _token;
+		contributionTarget = _contributionTarget;
+		totalTokensForSale = _totalTokensForSale;
 		startTime = _startTime;
 		endTime = _endTime;
-		tokensSold = 0;
+		totalContributionAmount = 0;
+		feeBps = _feeBps; // Set the fee in basis points
 		merkleRoot = bytes32(0); // Reset Merkle root
-		IERC20(_token).transferFrom(msg.sender, address(this), _totalTokens); // Transfer tokens to the contracts
+		IERC20(_token).transferFrom(msg.sender, address(this), _totalTokensForSale); // Transfer tokens to the contracts
 
-		emit SaleCreated(token, totalTokens);
+		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");
-		uint256 remaining = IERC20(token).balanceOf(address(this));
-		IERC20(token).transfer(owner, remaining);
+		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 (paymentToken == address(0)) {
+		if (paymentTokenAddress == address(0)) {
 			payable(owner).transfer(address(this).balance);
 		} else {
-			uint256 balance = IERC20(paymentToken).balanceOf(address(this));
-			IERC20(paymentToken).transfer(owner, balance);
+			uint256 balance = IERC20(paymentTokenAddress).balanceOf(address(this));
+			IERC20(paymentTokenAddress).transfer(owner, balance);
 		}
 	}
 
+	/**
+	 * @notice Cancels the sale before it starts and refunds all tokens to the owner.
+	 */
 	function cancelSale() public onlyOwner {
 		require(block.timestamp < startTime, "Sale already started");
-		require(token != address(0), "Sale not initialized");
-		uint256 balance = IERC20(token).balanceOf(address(this));
+		require(saleTokenAddress != address(0), "Sale not initialized");
+		uint256 balance = IERC20(saleTokenAddress).balanceOf(address(this));
 		require(balance > 0, "No tokens to refund");
-		IERC20(token).transfer(owner, balance); // Refund tokens to owner
-		token = address(0);
-		totalTokens = 0;
-		tokensSold = 0;
+		IERC20(saleTokenAddress).transfer(owner, balance); // Refund tokens to owner
+		saleTokenAddress = address(0);
+		totalTokensForSale = 0;
+		totalContributionAmount = 0;
 		startTime = 0;
 		endTime = 0;
 		merkleRoot = bytes32(0); // Reset Merkle root
-		emit SaleCancelled(token, balance);
+		emit SaleCancelled(saleTokenAddress, balance);
 	}
 
+	/**
+	 * @notice Enables claiming of purchased tokens after the sale ends.
+	 * @param _enableClaimTokenTime The timestamp when claiming is enabled.
+	 */
 	function enableClaimTokens(uint256 _enableClaimTokenTime) public onlyOwner {
 		require(_enableClaimTokenTime >= block.timestamp, "Enable claim time must be in the future");
 		require(_enableClaimTokenTime >= endTime, "Claiming tokens can only be enabled after the sale ends");
-		enableClaimToken = true;
+		claimEnabled = true;
 		claimStartTime = _enableClaimTokenTime; // Set claim start time to now
-		emit EnableClaimToken(enableClaimToken, claimStartTime);
+		emit EnableClaimToken(claimEnabled, claimStartTime);
 	}
 
+	/**
+	 * @notice Disables claiming of purchased tokens.
+	 */
 	function disableClaimTokens() public onlyOwner {
-		require(enableClaimToken, "Claiming tokens is already disabled");
-		enableClaimToken = false;
-		emit DisableClaimToken(enableClaimToken);
+		require(claimEnabled, "Claiming tokens is already disabled");
+		claimEnabled = false;
+		emit DisableClaimToken(claimEnabled);
 	}
 
 	// Setter
+	/**
+	 * @notice Transfers contract ownership to a new address.
+	 * @param _newOwner The address of the new owner.
+	 */
 	function setOwner(address _newOwner) public onlyOwner {
 		require(_newOwner != address(0), "Invalid owner address");
 		owner = _newOwner;
 	}
 
+	/**
+	 * @notice Sets the payment token and price for the sale.
+	 * @param _paymentToken The address of the payment token.
+	 * @param _price The price per token in payment token units.
+	 */
 	function setSalePayment(address _paymentToken, uint256 _price) public onlyOwner {
 		require(_price > 0, "Price must be greater than 0");
-		paymentToken = _paymentToken;
-		salePaymentPrice = _price;
+		paymentTokenAddress = _paymentToken;
+		paymentTokenPrice = _price;
 	}
 
+	/**
+	 * @notice Sets the Merkle root for whitelist verification.
+	 * @param _merkleRoot The new Merkle root.
+	 */
 	function setMerkleRoot(bytes32 _merkleRoot) public onlyOwner {
 		merkleRoot = _merkleRoot;
 	}
 
+	/**
+	 * @notice Sets the fee in basis points.
+	 * @param _feeBps The fee in basis points (max 10000).
+	 */
+	function setFeeBps(uint256 _feeBps) public onlyOwner {
+		require(_feeBps <= MAX_FEE_BPS, "Fee cannot exceed 100%");
+		feeBps = _feeBps; // Set the fee in basis points
+	}
+
 	//Getter
 
+	/**
+	 * @notice Returns the address of the contract owner.
+	 */
 	function getOwner() public view returns (address) {
 		return owner;
 	}
+	/**
+	 * @notice Returns the address of the sale token.
+	 */
 	function getSaleToken() public view returns (address) {
-		return token;
+		return saleTokenAddress;
 	}
-	function getTotalTokens() public view returns (uint256) {
-		return totalTokens;
+	/**
+	 * @notice Returns the total number of tokens for sale.
+	 */
+	function getTotalTokensForSale() public view returns (uint256) {
+		return totalTokensForSale;
 	}
-	function getTokensSold() public view returns (uint256) {
-		return tokensSold;
+	/**
+	 * @notice Returns the total contributed amount so far.
+	 */
+	function getTotalContributeAmount() public view returns (uint256) {
+		return totalContributionAmount;
 	}
+	/**
+	 * @notice Returns the sale start time.
+	 */
 	function getStartTime() public view returns (uint256) {
 		return startTime;
 	}
+	/**
+	 * @notice Returns the sale end time.
+	 */
 	function getEndTime() public view returns (uint256) {
 		return endTime;
 	}
+	/**
+	 * @notice Returns all sale details as a tuple.
+	 */
 	function getSaleDetails()
 		public
 		view
 		returns (
-			address token,
-			uint256 totalTokens,
-			uint256 tokensSold,
+			address saleTokenAddress,
+			uint256 totalTokensForSale,
+			uint256 totalContributeAmount,
 			uint256 startTime,
 			uint256 endTime,
 			bytes32 merkleRoot,
-			address paymentToken,
+			address paymentTokenAddress,
 			uint256 salePaymentPrice
 		)
 	{
-		return (token, totalTokens, tokensSold, startTime, endTime, merkleRoot, paymentToken, salePaymentPrice);
+		return (
+			saleTokenAddress,
+			totalTokensForSale,
+			totalContributionAmount,
+			startTime,
+			endTime,
+			merkleRoot,
+			paymentTokenAddress,
+			paymentTokenPrice
+		);
 	}
 
+	/**
+	 * @notice Returns the claimable tokens for a user.
+	 * @param _user The address of the user.
+	 */
 	function getClaimableTokens(address _user) public view returns (uint256) {
-		return claimableTokensAmount[_user];
+		return userContributionAmount[_user];
 	}
 
+	/**
+	 * @notice Returns the payment token price.
+	 */
 	function getPaymentTokenPrice() public view returns (uint256) {
-		return salePaymentPrice;
+		return paymentTokenPrice;
 	}
+	/**
+	 * @notice Returns the payment token address.
+	 */
 	function getPaymentTokens() public view returns (address) {
-		return paymentToken;
+		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 enableClaimToken;
+		return claimEnabled;
 	}
 
 	//Main Function
+	/**
+	 * @notice Calculates the net contribution after deducting the fee.
+	 * @param _commitAmount The original contribution amount.
+	 * @return The net contribution after fee deduction.
+	 */
+	function calculateNetContribution(uint256 _commitAmount) public view returns (uint256) {
+		// Calculate net contribution after fee
+		uint256 net = (_commitAmount * (MAX_FEE_BPS - feeBps)) / MAX_FEE_BPS;
+		return net;
+	}
 
-	//MUST TEST !
-	function contributeETH(
-		uint256 _buyAmount,
-		uint256 _maxBuyAmount,
+	/**
+	 * @notice Contribute to the sale using ETH. Requires whitelist proof.
+	 * @param _commitAmount The amount of ETH to contribute.
+	 * @param _maxCommitAmount The maximum allowed contribution for the user.
+	 * @param _proof The Merkle proof for whitelist verification.
+	 * @dev nonReentrant modifier is used to prevent reentrancy attacks.
+	 */
+	function contributeWithETH(
+		uint256 _commitAmount,
+		uint256 _maxCommitAmount,
 		bytes32[] memory _proof
 	) public payable saleActive nonReentrant {
-		require(paymentToken == address(0), "Payment token must be ETH for this sale");
-		require(msg.value > 0, "Must send ETH");
-		require(_buyAmount > 0, "Must buy a positive amount");
-		require(_buyAmount <= _maxBuyAmount, "Buy amount exceeds max buy amount");
-		require(salePaymentPrice > 0, "Payment token not accepted for this sale");
+		require(paymentTokenAddress == address(0), "Payment token must be ETH for this sale");
+		require(_commitAmount > 0, "Must buy a positive amount");
+		require(_commitAmount <= _maxCommitAmount, "Buy amount exceeds max buy amount");
+		require(msg.value >= _commitAmount, "Must send enough ETH");
+		require(paymentTokenPrice > 0, "Payment token not accepted for this sale");
 
 		// 验证 Merkle 证明
-		bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, _maxBuyAmount))));
+		bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, _maxCommitAmount))));
 		require(MerkleProof.verify(_proof, merkleRoot, leaf), "Invalid proof");
 
-		uint256 cost = (salePaymentPrice / 10 ** 18) * _buyAmount; // Assuming salePaymentPrice is in wei
-		require(msg.value >= cost, "Not enough ETH sent");
-		tokensSold += _buyAmount;
-		if (claimableTokensAmount[msg.sender] == 0) {
+		//户数统计
+		if (userContributionAmount[msg.sender] == 0) {
 			accountsCount++; // Increment accounts count only if this is the first contribution
 		}
-		claimableTokensAmount[msg.sender] += _buyAmount; // update user's claimable tokens
-		require(claimableTokensAmount[msg.sender] <= _maxBuyAmount, "Buy amount exceeds max buy amount");
-		payable(msg.sender).transfer(msg.value - cost); //refund excess ETH
-		emit Contributed(msg.sender, _buyAmount, address(0));
+
+		//币数统计
+		userContributionAmount[msg.sender] += _commitAmount; // update user's commit tokens
+		require(userContributionAmount[msg.sender] <= _maxCommitAmount, "Buy amount exceeds max buy amount");
+		totalContributionAmount += _commitAmount; // update total contributed amount
+		if (msg.value > _commitAmount) {
+			payable(msg.sender).transfer(msg.value - _commitAmount); //refund excess ETH
+		}
+		emit Contributed(msg.sender, _commitAmount, address(0));
 	}
 
-	function contributeERC20(
-		uint256 _buyAmount,
-		uint256 _maxBuyAmount,
+	/**
+	 * @notice Contribute to the sale using ERC20 tokens. Requires whitelist proof.
+	 * @param _commitAmount The amount of tokens to contribute.
+	 * @param _maxCommitAmount The maximum allowed contribution for the user.
+	 * @param _proof The Merkle proof for whitelist verification.
+	 * @dev nonReentrant modifier is used to prevent reentrancy attacks.
+	 */
+	function contributeWithERC20(
+		uint256 _commitAmount,
+		uint256 _maxCommitAmount,
 		bytes32[] memory _proof
 	) public saleActive nonReentrant {
-		require(_buyAmount > 0, "Must send a positive amount");
-		require(_buyAmount <= _maxBuyAmount, "Buy amount exceeds max buy amount");
+		require(_commitAmount > 0, "Must send a positive amount");
+		require(_commitAmount <= _maxCommitAmount, "Buy amount exceeds max buy amount");
 
-		bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, _maxBuyAmount))));
+		bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, _maxCommitAmount))));
 		require(MerkleProof.verify(_proof, merkleRoot, leaf), "Invalid proof");
 
-		uint256 price = salePaymentPrice;
-		require(price > 0, "Payment token not accepted for this sale");
-		uint256 cost = (price / 10 ** 18) * _buyAmount; // Assuming salePaymentPrice is in wei
 		require(
-			IERC20(paymentToken).allowance(msg.sender, address(this)) >= cost,
+			IERC20(paymentTokenAddress).allowance(msg.sender, address(this)) >= _commitAmount,
 			"Not enough allowance for payment token"
 		);
-		tokensSold += _buyAmount;
-		if (claimableTokensAmount[msg.sender] == 0) {
+		if (userContributionAmount[msg.sender] == 0) {
 			accountsCount++; // Increment accounts count only if this is the first contribution
 		}
-		claimableTokensAmount[msg.sender] += _buyAmount; // update user's claimable tokens
-		console.log("Claimable tokens amount for user:", claimableTokensAmount[msg.sender]);
-		require(claimableTokensAmount[msg.sender] <= _maxBuyAmount, "Buy amount exceeds max buy amount");
-		IERC20(paymentToken).transferFrom(msg.sender, address(this), cost); // 从用户账户转移支付代币
-
-		emit Contributed(msg.sender, _buyAmount, paymentToken);
+		userContributionAmount[msg.sender] += _commitAmount; // update user's claimable tokens
+		require(userContributionAmount[msg.sender] <= _maxCommitAmount, "Buy amount exceeds max buy amount");
+		totalContributionAmount += _commitAmount; // update total contributed amount
+		IERC20(paymentTokenAddress).transferFrom(msg.sender, address(this), _commitAmount); // 从用户账户转移支付代币
+		emit Contributed(msg.sender, _commitAmount, paymentTokenAddress);
 	}
 
+	/**
+	 * @notice Claim purchased tokens and receive refund if sale is oversubscribed.
+	 * @dev nonReentrant modifier is used to prevent reentrancy attacks.
+	 *      This function clears the user's contribution after claim to prevent double claim.
+	 */
 	function claimTokens() public validClaimTime {
 		require(block.timestamp > endTime, "Sale is still active");
-		uint256 amount = claimableTokensAmount[msg.sender];
-		require(amount > 0, "No tokens to claim");
-
-		if (tokensSold <= totalTokens) {
-			IERC20(token).transfer(msg.sender, amount); // 分发销售代币
-			emit TokensClaimed(msg.sender, amount);
+		uint256 contributeAmount = userContributionAmount[msg.sender];
+		require(contributeAmount > 0, "No tokens to claim");
+		uint256 refundCost = 0;
+		uint256 boughtToken = 0;
+		if (totalContributionAmount <= contributionTarget) {
+			uint256 netContribution = calculateNetContribution(contributeAmount);
+			boughtToken = (netContribution * DECIMALS) / paymentTokenPrice;
+			IERC20(saleTokenAddress).transfer(msg.sender, boughtToken);
 		} else {
-			uint256 boughtAmountAvg = totalTokens / accountsCount; // 平均每个账户购买的代币数量
-			uint256 refundTokenAmount = amount - boughtAmountAvg; // 计算退款金额
-			uint256 refundAmount = (refundAmount / 10 ** 18) * salePaymentPrice;
-			if (paymentToken == address(0)) {
-				payable(msg.sender).transfer(refundAmount); // 退款ETH
+			uint256 userValidContribution = (contributeAmount * contributionTarget) / totalContributionAmount;
+			refundCost = contributeAmount - userValidContribution;
+			uint256 netContribution = calculateNetContribution(userValidContribution);
+			boughtToken = (netContribution * DECIMALS) / paymentTokenPrice;
+			if (paymentTokenAddress == address(0)) {
+				(bool sent, ) = payable(msg.sender).call{ value: refundCost }("");
+				require(sent, "ETH refund failed");
 			} else {
-				IERC20(paymentToken).transfer(msg.sender, refundAmount); // 退款ERC20代币
+				IERC20(paymentTokenAddress).transfer(msg.sender, refundCost);
 			}
-			IERC20(token).transfer(msg.sender, boughtAmountAvg); // 分发销售代币
+			IERC20(saleTokenAddress).transfer(msg.sender, boughtToken);
 		}
+		userContributionAmount[msg.sender] = 0;
+		emit TokensClaimed(msg.sender, boughtToken, refundCost);
 	}
 }

+ 427 - 48
test/LaunchpadOwner.ts

@@ -1,11 +1,19 @@
 import { expect } from "chai"
 import hre, { ethers } from "hardhat"
 import { StandardMerkleTree } from "@openzeppelin/merkle-tree"
-import { time } from "@nomicfoundation/hardhat-toolbox/network-helpers"
+import { loadFixture, time } from "@nomicfoundation/hardhat-toolbox/network-helpers"
+import { network } from "hardhat"
+let snapshotId: string
 
 describe("Launchpad", () => {
-	async function deployLaunchpadFixture() {
+	const deployLaunchpadFixture = async () => {
 		const signers = await hre.ethers.getSigners()
+		// for (const signer of signers) {
+		// 	await hre.network.provider.send("hardhat_setBalance", [
+		// 		signer.address,
+		// 		"0x3635C9ADC5DEA00000", // 1000 ETH
+		// 	])
+		// }
 		const owner = signers[0].address
 		const Launchpad = await hre.ethers.getContractFactory("Launchpad")
 		const Erc20Token = await hre.ethers.getContractFactory("BasicERC20")
@@ -21,7 +29,7 @@ describe("Launchpad", () => {
 
 		const launchpadAddress = await launchpadContract.getAddress()
 
-		await saleTokenContract.mint(signers[0].address, ethers.parseEther("1000000"))
+		await saleTokenContract.mint(owner, ethers.parseEther("1000000"))
 		await saleTokenContract.approve(launchpadAddress, ethers.MaxUint256)
 		await buyTokenContract.mint(signers[1].address, ethers.parseEther("10000"))
 
@@ -33,23 +41,25 @@ describe("Launchpad", () => {
 		}
 	}
 
-	async function createNativeSaleFixture() {
+	const createNativeSaleFixture = async () => {
 		const { launchpadContract, saleTokenContract, owner } = await deployLaunchpadFixture()
-
 		const balanceBefore = await saleTokenContract.balanceOf(owner)
 		const saleTokenAddress = await saleTokenContract.getAddress()
-		const startTime = Math.floor(Date.now() / 1000) + 60 // 1 minute from now
-		const endTime = Math.floor(Date.now() / 1000) + 60 * 60 // 1 hour from now
+		const now = Math.floor(Date.now() / 1000)
+		const startTime = now + 60
+		const endTime = now + 60 * 60 * 8
 		const paymentTokens = ethers.ZeroAddress
 		const paymentTokenPrice = ethers.parseEther("1")
 
 		await launchpadContract.createSale(
 			saleTokenAddress,
 			balanceBefore,
+			ethers.parseEther("100"), // contributionTarget
 			startTime,
 			endTime,
 			paymentTokens,
-			paymentTokenPrice
+			paymentTokenPrice,
+			0n // feeBps
 		)
 
 		return {
@@ -61,34 +71,39 @@ describe("Launchpad", () => {
 
 	describe("init function and setters", () => {
 		it("Should Allow Owner to create a sale use native token", async () => {
-			const { launchpadContract, saleTokenContract, owner } = await deployLaunchpadFixture()
+			const { launchpadContract, saleTokenContract, owner } = await loadFixture(deployLaunchpadFixture)
 
 			const balanceBefore = await saleTokenContract.balanceOf(owner)
 			const saleTokenAddress = await saleTokenContract.getAddress()
-			const startTime = Math.floor(Date.now() / 1000) + 60 // 1 minute from now
-			const endTime = Math.floor(Date.now() / 1000) + 60 * 60 // 1 hour from now
+			const now = Math.floor(Date.now() / 1000)
+			let startTime = now + 60 // 1 minute from now
+			const endTime = now + 60 * 60 * 8 // 1 hour from now
 			const paymentTokens = ethers.ZeroAddress
-			const paymentTokenPrice = ethers.parseEther("1") / 2500n
+			const paymentTokenPrice = ethers.parseEther("1")
 			await launchpadContract.createSale(
 				saleTokenAddress,
 				balanceBefore,
+				ethers.parseEther("100"), // contributionTarget
 				startTime,
 				endTime,
 				paymentTokens,
-				paymentTokenPrice
+				paymentTokenPrice,
+				0n // feeBps
 			)
+			startTime = Number(await launchpadContract.getStartTime())
+			await time.increaseTo(startTime + 1)
 			expect(await saleTokenContract.balanceOf(owner)).to.equal(0)
 			expect(await launchpadContract.getSaleToken()).to.equal(saleTokenAddress)
-			expect(await launchpadContract.getTotalTokens()).to.equal(balanceBefore)
+			expect(await launchpadContract.getTotalTokensForSale()).to.equal(balanceBefore)
 			expect(await launchpadContract.getStartTime()).to.equal(startTime)
 			expect(await launchpadContract.getEndTime()).to.equal(endTime)
 			expect(await launchpadContract.getPaymentTokens()).to.equal(ethers.ZeroAddress)
 			expect(await launchpadContract.getPaymentTokenPrice()).to.equal(paymentTokenPrice)
-			expect(await launchpadContract.getTokensSold()).to.equal(0n)
+			expect(await launchpadContract.totalContributionAmount()).to.equal(0n)
 		})
 
 		it("should Allow Owner reset payment info", async () => {
-			const { launchpadContract, saleTokenContract, owner } = await createNativeSaleFixture()
+			const { launchpadContract, saleTokenContract, owner } = await loadFixture(createNativeSaleFixture)
 			const paymentTokenBefore = await launchpadContract.getPaymentTokens()
 			const paymentTokenPriceBefore = await launchpadContract.getPaymentTokenPrice()
 			expect(await launchpadContract.getPaymentTokens()).to.equal(paymentTokenBefore)
@@ -103,7 +118,7 @@ describe("Launchpad", () => {
 		})
 
 		it("should Allow Owner set MerkleRoot", async () => {
-			const { launchpadContract, owner } = await createNativeSaleFixture()
+			const { launchpadContract, owner } = await loadFixture(createNativeSaleFixture)
 			const signers = await hre.ethers.getSigners()
 
 			const values = [
@@ -128,7 +143,7 @@ describe("Launchpad", () => {
 
 	describe("after init buy function", () => {
 		it("should Allow user use native token buy token", async () => {
-			const { launchpadContract, owner } = await createNativeSaleFixture()
+			const { launchpadContract, owner } = await loadFixture(createNativeSaleFixture)
 
 			const signers = await hre.ethers.getSigners()
 			const values = [
@@ -156,44 +171,44 @@ describe("Launchpad", () => {
 					wrongProof = tree.getProof(i)
 				}
 			}
+
 			await expect(
-				launchpadContract.connect(buyer).contributeETH(buyAmount, maxBuyAmount, proof)
+				launchpadContract.connect(buyer).contributeWithETH(buyAmount, maxBuyAmount, proof)
 			).to.be.revertedWith("MerkleRoot not initialized")
 			await launchpadContract.setMerkleRoot(root, { from: owner })
-
-			await expect(
-				launchpadContract.connect(buyer).contributeETH(buyAmount, maxBuyAmount, proof)
-			).to.be.revertedWith("Sale is not active")
 			await time.increase(100)
 			await expect(
-				launchpadContract.connect(buyer).contributeETH(buyAmount + maxBuyAmount, maxBuyAmount, proof, {
+				launchpadContract.connect(buyer).contributeWithETH(buyAmount, maxBuyAmount, proof)
+			).to.be.revertedWith("Must send enough ETH")
+			await expect(
+				launchpadContract.connect(buyer).contributeWithETH(buyAmount + maxBuyAmount, maxBuyAmount, proof, {
 					value: ethers.parseEther("0.5"),
 				})
 			).to.be.revertedWith("Buy amount exceeds max buy amount")
 			await expect(
-				launchpadContract.connect(buyer).contributeETH(buyAmount, maxBuyAmount, wrongProof, {
-					value: ethers.parseEther("0.5"),
+				launchpadContract.connect(buyer).contributeWithETH(buyAmount, maxBuyAmount, wrongProof, {
+					value: ethers.parseEther("1"),
 				})
 			).to.be.revertedWith("Invalid proof")
 			await expect(
-				launchpadContract.connect(buyer).contributeETH(buyAmount, maxBuyAmount, proof, {
+				launchpadContract.connect(buyer).contributeWithETH(buyAmount, maxBuyAmount, proof, {
 					value: ethers.parseEther("0.5"),
 				})
-			).to.be.revertedWith("Not enough ETH sent")
+			).to.be.revertedWith("Must send enough ETH")
 			await expect(
-				launchpadContract.connect(buyer).contributeETH(ethers.parseEther("50"), maxBuyAmount, proof, {
+				launchpadContract.connect(buyer).contributeWithETH(ethers.parseEther("50"), maxBuyAmount, proof, {
 					value: ethers.parseEther("50"),
 				})
 			).to.revertedWith("Buy amount exceeds max buy amount")
 			//success once
 			await expect(
-				launchpadContract.connect(buyer).contributeETH(buyAmount, maxBuyAmount, proof, {
+				launchpadContract.connect(buyer).contributeWithETH(buyAmount, maxBuyAmount, proof, {
 					value: ethers.parseEther("1"),
 				})
 			).to.emit(launchpadContract, "Contributed")
 			//make it success twice
 			await expect(
-				launchpadContract.connect(buyer).contributeETH(ethers.parseEther("4"), maxBuyAmount, proof, {
+				launchpadContract.connect(buyer).contributeWithETH(ethers.parseEther("4"), maxBuyAmount, proof, {
 					value: ethers.parseEther("4"),
 				})
 			).to.emit(launchpadContract, "Contributed")
@@ -202,13 +217,13 @@ describe("Launchpad", () => {
 			)
 			//failed by exceeding max buy amount
 			await expect(
-				launchpadContract.connect(buyer).contributeETH(ethers.parseEther("10"), maxBuyAmount, proof, {
+				launchpadContract.connect(buyer).contributeWithETH(ethers.parseEther("10"), maxBuyAmount, proof, {
 					value: ethers.parseEther("10"),
 				})
 			).to.be.revertedWith("Buy amount exceeds max buy amount")
 		})
 		it("should Allow user claim token after finished sale", async () => {
-			const { launchpadContract, saleTokenContract, owner } = await createNativeSaleFixture()
+			const { launchpadContract, saleTokenContract, owner } = await loadFixture(createNativeSaleFixture)
 
 			const signers = await hre.ethers.getSigners()
 			const values = [
@@ -235,7 +250,7 @@ describe("Launchpad", () => {
 			await launchpadContract.setMerkleRoot(root, { from: owner })
 			await time.increase(100)
 			await expect(
-				launchpadContract.connect(buyer).contributeETH(buyAmount, maxBuyAmount, proof, {
+				launchpadContract.connect(buyer).contributeWithETH(buyAmount, maxBuyAmount, proof, {
 					value: ethers.parseEther("1"),
 				})
 			).to.emit(launchpadContract, "Contributed")
@@ -243,14 +258,14 @@ describe("Launchpad", () => {
 			await expect(launchpadContract.connect(buyer).claimTokens()).to.be.revertedWith(
 				"Claiming tokens is not enabled"
 			)
-			expect(await launchpadContract.enableClaimTokens(Math.floor(Date.now() / 1000) + 60 * 60)).to.emit(
+			expect(await launchpadContract.enableClaimTokens(Math.floor(Date.now() / 1000) + 60 * 60 * 8)).to.emit(
 				launchpadContract,
 				"EnableClaimToken"
 			)
 			await expect(launchpadContract.connect(buyer).claimTokens()).to.be.revertedWith(
 				"Claiming tokens not started yet"
 			)
-			await time.increase(60 * 60 + 100) // increase time to finish sale
+			await time.increase(60 * 60 * 8 + 1) // increase time to finish sale
 			await expect(launchpadContract.connect(signers[2]).claimTokens()).to.be.revertedWith("No tokens to claim")
 			//Transfer token
 			await expect(launchpadContract.connect(buyer).claimTokens()).to.emit(saleTokenContract, "Transfer")
@@ -258,7 +273,7 @@ describe("Launchpad", () => {
 			expect(await saleTokenContract.balanceOf(buyer.address)).to.equal(buyAmount)
 		})
 		it("should Allow owner claim contribution token after finished sale", async () => {
-			const { launchpadContract, saleTokenContract, owner } = await createNativeSaleFixture()
+			const { launchpadContract, saleTokenContract, owner } = await loadFixture(createNativeSaleFixture)
 
 			const signers = await hre.ethers.getSigners()
 			const values = [
@@ -283,35 +298,399 @@ describe("Launchpad", () => {
 				}
 			}
 			await launchpadContract.setMerkleRoot(root, { from: owner })
+			const launchpadAddress = await launchpadContract.getAddress()
+
 			await time.increase(100)
 
-			const launchpadAddress = await launchpadContract.getAddress()
 			await expect(
-				launchpadContract.connect(buyer).contributeETH(buyAmount, maxBuyAmount, proof, {
+				launchpadContract.connect(buyer).contributeWithETH(buyAmount, maxBuyAmount, proof, {
 					value: ethers.parseEther("1"),
 				})
 			).to.emit(launchpadContract, "Contributed")
 
 			await expect(launchpadContract.withdrawPayments()).to.be.revertedWith("Sale is still active")
 
-			await launchpadContract.enableClaimTokens(Math.floor(Date.now() / 1000) + 60 * 60)
+			await launchpadContract.enableClaimTokens(Math.floor(Date.now() / 1000) + 60 * 60 * 8)
 
-			await time.increase(60 * 60 + 100) // increase time to finish sale
+			await time.increase(60 * 60 * 8 + 1) // increase time to finish sale
 			await launchpadContract.connect(buyer).claimTokens()
-			//todo 写不动了 接下来测试 非 owner withdraw token/钱 owner 的 withdraw token/钱
 			await expect(launchpadContract.connect(signers[2]).withdrawPayments()).to.revertedWith(
 				"Only owner can call this function"
 			)
-			await expect(launchpadContract.withdrawPayments()).to.emit(launchpadContract, "WithdrawPayments")
-			expect(await ethers.provider.getBalance(launchpadAddress)).to.equal(0n)
 			await expect(launchpadContract.connect(signers[2]).withdrawRemainingTokens()).to.revertedWith(
 				"Only owner can call this function"
 			)
-			await expect(launchpadContract.withdrawRemainingTokens()).to.emit(
-				launchpadContract,
-				"WithdrawRemainingTokens"
-			)
+
+			await launchpadContract.withdrawPayments()
+			await launchpadContract.withdrawRemainingTokens()
+
+			expect(await ethers.provider.getBalance(launchpadAddress)).to.equal(0n)
 			expect(await saleTokenContract.balanceOf(launchpadAddress)).to.equal(0)
 		})
+		it("should allow user to buy and claim with ERC20 payment token", async () => {
+			const { launchpadContract, saleTokenContract, buyTokenContract, owner } =
+				await loadFixture(deployLaunchpadFixture)
+			const signers = await hre.ethers.getSigners()
+			const buyer = signers[1]
+			// 创建ERC20众筹
+			const saleTokenAddress = await saleTokenContract.getAddress()
+			const buyTokenAddress = await buyTokenContract.getAddress()
+			const now = Math.floor(Date.now() / 1000)
+			const startTime = now + 100
+			const endTime = now + 60 * 60 * 8
+			const paymentTokenPrice = ethers.parseEther("1")
+			const totalTokens = await saleTokenContract.balanceOf(owner)
+			await launchpadContract.createSale(
+				saleTokenAddress,
+				totalTokens,
+				totalTokens, // contributionTarget
+				startTime,
+				endTime,
+				buyTokenAddress,
+				paymentTokenPrice,
+				0n // feeBps
+			)
+			await time.increase(100)
+			// Merkle tree
+			const values = [[buyer.address, ethers.parseEther("100")]]
+			const tree = StandardMerkleTree.of(values, ["address", "uint256"])
+			const proof = tree.getProof(0)
+			await launchpadContract.setMerkleRoot(tree.root, { from: owner })
+			await time.increase(100)
+			// approve
+			await buyTokenContract
+				.connect(buyer)
+				.approve(await launchpadContract.getAddress(), ethers.parseEther("100"))
+			// buy
+			await expect(
+				launchpadContract
+					.connect(buyer)
+					.contributeWithERC20(ethers.parseEther("10"), ethers.parseEther("100"), proof)
+			).to.emit(launchpadContract, "Contributed")
+			// enable claim
+			await launchpadContract.enableClaimTokens(Math.floor(Date.now() / 1000) + 60 * 60 * 8)
+			await time.increase(60 * 60 * 8 + 1)
+			// claim
+			await expect(launchpadContract.connect(buyer).claimTokens()).to.emit(launchpadContract, "TokensClaimed")
+			expect(await saleTokenContract.balanceOf(buyer.address)).to.equal(ethers.parseEther("10"))
+		})
+		it("should refund excess and distribute tokens proportionally when oversubscribed", async () => {
+			const { launchpadContract, saleTokenContract, owner } = await loadFixture(deployLaunchpadFixture)
+			const signers = await hre.ethers.getSigners()
+			const tokenBalanceBefore = await saleTokenContract.balanceOf(owner)
+			const saleTokenAddress = await saleTokenContract.getAddress()
+			const now = Math.floor(Date.now() / 1000)
+			const startTime = now + 60
+			const endTime = now + 60 * 60 * 8
+			const paymentTokens = ethers.ZeroAddress
+			const paymentTokenPrice = ethers.parseEther("1")
+
+			await launchpadContract.createSale(
+				saleTokenAddress,
+				tokenBalanceBefore,
+				ethers.parseEther("100"), // contributionTarget
+				startTime,
+				endTime,
+				paymentTokens,
+				paymentTokenPrice,
+				0n // feeBps
+			)
+			// 设置白名单
+			const values = [
+				[signers[1].address, ethers.parseEther("100")],
+				[signers[2].address, ethers.parseEther("100")],
+			]
+			const tree = StandardMerkleTree.of(values, ["address", "uint256"])
+			await launchpadContract.setMerkleRoot(tree.root, { from: owner })
+			// 两人都买100,目标额度为100,实际总额200
+			const proof1 = tree.getProof(0)
+			const proof2 = tree.getProof(1)
+			await time.increase(100)
+			await launchpadContract
+				.connect(signers[1])
+				.contributeWithETH(ethers.parseEther("100"), ethers.parseEther("100"), proof1, {
+					value: ethers.parseEther("100"),
+				})
+			await launchpadContract
+				.connect(signers[2])
+				.contributeWithETH(ethers.parseEther("100"), ethers.parseEther("100"), proof2, {
+					value: ethers.parseEther("100"),
+				})
+			await launchpadContract.enableClaimTokens(Math.floor(Date.now() / 1000) + 60 * 60 * 8)
+			await time.increase(3600 * 8)
+			// 领取
+			const balanceBefore = await ethers.provider.getBalance(signers[1].address)
+			await launchpadContract.connect(signers[1]).claimTokens()
+			// 检查领取的token数量为50(按比例分配
+			const balanceAfter = await ethers.provider.getBalance(signers[1].address)
+
+			expect(Number(ethers.formatEther(balanceAfter - balanceBefore))).to.closeTo(50, 0.001)
+			// 断言退款金额接近50
+			// ...可进一步断言
+		})
+		it("should deduct fee from user contribution when claiming tokens", async () => {
+			const { launchpadContract, saleTokenContract, owner } = await loadFixture(deployLaunchpadFixture)
+			const tokenBalanceBefore = await saleTokenContract.balanceOf(owner)
+			const saleTokenAddress = await saleTokenContract.getAddress()
+			const now = Math.floor(Date.now() / 1000)
+			const startTime = now + 60
+			const endTime = now + 60 * 60 * 8
+			const paymentTokens = ethers.ZeroAddress
+			const paymentTokenPrice = ethers.parseEther("1")
+			await launchpadContract.createSale(
+				saleTokenAddress,
+				tokenBalanceBefore,
+				ethers.parseEther("100"), // contributionTarget
+				startTime,
+				endTime,
+				paymentTokens,
+				paymentTokenPrice,
+				0n // feeBps
+			)
+			const signers = await hre.ethers.getSigners()
+			const values = [[signers[1].address, ethers.parseEther("10")]]
+			const tree = StandardMerkleTree.of(values, ["address", "uint256"])
+			await launchpadContract.setMerkleRoot(tree.root, { from: owner })
+			await launchpadContract.setFeeBps(500) // 5% fee
+			await time.increase(100)
+			await launchpadContract
+				.connect(signers[1])
+				.contributeWithETH(ethers.parseEther("10"), ethers.parseEther("10"), tree.getProof(0), {
+					value: ethers.parseEther("10"),
+				})
+			await launchpadContract.enableClaimTokens(Math.floor(Date.now() / 1000) + 60 * 60 * 8)
+			await time.increase(60 * 60 * 8 + 1)
+			const before = await saleTokenContract.balanceOf(signers[1].address)
+			await launchpadContract.connect(signers[1]).claimTokens()
+			const after = await saleTokenContract.balanceOf(signers[1].address)
+			// 断言实际到账 < 10
+			expect(after - before).to.equal(ethers.parseEther("9.5")) // 5% fee
+		})
+		it("should not allow user to claim tokens twice", async () => {
+			const { launchpadContract, saleTokenContract, owner } = await loadFixture(deployLaunchpadFixture)
+			const tokenBalanceBefore = await saleTokenContract.balanceOf(owner)
+			const saleTokenAddress = await saleTokenContract.getAddress()
+			const now = Math.floor(Date.now() / 1000)
+			const startTime = now + 60
+			const endTime = now + 60 * 60 * 8
+			const paymentTokens = ethers.ZeroAddress
+			const paymentTokenPrice = ethers.parseEther("1")
+			await launchpadContract.createSale(
+				saleTokenAddress,
+				tokenBalanceBefore,
+				ethers.parseEther("100"), // contributionTarget
+				startTime,
+				endTime,
+				paymentTokens,
+				paymentTokenPrice,
+				0n // feeBps
+			)
+			const signers = await hre.ethers.getSigners()
+			const values = [[signers[1].address, ethers.parseEther("10")]]
+			const tree = StandardMerkleTree.of(values, ["address", "uint256"])
+			await launchpadContract.setMerkleRoot(tree.root, { from: owner })
+			await time.increase(100)
+			await launchpadContract
+				.connect(signers[1])
+				.contributeWithETH(ethers.parseEther("10"), ethers.parseEther("10"), tree.getProof(0), {
+					value: ethers.parseEther("10"),
+				})
+			await launchpadContract.enableClaimTokens(Math.floor(Date.now() / 1000) + 60 * 60 * 8)
+			await time.increase(60 * 60 * 8)
+			await launchpadContract.connect(signers[1]).claimTokens()
+			await expect(launchpadContract.connect(signers[1]).claimTokens()).to.be.revertedWith("No tokens to claim")
+		})
+		it("should revert claim if contract has insufficient token balance", async () => {
+			const { launchpadContract, saleTokenContract, owner } = await loadFixture(deployLaunchpadFixture)
+			const tokenBalanceBefore = await saleTokenContract.balanceOf(owner)
+			const saleTokenAddress = await saleTokenContract.getAddress()
+			const now = Math.floor(Date.now() / 1000)
+			const startTime = now + 60
+			const endTime = now + 60 * 60 * 8
+			const paymentTokens = ethers.ZeroAddress
+			const paymentTokenPrice = ethers.parseEther("1")
+			await launchpadContract.createSale(
+				saleTokenAddress,
+				tokenBalanceBefore,
+				ethers.parseEther("100"), // contributionTarget
+				startTime,
+				endTime,
+				paymentTokens,
+				paymentTokenPrice,
+				0n // feeBps
+			)
+			const signers = await hre.ethers.getSigners()
+			const values = [[signers[1].address, ethers.parseEther("10")]]
+			const tree = StandardMerkleTree.of(values, ["address", "uint256"])
+			await launchpadContract.setMerkleRoot(tree.root, { from: owner })
+			await time.increase(100)
+			await launchpadContract
+				.connect(signers[1])
+				.contributeWithETH(ethers.parseEther("10"), ethers.parseEther("10"), tree.getProof(0), {
+					value: ethers.parseEther("10"),
+				})
+			await launchpadContract.enableClaimTokens(Math.floor(Date.now() / 1000) + 60 * 60 * 8)
+			await time.increase(60 * 60 * 8)
+			// owner 提前提走所有token
+			await launchpadContract.withdrawRemainingTokens()
+			await expect(launchpadContract.connect(signers[1]).claimTokens()).to.be.reverted // 断言revert
+		})
+		it("should revert claim if contract has insufficient ETH balance for refund", async () => {
+			const { launchpadContract, saleTokenContract, owner } = await loadFixture(deployLaunchpadFixture)
+			const tokenBalanceBefore = await saleTokenContract.balanceOf(owner)
+			const saleTokenAddress = await saleTokenContract.getAddress()
+			const now = Math.floor(Date.now() / 1000)
+			const startTime = now + 60
+			const endTime = now + 60 * 60 * 8
+			const paymentTokens = ethers.ZeroAddress
+			const paymentTokenPrice = ethers.parseEther("1")
+			await launchpadContract.createSale(
+				saleTokenAddress,
+				tokenBalanceBefore,
+				ethers.parseEther("100"), // contributionTarget
+				startTime,
+				endTime,
+				paymentTokens,
+				paymentTokenPrice,
+				0n // feeBps
+			)
+			const signers = await hre.ethers.getSigners()
+			// 设置白名单
+			const values = [
+				[signers[1].address, ethers.parseEther("100")],
+				[signers[2].address, ethers.parseEther("100")],
+			]
+			const tree = StandardMerkleTree.of(values, ["address", "uint256"])
+			await launchpadContract.setMerkleRoot(tree.root, { from: owner })
+			await time.increase(100)
+			// 两人都买100,目标额度为100,实际总额200,claim时应部分退款
+			const proof1 = tree.getProof(0)
+			const proof2 = tree.getProof(1)
+
+			await launchpadContract
+				.connect(signers[1])
+				.contributeWithETH(ethers.parseEther("100"), ethers.parseEther("100"), proof1, {
+					value: ethers.parseEther("100"),
+				})
+			await launchpadContract
+				.connect(signers[2])
+				.contributeWithETH(ethers.parseEther("100"), ethers.parseEther("100"), proof2, {
+					value: ethers.parseEther("100"),
+				})
+			await launchpadContract.enableClaimTokens(Math.floor(Date.now() / 1000) + 60 * 60 * 8)
+			await time.increase(60 * 60 * 8)
+			// owner 提前提走所有ETH
+			await launchpadContract.withdrawPayments()
+			await expect(launchpadContract.connect(signers[1]).claimTokens()).to.be.reverted
+		})
+
+		it("should revert claim if contract has insufficient ERC20 balance for refund", async () => {
+			const { launchpadContract, saleTokenContract, buyTokenContract, owner } =
+				await loadFixture(deployLaunchpadFixture)
+			const signers = await hre.ethers.getSigners()
+			const buyer1 = signers[1]
+			const buyer2 = signers[2]
+			await buyTokenContract.mint(buyer1.address, ethers.parseEther("1000000000"))
+			await buyTokenContract.mint(buyer2.address, ethers.parseEther("1000000000"))
+			// 创建ERC20众筹
+			const saleTokenAddress = await saleTokenContract.getAddress()
+			const buyTokenAddress = await buyTokenContract.getAddress()
+			const now = Math.floor(Date.now() / 1000)
+			const startTime = now + 60
+			const endTime = now + 60 * 60 * 8
+			const paymentTokenPrice = ethers.parseEther("1")
+			const totalTokens = await saleTokenContract.balanceOf(owner)
+			await launchpadContract.createSale(
+				saleTokenAddress,
+				totalTokens,
+				totalTokens, // contributionTarget
+				startTime,
+				endTime,
+				buyTokenAddress,
+				paymentTokenPrice,
+				0n // feeBps
+			)
+			const blockInfo = await ethers.provider.getBlock("latest")
+			await time.increaseTo(blockInfo!.timestamp + 1)
+			// Merkle tree
+			const values = [
+				[buyer1.address, ethers.parseEther("100")],
+				[buyer2.address, ethers.parseEther("100")],
+			]
+			const tree = StandardMerkleTree.of(values, ["address", "uint256"])
+			await launchpadContract.setMerkleRoot(tree.root, { from: owner })
+			await time.increase(100)
+			// approve
+			await buyTokenContract
+				.connect(buyer1)
+				.approve(await launchpadContract.getAddress(), ethers.parseEther("100"))
+			await buyTokenContract
+				.connect(buyer2)
+				.approve(await launchpadContract.getAddress(), ethers.parseEther("100"))
+			// buy
+			await launchpadContract
+				.connect(buyer1)
+				.contributeWithERC20(ethers.parseEther("100"), ethers.parseEther("100"), tree.getProof(0))
+			await launchpadContract
+				.connect(buyer2)
+				.contributeWithERC20(ethers.parseEther("100"), ethers.parseEther("100"), tree.getProof(1))
+			await launchpadContract.enableClaimTokens(Math.floor(Date.now() / 1000) + 60 * 60 * 8)
+			await time.increase(3600 * 8)
+			// owner 提前提走所有ERC20
+			await launchpadContract.withdrawPayments()
+			await launchpadContract.connect(buyer1).claimTokens()
+			//可能是个 bug 点,在 claim alive 的时候 是否允许 claim token
+			await launchpadContract.withdrawRemainingTokens()
+			await expect(launchpadContract.connect(buyer2).claimTokens()).to.be.reverted
+		})
+
+		it("should allow multiple users to claim tokens concurrently without interference", async () => {
+			const { launchpadContract, saleTokenContract, owner } = await loadFixture(deployLaunchpadFixture)
+			const tokenBalanceBefore = await saleTokenContract.balanceOf(owner)
+			const saleTokenAddress = await saleTokenContract.getAddress()
+			const now = Math.floor(Date.now() / 1000)
+			const startTime = now + 60
+			const endTime = now + 60 * 60 * 8
+			const paymentTokens = ethers.ZeroAddress
+			const paymentTokenPrice = ethers.parseEther("1")
+			await launchpadContract.createSale(
+				saleTokenAddress,
+				tokenBalanceBefore,
+				ethers.parseEther("100"), // contributionTarget
+				startTime,
+				endTime,
+				paymentTokens,
+				paymentTokenPrice,
+				0n // feeBps
+			)
+			const signers = await hre.ethers.getSigners()
+			const values = [
+				[signers[1].address, ethers.parseEther("10")],
+				[signers[2].address, ethers.parseEther("10")],
+				[signers[3].address, ethers.parseEther("10")],
+			]
+			const tree = StandardMerkleTree.of(values, ["address", "uint256"])
+			await launchpadContract.setMerkleRoot(tree.root, { from: owner })
+			await time.increase(100)
+			for (let i = 1; i <= 3; i++) {
+				await launchpadContract
+					.connect(signers[i])
+					.contributeWithETH(values[i - 1][1].toString(), values[i - 1][1].toString(), tree.getProof(i - 1), {
+						value: values[i - 1][1].toString(),
+					})
+			}
+			await launchpadContract.enableClaimTokens(Math.floor(Date.now() / 1000) + 60 * 60 * 8)
+			await time.increase(3600 * 8)
+			// 多用户并发claim
+			await Promise.all([
+				launchpadContract.connect(signers[1]).claimTokens(),
+				launchpadContract.connect(signers[2]).claimTokens(),
+				launchpadContract.connect(signers[3]).claimTokens(),
+			])
+			// 检查每个用户都正确领取
+			for (let i = 1; i <= 3; i++) {
+				expect(await saleTokenContract.balanceOf(signers[i].address)).to.equal(values[i - 1][1].toString())
+			}
+		})
 	})
 })