Реализация стейкинг-интерфейса: от approve до realtime-наград
Пользователь заходит на сайт, подключает кошелёк через MetaMask и видит APR 18.5%. Кнопка 'Stake' серая — allowance меньше запрашиваемой суммы. Типичная боль DeFi: approve и stake — два отдельных действия, которые ломают UX, если не предусмотреть автоматический approve. Мы собираем их в единый флоу: сначала проверяем allowance, при необходимости отправляем approve-транзакцию, затем stake. Весь процесс занимает две транзакции, но пользователь видит только одну — мы скрываем approve за кулисами или предлагаем подписать обе сразу. Realtime-счётчик наград, обновляющийся каждые 100 мс, дополнительно удерживает пользователя на странице: награды растут на глазах.
Стейкинг-интерфейс — форма и дашборд для депонирования токенов в контракт с начислением наград. Пользователь вносит токены, видит накопленные rewards в реальном времени, может клеймить и выводить. Под капотом — approve + stake, периодический расчёт наград, unstaking с возможным lock-периодом. Мы используем стандартный Synthetix-подобный контракт, который легко адаптируется под любой ABI.
Типичные проблемы одобрения и стейкинга
Главная проблема — пользователь не понимает, почему нужно сначала approve, а потом stake. Мы решаем это через useStakeAction, который автоматически проверяет allowance и запускает approve перед staking, если allowance недостаточен. Вторая проблема — пользователь забывает подписать approve в MetaMask, и транзакция зависает. Мы показываем статус: 'Approve required', 'Approving...', 'Staking...'. Третье — газовый лимит: approve по стандарту ERC-20 стоит ~45k gas, stake ~150k gas. Если баланс мал, транзакция может отвалиться. Мы предупреждаем о минимальном балансе.
Стек и архитектура
В основе — wagmi/viem для взаимодействия с контрактами. All state читается через useReadContracts с интервалом 12 секунд. Для локальной интерполяции наград используем useEarnedRealtime. UI — Tailwind CSS, компоненты без сторонних библиотек.
Типичный ABI стейкинг-контракта:
const STAKING_ABI = parseAbi([
'function totalSupply() view returns (uint256)',
'function balanceOf(address account) view returns (uint256)',
'function earned(address account) view returns (uint256)',
'function rewardRate() view returns (uint256)',
'function rewardPerToken() view returns (uint256)',
'function periodFinish() view returns (uint256)',
'function lockPeriod() view returns (uint256)',
'function unlockTime(address) view returns (uint256)',
'function stake(uint256 amount) nonpayable',
'function withdraw(uint256 amount) nonpayable',
'function getReward() nonpayable',
'function exit() nonpayable',
]);
Расчёт APR/APY:
import { formatUnits } from 'viem';
export function calculateAPR(
rewardRate: bigint,
totalSupply: bigint,
stakingTokenPrice: number,
rewardTokenPrice: number,
stakingDecimals = 18,
rewardDecimals = 18,
): number {
if (totalSupply === 0n) return 0;
const rewardPerYear =
(parseFloat(formatUnits(rewardRate, rewardDecimals)) * 31_536_000) * rewardTokenPrice;
const totalStakedUSD =
parseFloat(formatUnits(totalSupply, stakingDecimals)) * stakingTokenPrice;
return (rewardPerYear / totalStakedUSD) * 100;
}
export function aprToApy(apr: number, compoundsPerYear = 365): number {
return (Math.pow(1 + apr / 100 / compoundsPerYear, compoundsPerYear) - 1) * 100;
}
Хук состояния стейкинга:
import { useReadContracts } from 'wagmi';
import { erc20Abi, formatUnits } from 'viem';
const STAKING = process.env.NEXT_PUBLIC_STAKING_CONTRACT as `0x${string}`;
const STAKE_TOKEN = process.env.NEXT_PUBLIC_STAKE_TOKEN as `0x${string}`;
const REWARD_TOKEN = process.env.NEXT_PUBLIC_REWARD_TOKEN as `0x${string}`;
export function useStakingState() {
const { address } = useAccount();
const { data } = useReadContracts({
contracts: [
{ address: STAKING, abi: STAKING_ABI, functionName: 'totalSupply' },
{ address: STAKING, abi: STAKING_ABI, functionName: 'rewardRate' },
{ address: STAKING, abi: STAKING_ABI, functionName: 'periodFinish' },
{ address: STAKE_TOKEN, abi: erc20Abi, functionName: 'balanceOf', args: [address!] },
{ address: STAKE_TOKEN, abi: erc20Abi, functionName: 'allowance', args: [address!, STAKING] },
{ address: STAKING, abi: STAKING_ABI, functionName: 'balanceOf', args: [address!] },
{ address: STAKING, abi: STAKING_ABI, functionName: 'earned', args: [address!] },
],
query: {
enabled: !!address,
refetchInterval: 12_000,
},
});
const totalSupply = data?.[0].result as bigint ?? 0n;
const rewardRate = data?.[1].result as bigint ?? 0n;
const periodFinish = Number(data?.[2].result as bigint ?? 0n);
const walletBalance = data?.[3].result as bigint ?? 0n;
const allowance = data?.[4].result as bigint ?? 0n;
const stakedBalance = data?.[5].result as bigint ?? 0n;
const earned = data?.[6].result as bigint ?? 0n;
const isActive = periodFinish > Date.now() / 1000;
return {
totalSupply, rewardRate, walletBalance, allowance, stakedBalance, earned, isActive,
needsApprove: (amount: bigint) => allowance < amount,
};
}
Approve + Stake в одном флоу:
import { useWriteContract } from 'wagmi';
import { erc20Abi, parseUnits } from 'viem';
import { waitForTransactionReceipt } from '@wagmi/core';
import { config } from '@/lib/wagmi';
export function useStakeAction() {
const { writeContractAsync } = useWriteContract();
const [step, setStep] = useState<'idle' | 'approving' | 'staking' | 'done' | 'error'>('idle');
const [txHash, setTxHash] = useState<`0x${string}`>();
const { needsApprove } = useStakingState();
const stake = async (amount: string, decimals: number) => {
const amountWei = parseUnits(amount, decimals);
try {
if (needsApprove(amountWei)) {
setStep('approving');
const approveTx = await writeContractAsync({
address: STAKE_TOKEN, abi: erc20Abi, functionName: 'approve',
args: [STAKING, amountWei],
});
await waitForTransactionReceipt(config, { hash: approveTx });
}
setStep('staking');
const stakeTx = await writeContractAsync({
address: STAKING, abi: STAKING_ABI, functionName: 'stake',
args: [amountWei],
});
setTxHash(stakeTx);
setStep('done');
} catch (e) {
setStep('error');
throw e;
}
};
return { stake, step, txHash };
}
Realtime счётчик наград:
export function useEarnedRealtime(
earnedOnChain: bigint,
stakedBalance: bigint,
rewardPerToken: bigint,
lastUpdatedAt: number,
): bigint {
const [displayed, setDisplayed] = useState(earnedOnChain);
useEffect(() => {
if (stakedBalance === 0n) {
setDisplayed(earnedOnChain);
return;
}
const interval = setInterval(() => {
const elapsed = BigInt(Math.floor((Date.now() / 1000) - lastUpdatedAt));
const delta = (stakedBalance * rewardPerToken * elapsed) / BigInt(1e18);
setDisplayed(earnedOnChain + delta);
}, 100);
return () => clearInterval(interval);
}, [earnedOnChain, stakedBalance, rewardPerToken, lastUpdatedAt]);
return displayed;
}
Сравнение: прямой запрос vs локальная интерполяция
| Параметр | Запрос каждые 12 с | Локальная интерполяция каждые 100 мс |
|---|---|---|
| Обновление наград | скачками ~0.06 токена/блок | плавный прирост ~0.001 токена/сек |
| Нагрузка на RPC | ~360 запросов/час | 0 (дополнительных запросов нет) |
| Восприятие пользователем | неравномерное, недоверие | естественный рост, вовлечение |
Что входит в работу
| Этап | Результат | Срок, дней |
|---|---|---|
| Аналитика | Спецификация стейкинг-контракта, согласование ABI | 1–2 |
| Проектирование | UX-сценарии, подготовка хуков и компонентов | 1 |
| Разработка | Approve-stake flow, дашборд, счётчик наград, тесты | 2–3 |
| Интеграция | Подключение к тестовой сети, дебаг, gas-оптимизация | 1 |
| Деплой | Развёртывание на Vercel, настройка env, документация | 0,5 |
| Сопровождение | 2 недели бесплатного мониторинга, исправление багов | — |
Как рассчитывается APY с учётом сложного процента?
Если пользователь клеймит награды раз в день и рестейкает их, эффективная доходность выше APR. Мы используем стандартную формулу: APY = (1 + APR/n)^n - 1, где n — число периодов капитализации в год. По умолчанию n=365 (ежедневное реинвестирование). При APR 18.5% APY будет около 20.3%. В интерфейсе отображается оба значения.Как долго ждать результат?
Базовый стейкинг-интерфейс со стандартным контрактом (approve + stake + claim + withdraw), APR-расчётом и realtime счётчиком — от 3 до 5 дней. Если нужен кастомный lock-период, мультипулы или интеграция с ораклами (Chainlink, Pyth) — сроки увеличиваются до 7–10 дней. Стоимость рассчитывается индивидуально — пишите для оценки.
Почему realtime-счётчик наград важен для UX?
Без интерполяции пользователь видит скачки наград раз в 12 секунд (частота блоков). Это сбивает доверие — кажется, что награды начисляются неравномерно. Наш useEarnedRealtime экстраполирует значения между запросами, создавая плавный рост. Сравните: интерполяция даёт прирост 0.001 токена/сек против скачков ~0.06 токена/блок. Визуальное восприятие меняется кардинально. Получите консультацию по внедрению под ваш контракт.
Что в итоге
Мы поставляем готовый стейкинг-интерфейс с исходными кодами, документацией по развёртыванию и 2-недельной поддержкой. Гарантируем совместимость с Ethereum, Polygon, BSC и любыми EVM-сетями. У нас 5 лет опыта работы с DeFi-контрактами — свяжитесь для оценки вашего проекта.







