Token Sale Landing Page Development
Token sale landing—not just marketing page. It's a web3 app interacting with token sale smart contract, handling crypto payments, managing whitelist, and must work reliably at high load (when thousands of users arrive simultaneously at round opening).
Smart Contract
Landing is frontend to contract. Design contract first.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
contract TokenSale is Ownable2Step, ReentrancyGuard, Pausable {
using SafeERC20 for IERC20;
IERC20 public immutable saleToken;
IERC20 public immutable paymentToken; // USDC
uint256 public immutable tokenPrice; // USDC per token, 6 decimals
uint256 public immutable hardCap; // total tokens for sale
uint256 public immutable minPurchase; // per wallet min
uint256 public immutable maxPurchase; // per wallet max
uint256 public saleStart;
uint256 public saleEnd;
bytes32 public whitelistMerkleRoot;
bool public whitelistRequired;
uint256 public totalSold;
mapping(address => uint256) public purchased;
event TokensPurchased(address indexed buyer, uint256 usdcAmount, uint256 tokenAmount);
event SaleConfigured(uint256 start, uint256 end, bool whitelistRequired);
constructor(
address _saleToken,
address _paymentToken,
uint256 _tokenPrice,
uint256 _hardCap,
uint256 _minPurchase,
uint256 _maxPurchase,
address _owner
) Ownable2Step() {
saleToken = IERC20(_saleToken);
paymentToken = IERC20(_paymentToken);
tokenPrice = _tokenPrice;
hardCap = _hardCap;
minPurchase = _minPurchase;
maxPurchase = _maxPurchase;
_transferOwnership(_owner);
}
function configureSale(
uint256 _start,
uint256 _end,
bytes32 _merkleRoot,
bool _whitelistRequired
) external onlyOwner {
saleStart = _start;
saleEnd = _end;
whitelistMerkleRoot = _merkleRoot;
whitelistRequired = _whitelistRequired;
emit SaleConfigured(_start, _end, _whitelistRequired);
}
function buy(
uint256 usdcAmount,
bytes32[] calldata merkleProof
) external nonReentrant whenNotPaused {
require(block.timestamp >= saleStart, "Sale not started");
require(block.timestamp <= saleEnd, "Sale ended");
if (whitelistRequired) {
bytes32 leaf = keccak256(abi.encodePacked(msg.sender));
require(
MerkleProof.verify(merkleProof, whitelistMerkleRoot, leaf),
"Not whitelisted"
);
}
uint256 tokenAmount = usdcAmount * 10**18 / tokenPrice;
require(usdcAmount >= minPurchase, "Below min purchase");
require(purchased[msg.sender] + tokenAmount <= maxPurchase, "Exceeds max per wallet");
require(totalSold + tokenAmount <= hardCap, "Hard cap reached");
purchased[msg.sender] += tokenAmount;
totalSold += tokenAmount;
paymentToken.safeTransferFrom(msg.sender, address(this), usdcAmount);
saleToken.safeTransfer(msg.sender, tokenAmount);
emit TokensPurchased(msg.sender, usdcAmount, tokenAmount);
}
function withdrawFunds(address to) external onlyOwner {
uint256 balance = paymentToken.balanceOf(address(this));
paymentToken.safeTransfer(to, balance);
}
function withdrawUnsoldTokens(address to) external onlyOwner {
require(block.timestamp > saleEnd, "Sale not ended");
uint256 balance = saleToken.balanceOf(address(this));
saleToken.safeTransfer(to, balance);
}
}
Merkle Tree whitelist: instead of storing each address on-chain (expensive), store only merkle root. Proof generated off-chain and passed on purchase. For 10,000 whitelist addresses—~$1000+ gas savings on deployment.
Frontend: Web3 Integration
Wallet Connect and Sale Status
import { useReadContracts, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { formatUnits, parseUnits } from "viem";
const SALE_ABI = [...] as const;
function useSaleData(saleAddress: `0x${string}`) {
const result = useReadContracts({
contracts: [
{ address: saleAddress, abi: SALE_ABI, functionName: "saleStart" },
{ address: saleAddress, abi: SALE_ABI, functionName: "saleEnd" },
{ address: saleAddress, abi: SALE_ABI, functionName: "totalSold" },
{ address: saleAddress, abi: SALE_ABI, functionName: "hardCap" },
{ address: saleAddress, abi: SALE_ABI, functionName: "tokenPrice" },
{ address: saleAddress, abi: SALE_ABI, functionName: "whitelistRequired" },
],
query: { refetchInterval: 10_000 }, // update every 10 sec
});
const [start, end, totalSold, hardCap, tokenPrice, whitelistRequired] =
result.data ?? [];
const now = Date.now() / 1000;
const saleStatus = !start?.result ? "loading" :
now < Number(start.result) ? "upcoming" :
now > Number(end?.result) ? "ended" :
"active";
const progress = totalSold?.result && hardCap?.result
? Number(totalSold.result * 100n / hardCap.result)
: 0;
return { saleStatus, progress, tokenPrice: tokenPrice?.result, whitelistRequired: whitelistRequired?.result };
}
Purchase with USDC Approve
USDC requires approve before buy. Pattern: first check allowance, if insufficient—first step approve, second step—buy:
function BuyFlow({ saleAddress, usdcAddress, amount }) {
const { address } = useAccount();
const [step, setStep] = useState<"approve" | "buy" | "done">("approve");
const { data: allowance } = useReadContract({
address: usdcAddress,
abi: ERC20_ABI,
functionName: "allowance",
args: [address, saleAddress],
query: { enabled: !!address },
});
const needsApprove = !allowance || allowance < parseUnits(amount, 6);
const { writeContract: approve, data: approveTx } = useWriteContract();
const { writeContract: buy, data: buyTx } = useWriteContract();
const { isSuccess: approveSuccess } = useWaitForTransactionReceipt({ hash: approveTx });
useEffect(() => {
if (approveSuccess) setStep("buy");
}, [approveSuccess]);
// Merkle proof for whitelist (if needed)
const merkleProof = useMerkleProof(address);
const handleApprove = () => {
approve({
address: usdcAddress,
abi: ERC20_ABI,
functionName: "approve",
args: [saleAddress, parseUnits(amount, 6)],
});
};
const handleBuy = () => {
buy({
address: saleAddress,
abi: SALE_ABI,
functionName: "buy",
args: [parseUnits(amount, 6), merkleProof ?? []],
});
};
if (needsApprove && step === "approve") {
return <Button onClick={handleApprove}>Allow USDC (step 1/2)</Button>;
}
return <Button onClick={handleBuy}>Buy tokens (step 2/2)</Button>;
}
Merkle Tree Whitelist: Generation and Management
import { MerkleTree } from "merkletreejs";
import { keccak256, encodePacked } from "viem";
function generateMerkleTree(addresses: string[]) {
const leaves = addresses.map((addr) =>
keccak256(encodePacked(["address"], [addr as `0x${string}`]))
);
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });
const root = tree.getHexRoot();
return { tree, root };
}
function getMerkleProof(tree: MerkleTree, address: string): `0x${string}`[] {
const leaf = keccak256(encodePacked(["address"], [address as `0x${string}`]));
return tree.getHexProof(leaf) as `0x${string}`[];
}
// API endpoint: GET /api/whitelist/proof?address=0x...
async function getProofForAddress(req, res) {
const { address } = req.query;
const whitelist = await loadWhitelistFromDB(); // your logic
const { tree } = generateMerkleTree(whitelist);
const proof = getMerkleProof(tree, address);
if (proof.length === 0) {
return res.status(403).json({ error: "Not whitelisted" });
}
res.json({ proof, address });
}
Real-time Updates and Queue
On round opening—load spike. Frontend shouldn't RPC node query every second for thousands of users.
Solution: WebSocket or SSE from backend → clients. Backend subscribes to contract events, on TokensPurchased pushes updated data to all connected clients.
// Backend: pusher or own WebSocket
import { WebSocket } from "ws";
const wss = new WebSocket.Server({ port: 3001 });
const clients = new Set<WebSocket>();
// Listen to contract events
publicClient.watchContractEvent({
address: SALE_ADDRESS,
abi: SALE_ABI,
eventName: "TokensPurchased",
onLogs: async (logs) => {
const totalSold = await publicClient.readContract({
address: SALE_ADDRESS,
abi: SALE_ABI,
functionName: "totalSold",
});
const update = JSON.stringify({ type: "sale_update", totalSold: totalSold.toString() });
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) client.send(update);
});
},
});
Important UX Details
Countdown to start: reverse countdown to saleStart. Don't use server time—synchronize with block.timestamp via contract.
Progress bar: totalSold / hardCap * 100%. Updates real-time via WebSocket.
Amount calculation: user enters USDC—show how many tokens they get, vice versa. Real price from contract, not hardcoded.
Gasless approve via Permit: if token supports EIP-2612, can combine approve + buy into one transaction via permit + buy pattern—improves UX.
Mobile responsive: most crypto users buy from phone. Large buttons, MetaMask Mobile deep link.
Stack
| Component | Technology |
|---|---|
| Frontend | Next.js 14 + TypeScript |
| Web3 | wagmi v2 + viem + RainbowKit |
| Whitelist | Merkle Tree + API endpoint |
| Real-time | WebSocket or Pusher |
| Analytics | custom events + Dune Dashboard |
| Hosting | Vercel + Cloudflare |
Development timeline: 2–3 weeks for full-stack landing with smart contract, whitelist, real-time updates. Design and marketing content—separately.







