Разработка дашборда токенсейла
Токенсейл дашборд — это не лендинг с кнопкой "купить". Это интерфейс, который должен работать безотказно именно тогда, когда нагрузка максимальна: первые часы после открытия продаж. В этот момент десятки тысяч пользователей одновременно подключают кошельки, проверяют whitelist, одобряют токены, отправляют транзакции — и ждут. Любая ошибка в UI/UX в этот момент стоит потерянными продажами и репутационным ущербом.
Смарт-контракт: что должен делать и что проверять
Дашборд — это frontend к sale контракту. Понимать контракт нужно полностью, не частично. Типичный sale контракт включает:
Sale параметры:
struct SaleConfig {
uint256 startTime;
uint256 endTime;
uint256 tokenPrice; // цена в USD/ETH за 1 токен
uint256 minPurchase; // минимальная покупка
uint256 maxPurchase; // максимум на адрес
uint256 hardCap; // общий hardcap
uint256 softCap; // если не достигнут — refund
address paymentToken; // USDC/USDT/ETH (address(0) для ETH)
bool whitelistEnabled;
bytes32 merkleRoot; // root для whitelist верификации
}
Ключевые события для frontend:
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);
Дашборд должен слушать эти события реально-временно и обновлять UI без перезагрузки страницы.
Архитектура дашборда
Состояние продажи: state machine
Sale контракт проходит через состояния. UI должен корректно отображать каждое:
Not Started → Active → Ended (Success) → Distribution
→ Ended (Failed) → Refund Available
Для WhitelistOnly sale добавляется:
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,
whitelistEndTime: bigint,
totalRaised: bigint,
softCap: bigint,
hardCap: bigint,
now: bigint
): SalePhase {
if (now < startTime) return 'not_started';
if (now >= startTime && now < whitelistEndTime) return 'whitelist_only';
if (now >= whitelistEndTime && now < endTime && totalRaised < hardCap) return 'public';
if (now >= endTime && totalRaised >= softCap) return 'ended_success';
if (now >= endTime && totalRaised < softCap) return 'ended_failed';
if (totalRaised >= hardCap) return 'distribution'; // или ended_success
return 'ended_failed';
}
Whitelist верификация через Merkle Proof
Merkle tree позволяет верифицировать on-chain что адрес в whitelist, не храня весь список on-chain:
import { MerkleTree } from 'merkletreejs';
import { keccak256 } from 'ethers';
// Генерация дерева (делается off-chain, root публикуется в контракт)
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 });
}
// Генерация proof для конкретного адреса
function getMerkleProof(tree: MerkleTree, address: string): string[] {
const leaf = keccak256(Buffer.from(address.toLowerCase().slice(2), 'hex'));
return tree.getHexProof(leaf);
}
// В UI: проверяем whitelist статус и готовим proof для транзакции
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 данные: polling vs events
Event subscription через WebSocket — оптимально для real-time progress bar:
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);
// Если это наш пользователь — обновляем его allocation
if (buyer.toLowerCase() === userAddress.toLowerCase()) {
setUserAllocation(prev => prev + tokenAmount);
}
});
Polling как fallback — WebSocket соединения нестабильны. Резервный polling каждые 15 секунд для критических данных (totalRaised, hardCap progress).
Оптимизация RPC вызовов через Multicall:
// Один RPC вызов вместо пяти
const multicall = new Contract(MULTICALL_ADDRESS, MULTICALL_ABI, provider);
const [totalRaised, hardCap, userPurchased, isWhitelisted, 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('isWhitelisted', [userAddress]) },
{ target: SALE_ADDRESS, callData: iface.encodeFunctionData('saleEnded') },
]);
Покупка: UX flow
Мультивалютные платежи
Большинство sale принимают несколько токенов. Для каждого нужна approval проверка:
async function handlePurchase(
paymentToken: string, // USDC/USDT/address(0) для ETH
paymentAmount: bigint,
proof: string[]
) {
if (paymentToken !== ethers.ZeroAddress) {
// Проверяем allowance
const allowance = await erc20.allowance(userAddress, SALE_ADDRESS);
if (allowance < paymentAmount) {
// Approval транзакция
setStep('approving');
const approveTx = await erc20.approve(SALE_ADDRESS, paymentAmount);
await approveTx.wait();
}
}
// Симуляция перед отправкой — показываем ожидаемый результат
setStep('simulating');
try {
await saleContract.buy.staticCall(paymentAmount, proof, { value: ethValue });
} catch (err) {
setError(parseContractError(err));
return;
}
// Отправка транзакции
setStep('confirming');
const tx = await saleContract.buy(paymentAmount, proof, { value: ethValue });
setStep('waiting');
const receipt = await tx.wait();
setStep('success');
}
Transaction simulation перед отправкой (staticCall) — обязательна. Позволяет показать пользователю ожидаемый результат и поймать ошибки (cap exceeded, not whitelisted) без трат на gas.
Gas estimation и EIP-1559
async function estimateGasWithBuffer(tx: ContractTransaction) {
const estimated = await provider.estimateGas(tx);
// +20% buffer для safety
return (estimated * 120n) / 100n;
}
// EIP-1559: рекомендуемые gas параметры
async function getFeeData() {
const feeData = await provider.getFeeData();
return {
maxFeePerGas: (feeData.maxFeePerGas! * 120n) / 100n,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas!
};
}
UI компоненты
Progress Bar
Hardcap progress — центральный элемент интерфейса. Должен обновляться в реальном времени без мерцания:
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)}%` }}
/>
{/* Маркер soft cap */}
<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
Синхронизация с блокчейн временем, не с системными часами:
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]); // обновляем с каждым новым блоком
return timeLeft;
}
Производительность при пиковой нагрузке
Первые минуты продаж — максимальная нагрузка на RPC и frontend. Подготовка:
- RPC rate limits: публичные ноды упадут. Нужен Alchemy/QuickNode с enterprise лимитами или собственная нода
- CDN для статики: JS bundle, изображения — через Cloudflare
- Optimistic UI: показываем предполагаемый статус до подтверждения транзакции
- Queue visualization: если продажи очень горячие — показываем очередь транзакций, pending count
- Кэширование: static данные (tokenomics, allocation table) — кэшируем, не запрашиваем каждый раз







