Реалізація Token Launchpad (IDO/ICO) на веб-сайті
Token Launchpad — це інтерфейс для публічного продажу токенів. Користувачі приходять, вносять ETH/USDC, отримують розподіл токенів. Під капотом: смарт-контракт прессейлу, UI з таймером, прогресом збору, персональними лімітами, whitelist-механізмом та клеймингом після TGE.
Це складна фронтенд-задача: багато станів (до старту, активна продаж, між раундами, клейминг), різні сценарії для whitelist та public учасників, критична точність у розрахунках токенів.
Фази launchpad
Upcoming → Whitelist Round → Public Round → Ended → Claiming
Кожна фаза — своє UI-стан, свої доступні дії, своя логіка смарт-контракту.
Читання стану контракту прессейлу
// 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,
};
}
Таймер зворотного відліку
// 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: 'днів' },
{ value: timeLeft.hours, label: 'годин' },
{ value: timeLeft.minutes, label: 'хвилин' },
{ value: timeLeft.seconds, label: 'секунд' },
].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>
);
}
Калькулятор розподілу токенів
// 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">Сума внеску (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>Мін: {formatEther(minContrib)} ETH</span>
<span>Залишилось: {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">Ви отримаєте токенів:</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">Вже внесено:</span>
<span>{formatEther(userContrib)} ETH</span>
</div>
</div>
<ContributeButton disabled={!canContribute} ethAmount={ethWei} />
</div>
);
}
Транзакція внеску з 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 раунд — потрібен proof
if (!isWhitelisted(address)) {
throw new Error('Адреса не в 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 };
}
Відсоток заповнення та прогрес-бар
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 зібрано</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 */}
<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>
);
}
Часові рамки: UI з одним раундом (публічна продаж), таймером та прогресом — 4–5 днів. Повний launchpad з whitelist-раундом через merkle tree, двофазною продажею, клеймингом та refund-механізмом — 10–14 днів.







