A full-stack decentralized staking protocol built with Solidity, Hardhat, and Next.js. Users stake ERC-20 tokens (STK) into pools and earn rewards in a separate ERC-20 token (RWD) based on a configurable APY rate. The protocol supports flexible and locked pools, early withdrawal penalties, and emergency withdrawals.
- Overview
- Architecture
- How Staking Works
- Reward Formula
- Smart Contracts
- Default Pools
- Frontend
- Getting Started
- Docker Setup
- Project Structure
- Contract API Reference
- Security
- Troubleshooting
This project demonstrates a complete DeFi staking flow:
- Owner deploys the protocol and creates staking pools with different APY rates and lock durations.
- Users connect their wallet (MetaMask), stake STK tokens into a pool, and earn RWD tokens over time.
- Rewards accrue linearly every second based on the pool's APY rate.
- Users can claim rewards, unstake (after the lock period), or early-withdraw with a 10% penalty.
- Dual-token model — Stake with STK, earn with RWD (separate ERC-20 tokens)
- Flexible pools — Stake/unstake anytime, lower APY
- Locked pools — Higher APY, tokens locked for a set duration
- Early withdrawal — Exit locked pools before expiry with a 10% penalty on principal
- Emergency withdrawal — Recover principal at any time, forfeit all rewards
- Pausable — Owner can pause/unpause the protocol
- Reentrancy protection — All state-changing functions use OpenZeppelin's
ReentrancyGuard - Real-time frontend — Next.js app with live pool data, staking UI, and position management
┌─────────────────────────────────────────────────────┐
│ Frontend (Next.js) │
│ ┌───────────┐ ┌────────────┐ ┌────────────────┐ │
│ │ PoolList │ │ StakePanel │ │ UserPositions │ │
│ └─────┬─────┘ └──────┬─────┘ └───────┬────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ ethers.js (JsonRpcProvider) │ │
│ └──────────────────────┬──────────────────────────┘ │
└─────────────────────────┼───────────────────────────┘
│ JSON-RPC
▼
┌─────────────────────────────────────────────────────┐
│ Hardhat Node (:8545) │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ StakingToken │ │ RewardToken │ │ Staking │ │
│ │ (STK) │ │ (RWD) │ │ Protocol │ │
│ └──────────────┘ └──────────────┘ └───────────┘ │
└─────────────────────────────────────────────────────┘
User approves STK → User calls stake(poolId, amount)
→ STK transferred to protocol contract
→ Position created with startTime = now
→ Rewards accrue every second based on APY
User calls claimRewards(positionId)
→ Pending RWD calculated and transferred
→ lastRewardClaimTime reset to now
User calls unstake(positionId) [after lock expires, or flexible]
→ Principal (STK) returned + pending RWD transferred
→ Position deactivated
| Type | Lock Period | APY | Early Withdrawal | Description |
|---|---|---|---|---|
| Flexible | None | 10% | N/A | Stake/unstake anytime, rewards accrue |
| Locked | 30 days | 15% | 10% penalty | Higher APY, principal locked until expiry |
If a user exits a locked pool before the lock period ends:
- 10% penalty is deducted from their principal
- The penalty is sent to the contract owner
- No rewards are paid out
Available on any pool, even when paused:
- Returns principal only (STK), no rewards
- All pending rewards are forfeited
- Bypasses the pause check — always available as a safety mechanism
Rewards are calculated using a simple linear interest formula:
Where:
- stakedAmount — Amount of STK tokens staked (in wei, 18 decimals)
- apyRate — Pool's annual percentage yield (e.g.,
10= 10%,15= 15%) - timeElapsed — Seconds since last reward claim (
block.timestamp - lastRewardClaimTime) - SECONDS_IN_YEAR — Constant:
31,536,000(365 days)
Stake 1,000 STK in a pool with 10% APY:
| Duration | Calculation | Reward (RWD) |
|---|---|---|
| 1 day | ≈ 0.274 | |
| 30 days | ≈ 8.219 | |
| 1 year | 100.0 |
Note: The APY rate maps 1:1 between token denominations. "10% APY" means for every 1 STK staked, you earn 0.1 RWD per year. Both tokens use 18 decimals.
All contracts are written in Solidity ^0.8.20 and use OpenZeppelin v5 libraries.
Standard ERC-20 token. Mints 1,000,000 STK to the deployer on construction. Used as the staking asset.
Standard ERC-20 token. Mints 1,000,000 RWD to the deployer on construction. During deployment, all RWD tokens are transferred to the StakingProtocol contract to fund reward payouts.
The core contract. Inherits:
Ownable— Owner-only pool creation, pause/unpausePausable— Circuit breaker for stake/unstake/claimReentrancyGuard— Protection against reentrancy attacks
The deployment script automatically creates two pools:
| Pool ID | Type | APY Rate | Lock Duration | Created By |
|---|---|---|---|---|
| 0 | Flexible | 10% | 0 (none) | Deploy script |
| 1 | Locked | 15% | 30 days (2,592,000 sec) | Deploy script |
The owner can create additional pools after deployment by calling createPool(apyRate, lockDuration, isFlexible).
The frontend is a Next.js 16 app built with:
- React 19 + TypeScript
- ethers.js v6 — Blockchain interaction
- TanStack React Query — Data fetching and cache management
- Tailwind CSS v4 — Styling
- Framer Motion — Animations
- Sonner — Toast notifications
- Lucide React — Icons
| Component | Description |
|---|---|
PoolList |
Displays all staking pools with APY, lock duration, and total staked |
StakePanel |
Input form to stake STK tokens into the selected pool |
UserPositions |
Lists user's active positions with claim, unstake, and withdraw actions |
WalletButton |
Connect/disconnect MetaMask wallet |
- Read operations (pools, TVL) use a read-only
JsonRpcProvider— no wallet needed - Write operations (stake, claim, unstake) use a
BrowserProviderfrom MetaMask - After any transaction, React Query caches for
poolsandtvlare invalidated for instant UI updates
- Node.js >= 18.x
- npm (comes with Node.js)
- MetaMask browser extension (for the frontend)
# Root (Hardhat + contracts)
npm install
# Frontend (Next.js)
cd frontend && npm install && cd ..npx hardhat nodeThis starts a local Ethereum node at http://localhost:8545 with pre-funded accounts.
In a new terminal:
npx hardhat deploy --network localhostThis will:
- Deploy StakingToken, RewardToken, and StakingProtocol
- Transfer 1,000,000 RWD to the protocol for reward payouts
- Create 2 default pools (Flexible 10% APY + Locked 15% APY)
- Output deployed addresses to the console
Create or update frontend/.env with the deployed addresses:
NEXT_PUBLIC_STAKING_CONTRACT_ADDRESS=0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
NEXT_PUBLIC_STAKING_TOKEN_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3
NEXT_PUBLIC_RPC_URL=http://localhost:8545The addresses above are the default Hardhat deterministic addresses. If you redeploy, update them from the deploy output.
cd frontend
npm run devOpen http://localhost:3000 in your browser.
- Open MetaMask → Add Network → Add manually:
- Network Name: Hardhat Local
- RPC URL:
http://localhost:8545 - Chain ID:
31337 - Currency Symbol: ETH
- Import a Hardhat test account using its private key (printed when you run
npx hardhat node) - Click Connect Wallet in the app
Run the entire stack (Hardhat node + contract deployment + Next.js frontend) with a single command:
docker-compose up --buildThis will:
- hardhat-node container — Starts a Hardhat node, deploys all contracts, writes deployed addresses to a shared volume
- frontend container — Waits for deployment to finish, picks up contract addresses from the shared volume, starts Next.js dev server
The app will be available at http://localhost:3000 and the RPC node at http://localhost:8545.
To tear down:
docker-compose down -vstaking-protocol/
├── contracts/ # Solidity smart contracts
│ ├── StakingProtocol.sol # Core staking logic
│ ├── StakingToken.sol # STK ERC-20 token
│ └── RewardToken.sol # RWD ERC-20 token
├── deploy/
│ └── 00_deploy_staking.ts # hardhat-deploy deployment script
├── scripts/
│ └── deploy.ts # Alternative deploy script (hardhat run)
├── frontend/ # Next.js frontend application
│ ├── app/ # Next.js App Router pages
│ ├── components/ # React components
│ ├── hooks/ # Custom React hooks (useStaking)
│ ├── lib/ # Utilities, providers, ABIs
│ └── providers/ # React Query provider
├── artifacts/ # Compiled contract artifacts (auto-generated)
├── deployments/ # hardhat-deploy deployment records
├── typechain-types/ # TypeScript contract bindings (auto-generated)
├── docker-compose.yml # Multi-container Docker setup
├── Dockerfile.hardhat # Hardhat node + deploy container
├── docker-entrypoint.sh # Hardhat container startup script
├── hardhat.config.ts # Hardhat configuration
├── package.json # Root dependencies
└── tsconfig.json # TypeScript configuration
| Function | Parameters | Description |
|---|---|---|
createPool |
apyRate, lockDuration, isFlexible |
Create a new staking pool |
pause |
— | Pause all stake/unstake/claim operations |
unpause |
— | Resume operations |
| Function | Parameters | Description |
|---|---|---|
stake |
poolId, amount |
Stake STK tokens into a pool (requires prior ERC-20 approval) |
claimRewards |
positionId |
Claim accrued RWD rewards |
unstake |
positionId |
Withdraw principal + rewards (must be unlocked or flexible) |
withdrawEarly |
positionId |
Exit locked pool early with 10% penalty, no rewards |
emergencyWithdraw |
positionId |
Withdraw principal only, forfeit rewards (works even when paused) |
| Function | Returns | Description |
|---|---|---|
poolCount() |
uint256 |
Number of pools |
getPoolInfo(poolId) |
Pool |
Pool details (APY, lock duration, total staked) |
getUserPositions(address) |
uint256[] |
Array of position IDs for a user |
positions(positionId) |
Position |
Position details (amount, pool, timestamps) |
getPendingRewards(positionId) |
uint256 |
Unclaimed reward amount |
totalValueLocked() |
uint256 |
Total STK staked across all pools |
- ReentrancyGuard — All state-changing functions are non-reentrant
- Pausable — Owner can halt operations in case of emergency
- Checks-Effects-Interactions — State is updated before external calls
- Immutable tokens —
stakingTokenandrewardTokenaddresses cannot be changed after deployment
- Never commit
.envfiles with private keys — add to.gitignore - Test thoroughly on a local network before any testnet/mainnet deployment
- Verify contracts on block explorers (Etherscan) after deployment
- Use hardware wallets for mainnet deployments
- Ensure the protocol contract is funded with sufficient RWD tokens before users start staking
| Problem | Solution |
|---|---|
| Pools not loading in frontend | Ensure Hardhat node is running at localhost:8545 and contracts are deployed |
| "MetaMask not found" | Install MetaMask extension and refresh the page |
| Transaction fails with "Insufficient reward balance" | The protocol contract needs more RWD tokens — owner must transfer more |
| Stale data after staking/unstaking | Should auto-refresh; if not, hard-refresh the page |
| Wrong contract addresses | Check frontend/.env matches the deploy output; restart the dev server after changes |
| Docker frontend can't connect | Ensure hardhat-node container is healthy before frontend starts; check docker-compose logs |
npx hardhat deploy fails |
Run npx hardhat compile first; ensure deploy/ directory has the script |
| Layer | Technology |
|---|---|
| Smart Contracts | Solidity 0.8.28, OpenZeppelin v5 |
| Development Framework | Hardhat, hardhat-deploy, TypeChain |
| Frontend | Next.js 16, React 19, TypeScript |
| Blockchain Interaction | ethers.js v6 |
| State Management | TanStack React Query v5 |
| Styling | Tailwind CSS v4 |
| Containerization | Docker, Docker Compose |
MIT