How I designed, deployed, and connected two smart contracts into a live staking dApp — with MetaMask, RainbowKit, and Ethers.js v6
Introduction
Four weeks into the EtherAuthority Web3 Internship, and this is the project I'm most proud of — a fully functional DeFi Staking Platform deployed live on SecureChain AI (SCAI) Mainnet.
If you've ever used a staking protocol, you know the core idea: deposit tokens, earn yield over time, withdraw whenever you want. Simple on the surface — but building one from scratch, end-to-end, is a completely different story. You have to think about token approvals, reward math, UI state management, wallet connectivity, and security, all at the same time.
This post breaks down everything I built, every decision I made, and every lesson I learned. By the end, you'll understand not just what I built, but why I built it the way I did.
Live Demo: https://de-fi-staking-platform-vert.vercel.app
GitHub: https://github.com/feudcommon/DeFi-staking-platform
What Is DeFi Staking?
Before jumping into code, let's make sure we're on the same page.
Staking in DeFi means locking (or depositing) tokens into a smart contract in exchange for rewards — usually more tokens — over time. The reward rate is typically expressed as APR (Annual Percentage Rate) or APY (Annual Percentage Yield, which accounts for compounding).
The key components of any staking platform are:
- An ERC20 token — the token users stake
- A staking contract — holds deposited tokens and calculates/distributes rewards
- A frontend — the UI users interact with to stake, withdraw, and claim
All three have to work together seamlessly. That's what this project is about.
System Architecture
Here's the high-level picture of how everything connects:
User
↓
MetaMask Wallet (signs transactions)
↓
React Frontend (TypeScript + Ethers.js v6)
↓ ↓
ERC20 Token Staking Contract
Contract (stake, withdraw, claimRewards)
The frontend is the bridge between the user and the blockchain. Every action the user takes — staking, withdrawing, claiming — gets translated into a signed transaction and sent to the appropriate smart contract via Ethers.js.
Smart Contracts
Contract 1: ERC20 Token Contract
Deployed at: 0x4EF03D37c441BcF78D61367f4EE709027632d929
This is the token that users stake. It follows the standard ERC20 interface, which means it has:
-
balanceOf(address)— check how many tokens a wallet holds -
transfer(to, amount)— send tokens to another address -
approve(spender, amount)— authorize another contract to spend your tokens -
transferFrom(from, to, amount)— used by the staking contract to pull tokens from the user
The approve → transferFrom pattern is critical to understand in DeFi. Because smart contracts can't pull tokens from your wallet without permission, you first have to call approve() on the token contract to authorize the staking contract to move your tokens. Only then can the staking contract call transferFrom() to actually move them.
This two-step process confuses a lot of users. We'll come back to how I handled this in the frontend.
Contract 2: Staking Contract
Deployed at: 0x9aab06FAE31e082c26979afca9E53897dB57D50C
This is the core of the platform. It handles everything: receiving tokens, tracking who staked how much, calculating rewards, and processing withdrawals.
Here's the full public interface:
// Deposit ERC20 tokens into the staking contract
function stake(uint amount) external;
// Withdraw previously staked tokens
function Withdraw(uint amount) external;
// Claim accumulated reward tokens
function claimRewards() external;
// Returns staked amount, pending rewards, and APR in one call
function getDashboardData(address user) external view returns (uint, uint, uint);
Let's break down each function:
stake(uint amount)
When a user calls this, the contract:
- Calls
transferFrom(msg.sender, address(this), amount)to pull tokens from the user's wallet - Updates the user's staked balance in a mapping
- Records the timestamp (used for reward calculation later)
Withdraw(uint amount)
This lets users retrieve their tokens at any time — no lock-up period. The contract:
- Checks the user has enough staked
- Calculates any pending rewards before the withdrawal (important — you don't want users to lose unclaimed rewards)
- Transfers the requested amount back to the user
claimRewards()
Calculates the rewards earned since the last claim and sends them to the user. Rewards are typically calculated based on:
- How much the user has staked
- How long they've been staking
- The reward rate (APR)
A simple reward formula looks like this:
rewards = stakedAmount × APR × (timeElapsed / 365 days)
getDashboardData(address)
This is a view function (no gas cost, no transaction needed) that returns everything the frontend needs in a single call:
- Current staked amount
- Pending rewards
- Current APR
Batching these into one call instead of three separate calls is a small but meaningful optimization — it reduces RPC calls and makes the UI load faster.
Why No Lock-Up Period?
I made a deliberate design choice here: users can withdraw anytime. Many staking protocols enforce a lock-up period to incentivize longer commitments. I opted against this for a few reasons:
- It's simpler to audit and understand
- It gives users full control of their funds
- It's more honest — you're not trapping people's tokens
Security: Smart Contract Audit
Both contracts were audited by EtherAuthority (https://etherauthority.io).
An audit matters for a few key reasons:
- Reentrancy attacks — a classic DeFi exploit where a malicious contract calls back into your contract before the first execution finishes, draining funds
- Integer overflow/underflow — though Solidity 0.8+ handles this natively, it's still worth verifying
- Access control — making sure only the right addresses can call admin functions
- Reward manipulation — ensuring the math can't be gamed
Getting an audit before going live isn't optional if you care about your users' funds. It's table stakes.
Frontend: React + TypeScript + Ethers.js v6
Tech Stack
| Layer | Technology |
|---|---|
| Framework | React 18 |
| Language | TypeScript |
| Blockchain | Ethers.js v6 |
| Wallet Connectivity | RainbowKit + Wagmi + WalletConnect |
| Deployment | Vercel |
| Network | SCAI Mainnet (Chain ID: 34) |
Project Structure
src/
├── contracts/
│ └── config.ts # ABIs and contract addresses
├── App.tsx # Main component
├── App.css
└── index.tsx
The config.ts file is the single source of truth for contract addresses and ABIs. Keeping these centralized means if you redeploy a contract, you only update one file.
Connecting to SCAI Mainnet
SCAI Mainnet isn't a default network in MetaMask, so users have to add it. Here are the network details:
| Field | Value |
|---|---|
| Network Name | SCAI Mainnet |
| RPC URL | https://mainnet-rpc.scai.network |
| Chain ID | 34 |
| Currency Symbol | SCAI |
| Block Explorer | https://explorer.securechain.ai |
The frontend detects the connected chain ID and prompts users to switch if they're on the wrong network — no silent failures.
Multi-Wallet Support with RainbowKit
Instead of building wallet connection from scratch, I used RainbowKit — which gives you MetaMask, WalletConnect, and Coinbase Wallet support out of the box with a polished UI.
import { RainbowKitProvider, getDefaultWallets } from '@rainbow-me/rainbowkit';
import { configureChains, createConfig, WagmiConfig } from 'wagmi';
RainbowKit handles the wallet modal, connection state, and chain switching. You just drop in the provider and use the hooks.
The Auto-Approval Flow (The Most Important UX Decision)
Here's the part I'm most proud of from a UX standpoint.
The problem: To stake tokens, users need to:
- Call
approve()on the ERC20 contract to authorize the staking contract - Then call
stake()on the staking contract
If you expose this as two separate buttons, you get confused users, failed transactions, and support headaches.
The solution: Handle it automatically behind the scenes.
const handleStake = async (amount: string) => {
const parsedAmount = ethers.parseUnits(amount, 18);
// Step 1: Check current allowance
const allowance = await tokenContract.allowance(userAddress, STAKING_CONTRACT_ADDRESS);
// Step 2: If allowance is insufficient, approve first
if (allowance < parsedAmount) {
const approveTx = await tokenContract.approve(STAKING_CONTRACT_ADDRESS, parsedAmount);
await approveTx.wait(); // Wait for confirmation
}
// Step 3: Now stake
const stakeTx = await stakingContract.stake(parsedAmount);
await stakeTx.wait();
// Step 4: Refresh dashboard data
await fetchDashboardData();
};
From the user's perspective, they just click "Stake" and confirm one or two MetaMask popups. No confusion about what approve means or why they need to do it first.
This is the difference between a dApp people actually use and one they bounce off immediately.
The Live Dashboard
The dashboard is the heart of the frontend. It shows:
- Staked Amount — how many tokens the user has deposited
- Available Rewards — unclaimed yield accumulated so far
- APR — current annual percentage rate
- APY — annualized yield with compounding factored in
- Wallet Balance — how many tokens the user holds (not yet staked)
All of this comes from a single call to getDashboardData():
const fetchDashboardData = async () => {
const [staked, rewards, apr] = await stakingContract.getDashboardData(userAddress);
const balance = await tokenContract.balanceOf(userAddress);
setStakedAmount(ethers.formatUnits(staked, 18));
setPendingRewards(ethers.formatUnits(rewards, 18));
setApr(apr.toString());
setWalletBalance(ethers.formatUnits(balance, 18));
};
The dashboard auto-refreshes after every transaction so users always see up-to-date numbers.
Transaction Hash Links
Every transaction surfaces a link to the block explorer:
const tx = await stakingContract.stake(parsedAmount);
console.log(`Transaction: https://explorer.securechain.ai/tx/${tx.hash}`);
This builds trust. Users can verify their transaction actually happened on-chain, not just take the UI's word for it.
Wrong Network Detection
If a user connects on the wrong chain, the app detects it and shows a prompt:
const { chain } = useNetwork();
const SCAI_CHAIN_ID = 34;
if (chain?.id !== SCAI_CHAIN_ID) {
return <WrongNetworkBanner />;
}
No silent failures. No confusing errors. Just a clear message: "Please switch to SCAI Mainnet."
Deployment
Smart Contracts — Hardhat + SCAI Mainnet
Contracts were compiled and deployed using Hardhat:
npx hardhat compile
npx hardhat run scripts/deploy.js --network scai
The hardhat.config.js includes the SCAI network configuration with the RPC URL and deployer private key (from environment variables — never hardcode keys).
Frontend — Vercel
The React app is deployed on Vercel with zero configuration. Push to main, it deploys. The live URL is:
https://de-fi-staking-platform-vert.vercel.app
How to Run It Locally
# 1. Clone the repo
git clone https://github.com/feudcommon/DeFi-staking-platform.git
cd DeFi-staking-platform
# 2. Install dependencies
npm install
# 3. Start the dev server
npm start
Open http://localhost:3000, connect your wallet, switch to SCAI Mainnet, and you're live.
What I'd Do Differently
A few things I'd improve with more time:
- Add a time-weighted rewards model — right now rewards are linear. A time-weighted model would reward long-term stakers more, which is better economics.
- Staking tiers — higher APR for larger stakes or longer commitments
- Mobile responsiveness — the UI works on desktop, but could be tighter on mobile
- Event-based UI updates — instead of polling for new data, listen to contract events for real-time updates without constant RPC calls
Key Takeaways
1. The approve → stake flow is where most dApp UX breaks. Hiding the approval call behind the scenes and automating it completely changed how smooth the experience feels. Always think about what the user actually needs to do, not what the contract requires.
2. view functions are free — use them generously. Batching all dashboard data into getDashboardData() instead of making three separate calls made the UI noticeably faster.
3. Wrong network detection is non-negotiable. If a user is on the wrong chain and tries to interact with your contracts, they'll get confusing errors. Detect it early, surface it clearly.
4. Always audit before going live. Especially when user funds are involved. An audit isn't just about finding bugs — it's about building trust.
Conclusion
Building this staking platform from scratch — two contracts, a full React frontend, multi-wallet support, and live deployment — is the most complete Web3 project I've shipped so far.
The EtherAuthority internship forced me to actually build and deploy something real every week. That's the only way to learn this stuff. Tutorials can teach you syntax. Shipping teaches you everything else.
Built during the EtherAuthority Web3 Internship — Week 4
Deployed on SecureChain AI (SCAI) Mainnet
Token Contract: 0x4EF03D37c441BcF78D61367f4EE709027632d929
Staking Contract: 0x9aab06FAE31e082c26979afca9E53897dB57D50C
GitHub: https://github.com/feudcommon/DeFi-staking-platform
Live Demo: https://de-fi-staking-platform-vert.vercel.app
Top comments (0)