Skip to content

ERC-20 staking vault with streaming rewards, gasless staking via permit and UUPS upgradeable contracts

Notifications You must be signed in to change notification settings

Kazopl/erc20-staking-vault

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

17 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Advanced ERC-20 + Staking Vault

License: MIT Solidity

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.

Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                           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                           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Features

VaultToken (ERC-20)

  • 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

StakingVault

  • 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

Installation

Prerequisites

Setup

# Clone the repository
git clone https://github.com/Kazopl/erc20-staking-vault.git
cd erc20-staking-vault

# Install dependencies
forge install OpenZeppelin/openzeppelin-contracts@v5.0.1
forge install OpenZeppelin/openzeppelin-contracts-upgradeable@v5.0.1

# Build
forge build

# Run tests
forge test

Environment Setup

Create 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

Testing

# 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

Test Categories

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

Deployment

Deploy to Sepolia

# 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

Post-Deployment Interactions

# 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 Contracts

# Upgrade VaultToken to V2
TOKEN_PROXY=0x... forge script script/Upgrade.s.sol:UpgradeVaultToken \
  --rpc-url $SEPOLIA_RPC_URL \
  --private-key $PRIVATE_KEY \
  --broadcast \
  --verify

Reward Distribution Math

The 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]

Key Properties

  • 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)

Security

Threat Model

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

Access Control Roles

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

Invariants Verified

  1. totalStaked == Ξ£ stakedBalance[users]
  2. token.balanceOf(vault) >= totalStaked
  3. totalSupply <= maxSupply
  4. rewardPerToken monotonically non-decreasing
  5. earned(user) >= 0 for all users

Gas Optimization

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.

Project Structure

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

Upgrade Path

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

License

MIT License - see LICENSE for details.

Acknowledgments