Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
edf2c93
add stake on behalf of account functionality
daveroga Jan 19, 2026
2bcc499
fix solhint
daveroga Jan 19, 2026
7adef64
contract layout in comments
daveroga Jan 19, 2026
e96ff5a
simplify staking contract
daveroga Jan 19, 2026
21b638b
update comments and requirements
daveroga Jan 20, 2026
a495e46
allow users with stake locked to receive with stakeAndLockOnBehalf
daveroga Jan 20, 2026
34a9ba1
adjust lock period automatically to longer one
daveroga Jan 20, 2026
abf7107
remove unused _stakeAndLock
daveroga Jan 20, 2026
c8be7bd
add audits
daveroga Jan 30, 2026
301a35f
update readme with audit files
daveroga Jan 30, 2026
8a43afe
merge from main
daveroga Feb 3, 2026
545eda7
add function stakeOnBehalf
daveroga Feb 3, 2026
c4f1948
fix package-lock.json
daveroga Feb 3, 2026
f8a89cf
merge from main
daveroga Mar 12, 2026
345aecf
add only lock staking period not allowing get rewards and withdraw
daveroga Mar 15, 2026
a3630c5
add gap for future upgrades
daveroga Mar 16, 2026
b254570
allow different rewards distributors and other updates
daveroga Mar 16, 2026
bacaa1a
fixes from review
daveroga Mar 16, 2026
0a46b1a
fix typo
daveroga Mar 16, 2026
c1b0fc1
update verify
daveroga Mar 16, 2026
4b7e38f
remove condition that is not taken effect
daveroga Mar 17, 2026
646034f
initialLockPeriodStart -> initialLockPeriodFinish
daveroga Mar 17, 2026
307b70a
some fixes
daveroga Mar 18, 2026
91f6606
fix some issues
daveroga Mar 19, 2026
5250377
fixes from audit find-001, find-003, find-004, find-005
daveroga Mar 19, 2026
71b1b65
other fixes
daveroga Mar 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ artifacts/
.vscode
typechain-types
scripts/safe/*.json
scripts/deployStakingRewards/*testnet*.json
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,4 @@ All upgrades go through the Timelock (2-day minimum delay):

## Security Audits
1. [HALBORN](https://www.halborn.com/audits) has performed a security audit of `BillionsNetworkToken` smart contract and compiled report on Dec 5, 2025: [billions-token-7661d8](https://github.com/BillionsNetwork/billions-token/blob/main/audits/Billions_Token_SSC.pdf).
2. [HALBORN](https://www.halborn.com/audits) has performed a security audit of `StakingRewards` smart contract and compiled report on Jan 5, 2026: [staking-rewards-0b472f](https://github.com/BillionsNetwork/billions-token/blob/main/audits/Staking_Rewards_SSC.pdf).
2. [HALBORN](https://www.halborn.com/audits) has performed a security audit of `StakingRewards` smart contract and compiled report on Jan 5, 2026: [staking-rewards-0b472f](https://github.com/BillionsNetwork/billions-token/blob/main/audits/Staking_Rewards_SSC.pdf).
194 changes: 138 additions & 56 deletions contracts/staking/StakingRewards.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol
import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol";
import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";

import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import {IStakingRewards} from "./interfaces/IStakingRewards.sol";

/**
Expand Down Expand Up @@ -76,10 +76,13 @@ import {IStakingRewards} from "./interfaces/IStakingRewards.sol";
contract StakingRewards is
IStakingRewards,
Ownable2StepUpgradeable,
AccessControlUpgradeable,
ReentrancyGuardUpgradeable,
PausableUpgradeable
{
using SafeERC20 for IERC20;
bytes32 public constant STAKER_ON_BEHALF_ROLE = keccak256("STAKER_ON_BEHALF_ROLE");
bytes32 public constant REWARDS_DISTRIBUTOR_ROLE = keccak256("REWARDS_DISTRIBUTOR_ROLE");

/* ========== STATE VARIABLES ========== */

Expand All @@ -90,7 +93,6 @@ contract StakingRewards is
uint256 public rewardsDuration;
uint256 public lastUpdateTime;
uint256 public rewardPerTokenStored;
address public rewardsDistribution;

mapping(address => uint256) public userRewardPerTokenPaid;
mapping(address => uint256) public rewards;
Expand All @@ -102,6 +104,13 @@ contract StakingRewards is

mapping(address => LockedStake) public addressToLockedStake;

uint256 public initialLockPeriodStart;
uint256 public initialLockPeriodDuration;

/// @dev Reserved storage gap for future upgrades. Reduces the gap by 1 for each new
/// state variable added to this contract in subsequent versions.
uint256[50] private __gap;

/* ========== CONSTRUCTOR ========== */

/// @custom:oz-upgrades-unsafe-allow constructor
Expand All @@ -125,18 +134,22 @@ contract StakingRewards is
uint256 _rewardsDuration
) external initializer {
require(_owner != address(0), "Owner cannot be zero address");
require(_rewardsDistribution != address(0), "RewardsDistribution cannot be zero address");
require(_rewardsToken != address(0), "RewardsToken cannot be zero address");
require(_stakingToken != address(0), "StakingToken cannot be zero address");

// Initialize inherited OZ contracts
__Ownable_init(_owner);
__AccessControl_init();
__ReentrancyGuard_init();
__Pausable_init();

_grantRole(DEFAULT_ADMIN_ROLE, _owner);

rewardsToken = IERC20(_rewardsToken);
stakingToken = IERC20(_stakingToken);

_setRewardsDistribution(_rewardsDistribution);
_grantRole(REWARDS_DISTRIBUTOR_ROLE, _rewardsDistribution);
_setRewardsDuration(_rewardsDuration);
}

Expand Down Expand Up @@ -220,12 +233,18 @@ contract StakingRewards is
* @dev Tokens are staked unlocked by default. Use lockStake() to lock staked tokens.
* @param amount The amount of tokens to stake
*/
function stake(uint256 amount) public nonReentrant whenNotPaused updateReward(msg.sender) {
require(amount > 0, "Cannot stake 0");
_totalSupply = _totalSupply + amount;
_balances[msg.sender] = _balances[msg.sender] + amount;
stakingToken.safeTransferFrom(msg.sender, address(this), amount);
emit Staked(msg.sender, amount);
function stake(uint256 amount) public {
_stake(msg.sender, amount);
}

/**
* @notice Stakes tokens on behalf of an account
* @dev Tokens are staked unlocked by default. Use stakeAndLockOnBehalf to lock staked tokens.
* @param account The address on whose behalf to stake
* @param amount The amount of tokens to stake
*/
function stakeOnBehalf(address account, uint256 amount) public onlyRole(STAKER_ON_BEHALF_ROLE) {
_stake(account, amount);
}

/**
Expand All @@ -237,35 +256,8 @@ contract StakingRewards is
* @param amount The amount of staked tokens to lock
* @param lockDuration The duration in seconds to lock the tokens
*/
function lockStake(uint256 amount, uint256 lockDuration) public whenNotPaused {
require(lockDuration > 0, "Lock duration must be greater than 0");
require(amount > 0, "Amount must be greater than 0");

// Check that user has enough unlocked staked balance to lock
uint256 currentLockedStakeAmount = getLockedStakeAmount(msg.sender);
uint256 newUnlockTimestamp = block.timestamp + lockDuration;

// Check if user has any locked tokens
if (currentLockedStakeAmount != 0) {
require(
amount >= currentLockedStakeAmount,
"Cannot reduce the amount of locked tokens"
);
require(
newUnlockTimestamp >= addressToLockedStake[msg.sender].unlockTimestamp,
"Cannot shorten the lock duration"
);
}

// Check that user has enough staked balance to lock
require(_balances[msg.sender] >= amount, "Not enough staked balance to lock");

// Update the locked tokens
addressToLockedStake[msg.sender].lockDuration = lockDuration;
addressToLockedStake[msg.sender].unlockTimestamp = newUnlockTimestamp;
addressToLockedStake[msg.sender].amount = amount;

emit StakeLocked(msg.sender, amount, lockDuration);
function lockStake(uint256 amount, uint256 lockDuration) public {
_lockStake(msg.sender, amount, lockDuration);
}

/**
Expand All @@ -280,8 +272,31 @@ contract StakingRewards is
uint256 amountToLock,
uint256 lockDuration
) public {
stake(amountToStake);
lockStake(amountToLock, lockDuration);
_stake(msg.sender, amountToStake);
_lockStake(msg.sender, amountToLock, lockDuration);
}

/**
* @notice Stakes and locks tokens on behalf of an account for a specified duration
* @dev Can only be called by the authorized stakerOnBehalf address. If the account already has a locked stake,
* the new lock duration will be the maximum of the existing unlock timestamp and the new lock duration.
* @param account The address on whose behalf to stake and lock
* @param amount The amount of tokens to stake and lock
* @param lockDuration The duration in seconds to lock the tokens
*/
function stakeAndLockOnBehalf(
address account,
uint256 amount,
uint256 lockDuration
) public onlyRole(STAKER_ON_BEHALF_ROLE) {
uint256 currentLockedStakeAmount = getLockedStakeAmount(account);
if (currentLockedStakeAmount > 0) {
if (block.timestamp + lockDuration < addressToLockedStake[account].unlockTimestamp) {
lockDuration = addressToLockedStake[account].unlockTimestamp - block.timestamp;
}
}
_stake(account, amount);
_lockStake(account, currentLockedStakeAmount + amount, lockDuration);
}

/**
Expand All @@ -292,6 +307,12 @@ contract StakingRewards is
function withdraw(uint256 amount) public nonReentrant updateReward(msg.sender) {
require(amount > 0, "Cannot withdraw 0");

require(
initialLockPeriodStart == 0 ||
block.timestamp >= initialLockPeriodStart + initialLockPeriodDuration,
"Withdraw not allowed during initial lock period"
);

// Check if there are any locked tokens
uint256 lockedStakeAmount = getLockedStakeAmount(msg.sender);

Expand All @@ -311,6 +332,11 @@ contract StakingRewards is
* @notice Claims all pending rewards for the caller
*/
function getReward() public nonReentrant updateReward(msg.sender) {
require(
initialLockPeriodStart == 0 ||
block.timestamp >= initialLockPeriodStart + initialLockPeriodDuration,
"Get rewards not allowed during initial lock period"
);
uint256 reward = rewards[msg.sender];
if (reward > 0) {
rewards[msg.sender] = 0;
Expand All @@ -336,7 +362,7 @@ contract StakingRewards is
*/
function notifyRewardAmount(
uint256 reward
) external onlyRewardsDistribution updateReward(address(0)) {
) external onlyRole(REWARDS_DISTRIBUTOR_ROLE) updateReward(address(0)) {
if (block.timestamp >= periodFinish) {
rewardRate = reward / rewardsDuration;
} else {
Expand All @@ -362,11 +388,19 @@ contract StakingRewards is
}

/**
* @notice Sets the rewards distribution address
* @param _rewardsDistribution The address authorized to call notifyRewardAmount
* @notice Sets the initial period during which withdrawals and get rewards are not allowed
* @param duration The duration in seconds for which which withdrawals and get rewards are not allowed
*/
function setRewardsDistribution(address _rewardsDistribution) external onlyOwner {
_setRewardsDistribution(_rewardsDistribution);
function setInitialLockPeriod(uint256 duration) external onlyOwner {
require(duration > 0, "Initial lock period must be greater than 0");
require(
initialLockPeriodStart == 0 ||
block.timestamp > initialLockPeriodStart + initialLockPeriodDuration,
"Previous initial lock period must be complete before changing the duration for the new period"
);
initialLockPeriodStart = block.timestamp;
initialLockPeriodDuration = duration;
emit InitialLockPeriodUpdated(duration);
}

/**
Expand Down Expand Up @@ -414,13 +448,65 @@ contract StakingRewards is
/* ========== INTERNAL FUNCTIONS ========== */

/**
* @notice Internal function to set the rewards distribution address
* @param _rewardsDistribution The address authorized to call notifyRewardAmount
* @notice Stakes tokens
* @dev Tokens are staked unlocked by default. Use lockStake() to lock staked tokens.
* @param account The address on whose behalf to stake
* @param amount The amount of tokens to stake
*/
function _setRewardsDistribution(address _rewardsDistribution) internal {
require(_rewardsDistribution != address(0), "RewardsDistribution cannot be zero address");
rewardsDistribution = _rewardsDistribution;
emit RewardsDistributionUpdated(_rewardsDistribution);
function _stake(
address account,
uint256 amount
) internal nonReentrant whenNotPaused updateReward(account) {
require(amount > 0, "Cannot stake 0");
_totalSupply = _totalSupply + amount;
_balances[account] = _balances[account] + amount;
stakingToken.safeTransferFrom(msg.sender, address(this), amount);
emit Staked(account, amount);
}

/**
* @notice Locks staked tokens for a specified duration
* @dev User must have enough staked balance to cover the new lock amount. If a lock already exists,
* the new amount cannot be less than the currently locked amount and the new unlock timestamp
* cannot be earlier than the current unlock timestamp. Increasing the lock amount uses additional
* unlocked staked balance on top of the already locked tokens.
* @param amount The amount of staked tokens to lock
* @param account The address on whose behalf to lock the tokens
* @param lockDuration The duration in seconds to lock the tokens
*/
function _lockStake(
address account,
uint256 amount,
uint256 lockDuration
) internal whenNotPaused {
require(lockDuration > 0, "Lock duration must be greater than 0");
require(amount > 0, "Amount must be greater than 0");

// Check that user has enough unlocked staked balance to lock
uint256 currentLockedStakeAmount = getLockedStakeAmount(account);
uint256 newUnlockTimestamp = block.timestamp + lockDuration;

// Check if user has any locked tokens
if (currentLockedStakeAmount != 0) {
require(
amount >= currentLockedStakeAmount,
"Cannot reduce the amount of locked tokens"
);
require(
newUnlockTimestamp >= addressToLockedStake[account].unlockTimestamp,
"Cannot shorten the lock duration"
);
}

// Check that user has enough staked balance to lock
require(_balances[account] >= amount, "Not enough staked balance to lock");

// Update the locked tokens
addressToLockedStake[account].lockDuration = lockDuration;
addressToLockedStake[account].unlockTimestamp = newUnlockTimestamp;
addressToLockedStake[account].amount = amount;

emit StakeLocked(account, amount, lockDuration);
}

/**
Expand All @@ -439,11 +525,6 @@ contract StakingRewards is

/* ========== MODIFIERS ========== */

modifier onlyRewardsDistribution() {
require(msg.sender == rewardsDistribution, "Caller is not RewardsDistribution contract");
_;
}

modifier updateReward(address account) {
rewardPerTokenStored = rewardPerToken();
lastUpdateTime = lastTimeRewardApplicable();
Expand All @@ -464,4 +545,5 @@ contract StakingRewards is
event Recovered(address token, uint256 amount);
event RewardsDistributionUpdated(address indexed newRewardsDistribution);
event StakeLocked(address indexed user, uint256 amount, uint256 lockDuration);
event InitialLockPeriodUpdated(uint256 newDuration);
}
14 changes: 10 additions & 4 deletions contracts/staking/interfaces/IStakingRewards.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* @notice Interface for the StakingRewards contract
* @dev Based on Synthetix StakingRewards with token locking support
*/
interface IStakingRewards {

Check warning on line 11 in contracts/staking/interfaces/IStakingRewards.sol

View workflow job for this annotation

GitHub Actions / solhint

Missing @author tag in contract 'IStakingRewards'
/* ========== STRUCTS ========== */

struct LockedStake {
Expand All @@ -19,7 +19,7 @@

/* ========== VIEWS ========== */

function rewardsToken() external view returns (IERC20);

Check warning on line 22 in contracts/staking/interfaces/IStakingRewards.sol

View workflow job for this annotation

GitHub Actions / solhint

Mismatch in @return count for function 'rewardsToken'. Expected: 1, Found: 0

Check warning on line 22 in contracts/staking/interfaces/IStakingRewards.sol

View workflow job for this annotation

GitHub Actions / solhint

Missing @return tag in function 'rewardsToken'

Check warning on line 22 in contracts/staking/interfaces/IStakingRewards.sol

View workflow job for this annotation

GitHub Actions / solhint

Missing @notice tag in function 'rewardsToken'

function stakingToken() external view returns (IERC20);

Expand All @@ -33,8 +33,6 @@

function rewardPerTokenStored() external view returns (uint256);

function rewardsDistribution() external view returns (address);

function userRewardPerTokenPaid(address account) external view returns (uint256);

function rewards(address account) external view returns (uint256);
Expand All @@ -61,6 +59,8 @@

function stake(uint256 amount) external;

function stakeOnBehalf(address account, uint256 amount) external;

function withdraw(uint256 amount) external;

function lockStake(uint256 amount, uint256 lockDuration) external;
Expand All @@ -71,6 +71,12 @@
uint256 lockDuration
) external;

function stakeAndLockOnBehalf(
address account,
uint256 amountToStake,
uint256 lockDuration
) external;

function getReward() external;

function exit() external;
Expand All @@ -79,13 +85,13 @@

function notifyRewardAmount(uint256 reward) external;

function setRewardsDistribution(address _rewardsDistribution) external;

function recoverERC20(address tokenAddress, uint256 tokenAmount) external;

function setRewardsDuration(uint256 _rewardsDuration) external;

function pause() external;

function unpause() external;

function setInitialLockPeriod(uint256 duration) external;
}
Loading
Loading