Token Sale Dashboard Development
Token sale dashboard is not a landing page with "buy" button. It's interface that must work flawlessly exactly when load peaks: first hours after sales open. Tens of thousands users simultaneously connect wallets, check whitelist, approve tokens, send transactions—and wait. Any UI/UX error at this moment costs lost sales and reputation damage.
Smart Contract: What to Understand
Dashboard is frontend to sale contract. Understand contract fully, not partially. Typical sale contract includes:
Sale parameters:
struct SaleConfig {
uint256 startTime;
uint256 endTime;
uint256 tokenPrice; // price in USD/ETH per 1 token
uint256 minPurchase; // min buy per wallet
uint256 maxPurchase; // max per wallet
uint256 hardCap; // total hardcap
uint256 softCap; // if not reached—refund
address paymentToken; // USDC/USDT/ETH (address(0) for ETH)
bool whitelistEnabled;
bytes32 merkleRoot; // for whitelist verification
}
Key events:
event TokensPurchased(address indexed buyer, uint256 paymentAmount, uint256 tokenAmount);
event SaleStarted(uint256 startTime, uint256 endTime);
event HardCapReached(uint256 totalRaised);
event RefundClaimed(address indexed buyer, uint256 amount);
Dashboard must listen to events real-time and update UI without page reload.
Dashboard Architecture
Sale State: State Machine
Sale contract goes through states. UI displays each correctly:
Not Started → Active → Ended (Success) → Distribution
→ Ended (Failed) → Refund Available
For WhitelistOnly:
Whitelist Round → Public Round → Ended
type SalePhase =
| 'not_started'
| 'whitelist_only'
| 'public'
| 'ended_success'
| 'ended_failed'
| 'distribution'
| 'refund_available';
function getSalePhase(
startTime: bigint,
endTime: bigint,
totalRaised: bigint,
softCap: bigint,
hardCap: bigint,
now: bigint
): SalePhase {
if (now < startTime) return 'not_started';
if (now >= startTime && now < endTime && totalRaised < hardCap) return 'public';
if (now >= endTime && totalRaised >= softCap) return 'ended_success';
if (now >= endTime && totalRaised < softCap) return 'ended_failed';
return 'ended_failed';
}
Whitelist Verification via Merkle Proof
Merkle tree verifies address is in whitelist without storing full list on-chain:
import { MerkleTree } from 'merkletreejs';
import { keccak256 } from 'ethers';
function buildMerkleTree(whitelist: string[]): MerkleTree {
const leaves = whitelist.map(addr =>
keccak256(Buffer.from(addr.toLowerCase().slice(2), 'hex'))
);
return new MerkleTree(leaves, keccak256, { sortPairs: true });
}
function getMerkleProof(tree: MerkleTree, address: string): string[] {
const leaf = keccak256(Buffer.from(address.toLowerCase().slice(2), 'hex'));
return tree.getHexProof(leaf);
}
async function checkWhitelistStatus(address: string): Promise<{
isWhitelisted: boolean;
proof: string[];
}> {
const proof = getMerkleProof(merkleTree, address);
const isWhitelisted = merkleTree.verify(
proof,
keccak256(Buffer.from(address.toLowerCase().slice(2), 'hex')),
merkleTree.getRoot()
);
return { isWhitelisted, proof };
}
Real-Time Data: Polling vs Events
Event subscription via WebSocket — optimal for real-time progress:
const provider = new ethers.WebSocketProvider(WS_RPC_URL);
const saleContract = new ethers.Contract(SALE_ADDRESS, SALE_ABI, provider);
saleContract.on('TokensPurchased', (buyer, paymentAmount, tokenAmount, event) => {
setTotalRaised(prev => prev + paymentAmount);
setParticipantCount(prev => prev + 1);
if (buyer.toLowerCase() === userAddress.toLowerCase()) {
setUserAllocation(prev => prev + tokenAmount);
}
});
Polling as fallback — WebSocket unstable. Reserve polling every 15 seconds for critical data.
Optimize RPC calls via Multicall:
const multicall = new Contract(MULTICALL_ADDRESS, MULTICALL_ABI, provider);
const [totalRaised, hardCap, userPurchased, saleEnded] =
await multicall.aggregate([
{ target: SALE_ADDRESS, callData: iface.encodeFunctionData('totalRaised') },
{ target: SALE_ADDRESS, callData: iface.encodeFunctionData('hardCap') },
{ target: SALE_ADDRESS, callData: iface.encodeFunctionData('purchased', [userAddress]) },
{ target: SALE_ADDRESS, callData: iface.encodeFunctionData('saleEnded') },
]);
Purchase: UX Flow
Multi-Currency Payments
Most sales accept multiple tokens. Check approval for each:
async function handlePurchase(
paymentToken: string, // USDC/USDT/address(0) for ETH
paymentAmount: bigint,
proof: string[]
) {
if (paymentToken !== ethers.ZeroAddress) {
const allowance = await erc20.allowance(userAddress, SALE_ADDRESS);
if (allowance < paymentAmount) {
setStep('approving');
const approveTx = await erc20.approve(SALE_ADDRESS, paymentAmount);
await approveTx.wait();
}
}
// Simulate before sending
setStep('simulating');
try {
await saleContract.buy.staticCall(paymentAmount, proof, { value: ethValue });
} catch (err) {
setError(parseContractError(err));
return;
}
// Send transaction
setStep('confirming');
const tx = await saleContract.buy(paymentAmount, proof, { value: ethValue });
setStep('waiting');
const receipt = await tx.wait();
setStep('success');
}
Transaction simulation before send (staticCall) is mandatory. Shows expected result, catches errors without gas waste.
Gas Estimation and EIP-1559
async function estimateGasWithBuffer(tx: ContractTransaction) {
const estimated = await provider.estimateGas(tx);
return (estimated * 120n) / 100n; // +20% buffer
}
async function getFeeData() {
const feeData = await provider.getFeeData();
return {
maxFeePerGas: (feeData.maxFeePerGas! * 120n) / 100n,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas!
};
}
UI Components
Progress Bar
Hardcap progress—central dashboard element. Must update real-time without flicker:
function SaleProgress({ totalRaised, hardCap, softCap }: SaleProgressProps) {
const progress = Number((totalRaised * 100n) / hardCap);
const softCapProgress = Number((softCap * 100n) / hardCap);
return (
<div className="sale-progress">
<div className="progress-bar-container">
<div
className="progress-fill"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
<div
className="softcap-marker"
style={{ left: `${softCapProgress}%` }}
title={`Soft Cap: ${formatUSD(softCap)}`}
/>
</div>
<div className="progress-labels">
<span>{formatUSD(totalRaised)} raised</span>
<span>{progress.toFixed(1)}%</span>
<span>Hard Cap: {formatUSD(hardCap)}</span>
</div>
</div>
);
}
Countdown Timer
Sync with blockchain time, not system clock:
function useBlockchainCountdown(targetTimestamp: bigint) {
const [timeLeft, setTimeLeft] = useState<number>(0);
const { data: blockNumber } = useBlockNumber({ watch: true });
useEffect(() => {
const now = BigInt(Math.floor(Date.now() / 1000));
const diff = Number(targetTimestamp - now);
setTimeLeft(Math.max(0, diff));
}, [blockNumber, targetTimestamp]); // update on new block
return timeLeft;
}
Performance at Peak Load
First sales minutes—maximum RPC and frontend load. Preparation:
- RPC rate limits: public nodes fail. Need Alchemy/QuickNode enterprise or own node
- CDN for static: JS bundle, images via Cloudflare
- Optimistic UI: show assumed status before confirmation
- Queue visualization: if hot—show transaction queue, pending count
- Caching: static data (tokenomics, allocation table)—cache, don't request every time







