Development of Launchpad Contract
Launchpad is not just "a contract that sells tokens". If task reduced to this, 50 lines of Solidity enough. Real task — system simultaneously managing whitelist participants, multiple rounds with different prices and limits, vesting schedule for allocated tokens, refund mechanic if softcap not met, and anti-whale logic. Plus need survive at least one audit without critical findings. Let's see what this means practically.
Multi-Round Launchpad Architecture
Round Structure
Typical launchpad has several sequential rounds:
Seed Round → Private Round → Public Round (IDO)
[whitelist] [whitelist] [open / guaranteed + FCFS]
[limit $500] [limit $2000] [limit $300 / no limit FCFS]
[$0.05/token] [$0.08/token] [$0.12/token]
In smart contract represented via configurable rounds:
struct Round {
uint256 startTime;
uint256 endTime;
uint256 price; // in stablecoin (6 decimals for USDC)
uint256 minAllocation; // minimum purchase
uint256 maxAllocation; // maximum purchase per address
uint256 totalCap; // maximum for entire round
uint256 raised; // already raised
bytes32 merkleRoot; // whitelist via Merkle tree
bool requiresKYC; // KYC verification flag
bool isActive;
}
Merkle tree for whitelist — standard pattern. Alternative (mapping of approved addresses) doesn't scale: 10k addresses in mapping — 10k transactions to fill. Merkle proof verifies participation without storing entire list on-chain.
function participate(
uint256 roundId,
uint256 amount,
bytes32[] calldata merkleProof
) external nonReentrant whenNotPaused {
Round storage round = rounds[roundId];
require(block.timestamp >= round.startTime, "Not started");
require(block.timestamp < round.endTime, "Ended");
require(round.raised + amount <= round.totalCap, "Cap exceeded");
// Whitelist verification via Merkle proof
if (round.merkleRoot != bytes32(0)) {
bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
require(
MerkleProof.verify(merkleProof, round.merkleRoot, leaf),
"Not whitelisted"
);
}
// KYC check via on-chain registry
if (round.requiresKYC) {
require(kycRegistry.isVerified(msg.sender), "KYC required");
}
uint256 newTotal = contributions[roundId][msg.sender] + amount;
require(newTotal >= round.minAllocation, "Below minimum");
require(newTotal <= round.maxAllocation, "Exceeds maximum");
contributions[roundId][msg.sender] = newTotal;
round.raised += amount;
// Accept payment (USDC)
paymentToken.transferFrom(msg.sender, address(this), amount);
emit Participated(msg.sender, roundId, amount);
}
Vesting: Main Technical Complexity
Most audit findings in launchpad contracts — in vesting logic. Not because it's fundamentally complex, but because edge cases poorly tested.
Linear Vesting with Cliff
struct VestingSchedule {
uint256 totalAmount; // total tokens to receive
uint256 cliffEnd; // until this time — nothing
uint256 vestingStart; // linear vesting start (usually = cliffEnd)
uint256 vestingEnd; // vesting period end
uint256 claimed; // already received
bool revocable; // can be revoked (for team, not investors)
}
function claimable(address beneficiary) public view returns (uint256) {
VestingSchedule memory schedule = vestingSchedules[beneficiary];
if (block.timestamp < schedule.cliffEnd) {
return 0;
}
uint256 elapsed = block.timestamp - schedule.vestingStart;
uint256 total = schedule.vestingEnd - schedule.vestingStart;
uint256 vested = elapsed >= total
? schedule.totalAmount
: (schedule.totalAmount * elapsed) / total;
return vested - schedule.claimed;
}
function claim() external nonReentrant {
uint256 amount = claimable(msg.sender);
require(amount > 0, "Nothing to claim");
vestingSchedules[msg.sender].claimed += amount;
projectToken.transfer(msg.sender, amount);
emit TokensClaimed(msg.sender, amount);
}
Typical Vesting Mistakes
Problem 1: TGE (Token Generation Event) percent — part unlocked immediately at TGE, rest vests. Inexperienced devs implement as separate claim, users often forget. Correct — TGE part included in claimable() calculation automatically from TGE moment.
Problem 2: Revoke while preserving earned — if team vesting is revocable, on revoke must preserve already earned tokens. Only unearned part returned to project.
Problem 3: Precision loss on division — (totalAmount * elapsed) / total with small amounts and large total can give 0. Operation order matters: always multiply before dividing.
Softcap / Hardcap and Refund Mechanism
uint256 public softcap; // minimum for successful IDO
uint256 public hardcap; // maximum
enum SaleStatus { Active, SoftcapMet, HardcapMet, Failed, Finalized }
SaleStatus public status;
function finalizeSale() external onlyOwner {
require(block.timestamp > saleEndTime, "Sale not ended");
uint256 totalRaised = getTotalRaised();
if (totalRaised < softcap) {
status = SaleStatus.Failed;
// Activate refund mode
emit SaleFailed(totalRaised, softcap);
} else {
status = SaleStatus.Finalized;
// Transfer funds to treasury
paymentToken.transfer(treasury, totalRaised);
// Enable vesting claimability
vestingStartTime = block.timestamp + TGE_DELAY;
emit SaleFinalized(totalRaised);
}
}
function refund(uint256 roundId) external nonReentrant {
require(status == SaleStatus.Failed, "Sale not failed");
uint256 contribution = contributions[roundId][msg.sender];
require(contribution > 0, "Nothing to refund");
contributions[roundId][msg.sender] = 0;
paymentToken.transfer(msg.sender, contribution);
emit Refunded(msg.sender, roundId, contribution);
}
Anti-Whale and Fairness Mechanics
Max allocation per wallet — basic protection, implemented via maxAllocation in round.
Anti-bot protection in FCFS round — First-Come-First-Served rounds attacked by bots. Common protections:
- Whitelist even for public round: all who registered before certain time get guaranteed allocation. FCFS only for unregistered.
- Commit-reveal: participant first commits (hash of purchase intent), reveal happens in separate transaction N blocks later. Bots lose speed advantage.
- Dutch auction instead fixed price: price starts high and decreases. Market mechanism finds equilibrium price, anti-bot built-in.
Anti-sniper on round start:
modifier antiSnipe(uint256 roundId) {
Round storage round = rounds[roundId];
// First 30 seconds — only whitelist tier 1 (OG participants)
if (block.timestamp < round.startTime + 30) {
require(tier1Whitelist[msg.sender], "OG round");
}
_;
}
Token Contract Integration
Launchpad doesn't issue tokens immediately — records allocations, tokens issued via vesting. Requires either pre-mint tokens to launchpad or mint-on-claim mechanism:
// Option 1: pre-funded
// Before IDO start project team transfers needed token quantity
// to launchpad contract
projectToken.transferFrom(projectOwner, address(this), totalTokensForSale);
// Option 2: mint-on-claim (if token grants MINTER_ROLE to launchpad)
function claim() external nonReentrant {
uint256 amount = claimable(msg.sender);
require(amount > 0, "Nothing to claim");
vestingSchedules[msg.sender].claimed += amount;
IProjectToken(projectToken).mint(msg.sender, amount); // mint instead of transfer
emit TokensClaimed(msg.sender, amount);
}
Option 2 more popular for projects where total supply not finalized before IDO closing.
Testing and Audit
For launchpad contract fork testing mandatory — tests on mainnet fork with real USDC addresses:
forge test --fork-url $ETH_RPC -vvv --match-contract LaunchpadTest
Fuzz testing for vesting:
function testFuzz_vestingClaimable(
uint256 totalAmount,
uint256 elapsed,
uint256 duration
) public {
totalAmount = bound(totalAmount, 1e6, 1e30); // reasonable bounds
duration = bound(duration, 1 days, 4 * 365 days);
elapsed = bound(elapsed, 0, duration);
// Invariant: claimable never exceeds totalAmount
uint256 vested = (totalAmount * elapsed) / duration;
assertLe(vested, totalAmount);
}
Critical scenarios for manual testing: refund after failed sale with multiple rounds; claim with partially revoked vesting; participation via contract wallet (not EOA) — FOMO.finance hack 2021 was exactly such exploit.
Timeline: full launchpad contract with audit — 8–14 weeks. Without audit won't go to production — launchpad TVL too high.







