Skip to main content

staking-reward-patterns

Use when implementing staking contracts, reward distribution systems, or yield farming. Covers the Synthetix reward model, per-second accrual, cooldown periods, and boosted reward mechanics.

Jump to install

Source facts

Repository
ccashwell/evm-cortex
Last source activity
April 10, 2026 at 16:31
Detected SKILL.md language
English
Stars
131
Forks
18

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
staking-reward-patterns
description
Use when implementing staking contracts, reward distribution systems, or yield farming. Covers the Synthetix reward model, per-second accrual, cooldown periods, and boosted reward mechanics.
# Staking & Reward Distribution Patterns ## Synthetix Reward Model The industry-standard approach for distributing rewards proportionally to stakers without iterating over all stakers. Gas cost is O(1) per user action. **Core formula**: ``` rewardPerToken = rewardPerToken + (elapsed * rewardRate / totalStaked) earned(user) = balance(user) * (rewardPerToken - userRewardPerTokenPaid(user)) + rewards(user) ``` ## Synthetix-Style Reward Contract ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; contract StakingRewards is ReentrancyGuard { using SafeERC20 for IERC20; IERC20 public immutable stakingToken; IERC20 public immutable rewardToken; address public rewardDistributor; uint256 public rewardRate; // rewards per second uint256 public periodFinish; // when current reward period ends uint256 public lastUpdateTime; uint256 public rewardPerTokenStored; uint256 public totalSupply; mapping(address => uint256) public balanceOf; mapping(address => uint256) public userRewardPerTokenPaid; mapping(address => uint256) public rewards; uint256 public constant DURATION = 7 days; constructor(address _stakingToken, address _rewardToken, address _distributor) { stakingToken = IERC20(_stakingToken); rewardToken = IERC20(_rewardToken); rewardDistributor = _distributor; } modifier updateReward(address account) { rewardPerTokenStored = rewardPerToken(); lastUpdateTime = lastTimeRewardApplicable(); if (account != address(0)) { rewards[account] = earned(account); userRewardPerTokenPaid[account] = rewardPerTokenStored; } _; } function lastTimeRewardApplicable() public view returns (uint256) { return block.timestamp < periodFinish ? block.timestamp : periodFinish; } function rewardPerToken() public view returns (uint256) { if (totalSupply == 0) return rewardPerTokenStored; return rewardPerTokenStored + ( (lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalSupply ); } function earned(address account) public view returns (uint256) { return ( balanceOf[account] * (rewardPerToken() - userRewardPerTokenPaid[account]) / 1e18 ) + rewards[account]; } function stake(uint256 amount) external nonReentrant updateReward(msg.sender) { require(amount > 0, "Cannot stake 0"); totalSupply += amount; balanceOf[msg.sender] += amount; stakingToken.safeTransferFrom(msg.sender, address(this), amount); emit Staked(msg.sender, amount); } function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) { require(amount > 0, "Cannot withdraw 0"); totalSupply -= amount; balanceOf[msg.sender] -= amount; stakingToken.safeTransfer(msg.sender, amount); emit Withdrawn(msg.sender, amount); } function claim() external nonReentrant updateReward(msg.sender) { uint256 reward = rewards[msg.sender]; if (reward > 0) { rewards[msg.sender] = 0; rewardToken.safeTransfer(msg.sender, reward); emit RewardPaid(msg.sender, reward); } } function exit() external { withdraw(balanceOf[msg.sender]); claim(); } function notifyRewardAmount(uint256 reward) external updateReward(address(0)) { require(msg.sender == rewardDistributor, "unauthorized"); if (block.timestamp >= periodFinish) { rewardRate = reward / DURATION; } else { uint256 remaining = periodFinish - block.timestamp; uint256 leftover = remaining * rewardRate; rewardRate = (reward + leftover) / DURATION; } require(rewardRate > 0, "reward rate = 0"); require( rewardRate * DURATION <= rewardToken.balanceOf(address(this)), "reward amount > balance" ); lastUpdateTime = block.timestamp; periodFinish = block.timestamp + DURATION; emit RewardAdded(reward); } event Staked(address indexed user, uint256 amount); event Withdrawn(address indexed user, uint256 amount); event RewardPaid(address indexed user, uint256 reward); event RewardAdded(uint256 reward); } ``` ## Cooldown Period Pattern ```solidity uint256 public constant COOLDOWN_DURATION = 10 days; uint256 public constant UNSTAKE_WINDOW = 2 days; mapping(address => uint256) public cooldownStart; function startCooldown() external { require(balanceOf[msg.sender] > 0, "nothing staked"); cooldownStart[msg.sender] = block.timestamp; } function withdraw(uint256 amount) external { uint256 cooldown = cooldownStart[msg.sender]; require(cooldown > 0, "cooldown not started"); require(block.timestamp >= cooldown + COOLDOWN_DURATION, "cooldown active"); require( block.timestamp <= cooldown + COOLDOWN_DURATION + UNSTAKE_WINDOW, "unstake window closed" ); cooldownStart[msg.sender] = 0; // ... transfer logic } ``` ## Reward Boosting (ve-Style) ```solidity // Boost based on lock duration: longer lock = higher multiplier function getBoost(address user) public view returns (uint256) { uint256 lockEnd = lockEndTime[user]; if (lockEnd <= block.timestamp) return 1e18; // 1x (no boost) uint256 remaining = lockEnd - block.timestamp; uint256 maxDuration = 4 * 365 days; // Linear boost: 1x to 2.5x based on lock duration return 1e18 + (remaining * 15e17 / maxDuration); } function earned(address account) public view returns (uint256) { uint256 base = balanceOf[account] * (rewardPerToken() - userRewardPerTokenPaid[account]) / 1e18; return (base * getBoost(account) / 1e18) + rewards[account]; } ``` ## Checklist - [ ] Use `updateReward` modifier on every state-changing function - [ ] `rewardPerToken()` handles `totalSupply == 0` (avoid division by zero) - [ ] `notifyRewardAmount` checks sufficient reward token balance - [ ] Reward rate calculation handles mid-period top-ups correctly - [ ] Apply `ReentrancyGuard` on stake/withdraw/claim - [ ] Use `SafeERC20` for all token transfers - [ ] Consider cooldown period for protocol safety - [ ] Test reward accrual across multiple stakers and time periods - [ ] Verify no reward dust is lost due to integer division
View on GitHub