A production-grade ERC-20 token with EIP-2612 Permit and a Synthetix-style staking vault with streaming rewards. Built with Foundry, featuring comprehensive testing (unit, fuzz, invariant) and UUPS upgradeability.
┌─────────────────────────────────────────────────────────────────────────┐
│ SYSTEM OVERVIEW │
└─────────────────────────────────────────────────────────────────────────┘
┌──────────────────┐ ┌──────────────────────────────────────────┐
│ │ │ StakingVault │
│ VaultToken │◄───────►│ ┌────────────────────────────────────┐ │
│ (ERC-20) │ │ │ Streaming Rewards │ │
│ │ │ │ • Time-weighted distribution │ │
│ • EIP-2612 │ stake │ │ • Proportional to stake share │ │
│ • Permit │─────────► │ • Continuous accrual │ │
│ • Burnable │ │ └────────────────────────────────────┘ │
│ • Capped Supply │◄────────│ │
│ • Access Control│ withdraw│ ┌────────────────────────────────────┐ │
│ │ │ │ Emergency Features │ │
└────────┬─────────┘ │ │ • Emergency withdraw (penalty) │ │
│ │ │ • Pausable operations │ │
│ │ │ • ERC-20 recovery │ │
┌─────▼─────┐ │ └────────────────────────────────────┘ │
│ Users │ └──────────────────────────────────────────┘
└───────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ UPGRADEABILITY (UUPS) │
├─────────────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────────────┐ ┌─────────────┐ │
│ │ ERC1967 │ ───► │ Implementation V1 │ ───► │ Impl V2 │ │
│ │ Proxy │ │ (current logic) │ │ (upgrade) │ │
│ └─────────────┘ └─────────────────────┘ └─────────────┘ │
│ │ │
│ └─── Storage preserved across upgrades │
└─────────────────────────────────────────────────────────────────────────┘
- EIP-2612 Permit: Gasless approvals via signatures
- Role-Based Access Control: MINTER_ROLE, UPGRADER_ROLE
- Capped Supply: Configurable maximum supply
- Burnable: Users can burn their tokens
- UUPS Upgradeable: Safe upgrade pattern with storage gaps
- Streaming Rewards: Synthetix-style continuous reward distribution
- Permit Staking: Stake without separate approval transaction
- Emergency Withdraw: Exit with penalty, forfeit rewards
- Pausable: Admin can pause/unpause operations
- Configurable: Adjustable reward duration and penalty
- UUPS Upgradeable: Safe upgrade pattern
- Foundry
- Git
# Clone the repository
git clone https://github.com/Kazopl/erc20-staking-vault.git
cd erc20-staking-vault
# Install dependencies
forge install OpenZeppelin/[email protected]
forge install OpenZeppelin/[email protected]
# Build
forge build
# Run tests
forge testCreate a .env file:
# Private key for deployment (without 0x prefix)
PRIVATE_KEY=your_private_key_here
# RPC URLs (get free keys from Alchemy or Infura)
SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY
# Etherscan API key for contract verification
ETHERSCAN_API_KEY=your_etherscan_api_key# Run all tests
forge test
# Run with verbosity
forge test -vvv
# Run specific test file
forge test --match-path test/unit/VaultToken.t.sol
# Run fuzz tests
forge test --match-path "test/fuzz/*"
# Run invariant tests
forge test --match-path "test/invariant/*"
# Gas report
forge test --gas-report
# Coverage
forge coverage| Category | Description | Files |
|---|---|---|
| Unit | Individual function tests | test/unit/*.t.sol |
| Fuzz | Property-based testing with random inputs | test/fuzz/*.t.sol |
| Invariant | System-wide property verification | test/invariant/*.t.sol |
# Load environment variables
source .env
# Deploy all contracts
forge script script/DeployAll.s.sol:DeployAll \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEY \
--broadcast \
--verify \
--etherscan-api-key $ETHERSCAN_API_KEY# Add rewards to vault
VAULT_ADDRESS=0x... TOKEN_ADDRESS=0x... REWARD_AMOUNT=1000 \
forge script script/Interactions.s.sol:AddRewards \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEY \
--broadcast
# Check vault status
VAULT_ADDRESS=0x... forge script script/Interactions.s.sol:CheckStatus \
--rpc-url $SEPOLIA_RPC_URL# Upgrade VaultToken to V2
TOKEN_PROXY=0x... forge script script/Upgrade.s.sol:UpgradeVaultToken \
--rpc-url $SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEY \
--broadcast \
--verifyThe staking vault uses a time-weighted reward distribution mechanism:
rewardPerToken = rewardPerTokenStored +
((lastTimeRewardApplicable - lastUpdateTime) * rewardRate * PRECISION) / totalStaked
earned(user) = (stakedBalance[user] * (rewardPerToken - userRewardPerTokenPaid[user])) / PRECISION
+ rewards[user]
- Rewards accrue continuously per second
- Distribution proportional to stake share
- Late stakers earn from time of stake only
- Early exit forfeits unclaimed rewards (emergency withdraw)
| Threat | Mitigation |
|---|---|
| Reentrancy | ReentrancyGuard on all state-changing functions |
| Flash Loan Attacks | Rewards calculated over time, not instantaneous |
| Admin Misuse | Role-based access, multi-sig recommended for mainnet |
| Reward Drain | Reward pool tracked separately, covered by invariants |
| Upgrade Attacks | UPGRADER_ROLE required, timelock recommended |
| Integer Overflow | Solidity 0.8.24 built-in overflow checks |
| Permit Replay | Nonce tracking, domain separator |
| Role | VaultToken | StakingVault |
|---|---|---|
| DEFAULT_ADMIN_ROLE | Grant/revoke roles, set max supply | Grant/revoke roles, set penalty, recover tokens |
| MINTER_ROLE | Mint new tokens | - |
| UPGRADER_ROLE | Upgrade contract | Upgrade contract |
| REWARDS_MANAGER_ROLE | - | Add rewards, set duration |
| PAUSER_ROLE | - | Pause/unpause operations |
totalStaked == Σ stakedBalance[users]token.balanceOf(vault) >= totalStakedtotalSupply <= maxSupplyrewardPerTokenmonotonically non-decreasingearned(user) >= 0for all users
| Function | Gas (approx) |
|---|---|
stake() |
~85,000 |
withdraw() |
~65,000 |
claimRewards() |
~55,000 |
stakeWithPermit() |
~110,000 |
emergencyWithdraw() |
~70,000 |
Run forge test --gas-report for detailed breakdown.
erc20-staking-vault/
├── src/
│ ├── VaultToken.sol # ERC-20 with Permit
│ ├── StakingVault.sol # Main staking contract
│ ├── interfaces/
│ │ ├── IVaultToken.sol
│ │ └── IStakingVault.sol
│ └── upgrades/
│ ├── VaultTokenV2.sol # V2 with blacklist
│ └── StakingVaultV2.sol # V2 with lock period
├── test/
│ ├── BaseTest.sol # Shared test setup
│ ├── unit/
│ │ ├── VaultToken.t.sol
│ │ └── StakingVault.t.sol
│ ├── fuzz/
│ │ ├── VaultTokenFuzz.t.sol
│ │ └── StakingVaultFuzz.t.sol
│ └── invariant/
│ └── StakingVaultInvariant.t.sol
├── script/
│ ├── DeployAll.s.sol # Full deployment
│ ├── DeployVaultToken.s.sol
│ ├── DeployStakingVault.s.sol
│ ├── Upgrade.s.sol # Upgrade scripts
│ └── Interactions.s.sol # Post-deploy interactions
├── foundry.toml
└── README.md
The contracts use the UUPS (Universal Upgradeable Proxy Standard) pattern:
V1 Features → V2 Features
VaultToken:
├── Basic ERC-20 → + Blacklist functionality
└── Permit support → + Per-address transfer restrictions
StakingVault:
├── Streaming rewards → + Lock period enforcement
└── Emergency withdraw → + Time-until-unlock tracking
MIT License - see LICENSE for details.
- OpenZeppelin Contracts
- Foundry
- Cyfrin - Template patterns and best practices
- Synthetix - Staking reward math inspiration