DEV Community

Cover image for Building a Full-Stack DeFi Staking Platform on SecureChain AI — A Week 4 Web3 Internship Journey
Nikhil Siripurapu
Nikhil Siripurapu

Posted on

Building a Full-Stack DeFi Staking Platform on SecureChain AI — A Week 4 Web3 Internship Journey

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:

  1. An ERC20 token — the token users stake
  2. A staking contract — holds deposited tokens and calculates/distributes rewards
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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 approvetransferFrom 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);
Enter fullscreen mode Exit fullscreen mode

Let's break down each function:

stake(uint amount)

When a user calls this, the contract:

  1. Calls transferFrom(msg.sender, address(this), amount) to pull tokens from the user's wallet
  2. Updates the user's staked balance in a mapping
  3. 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:

  1. Checks the user has enough staked
  2. Calculates any pending rewards before the withdrawal (important — you don't want users to lose unclaimed rewards)
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. It's simpler to audit and understand
  2. It gives users full control of their funds
  3. 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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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:

  1. Call approve() on the ERC20 contract to authorize the staking contract
  2. 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();
};
Enter fullscreen mode Exit fullscreen mode

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));
};
Enter fullscreen mode Exit fullscreen mode

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}`);
Enter fullscreen mode Exit fullscreen mode

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 />;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. Staking tiers — higher APR for larger stakes or longer commitments
  3. Mobile responsiveness — the UI works on desktop, but could be tighter on mobile
  4. 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 approvestake 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)