A complete Solidity-based liquid staking smart contract protocol inspired by Lido Finance's liquid staking on Ethereum. This project implements a simplified clone featuring stETH-like rebasing tokens, wstETH wrapper, withdrawal queue with ERC-721 NFTs, and mock oracle integration.
- Project Overview
- Key Features
- Architecture
- Setup Instructions
- Testing
- Deployment
- Usage Examples
- Contract Details
- Assumptions & Limitations
- Contact
This is a simplified educational clone of Lido Finance's liquid staking protocol designed for EVM-compatible blockchains. The protocol allows users to:
- Deposit ETH and receive liquid staking tokens (stETH) that represent their stake
- Earn rewards automatically as the protocol accrues staking rewards
- Wrap/unwrap stETH to wstETH (non-rebasing version) for DeFi compatibility
- Request withdrawals via a FIFO queue system with ERC-721 NFT representation
- Claim withdrawals after oracle finalization
The protocol uses a shares-based system to avoid rebasing transfers, where token balances are calculated dynamically based on the exchange rate between shares and pooled ETH.
-
ETH Staking β Liquid Token
- Users deposit ETH via
submit()function - Receive stETH tokens (rebasing ERC-20) representing their stake
- Initial 1:1 ratio that grows with rewards
- Users deposit ETH via
-
Rebasing vs Wrapped Tokens
- stETH: Rebasing token where
balanceOf()returns ETH value (shares Γ exchange rate) - wstETH: Non-rebasing wrapper with fixed balance that appreciates in value
- Seamless wrap/unwrap between the two
- stETH: Rebasing token where
-
Withdrawal Request & Claims
- Request withdrawal by locking stETH/wstETH
- Receive ERC-721 NFT (unstETH) representing withdrawal position
- FIFO queue system for fair processing
- Oracle finalizes requests in batches
- Claim finalized withdrawals to receive ETH
-
Mock Oracle Rewards
- Simulates beacon chain reports
- Updates total pooled ETH (accrues rewards)
- Finalizes withdrawal requests
- Deducts treasury fees from rewards
-
Governance & Access Control
- Role-based access control (OpenZeppelin)
- Configurable treasury fees
- Pausability for emergency stops
- UUPS upgradeable proxy pattern
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β User Interactions β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββ
β StakingPool (stETH) β
β - Handles ETH deposits β
β - Mints/burns shares (rebasing) β
β - Processes oracle reports β
β - Manages treasury fees β
βββββββββββββββββββββββββββββββββββββββββ
β β
βββββββββββββ βββββββββββββ
βΌ βΌ
βββββββββββββββββ βββββββββββββββββββββββ
β WstETH β β WithdrawalQueueERC721β
β - Wraps β β - FIFO queue β
β stETH β β - ERC-721 NFTs β
β - Unwraps β β - Lock tokens β
β - Non-rebasingβ β - Claim withdrawals β
βββββββββββββββββ βββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββ
β MockAccountingOracle β
β - Submit reports β
β - Finalize withdrawals β
β - Simulate rewards β
βββββββββββββββββββββββββββββ
- StakingPool: Core contract managing ETH deposits, shares, and rewards
- WstETH: Wrapper contract for non-rebasing stETH
- WithdrawalQueueERC721: Manages withdrawal requests with NFT representation
- MockAccountingOracle: Simulates oracle reports for testing
- Node.js (v16 or higher)
- npm or yarn
- Git
-
Clone the repository (or navigate to project directory):
cd evm-liquid-staking-token -
Install dependencies:
npm install # or yarn install -
Configure environment variables:
cp .env.example .env
Edit
.envand add your configuration:SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/YOUR_INFURA_KEY PRIVATE_KEY=your_private_key_here ETHERSCAN_API_KEY=your_etherscan_api_key_here INITIAL_TREASURY_FEE=1000
-
Compile contracts:
npx hardhat compile
Run the comprehensive test suite:
# Run all tests
npx hardhat test
# Run with coverage
npm run test:coverage
# Run specific test file
npx hardhat test test/StakingPool.test.jsThe test suite covers:
- β Deposit/mint functionality
- β Wrapping/unwrapping stETH β wstETH
- β Withdrawal request/claim flows
- β Oracle report processing
- β Reward accrual over time
- β Fee deductions
- β Pausing mechanisms
- β Edge cases (zero deposits, max queue, unauthorized access, insufficient buffer)
- β Integration tests for complete flows
-
Start local Hardhat node:
npx hardhat node
-
Deploy contracts (in another terminal):
npx hardhat run scripts/deploy.js --network localhost
-
Configure
.envwith your Sepolia RPC URL and private key -
Deploy:
npx hardhat run scripts/deploy.js --network sepolia
-
Verify contracts (optional):
npx hardhat run scripts/verify.js --network sepolia
The deployment script creates a deployments/{network}.json file with all contract addresses and configuration.
const { ethers } = require("ethers");
const StakingPool = require("./artifacts/contracts/StakingPool.sol/StakingPool.json");
// Connect to contract
const provider = new ethers.JsonRpcProvider("http://localhost:8545");
const signer = await provider.getSigner();
const stakingPool = new ethers.Contract(
"0x...", // StakingPool address
StakingPool.abi,
signer
);
// 1. Deposit ETH
const tx1 = await stakingPool.submit({ value: ethers.parseEther("1.0") });
await tx1.wait();
console.log("Deposited 1 ETH");
// 2. Check balance (rebasing)
const balance = await stakingPool.balanceOf(signer.address);
console.log(`stETH balance: ${ethers.formatEther(balance)}`);
// 3. Wrap to wstETH
const wstETH = new ethers.Contract(/* wstETH address */, /* ABI */, signer);
await stakingPool.approve(wstETH.address, balance);
const tx2 = await wstETH.wrap(balance);
await tx2.wait();
console.log("Wrapped to wstETH");
// 4. Request withdrawal
const withdrawalQueue = new ethers.Contract(/* address */, /* ABI */, signer);
await stakingPool.approve(withdrawalQueue.address, ethers.parseEther("0.5"));
const tx3 = await withdrawalQueue.requestWithdrawal(ethers.parseEther("0.5"));
const receipt = await tx3.wait();
const requestId = receipt.logs[0].args.requestId;
console.log(`Withdrawal requested: NFT #${requestId}`);
// 5. Simulate oracle report (admin/oracle only)
const mockOracle = new ethers.Contract(/* address */, /* ABI */, signer);
const totalPooled = await stakingPool.getTotalPooledEther();
const reward = ethers.parseEther("0.1");
await mockOracle.submitReport(totalPooled + reward, 0, 0);
console.log("Oracle report submitted");
// 6. Finalize and claim withdrawal (oracle + funding required)
await mockOracle.finalizeWithdrawals([requestId], [ethers.parseEther("0.5")]);
await withdrawalQueue.claimWithdrawal(requestId);
console.log("Withdrawal claimed!");npx hardhat console --network localhostconst [deployer, user] = await ethers.getSigners();
const StakingPool = await ethers.getContractFactory("StakingPool");
const stakingPool = StakingPool.attach("0x...");
// Deposit
await stakingPool.connect(user).submit({ value: ethers.parseEther("1.0") });
// Check balance
await stakingPool.balanceOf(user.address);Main Functions:
submit(): Deposit ETH and receive stETH sharesgetPooledEthByShares(uint256): Convert shares to ETHgetSharesByPooledEth(uint256): Convert ETH to sharesburnShares(address, uint256): Burn shares (called by withdrawal queue)processOracleReport(...): Update pool state with rewards
Key Properties:
- Rebasing token:
balanceOf()returns ETH value, not shares - Shares-based: Internal accounting uses shares to avoid rebasing transfers
- Treasury fees: Configurable fee on rewards (default 10%)
Main Functions:
wrap(uint256): Convert stETH to wstETHunwrap(uint256): Convert wstETH to stETHgetStETHByWstETH(uint256): Get stETH value of wstETHgetWstETHByStETH(uint256): Get wstETH amount for stETH
Key Properties:
- Non-rebasing: Balance stays fixed, value appreciates
- 1:1 initial ratio with stETH
- Value increases as stETH accrues rewards
Main Functions:
requestWithdrawal(uint256): Lock stETH and receive NFTrequestWithdrawalWstETH(uint256): Lock wstETH and receive NFTfinalizeWithdrawals(uint256[], uint256[]): Finalize requests (oracle only)claimWithdrawal(uint256): Claim finalized withdrawal
Key Properties:
- FIFO queue: First-in-first-out processing
- ERC-721 NFTs: Each request is an NFT (unstETH)
- Requires ETH funding: Contract must have ETH to pay claims
Main Functions:
submitReport(uint256, uint256, uint256): Submit oracle reportfinalizeWithdrawals(uint256[], uint256[]): Finalize withdrawal requestssubmitReportAndFinalize(...): Combined operation
Key Properties:
- Mock implementation: For testing/educational purposes only
- Role-based: Requires ORACLE_ROLE
- Simulates real oracle: Updates pool state and finalizes withdrawals
This is a simplified clone for educational purposes. For production use, you would need:
-
Real Oracle Integration
- Actual beacon chain oracle daemon
- Validator exit monitoring
- Secure oracle infrastructure
-
Professional Security Audit
- Comprehensive security review
- Formal verification where applicable
- Bug bounty program
-
Validator Infrastructure
- Actual validator node setup
- Slashing protection
- Validator key management
-
Enhanced Features
- MEV protection
- Advanced fee mechanisms
- Governance token and DAO
- Insurance fund
- β Mock oracle only (no real beacon chain integration)
- β Simplified fee mechanism
- β No validator management
- β No slashing handling
- β Basic withdrawal queue (no priority system)
- β No insurance fund
- β Simplified governance
- Inspired by Lido Finance
- Built with Hardhat
- Uses OpenZeppelin Contracts
- telegram: https://t.me/rouncey
- twitter: https://x.com/rouncey_