Creating a Staking Interface with Real-Time Rewards
A user visits your site, connects a wallet via MetaMask, and sees an APR of 18.5%. The 'Stake' button is grayed out—allowance is less than the requested amount. A typical DeFi pain point: approve and stake are two separate actions that break UX if you don't handle automatic approval. We combine them into a single flow: first check allowance, send an approve transaction if needed, then stake. The process takes two transactions, but the user sees only one—we hide the approve behind the scenes or ask them to sign both at once. A real-time rewards counter updating every 100 ms further keeps users on the page: rewards grow before their eyes.
A staking interface is a form and dashboard for depositing tokens into a contract that accrues rewards. The user deposits tokens, sees accumulated rewards in real time, and can claim and withdraw. Under the hood: approve + stake, periodic reward calculation, unstaking with possible lock periods. We use a standard Synthetix-like contract that can be easily adapted to any ABI (Wikipedia).
Typical Approve and Stake Problems
The main problem is that users don't understand why they need to approve first and then stake. We solve this via useStakeAction, which automatically checks allowance and triggers approval before staking if allowance is insufficient. The second problem is the user forgetting to sign the approve in MetaMask, causing the transaction to hang. We show status: 'Approve required', 'Approving...', 'Staking...'. Third is gas limits: approve on ERC-20 costs ~45k gas, stake ~150k gas. If the wallet balance is low, the transaction may fail. We warn about minimum balance requirements.
Tech Stack and Architecture
At the core, we use wagmi/viem for contract interactions. All state is read via useReadContracts with a 12-second interval. For local reward interpolation we use useEarnedRealtime. UI is built with Tailwind CSS; no third-party component libraries.
Typical staking contract 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 calculation:
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;
}
Staking state hook:
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 in one flow:
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 };
}
Real-time rewards counter:
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;
}
Comparison: Direct RPC Polling vs Local Interpolation
| Parameter | Poll Every 12s | Local Interpolation Every 100ms |
|---|---|---|
| Reward updates | jumps of ~0.06 token/block | smooth increment of ~0.001 token/s |
| RPC load | ~360 requests/hour | 0 additional requests |
| User perception | erratic, low trust | natural growth, high engagement |
| Smoothness multiplier | 1x | 120x smoother |
For instance, local interpolation is 120 times smoother than direct RPC polling, providing a vastly superior user experience.
Step-by-Step Guide to Implement Staking Interface
- Get contract ABI: Extract ABI from your staking contract (Synthetix-like).
- Set up frontend: Create a Next.js project with wagmi and Tailwind CSS.
- Read contract state: Use
useReadContractsto fetch balances, rewards, and rates. - Implement approve+stake flow: Use
useWriteContractto send transactions, handle approvals automatically. - Display real-time rewards: Use
useEarnedRealtimeto interpolate rewards between blockchain polls. - Test on testnet: Deploy to Goerli or Sepolia, verify functionality.
- Deploy to mainnet: After audits and testing, deploy to production.
What's Included in Our Work
| Stage | Deliverable | Duration (days) |
|---|---|---|
| Analysis | Staking contract spec, ABI sign-off | 1–2 |
| Design | UX scenarios, hook & component design | 1 |
| Development | Approve-stake flow, dashboard, rewards counter, tests | 2–3 |
| Integration | Testnet deployment, debugging, gas optimization | 1 |
| Deployment | Vercel deploy, env setup, documentation | 0.5 |
| Support | 2 weeks free monitoring & bug fixes | — |
How is APY calculated with compound interest?
If users claim rewards daily and restake them, the effective yield exceeds APR. We use the standard formula: APY = (1 + APR/n)^n - 1, where n is the number of compounding periods per year. By default, n=365 (daily compounding). At APR 18.5%, APY becomes about 20.3%. Both values are displayed in the interface.How Long Until Delivery?
A basic staking interface with a standard contract (approve + stake + claim + withdraw), APR calculation, and real-time rewards counter takes 3 to 5 days. If you need custom lock periods, multi-pools, or oracle integration (Chainlink, Pyth), the timeline extends to 7–10 days. Pricing is determined individually—contact us for an estimate. By choosing our pre-built solution, you save up to 50% compared to developing from scratch (typical cost $3,000–$5,000).
Why Is the Real-Time Rewards Counter Important for UX?
Without interpolation, users see reward jumps every 12 seconds (block interval). This erodes trust—rewards seem uneven. Our useEarnedRealtime extrapolates values between requests, creating a smooth increase. Compare: interpolation yields ~0.001 token/s growth versus jumps of ~0.06 token/block. The visual perception changes drastically—local interpolation is 120x smoother than direct RPC polling. Get a consultation on implementing this for your contract.
What You Get
We deliver a ready-to-use staking interface with source code, deployment documentation, and 2 weeks of support. We guarantee compatibility with Ethereum, Polygon, BSC, and any EVM network. With 5 years of experience in DeFi contract development and over 30 projects delivered, contact us to discuss your project.







