Implementing Token Launchpad (IDO/ICO) on a Website
Token Launchpad is an interface for public token sales. Users come, deposit ETH/USDC, and receive token allocation. Under the hood: presale smart contract, UI with timer, collection progress, personal limits, whitelist mechanism, and claiming after TGE.
This is a complex frontend task: many states (before start, active sale, between rounds, claiming), different scenarios for whitelist and public participants, critical precision in token calculations.
Launchpad Phases
Upcoming → Whitelist Round → Public Round → Ended → Claiming
Each phase has its own UI state, available actions, and smart contract logic.
Reading Presale Contract State
// lib/presale.ts
import { createPublicClient, http, parseAbi } from 'viem';
import { mainnet } from 'viem/chains';
const PRESALE_ABI = parseAbi([
'function salePhase() view returns (uint8)', // 0=upcoming, 1=whitelist, 2=public, 3=ended
'function startTime() view returns (uint256)',
'function endTime() view returns (uint256)',
'function claimStartTime() view returns (uint256)',
'function hardCap() view returns (uint256)',
'function softCap() view returns (uint256)',
'function totalRaised() view returns (uint256)',
'function tokenPrice() view returns (uint256)', // wei per token
'function minContribution() view returns (uint256)',
'function maxContribution() view returns (uint256)',
'function contributions(address) view returns (uint256)',
'function tokenAllocation(address) view returns (uint256)',
'function claimed(address) view returns (bool)',
'function contribute(bytes32[] proof) payable',
'function contributePublic() payable',
'function claim() nonpayable',
'function refund() nonpayable',
]);
export async function getPresaleState(
contractAddress: `0x${string}`,
userAddress?: `0x${string}`,
) {
const client = createPublicClient({ chain: mainnet, transport: http() });
const base = await client.multicall({
contracts: [
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'salePhase' },
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'startTime' },
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'endTime' },
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'claimStartTime' },
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'hardCap' },
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'softCap' },
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'totalRaised' },
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'tokenPrice' },
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'minContribution' },
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'maxContribution' },
],
});
const userCalls = userAddress ? await client.multicall({
contracts: [
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'contributions', args: [userAddress] },
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'tokenAllocation', args: [userAddress] },
{ address: contractAddress, abi: PRESALE_ABI, functionName: 'claimed', args: [userAddress] },
],
}) : null;
return {
phase: base[0].result as number,
startTime: Number(base[1].result as bigint),
endTime: Number(base[2].result as bigint),
claimStartTime: Number(base[3].result as bigint),
hardCap: base[4].result as bigint,
softCap: base[5].result as bigint,
totalRaised: base[6].result as bigint,
tokenPrice: base[7].result as bigint,
minContribution: base[8].result as bigint,
maxContribution: base[9].result as bigint,
userContribution: userCalls?.[0].result as bigint ?? 0n,
userAllocation: userCalls?.[1].result as bigint ?? 0n,
userClaimed: userCalls?.[2].result as boolean ?? false,
};
}
Countdown Timer
// components/CountdownTimer.tsx
import { useEffect, useState } from 'react';
interface TimeLeft {
days: number;
hours: number;
minutes: number;
seconds: number;
}
function calcTimeLeft(targetTs: number): TimeLeft {
const diff = Math.max(0, targetTs * 1000 - Date.now());
return {
days: Math.floor(diff / 86_400_000),
hours: Math.floor((diff % 86_400_000) / 3_600_000),
minutes: Math.floor((diff % 3_600_000) / 60_000),
seconds: Math.floor((diff % 60_000) / 1_000),
};
}
export function CountdownTimer({ targetTs, label }: { targetTs: number; label: string }) {
const [timeLeft, setTimeLeft] = useState<TimeLeft>(calcTimeLeft(targetTs));
useEffect(() => {
const interval = setInterval(() => setTimeLeft(calcTimeLeft(targetTs)), 1000);
return () => clearInterval(interval);
}, [targetTs]);
return (
<div className="text-center">
<p className="mb-3 text-sm text-neutral-400">{label}</p>
<div className="flex items-center gap-3">
{[
{ value: timeLeft.days, label: 'days' },
{ value: timeLeft.hours, label: 'hours' },
{ value: timeLeft.minutes, label: 'minutes' },
{ value: timeLeft.seconds, label: 'seconds' },
].map(({ value, label }) => (
<div key={label} className="min-w-[60px] rounded-xl bg-neutral-800 p-3 text-center">
<span className="block text-2xl font-bold tabular-nums">
{String(value).padStart(2, '0')}
</span>
<span className="text-xs text-neutral-500">{label}</span>
</div>
))}
</div>
</div>
);
}
Token Allocation Calculator
// components/ContributionCalculator.tsx
import { formatEther, formatUnits, parseEther } from 'viem';
interface Props {
tokenPrice: bigint; // wei per token
minContrib: bigint;
maxContrib: bigint;
userContrib: bigint;
tokenDecimals?: number;
}
export function ContributionCalculator({
tokenPrice, minContrib, maxContrib, userContrib, tokenDecimals = 18,
}: Props) {
const [ethAmount, setEthAmount] = useState('');
const ethWei = ethAmount ? parseEther(ethAmount) : 0n;
const tokensReceived = tokenPrice > 0n ? (ethWei * BigInt(10 ** tokenDecimals)) / tokenPrice : 0n;
const remaining = maxContrib - userContrib;
const canContribute = ethWei >= minContrib && ethWei <= remaining;
return (
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm text-neutral-400">Contribution Amount (ETH)</label>
<input
type="number"
step="0.01"
min={formatEther(minContrib)}
max={formatEther(remaining)}
value={ethAmount}
onChange={e => setEthAmount(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-neutral-800 px-4 py-2.5"
/>
<div className="mt-1 flex justify-between text-xs text-neutral-500">
<span>Min: {formatEther(minContrib)} ETH</span>
<span>Remaining: {formatEther(remaining)} ETH</span>
</div>
</div>
<div className="rounded-lg bg-neutral-800/50 p-4">
<div className="flex justify-between text-sm">
<span className="text-neutral-400">You will receive:</span>
<span className="font-semibold">
{formatUnits(tokensReceived, tokenDecimals)} TOKEN
</span>
</div>
<div className="mt-2 flex justify-between text-sm">
<span className="text-neutral-400">Already contributed:</span>
<span>{formatEther(userContrib)} ETH</span>
</div>
</div>
<ContributeButton disabled={!canContribute} ethAmount={ethWei} />
</div>
);
}
Contribution Transaction with Merkle Proof
// hooks/useContribute.ts
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { getMerkleProof, isWhitelisted } from '@/lib/merkle';
export function useContribute(contractAddress: `0x${string}`, phase: number) {
const { address } = useAccount();
const { writeContract, data: txHash, isPending } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash: txHash });
const contribute = async (ethValue: bigint) => {
if (!address) return;
if (phase === 1) {
// Whitelist round — need proof
if (!isWhitelisted(address)) {
throw new Error('Address not in whitelist');
}
const proof = getMerkleProof(address);
writeContract({
address: contractAddress,
abi: PRESALE_ABI,
functionName: 'contribute',
args: [proof],
value: ethValue,
});
} else {
writeContract({
address: contractAddress,
abi: PRESALE_ABI,
functionName: 'contributePublic',
value: ethValue,
});
}
};
return { contribute, txHash, isPending, isConfirming, isSuccess };
}
Funding Progress and Progress Bar
function FundingProgress({ raised, hardCap, softCap }: { raised: bigint; hardCap: bigint; softCap: bigint }) {
const raisedEth = parseFloat(formatEther(raised));
const hardCapEth = parseFloat(formatEther(hardCap));
const softCapEth = parseFloat(formatEther(softCap));
const progress = (raisedEth / hardCapEth) * 100;
const softCapPercent = (softCapEth / hardCapEth) * 100;
return (
<div>
<div className="mb-2 flex justify-between text-sm">
<span>{raisedEth.toFixed(2)} ETH raised</span>
<span>{progress.toFixed(1)}%</span>
</div>
<div className="relative h-3 overflow-hidden rounded-full bg-neutral-800">
<div
className="h-full rounded-full bg-gradient-to-r from-blue-600 to-violet-600 transition-all duration-500"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
{/* Soft cap marker */}
<div
className="absolute top-0 h-full w-0.5 bg-yellow-400"
style={{ left: `${softCapPercent}%` }}
/>
</div>
<div className="mt-1 flex justify-between text-xs text-neutral-500">
<span>Soft cap: {softCapEth} ETH</span>
<span>Hard cap: {hardCapEth} ETH</span>
</div>
</div>
);
}
Timeframe: UI with one round (public sale), timer and progress — 4–5 days. Full launchpad with whitelist round via merkle tree, two-phase sale, claiming and refund mechanism — 10–14 days.







