Implementing a Staking Interface on a Website
A staking interface is a form and dashboard for depositing tokens into a contract with reward accrual. The user deposits tokens, sees accumulated rewards in real-time, can claim and withdraw. Under the hood — approve + stake, periodic reward calculation, unstaking with optional lock period.
Typical Staking Contract ABI
// Standard Synthetix-like staking
const STAKING_ABI = parseAbi([
// View
'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)', // optional
'function unlockTime(address) view returns (uint256)', // optional
// Write
'function stake(uint256 amount) nonpayable',
'function withdraw(uint256 amount) nonpayable',
'function getReward() nonpayable',
'function exit() nonpayable', // withdraw all + getReward
]);
APR/APY Calculation
APR is calculated from rewardRate (tokens per second) and totalSupply (total staked):
import { formatUnits } from 'viem';
export function calculateAPR(
rewardRate: bigint, // reward tokens per second
totalSupply: bigint, // staked tokens
stakingTokenPrice: number, // USD
rewardTokenPrice: number, // USD
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;
}
// APY with compounding (if claiming once per day and restaking)
export function aprToApy(apr: number, compoundsPerYear = 365): number {
return (Math.pow(1 + apr / 100 / compoundsPerYear, compoundsPerYear) - 1) * 100;
}
Staking State Hook
// hooks/useStakingState.ts
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: [
// Global state
{ address: STAKING, abi: STAKING_ABI, functionName: 'totalSupply' },
{ address: STAKING, abi: STAKING_ABI, functionName: 'rewardRate' },
{ address: STAKING, abi: STAKING_ABI, functionName: 'periodFinish' },
// User token balance
{ address: STAKE_TOKEN, abi: erc20Abi, functionName: 'balanceOf', args: [address!] },
{ address: STAKE_TOKEN, abi: erc20Abi, functionName: 'allowance', args: [address!, STAKING] },
// User position
{ address: STAKING, abi: STAKING_ABI, functionName: 'balanceOf', args: [address!] },
{ address: STAKING, abi: STAKING_ABI, functionName: 'earned', args: [address!] },
],
query: {
enabled: !!address,
refetchInterval: 12_000, // every block
},
});
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,
// Need approve?
needsApprove: (amount: bigint) => allowance < amount,
};
}
Approve + Stake in One Flow
// hooks/useStakeAction.ts
import { useWriteContract, useWaitForTransactionReceipt } 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 Reward Counter
earned() updates on each call, but constant contract reads are expensive. Intermediate values are interpolated locally:
// hooks/useEarnedRealtime.ts
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));
// Simplified extrapolation: earned + staked * rewardPerTokenPerSec * elapsed
const delta = (stakedBalance * rewardPerToken * elapsed) / BigInt(1e18);
setDisplayed(earnedOnChain + delta);
}, 100);
return () => clearInterval(interval);
}, [earnedOnChain, stakedBalance, rewardPerToken, lastUpdatedAt]);
return displayed;
}
The counter ticks every 100ms — visually smooth, no RPC load.
UI Component
export function StakingWidget() {
const { totalSupply, rewardRate, walletBalance, stakedBalance, earned, isActive } = useStakingState();
const { stake, step } = useStakeAction();
const { withdraw } = useWithdrawAction();
const { claimReward } = useClaimAction();
const [stakeAmount, setStakeAmount] = useState('');
const apr = useMemo(() => calculateAPR(rewardRate, totalSupply, stakingTokenPrice, rewardTokenPrice), [rewardRate, totalSupply]);
return (
<div className="space-y-6 rounded-2xl border border-white/10 bg-neutral-900 p-6">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<Stat label="APR" value={`${apr.toFixed(1)}%`} highlight />
<Stat label="Total Staked" value={`${formatUnits(totalSupply, 18)} TOKEN`} />
<Stat label="Active" value={isActive ? 'Yes' : 'Ended'} />
</div>
<StakeForm amount={stakeAmount} onChange={setStakeAmount} max={walletBalance} onSubmit={stake} step={step} />
<UserPosition staked={stakedBalance} earned={earned} onClaim={claimReward} onWithdraw={withdraw} />
</div>
);
}
Timeframe: staking interface with standard contract (approve + stake + claim + withdraw), APR calculation and real-time reward counter — 3–5 days.







