Lone Wintergreen Rattlesnake
High
A critical vulnerability has been identified in the TokenRewards contract's reward distribution logic. The issue affects the handling of excluded rewards for paused tokens during share modifications, potentially leading to permanent loss of rewards for users. The vulnerability stems from an inconsistency in how paused tokens are handled between two key functions: In _resetExcluded:
function _resetExcluded(address _wallet) internal {
for (uint256 _i; _i < _allRewardsTokens.length; _i++) {
address _token = _allRewardsTokens[_i];
rewards[_token][_wallet].excluded = _cumulativeRewards(_token, shares[_wallet], true);
}
}
In _distributeReward:
function _distributeReward(address _wallet) internal {
if (shares[_wallet] == 0) {
return;
}
for (uint256 _i; _i < _allRewardsTokens.length; _i++) {
address _token = _allRewardsTokens[_i];
if (REWARDS_WHITELISTER.paused(_token)) {
continue;
}
// ... reward distribution logic
}
}
The vulnerability arises from an inconsistency in how excluded rewards are handled between paused and active tokens during share modifications. When tokens are paused: _resetExcluded updates excluded amounts for ALL tokens during share changes, including paused tokens _distributeReward skips updating excluded amounts for paused tokens during reward distribution This asymmetry leads to artificially inflated excluded amounts for paused tokens
_resetExcluded updates excluded rewards for ALL tokens, including paused ones _distributeReward skips paused tokens When calculating unpaid rewards:
function getUnpaid(address _token, address _wallet) public view returns (uint256) {
uint256 earnedRewards = _cumulativeRewards(_token, shares[_wallet], false);
uint256 rewardsExcluded = rewards[_token][_wallet].excluded;
if (earnedRewards <= rewardsExcluded) {
return 0;
}
return earnedRewards - rewardsExcluded;
}
The excluded amount will always be greater than or equal to earned rewards due to rounding up in _resetExcluded, resulting in zero rewards.
No response
No response
No response
Users permanently lose rewards that accumulated while tokens were paused. Could result in significant financial losses depending on:
- Duration of token pause
- Amount of rewards accumulated
- Frequency of share modifications
Update the MockRewardWhiteliste.sol contract to correctly mimick the contract:
contract MockRewardsWhitelister {
bool private _paused;
mapping(address => bool) private _whitelist;
mapping(address => bool) private _isPaused;
function whitelist(address token) public view returns (bool) {
return _whitelist[token];
}
function setWhitelist(address token, bool status) public {
_whitelist[token] = status;
}
function paused(address _token) public view returns (bool) {
return _isPaused[_token];
}
function setTokenPaused(address _token, bool __isPaused) public {
_isPaused[_token] = __isPaused;
}
function setPaused(bool paused_) public {
_paused = paused_;
}
}
run test: forge test --match-contract TokenRewardsTest --match-test testPausedTokenExcludedRewardsDiscrepancy -vvv --fork-url ""
POC:
function testPausedTokenExcludedRewardsDiscrepancy() public {
// Setup
rewardsWhitelister.setWhitelist(address(rewardsToken), true);
rewardsWhitelister.setWhitelist(address(secondaryRewardToken), true);
vm.startPrank(address(trackingToken));
tokenRewards.setShares(user1, 100e18, false);
vm.stopPrank();
uint256 depositAmount = 100e18;
tokenRewards.depositRewards(address(secondaryRewardToken), depositAmount);
// Record initial unpaid rewards
uint256 initialUnpaid = tokenRewards.getUnpaid(address(secondaryRewardToken), user1);
assertEq(initialUnpaid, depositAmount, "Initial unpaid rewards should match deposit amount");
rewardsWhitelister.setTokenPaused(address(secondaryRewardToken), true);
tokenRewards.depositRewards(address(secondaryRewardToken), depositAmount);
uint256 unpaidAfterSecondDepositRewards = tokenRewards.getUnpaid(address(secondaryRewardToken), user1);
assertEq(unpaidAfterSecondDepositRewards, depositAmount * 2, "Unpaid rewards should not change while token is paused");
// get user 1 balance of secondary token
uint256 user1Balance = secondaryRewardToken.balanceOf(user1);
assertEq(user1Balance, 0, "User 1 balance of secondary token should be 0");
vm.startPrank(address(trackingToken));
tokenRewards.setShares(user1, 150e18, false); // Increase shares
vm.stopPrank();
uint256 user1BalanceAfterSetShareAndTokenPause = secondaryRewardToken.balanceOf(user1);
// This shows user 1 did not receive any rewards because token is paused
assertEq(user1BalanceAfterSetShareAndTokenPause, 0, "User 1 balance of secondary token should be 0");
initialUnpaid = tokenRewards.getUnpaid(address(secondaryRewardToken), user1);
// This assertion should fail because _resetExcluded updated the excluded amount
// while the token was paused, but _distributeReward skipped it
assertEq(initialUnpaid, 0, "Should have unpaid rewards after set share");
// token deposit
tokenRewards.depositRewards(address(secondaryRewardToken), depositAmount);
uint256 balanceOfTokenReward = secondaryRewardToken.balanceOf(address(tokenRewards));
assertEq(balanceOfTokenReward, depositAmount * 3, "Token reward balance should be 0");
rewardsWhitelister.setTokenPaused(address(secondaryRewardToken), false);
uint256 finalUnpaid = tokenRewards.getUnpaid(address(secondaryRewardToken), user1);
// This assertion should fail because _resetExcluded updated the excluded amount
// while the token was paused, but _distributeReward skipped it
assertGt(balanceOfTokenReward, finalUnpaid, "Should have unpaid rewards after unpausing");
}
Result
[⠊] Compiling...
[⠢] Compiling 1 files with Solc 0.8.28
[⠆] Solc 0.8.28 finished in 1.08s
Compiler run successful!
Ran 1 test for test/TokenRewards.t.sol:TokenRewardsTest
[PASS] testPausedTokenExcludedRewardsDiscrepancy() (gas: 397500)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 16.57s (6.04ms CPU time)
Ran 1 test suite in 16.96s (16.57s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Modify _resetExcluded to skip paused tokens:
function _resetExcluded(address _wallet) internal {
for (uint256 _i; _i < _allRewardsTokens.length; _i++) {
address _token = _allRewardsTokens[_i];
if (REWARDS_WHITELISTER.paused(_token)) {
continue;
}
rewards[_token][_wallet].excluded = _cumulativeRewards(_token, shares[_wallet], true);
}
}