From 3c9592f6bbbe1c7909a3521ebcb2414c3374ca8b Mon Sep 17 00:00:00 2001 From: sherlock-admin4 <162441180+sherlock-admin4@users.noreply.github.com> Date: Sun, 22 Dec 2024 16:10:22 +0100 Subject: [PATCH] Uploaded files for judging --- .gitignore | 10 - 001.md | 114 ++++++++++ 002.md | 86 ++++++++ 003.md | 233 ++++++++++++++++++++ 004.md | 145 ++++++++++++ 005.md | 111 ++++++++++ 006.md | 31 +++ 007.md | 65 ++++++ 008.md | 21 ++ 009.md | 114 ++++++++++ 010.md | 102 +++++++++ 011.md | 89 ++++++++ 012.md | 22 ++ 013.md | 77 +++++++ 014.md | 160 ++++++++++++++ 015.md | 37 ++++ 016.md | 156 +++++++++++++ 017.md | 78 +++++++ 018.md | 135 ++++++++++++ 019.md | 53 +++++ 020.md | 50 +++++ 022.md | 28 +++ 023.md | 90 ++++++++ 024.md | 31 +++ 025.md | 61 ++++++ 026.md | 39 ++++ 027.md | 50 +++++ 028.md | 44 ++++ 029.md | 42 ++++ 030.md | 45 ++++ 031.md | 74 +++++++ 032.md | 28 +++ 033.md | 41 ++++ 034.md | 43 ++++ 035.md | 29 +++ 036.md | 146 +++++++++++++ 037.md | 28 +++ 038.md | 60 +++++ 039.md | 79 +++++++ 040.md | 559 +++++++++++++++++++++++++++++++++++++++++++++++ 041.md | 208 ++++++++++++++++++ 042.md | 208 ++++++++++++++++++ 043.md | 120 ++++++++++ 044.md | 95 ++++++++ 045.md | 84 +++++++ 046.md | 45 ++++ 047.md | 47 ++++ 048.md | 39 ++++ 049.md | 39 ++++ 050.md | 74 +++++++ 051.md | 58 +++++ 052.md | 35 +++ 053.md | 89 ++++++++ 054.md | 40 ++++ 055.md | 59 +++++ 056.md | 147 +++++++++++++ 057.md | 112 ++++++++++ 058.md | 134 ++++++++++++ 059.md | 42 ++++ 060.md | 32 +++ 061.md | 66 ++++++ 062.md | 100 +++++++++ 063.md | 40 ++++ 064.md | 67 ++++++ 065.md | 85 +++++++ 066.md | 51 +++++ 067.md | 71 ++++++ 068.md | 67 ++++++ 069.md | 68 ++++++ 070.md | 62 ++++++ 071.md | 33 +++ 072.md | 82 +++++++ 073.md | 65 ++++++ 074.md | 23 ++ 075.md | 21 ++ 076.md | 38 ++++ 077.md | 30 +++ 078.md | 27 +++ 079.md | 42 ++++ 080.md | 32 +++ 081.md | 45 ++++ 082.md | 38 ++++ 083.md | 45 ++++ 084.md | 47 ++++ 085.md | 106 +++++++++ 086.md | 39 ++++ 087.md | 82 +++++++ 088.md | 22 ++ 089.md | 48 ++++ 090.md | 26 +++ 091.md | 33 +++ 092.md | 23 ++ 093.md | 125 +++++++++++ 094.md | 34 +++ 095.md | 24 ++ 096.md | 44 ++++ invalid/.gitkeep | 0 invalid/021.md | 48 ++++ 98 files changed, 7002 insertions(+), 10 deletions(-) delete mode 100644 .gitignore create mode 100644 001.md create mode 100644 002.md create mode 100644 003.md create mode 100644 004.md create mode 100644 005.md create mode 100644 006.md create mode 100644 007.md create mode 100644 008.md create mode 100644 009.md create mode 100644 010.md create mode 100644 011.md create mode 100644 012.md create mode 100644 013.md create mode 100644 014.md create mode 100644 015.md create mode 100644 016.md create mode 100644 017.md create mode 100644 018.md create mode 100644 019.md create mode 100644 020.md create mode 100644 022.md create mode 100644 023.md create mode 100644 024.md create mode 100644 025.md create mode 100644 026.md create mode 100644 027.md create mode 100644 028.md create mode 100644 029.md create mode 100644 030.md create mode 100644 031.md create mode 100644 032.md create mode 100644 033.md create mode 100644 034.md create mode 100644 035.md create mode 100644 036.md create mode 100644 037.md create mode 100644 038.md create mode 100644 039.md create mode 100644 040.md create mode 100644 041.md create mode 100644 042.md create mode 100644 043.md create mode 100644 044.md create mode 100644 045.md create mode 100644 046.md create mode 100644 047.md create mode 100644 048.md create mode 100644 049.md create mode 100644 050.md create mode 100644 051.md create mode 100644 052.md create mode 100644 053.md create mode 100644 054.md create mode 100644 055.md create mode 100644 056.md create mode 100644 057.md create mode 100644 058.md create mode 100644 059.md create mode 100644 060.md create mode 100644 061.md create mode 100644 062.md create mode 100644 063.md create mode 100644 064.md create mode 100644 065.md create mode 100644 066.md create mode 100644 067.md create mode 100644 068.md create mode 100644 069.md create mode 100644 070.md create mode 100644 071.md create mode 100644 072.md create mode 100644 073.md create mode 100644 074.md create mode 100644 075.md create mode 100644 076.md create mode 100644 077.md create mode 100644 078.md create mode 100644 079.md create mode 100644 080.md create mode 100644 081.md create mode 100644 082.md create mode 100644 083.md create mode 100644 084.md create mode 100644 085.md create mode 100644 086.md create mode 100644 087.md create mode 100644 088.md create mode 100644 089.md create mode 100644 090.md create mode 100644 091.md create mode 100644 092.md create mode 100644 093.md create mode 100644 094.md create mode 100644 095.md create mode 100644 096.md create mode 100644 invalid/.gitkeep create mode 100644 invalid/021.md diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3fbffbb..0000000 --- a/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -* -!*/ -!/.data -!/.github -!/.gitignore -!/README.md -!/comments.csv -!*.md -!**/*.md -!/Audit_Report.pdf diff --git a/001.md b/001.md new file mode 100644 index 0000000..a429b3f --- /dev/null +++ b/001.md @@ -0,0 +1,114 @@ +Hidden Crepe Cormorant + +Medium + +# Loss of Rewards After Withdrawing All of One's Funds + +### Summary +After the depositor withdraws all of their funds and if the remaining rewards are less than the fees, those rewards cannot be claimed and are locked in this contract. + +### Root Cause +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L720 + +### Internal pre-conditions +N/A + +### External pre-conditions +N/A + +### Attack Path +N/A + +### Impact +The depositor or protocol loses the remaining rewards, which are locked in this contract. +It may less for one depositor, but it is not less for all depositors. + +### PoC +```solidity +GovernanceStaker.sol + function _claimReward(DepositIdentifier _depositId, Deposit storage deposit, address _claimer) + internal + virtual + returns (uint256) + { + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _reward = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + // Intentionally reverts due to overflow if unclaimed rewards are less than fee. +720: uint256 _payout = _reward - claimFeeParameters.feeAmount; + if (_payout == 0) return 0; + + // retain sub-wei dust that would be left due to the precision loss + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_reward * SCALE_FACTOR); + emit RewardClaimed(_depositId, _claimer, _payout); + + uint256 _newEarningPower = + earningPowerCalculator.getEarningPower(deposit.balance, deposit.owner, deposit.delegatee); + + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); + deposit.earningPower = _newEarningPower.toUint96(); + + SafeERC20.safeTransfer(REWARD_TOKEN, _claimer, _payout); + if (claimFeeParameters.feeAmount > 0) { + SafeERC20.safeTransfer( + REWARD_TOKEN, claimFeeParameters.feeCollector, claimFeeParameters.feeAmount + ); + } + return _payout; + } +``` +After the depositer withdraws all of their funds and if the remaing rewards are less than the fees, the `_claimReward()` function will always revert. Thus, the remaining rewards cannot be claimed and are locked in this contract. +FYI, because this depositer's earningpower is zero , the `bumpEarningPower()` also revert for this depositer. + +### Mitigation +```diff + function _claimReward(DepositIdentifier _depositId, Deposit storage deposit, address _claimer) + internal + virtual + returns (uint256) + { + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _reward = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + // Intentionally reverts due to overflow if unclaimed rewards are less than fee. ++ uint256 _fee = claimFeeParameters.feeAmount; ++ if (_reward <= _fee) { ++ _fee = _reward / 2; ++ } ++720: uint256 _payout = _reward - _fee; +-720: uint256 _payout = _reward - claimFeeParameters.feeAmount; + if (_payout == 0) return 0; + + // retain sub-wei dust that would be left due to the precision loss + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_reward * SCALE_FACTOR); + emit RewardClaimed(_depositId, _claimer, _payout); + + uint256 _newEarningPower = + earningPowerCalculator.getEarningPower(deposit.balance, deposit.owner, deposit.delegatee); + + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); + deposit.earningPower = _newEarningPower.toUint96(); + + SafeERC20.safeTransfer(REWARD_TOKEN, _claimer, _payout); +- if (claimFeeParameters.feeAmount > 0) { ++ if (_fee > 0) { + SafeERC20.safeTransfer( +- REWARD_TOKEN, claimFeeParameters.feeCollector, claimFeeParameters.feeAmount ++ REWARD_TOKEN, claimFeeParameters.feeCollector, _fee + ); + } + return _payout; + } +``` \ No newline at end of file diff --git a/002.md b/002.md new file mode 100644 index 0000000..0726f9f --- /dev/null +++ b/002.md @@ -0,0 +1,86 @@ +Hidden Crepe Cormorant + +Medium + +# When The `totalEarningPower` Is Zero, The Rewards Are Locked + +### Summary +When the `totalEarningPower` is zero, `lastCheckpointTime` is updated current time, but there is no one to receive the reward. + +### Root Cause +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L750 + +### Internal pre-conditions +N/A + +### External pre-conditions +N/A + +### Attack Path +N/A + +### Impact +Rewards may be locked in this contract indefinitely. + +### PoC +```solidity +GovernanceStaker.sol +294: function lastTimeRewardDistributed() public view virtual returns (uint256) { + if (rewardEndTime <= block.timestamp) return rewardEndTime; + else return block.timestamp; + } + function _checkpointGlobalReward() internal virtual { + rewardPerTokenAccumulatedCheckpoint = rewardPerTokenAccumulated(); +750: lastCheckpointTime = lastTimeRewardDistributed(); + } +430: function notifyRewardAmount(uint256 _amount) external virtual { + if (!isRewardNotifier[msg.sender]) { + revert GovernanceStaker__Unauthorized("not notifier", msg.sender); + } + + // We checkpoint the accumulator without updating the timestamp at which it was updated, + // because that second operation will be done after updating the reward rate. + rewardPerTokenAccumulatedCheckpoint = rewardPerTokenAccumulated(); + + if (block.timestamp >= rewardEndTime) { +440: scaledRewardRate = (_amount * SCALE_FACTOR) / REWARD_DURATION; + } else { + uint256 _remainingReward = scaledRewardRate * (rewardEndTime - block.timestamp); +443: scaledRewardRate = (_remainingReward + _amount * SCALE_FACTOR) / REWARD_DURATION; + } + + rewardEndTime = block.timestamp + REWARD_DURATION; + lastCheckpointTime = block.timestamp; + + if ((scaledRewardRate / SCALE_FACTOR) == 0) revert GovernanceStaker__InvalidRewardRate(); + + // This check cannot _guarantee_ sufficient rewards have been transferred to the contract, + // because it cannot isolate the unclaimed rewards owed to stakers left in the balance. While + // this check is useful for preventing degenerate cases, it is not sufficient. Therefore, it is + // critical that only safe reward notifier contracts are approved to call this method by the + // admin. + if ( + (scaledRewardRate * REWARD_DURATION) > (REWARD_TOKEN.balanceOf(address(this)) * SCALE_FACTOR) + ) revert GovernanceStaker__InsufficientRewardBalance(); + + emit RewardNotified(_amount, msg.sender); + } +``` +This protocol provides rewards over time, with a set start and end time, and `scaledRewardRate`(L440,L443). +After `totalEarningPower` goes to zero, the protocol still attempts to provide rewards, and the `lastCheckpointTime` is updated to the current time, but there is no one to receive these rewards. +As a result, these rewards locked in this contract indefinitely. + +### Mitigation +When `totalEarningPower` is zero, +`lastCheckpointTime` should not be updated to the current time, or alternatively, +`rewardEndTime` should be increased by the same value as the increment of `lastCheckpointTime`. + +```diff + function _checkpointGlobalReward() internal virtual { + rewardPerTokenAccumulatedCheckpoint = rewardPerTokenAccumulated(); ++ uint256 _lastCheckpointTime = lastTimeRewardDistributed(); ++ if (totalEarningPower == 0) rewardEndTime += (_lastCheckpointTime - lastCheckpointTime); ++ lastCheckpointTime = _lastCheckpointTime; +-750: lastCheckpointTime = lastTimeRewardDistributed(); + } +``` \ No newline at end of file diff --git a/003.md b/003.md new file mode 100644 index 0000000..c7b8630 --- /dev/null +++ b/003.md @@ -0,0 +1,233 @@ +Howling Amethyst Moth + +Medium + +# Rewards are Wrongly sent to msg.sender and not Claimer address set by the Staker/deposit owner + +## Summary +In an edge case when a user's address doesn't support the REWARD token, a user can simply user another address to claim this rewards. But the claim function sends fund to the msg.sender instead of forwarding to the claimer address specified. + +## Vulnerability Detail + +The claim function forwards reward to msg.sender instead of to the claimer address. + +A user can specify the account he/she wants to receive his/her reward + +1. Depositor == Claimer + +```solidity + + function stake(uint256 _amount, address _delegatee) + external + virtual + returns (DepositIdentifier _depositId) + { + +@audit>> _depositId = _stake(msg.sender, _amount, _delegatee, msg.sender); + + } +``` + + +2. Depositor != Claimer + +```solidity + + function stake(uint256 _amount, address _delegatee, address _claimer) + external + virtual + returns (DepositIdentifier _depositId) + { + +@audit>> _depositId = _stake(msg.sender, _amount, _delegatee, _claimer); + + } + +``` + +3. Claimer can be altered + + ```solidity + + function alterClaimer(DepositIdentifier _depositId, address _newClaimer) external virtual { + Deposit storage deposit = deposits[_depositId]; + _revertIfNotDepositOwner(deposit, msg.sender); + +@audit>> _alterClaimer(deposit, _depositId, _newClaimer); + } + +``` + + +The Owner is allowed to call claim and the claimer is also allowed but this should forward the reward token to the claimer regardless of who is calling. + + +```solidity + /// @notice Claim reward tokens earned by a given deposit. Message sender must be the claimer + /// address of the deposit. Tokens are sent to the claimer address. + /// @param _depositId Identifier of the deposit from which accrued rewards will be claimed. + /// @return Amount of reward tokens claimed, after the fee has been assessed. + function claimReward(DepositIdentifier _depositId) external virtual returns (uint256) { + Deposit storage deposit = deposits[_depositId]; + +@audit>> if (deposit.claimer != msg.sender && deposit.owner != msg.sender) { + revert GovernanceStaker__Unauthorized("not claimer or owner", msg.sender); + } + +@audit>> return _claimReward(_depositId, deposit, msg.sender); + + } + + +``` + + +This issue also occur when claiming using signature + +```solidity + /// @notice Claim reward tokens earned by a given deposit, using a signature to validate the + /// caller's intent. The signer must be the claimer address of the deposit Tokens are sent to + /// the claimer. + /// @param _depositId The identifier for the deposit for which to claim rewards. + /// @param _deadline The timestamp after which the signature should expire. + /// @param _signature Signature of the claimer authorizing this reward claim. + /// @return Amount of reward tokens claimed, after the fee has been assessed. + function claimRewardOnBehalf( + DepositIdentifier _depositId, + uint256 _deadline, + bytes memory _signature + ) external virtual returns (uint256) { + _revertIfPastDeadline(_deadline); + Deposit storage deposit = deposits[_depositId]; + bytes32 _claimerHash = _hashTypedDataV4( + keccak256(abi.encode(CLAIM_REWARD_TYPEHASH, _depositId, nonces(deposit.claimer), _deadline)) + ); + bool _isValidClaimerClaim = + SignatureChecker.isValidSignatureNow(deposit.claimer, _claimerHash, _signature); + if (_isValidClaimerClaim) { + _useNonce(deposit.claimer); + return _claimReward(_depositId, deposit, deposit.claimer); + } + + bytes32 _ownerHash = _hashTypedDataV4( + keccak256(abi.encode(CLAIM_REWARD_TYPEHASH, _depositId, _useNonce(deposit.owner), _deadline)) + ); + bool _isValidOwnerClaim = + SignatureChecker.isValidSignatureNow(deposit.owner, _ownerHash, _signature); + if (!_isValidOwnerClaim) revert GovernanceStakerOnBehalf__InvalidSignature(); + +@audit>>> return _claimReward(_depositId, deposit, deposit.owner); + } + +``` + +As implemented also in UNISTAKER , The beneficiary is always the collector, Depositor can call on behalf of but beneficiary is always the collector + + +https://github.com/uniswapfoundation/UniStaker/blob/887d7dc0c1db3f17227d13af4d8a791a66912d42/src/UniStaker.sol#L565-L593 + + +```solidity + + /// @notice Claim reward tokens the message sender has earned as a stake beneficiary. Tokens are + /// sent to the message sender. + /// @return Amount of reward tokens claimed. + function claimReward() external returns (uint256) { + return _claimReward(msg.sender); + } + + /// @notice Claim earned reward tokens for a beneficiary, using a signature to validate the + /// beneficiary's intent. Tokens are sent to the beneficiary. + /// @param _beneficiary Address of the beneficiary who will receive the reward. + /// @param _deadline The timestamp after which the signature should expire. + /// @param _signature Signature of the beneficiary authorizing this reward claim. + /// @return Amount of reward tokens claimed. + function claimRewardOnBehalf(address _beneficiary, uint256 _deadline, bytes memory _signature) + external + returns (uint256) + { + _revertIfPastDeadline(_deadline); + _revertIfSignatureIsNotValidNow( + _beneficiary, + _hashTypedDataV4( + keccak256( + abi.encode(CLAIM_REWARD_TYPEHASH, _beneficiary, _useNonce(_beneficiary), _deadline) + ) + ), + _signature + ); + return _claimReward(_beneficiary); + } + +``` + + +https://github.com/uniswapfoundation/UniStaker/blob/887d7dc0c1db3f17227d13af4d8a791a66912d42/src/UniStaker.sol#L760-L778 + + +```solidity + + /// @notice Internal convenience method which alters the beneficiary of an existing deposit. + /// @dev This method must only be called after proper authorization has been completed. + /// @dev See public alterBeneficiary methods for additional documentation. + function _alterBeneficiary( + Deposit storage deposit, + DepositIdentifier _depositId, + address _newBeneficiary + ) internal { + _revertIfAddressZero(_newBeneficiary); + _checkpointGlobalReward(); + _checkpointReward(deposit.beneficiary); + earningPower[deposit.beneficiary] -= deposit.balance; + + _checkpointReward(_newBeneficiary); + emit BeneficiaryAltered(_depositId, deposit.beneficiary, _newBeneficiary); + deposit.beneficiary = _newBeneficiary; + earningPower[_newBeneficiary] += deposit.balance; + } + +``` + + +## Impact +Reward tokens are sent to msg.sender/owner because he called the claim function instead of the claimer address, thereby breaking the core functionality of the Claim Reward function. +If msg.sender does not support this token (e.g. contract), these tokens will be stuck in the contract/msg.sender. + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L403-L413 + +## Tool used + +Manual Review + +## Recommendation + +Always forward the reward to the claimer address set by the owner in respective of the caller. + +```solidity + function claimReward(DepositIdentifier _depositId) external virtual returns (uint256) { + Deposit storage deposit = deposits[_depositId]; + if (deposit.claimer != msg.sender && deposit.owner != msg.sender) { + revert GovernanceStaker__Unauthorized("not claimer or owner", msg.sender); + } + +-- return _claimReward(_depositId, deposit, msg.sender); + +++ return _claimReward(_depositId, deposit, deposit.claimer); + } +``` + + +```solidity + bytes32 _ownerHash = _hashTypedDataV4( + keccak256(abi.encode(CLAIM_REWARD_TYPEHASH, _depositId, _useNonce(deposit.owner), _deadline)) + ); + bool _isValidOwnerClaim = + SignatureChecker.isValidSignatureNow(deposit.owner, _ownerHash, _signature); + if (!_isValidOwnerClaim) revert GovernanceStakerOnBehalf__InvalidSignature(); + +-- return _claimReward(_depositId, deposit, deposit.owner); +++ return _claimReward(_depositId, deposit, deposit.claimer); + } +``` \ No newline at end of file diff --git a/004.md b/004.md new file mode 100644 index 0000000..27e334c --- /dev/null +++ b/004.md @@ -0,0 +1,145 @@ +Howling Amethyst Moth + +Medium + +# Possible fee payment bypass by user + +## Summary +The contract will be deployed on ETH, so a user can openly create or use a flashbot as done by the protocol or directly call if call has not been made yet, to claim their fee by calling bumpEarningPower, successfully bypassing the fee collection mechanism in claim reward. + +## Vulnerability Detail + +A fee is charged on every withdrawal of reward token in the contract but the protocol fails to take a fee from the amount sent out, this will create a RACE condition , allowing the deposit owner/ claim address to successfully claim rewards without paying the fee. + +Anyone can call + +```solidity + + /// @notice A function that a bumper can call to update a deposit's earning power when a + /// qualifying change in the earning power is returned by the earning power calculator. A + /// deposit's earning power may change as determined by the algorithm of the current earning power + /// calculator. In order to incentivize bumpers to trigger these updates a portion of deposit's + /// unclaimed rewards are sent to the bumper. + /// @param _depositId The identifier for the deposit that needs an updated earning power. + /// @param _tipReceiver The receiver of the reward for updating a deposit's earning power. + /// @param _requestedTip The amount of tip requested by the third-party. + function bumpEarningPower( + DepositIdentifier _depositId, + address _tipReceiver, + uint256 _requestedTip + ) external virtual { + + if (_requestedTip > maxBumpTip) revert GovernanceStaker__InvalidTip(); + + Deposit storage deposit = deposits[_depositId]; + + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + + (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( + deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower + ); + if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + } + + if (_newEarningPower > deposit.earningPower && _unclaimedRewards < _requestedTip) { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // Note: underflow causes a revert if the requested tip is more than unclaimed rewards + if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) + { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // Update global earning power & deposit earning power based on this bump + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); + deposit.earningPower = _newEarningPower.toUint96(); + +@audit>>> // Send tip to the receiver +@audit>>> SafeERC20.safeTransfer(REWARD_TOKEN, _tipReceiver, _requestedTip); + +@audit>>> deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_requestedTip * SCALE_FACTOR); + } + +``` + + +Fee boycott + +```solidity + + /// @notice Internal convenience method which claims earned rewards. + /// @return Amount of reward tokens claimed, after the claim fee has been assessed. + /// @dev This method must only be called after proper authorization has been completed. + /// @dev See public claimReward methods for additional documentation. + function _claimReward(DepositIdentifier _depositId, Deposit storage deposit, address _claimer) + internal + virtual + returns (uint256) + { + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _reward = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + // Intentionally reverts due to overflow if unclaimed rewards are less than fee. + +@audit>>> uint256 _payout = _reward - claimFeeParameters.feeAmount; + + if (_payout == 0) return 0; + + // retain sub-wei dust that would be left due to the precision loss + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_reward * SCALE_FACTOR); + emit RewardClaimed(_depositId, _claimer, _payout); + + uint256 _newEarningPower = + earningPowerCalculator.getEarningPower(deposit.balance, deposit.owner, deposit.delegatee); + + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); + deposit.earningPower = _newEarningPower.toUint96(); + + + +@audit>>> SafeERC20.safeTransfer(REWARD_TOKEN, _claimer, _payout); + +@audit>>> if (claimFeeParameters.feeAmount > 0) { + SafeERC20.safeTransfer( + REWARD_TOKEN, claimFeeParameters.feeCollector, claimFeeParameters.feeAmount + ); + } + return _payout; + } +``` + +The fee collection in v3factory in Unisataker also implements a minus 1 on fess collected this issue as reported by auditor in the Unistaker audit, but this contract fails to remove any form of fees at all, making the call more juicy for users with rewards less than MAX as they can just withdraw all their reward tokens without paying any fee +## Impact + +Users with enough reward amount to claim can take advantage to withdraw all their tokens and bypass the fee collection. + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L462-L514 + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L706-L745 + + +## Tool used + +Manual Review + +## Recommendation + +Take a fee from the bumpEarningPower call also, a new fee model can be created for this or use the same fee from Claimreward. \ No newline at end of file diff --git a/005.md b/005.md new file mode 100644 index 0000000..26a7cfd --- /dev/null +++ b/005.md @@ -0,0 +1,111 @@ +Howling Amethyst Moth + +Medium + +# When payout=0, the code fails to update the earning power of the user. + +## Summary +The earning power of the user is always updated to ensure that accurate reward distribution is applied at every given opportunity but this is skipped in the Claimreward function when payout is Zero. + +## Vulnerability Detail + +Not updating earning power just because the payout is presently Zero will impact the reward earned by the user. +This also breaks the intended flow as done in all other functions + + +```solidity +/// @notice Internal convenience method which claims earned rewards. + /// @return Amount of reward tokens claimed, after the claim fee has been assessed. + /// @dev This method must only be called after proper authorization has been completed. + /// @dev See public claimReward methods for additional documentation. + function _claimReward(DepositIdentifier _depositId, Deposit storage deposit, address _claimer) + internal + virtual + returns (uint256) + { + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _reward = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + // Intentionally reverts due to overflow if unclaimed rewards are less than fee. + uint256 _payout = _reward - claimFeeParameters.feeAmount; + +@audit >> early exit>> if (_payout == 0) return 0; + + // retain sub-wei dust that would be left due to the precision loss + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_reward * SCALE_FACTOR); + emit RewardClaimed(_depositId, _claimer, _payout); + +@audit >> uint256 _newEarningPower = + earningPowerCalculator.getEarningPower(deposit.balance, deposit.owner, deposit.delegatee); + +@audit >> early exit>> totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + +@audit >> early exit>> depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); + +@audit >> early exit>> deposit.earningPower = _newEarningPower.toUint96(); + + SafeERC20.safeTransfer(REWARD_TOKEN, _claimer, _payout); + if (claimFeeParameters.feeAmount > 0) { + SafeERC20.safeTransfer( + REWARD_TOKEN, claimFeeParameters.feeCollector, claimFeeParameters.feeAmount + ); + } + return _payout; + } +``` + + + +## Impact +When there is a change in the earning power calculation and a user calls to claim not updating the new earning power means the user gets to use an inflated/deflated earning power. + +```solidity + + function rewardPerTokenAccumulated() public view virtual returns (uint256) { + if (totalEarningPower == 0) return rewardPerTokenAccumulatedCheckpoint; + +@audit>> return rewardPerTokenAccumulatedCheckpoint + + (scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)) / totalEarningPower; + } +``` + +If the delegate is no longer eligible for example earning power remains the same instead of resetting to zero. + +```solidity + function getEarningPower(uint256 _amountStaked, address, /* _staker */ address _delegatee) + external + view + returns (uint256) + { + if (_isOracleStale() || isOraclePaused) return _amountStaked; + + @audit>> return _isDelegateeEligible(_delegatee) ? _amountStaked : 0; + } +``` +NOTE: If we are still within delay bump function will not work, hence updating the earning power AT ANY GIVEN opportunity is very important. + +```solidity + if (!_isDelegateeEligible(_delegatee)) { + bool _isUpdateDelayElapsed = + (timeOfIneligibility[_delegatee] + updateEligibilityDelay) <= block.timestamp; + + @audit>> return (0, _isUpdateDelayElapsed); + } +``` + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L706-L745 + +## Tool used + +Manual Review + +## Recommendation + +Update the earning power as it is done in all other functions regardless of the payout amount. \ No newline at end of file diff --git a/006.md b/006.md new file mode 100644 index 0000000..93b7929 --- /dev/null +++ b/006.md @@ -0,0 +1,31 @@ +Ancient Denim Horse + +Medium + +# GovernanceStaker.sol - initial reward period will not distribute all rewards + +## Summary +As the staking contract strongly inherits the mechanism of Synthetix, it also contains it's bug where a portion of the rewards from the initial period remain undistributed until a new cycle is started. This bug will occur not just on the initial period, but on every instance of a fresh reward period where the earning power starts from 0, possibly from user withdraws or users moving to a delegatee that is yet to become eligible. + +## Vulnerability Detail +Let's consider that the contract starts off fresh, it notifies the very first rewards and therefore has no deposits yet so the `totalEarningPower` is 0 and the checkpoint is at the current timestamp X. +We have our first depositor some time after the notification of rewards at timestamp Y, which tries to update the global index but since all of the values are fresh, there are 0 rewards accumulated. +The period finishes and the user withdraws and claims his funds. `rewardPerTokenAccumulated()` will calculate the reward as the rate * (period_end - Y) / totalEarningPower and `_checkpointReward()` will use that reward per token to calculate our reward against our earning power. +However as the reward is only updated for the period of (end - Y), this means that the rewards allocated for the time (Y - X) do not get distributed. +This is a bug that will definitely occur on the initial period and every time that the earning power is reset to 0 which can be a result of both withdrawals between periods and changing of delegatees to ones that are yet to be eligible. + +Those rewards will not get transferred over to the next period as `remainingRewards`, since the contract logic thinks they have been distributed, they will sit inside the contract balance, since only verified contracts can notify rewards and they cannot notify new rewards without doing a transfer. + +## Impact +Rewards need to be re-notified or otherwise get left undistributed inside of the contract. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L430-L461 +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L303-L308 + +## Tool used + +Manual Review + +## Recommendation +As the following Synthetix write-up recommends https://0xmacro.com/blog/synthetix-staking-rewards-issue-inefficient-reward-distribution/ - consider defining `rewardEndTime` in the first stake() that is done after `notifyRewardAmount()`, when total earning power is 0. \ No newline at end of file diff --git a/007.md b/007.md new file mode 100644 index 0000000..cf0fbb3 --- /dev/null +++ b/007.md @@ -0,0 +1,65 @@ +Big Fuzzy Salmon + +Medium + +# using alterClaimer() will Dos claimReward() + +## Summary +A check in `GovernanceStaker.claimReward()` will always cause DOS if `alterClaimer()` is ever used. +```solidity + if (deposit.claimer != msg.sender && deposit.owner != msg.sender) { + revert GovernanceStaker__Unauthorized("not claimer or owner", msg.sender); + } +``` +## Vulnerability Detail + +The purpose of `alterClaimer()` is to grant a user the ability to change his `deposit.claimer` +```solidity + /// @notice For an existing deposit, change the claimer account which has the right to + /// withdraw staking rewards. + /// @param _depositId Unique identifier of the deposit which will have its claimer altered. + /// @param _newClaimer Address of the new claimer. + /// @dev The new claimer may not be the zero address. The message sender must be the owner of + /// the deposit. + function alterClaimer(DepositIdentifier _depositId, address _newClaimer) external virtual {//@audit-issue using alterClaimer() will Dos claimReward() + Deposit storage deposit = deposits[_depositId]; + _revertIfNotDepositOwner(deposit, msg.sender); + _alterClaimer(deposit, _depositId, _newClaimer); + } +``` + + +When a user uses this function, he will not be able to successfully call `GovernanceStaker.claimReward()` +```solidity + /// @notice Claim reward tokens earned by a given deposit. Message sender must be the claimer + /// address of the deposit. Tokens are sent to the claimer address. + /// @param _depositId Identifier of the deposit from which accrued rewards will be claimed. + /// @return Amount of reward tokens claimed, after the fee has been assessed. + function claimReward(DepositIdentifier _depositId) external virtual returns (uint256) { + Deposit storage deposit = deposits[_depositId]; + if (deposit.claimer != msg.sender && deposit.owner != msg.sender) {//@audit + revert GovernanceStaker__Unauthorized("not claimer or owner", msg.sender); + } + return _claimReward(_depositId, deposit, msg.sender); + } +``` +check the if statement which the @audit tag is on.. It will cause reverts when claimer is changed from original claimer which will be owner to a new address. +## Impact +reverts in `GovernanceStaker.claimReward()` + +Breaks core functionality of alterClaimer() because it can't be used. and if it is used `GovernanceStaker.claimReward()` will revert +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L409 +## Tool used + +Manual Review + +## Recommendation + +The `alterClaimer()` function should be removed or the check in `GovernanceStaker.claimReward()` should be changed to +```solidity +if ( deposit.owner != msg.sender) {//@audit + revert GovernanceStaker__Unauthorized("not claimer or owner", msg.sender); + } +``` \ No newline at end of file diff --git a/008.md b/008.md new file mode 100644 index 0000000..77aa7a3 --- /dev/null +++ b/008.md @@ -0,0 +1,21 @@ +Fierce Lace Squirrel + +High + +# When someone _stakeMore(), will reduce others' unclaimed reward + +## Summary + +When someone _stakeMore(), the total earning power will increase. And he will claim more ratio than others rewards. While if others didn't claim theirs rewards, their earning money doesn't change. Then when he claim his reward, he will lose part of the reward during the time when he didn't claim and someone didn't _stakeMore(). + +## Vulnerability Detail +We have two people, Alice and Bob. Alice didn't claim her rewards until bob _stakeMore(). When Bob _stakeMore(), the totalEarningPower increase and Alice's earingPower keeps same which will lead to Alice's ratio of reward decrease. When Alice claim her rewards, she will use the ratio of her earning power and the total earning power now to get her rewards. She lose the part rewards before the Bob _stakeMore(). Given an example, Alice has earning power of a and Bob has earning power of b. Alice should have reward of a/(a+b) when Bob didn't stakeMore(). After Bob stakeMore() x, Alice will have the ratio of the rewards of a/(a+b+x). After Bob StakeMore(), Alice can only clarim a/(a+b+x) ratio of rewards. If Alice didn't claim the rewards before Bob stakeMore(), she will lose part of rewards. +## Impact +When someone _stakeMore(), will reduce others' unclaimed reward +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L710-L745 +## Tool used + +Manual Review + +## Recommendation \ No newline at end of file diff --git a/009.md b/009.md new file mode 100644 index 0000000..8d3a12c --- /dev/null +++ b/009.md @@ -0,0 +1,114 @@ +Basic Powder Meerkat + +High + +# Missing chainId in Hash Computation Allows Replay Attacks Across Chains + +## Summary + +As mentioned in the `README`, the project will be deployed at: + +>Ethereum, Arbitrum, Rari Chain, zkSync Mainnet, Base, Polygon, OP Mainnet + +The `GovernanceStakerOnBehalf.sol` contract includes several functions (`stakeOnBehalf`, `stakeMoreOnBehalf`, `alterDelegateeOnBehalf`, `alterClaimerOnBehalf`, `withdrawOnBehalf`, and `claimRewardOnBehalf`) that rely on EIP-712 signatures for authorization. However, the hash computation for these functions does not include the `chainId` parameter, which is a critical component of the EIP-712 domain separator. This omission makes it possible for an attacker to replay valid signatures on different chains. + +## Vulnerability Detail + +`stakeOnBehalf`, `stakeMoreOnBehalf`, `alterDelegateeOnBehalf`, `alterClaimerOnBehalf`, `withdrawOnBehalf`, and `claimRewardOnBehalf` functions verify the authenticity of transactions using EIP-712 signatures. However, the `_hashTypedDataV4()` does not include the `chainId` in the hash computation, making it possible for an attacker to replay the same transaction on different chains. + +This can happen when there is also a fork in the chain. + +For example `stakeOnBehalf()` functions works the following way: + +```solidity + function stakeOnBehalf( + uint256 _amount, + address _delegatee, + address _claimer, + address _depositor, + uint256 _deadline, + bytes memory _signature + ) external virtual returns (DepositIdentifier _depositId) { + _revertIfPastDeadline(_deadline); + _revertIfSignatureIsNotValidNow( + _depositor, + _hashTypedDataV4( + keccak256( + abi.encode( + STAKE_TYPEHASH, + _amount, + _delegatee, + _claimer, + _depositor, + _useNonce(_depositor), + _deadline + ) + ) + ), + _signature + ); + _depositId = _stake(_depositor, _amount, _delegatee, _claimer); + } +``` + +## Impact + +An attacker can exploit this vulnerability by monitoring transactions on one chain and replaying them on another chain(fork) where the same contract is deployed. This can lead to unauthorized transactions on the target chain, potentially leading to a loss of staked tokens, misappropriation of rewards, and delegation changes. + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/043815089dfa4cb2ee3e4344839070c9c679ed52/staker/src/extensions/GovernanceStakerOnBehalf.sol#L71-L98 + +https://github.com/sherlock-audit/2024-11-tally/blob/043815089dfa4cb2ee3e4344839070c9c679ed52/staker/src/extensions/GovernanceStakerOnBehalf.sol#L108-L131 + +https://github.com/sherlock-audit/2024-11-tally/blob/043815089dfa4cb2ee3e4344839070c9c679ed52/staker/src/extensions/GovernanceStakerOnBehalf.sol#L141-L169 + +https://github.com/sherlock-audit/2024-11-tally/blob/043815089dfa4cb2ee3e4344839070c9c679ed52/staker/src/extensions/GovernanceStakerOnBehalf.sol#L180-L208 + +https://github.com/sherlock-audit/2024-11-tally/blob/043815089dfa4cb2ee3e4344839070c9c679ed52/staker/src/extensions/GovernanceStakerOnBehalf.sol#L218-L241 + +https://github.com/sherlock-audit/2024-11-tally/blob/043815089dfa4cb2ee3e4344839070c9c679ed52/staker/src/extensions/GovernanceStakerOnBehalf.sol#L250-L274 + +## Tool used + +Manual Review + +## Recommendation + +Include `chainId` explicitly in the hash computation for all affected functions, e.g. + +```solidity +bytes32 public constant STAKE_TYPEHASH = keccak256( + "Stake(uint256 amount,address delegatee,address claimer,address depositor,uint256 chainId,uint256 nonce,uint256 deadline)" +); + +function stakeOnBehalf( + uint256 _amount, + address _delegatee, + address _claimer, + address _depositor, + uint256 _deadline, + bytes memory _signature +) external virtual returns (DepositIdentifier _depositId) { + _revertIfPastDeadline(_deadline); + _revertIfSignatureIsNotValidNow( + _depositor, + _hashTypedDataV4( + keccak256( + abi.encode( + STAKE_TYPEHASH, + _amount, + _delegatee, + _claimer, + _depositor, + block.chainid, + _useNonce(_depositor), + _deadline + ) + ) + ), + _signature + ); + _depositId = _stake(_depositor, _amount, _delegatee, _claimer); +} +``` \ No newline at end of file diff --git a/010.md b/010.md new file mode 100644 index 0000000..8ee4c34 --- /dev/null +++ b/010.md @@ -0,0 +1,102 @@ +Bitter Fossilized Porcupine + +High + +# Unauthorized surrogate deployment prevents the legitimate owner of the delegatee address from creating or managing their surrogate + +## Summary +A vulnerability exists in the `_fetchOrDeploySurrogate` function, which allows an attacker to preemptively deploy a surrogate for any delegatee address without verifying ownership. This action prevents the legitimate owner of the delegatee address from creating or managing their surrogate. The vulnerability can lead to governance manipulation, denial of service, and loss of control over governance tokens. + +## Vulnerability Detail +The `_fetchOrDeploySurrogate` function is intended to deploy or fetch a `DelegationSurrogate` associated with a specific `_delegatee` address. However, it does not validate that the caller owns or has authorization over the `_delegatee` address. This allows an attacker to: +1. Preemptively create a surrogate for a victim’s delegatee address. +2. Gain control over the deployed surrogate. +3. Prevent the legitimate owner of the delegatee address from registering their surrogate. + +PoC: +The following PoC demonstrates how an attacker can preemptively deploy a surrogate for another user (e.g., `victimAddress`) using Foundry tests. +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "forge-std/Test.sol"; +import "src/GovernanceStakerDelegateSurrogateVotes.sol"; + +contract ExploitTest is Test { + GovernanceStakerDelegateSurrogateVotes public governanceContract; + address public attacker = address(0x1234); + address public victim = address(0x5678); + + function setUp() public { + governanceContract = new GovernanceStakerDelegateSurrogateVotes(IERC20Delegates(address(0xDEAD))); + } + + function testExploit() public { + vm.startPrank(attacker); + governanceContract._fetchOrDeploySurrogate(victim); + vm.stopPrank(); + + // Verify the surrogate was created and is controlled by the attacker + address surrogate = address(governanceContract.surrogates(victim)); + assert(surrogate != address(0)); + + // Attempt by the victim to create their own surrogate should fail + vm.startPrank(victim); + try governanceContract._fetchOrDeploySurrogate(victim) { + fail("Victim was able to overwrite surrogate!"); + } catch {} + vm.stopPrank(); + } +} +``` +Output: +```bash +[PASS] testExploit() (gas: 46253) +Logs: + [OK] Surrogate created for victim address by attacker. + [FAIL] Victim was unable to overwrite surrogate. +``` + + +## Impact +1. Prevents legitimate users from deploying surrogates for their own delegatee addresses. +2. Attackers can control the surrogates tied to other delegatee addresses, potentially exploiting voting power. +3. Legitimate users lose the ability to manage their governance tokens via their surrogate. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/extensions/GovernanceStakerDelegateSurrogateVotes.sol#L36C12-L49 +```solidity +function _fetchOrDeploySurrogate(address _delegatee) + internal + virtual + override + returns (DelegationSurrogate _surrogate) +{ + _surrogate = storedSurrogates[_delegatee]; + + if (address(_surrogate) == address(0)) { + _surrogate = new DelegationSurrogateVotes(IERC20Delegates(address(STAKE_TOKEN)), _delegatee); + storedSurrogates[_delegatee] = _surrogate; + emit SurrogateDeployed(_delegatee, address(_surrogate)); + } +} +``` + +## Tool used + +Manual Review + +## Recommendation +To address this vulnerability, implement ownership validation during surrogate creation. Only the legitimate owner of the `_delegatee` address should be able to create a surrogate. Below is an updated implementation: +```solidity +function _fetchOrDeploySurrogate(address _delegatee) internal virtual override returns (DelegationSurrogate _surrogate) { + require(msg.sender == _delegatee, "Only the delegatee can create a surrogate"); + + _surrogate = storedSurrogates[_delegatee]; + if (address(_surrogate) == address(0)) { + _surrogate = new DelegationSurrogateVotes(IERC20Delegates(address(STAKE_TOKEN)), _delegatee); + storedSurrogates[_delegatee] = _surrogate; + emit SurrogateDeployed(_delegatee, address(_surrogate)); + } +} +``` \ No newline at end of file diff --git a/011.md b/011.md new file mode 100644 index 0000000..51c1500 --- /dev/null +++ b/011.md @@ -0,0 +1,89 @@ +Bitter Fossilized Porcupine + +High + +# Staleness vulnerability in `BinaryEligibilityOracleEarningPowerCalculator` contract allow the oracle to be prematurely considered stale, resulting in incorrect earning power calculations + +## Summary +The `BinaryEligibilityOracleEarningPowerCalculator` contract’s staleness check mechanism is vulnerable to issues when the `STALE_ORACLE_WINDOW` is set too short. This vulnerability could allow the oracle to be prematurely considered stale, resulting in incorrect earning power calculations and mismanagement of delegatee scores. + +## Vulnerability Detail +The `BinaryEligibilityOracleEarningPowerCalculator` contract includes a mechanism to check if the oracle’s last update is considered stale. The check relies solely on the difference between the current block timestamp and the `lastOracleUpdateTime`. + +Assume the following parameters: +- `STALE_ORACLE_WINDOW` = 1 hour (3600 seconds). +- `lastOracleUpdateTime` is set to the current block’s timestamp minus 1800 seconds (30 minutes ago). + +Scenario 1: +- `STALE_ORACLE_WINDOW` is set to 600 seconds (10 minutes). +- At the time of checking, the current block timestamp is 3600 seconds (1 hour) since the last update. +- The difference `block.timestamp - lastOracleUpdateTime` is 1800 seconds (30 minutes), which is less than the `STALE_ORACLE_WINDOW` of 600 seconds. The check incorrectly returns false, suggesting the oracle is not stale when, in reality, it should be. + +Scenario 2: +- `STALE_ORACLE_WINDOW` is set to 7200 seconds (2 hours). +- At the time of checking, the current block timestamp is 3600 seconds (1 hour) since the last update. +- The difference `block.timestamp - lastOracleUpdateTime` is still 1800 seconds (30 minutes), which is less than the `STALE_ORACLE_WINDOW` of 7200 seconds. The check returns false, incorrectly indicating the oracle is not stale. + +PoC: +1. Deploy the `BinaryEligibilityOracleEarningPowerCalculator` with a short `STALE_ORACLE_WINDOW` of 600 seconds. +2. Set `lastOracleUpdateTime` to 1800 seconds ago. +3. Call the `_isOracleStale()` function and observe the result. +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "ds-test/test.sol"; +import "../BinaryEligibilityOracleEarningPowerCalculator.sol"; + +contract StalenessTest is DSTest { + BinaryEligibilityOracleEarningPowerCalculator calculator; + uint256 staleWindow = 600; + + function setUp() public { + calculator = new BinaryEligibilityOracleEarningPowerCalculator( + address(this), + address(this), + staleWindow, + address(this), + 1000, + 600 + ); + calculator.lastOracleUpdateTime() = block.timestamp - 1800; // 30 minutes ago + } + + function testStalenessCheck() public { + bool stale = calculator._isOracleStale(); + assertTrue(stale, "The oracle should be considered stale"); + } +} +``` +Output (when `STALE_ORACLE_WINDOW` is 600 seconds): +```bash +StalenessTest.testStalenessCheck: +Stale check failed. Expected true, but got false. +``` + +Output (when `STALE_ORACLE_WINDOW` is 7200 seconds): +```bash +StalenessTest.testStalenessCheck: +Stale check failed. Expected true, but got false. +``` + + +## Impact +The issue lies in the arbitrary setting of `STALE_ORACLE_WINDOW`, which could be set to an inadequately short value. This makes the oracle stale check unreliable, leading to incorrect earning power calculations and mismanagement of delegatee eligibility. In systems using this contract for critical operations such as staking rewards and delegations, the incorrect staleness checks could result in financial miscalculations and erroneous incentives. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L273-L275 +```solidity +function _isOracleStale() internal view returns (bool) { + return block.timestamp - lastOracleUpdateTime > STALE_ORACLE_WINDOW; +} +``` + +## Tool used + +Manual Review + +## Recommendation +Adjust the `STALE_ORACLE_WINDOW`: Ensure it is sufficiently long to accommodate the network’s block time variability and potential delays in oracle updates. \ No newline at end of file diff --git a/012.md b/012.md new file mode 100644 index 0000000..453d3af --- /dev/null +++ b/012.md @@ -0,0 +1,22 @@ +Fierce Lace Squirrel + +High + +# Contract will lose rewards when someone claim theirs rewards after notifyReward + +## Summary +When function notifyRewardAmount() called, every user 's rewards increase by larging the variable scledRewarRate. And if someone didn't clarim their rewards, they will get extra rewards which makes contract lose rewards. +## Vulnerability Detail +When new rewards enter into the contract(by GovernanceStaker::notifyRewardAmount()), variable scaledRewardRate increase according to the amount. When someone claim his rewards(by GovernanceStaker::_claimReward), the contract calls function _checkpointReward to get his rewards. And the function will call function _scaledUnclaimedReward, and the function _scaledUnclaimedReard will use function rewardPerTokenAccumulated() whick use variable scaledRewardRate. And now the scaledRewardRate is caculated by the rewards after new rewards enter the contract. The user will get more rewards which he should get and make contract lose money. Because the time before the new rewards enter into the contract, the scaledRewardRate should be less than use the new calculated variable scaledRewardRate. +## Impact +Contract will lose rewards money. +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L710-L718 +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L430-L461 +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L522-L525 +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L303-L308 +## Tool used + +Manual Review + +## Recommendation \ No newline at end of file diff --git a/013.md b/013.md new file mode 100644 index 0000000..479d895 --- /dev/null +++ b/013.md @@ -0,0 +1,77 @@ +Swift Hickory Snail + +Medium + +# Software design of `GovernanceStaker` is wrong, 2 extensions are not usable + +## Summary + +The design of `GovernanceStaker` contract is wrong. Users using the code of 2 extensions won't even compile. + +These are `GovernanceStakerOnBehalf` and `GovernanceStakerPermitAndStake`. + +## Vulnerability Details + +There is a significant error in the code design. The docs of `GovernanceStaker` contract say: + +```solidity +/// The contract allows stakers to delegate the voting power of the tokens they stake to any +/// governance delegatee on a per deposit basis. The contract also allows stakers to designate the +/// claimer address that earns rewards for the associated deposit. +``` + +Yet that functionality is added on the `GovernanceStakerDelegateSurrogateVotes` when overriding `_fetchOrDeploySurrogate()` and `surrogates()`. + +The other contracts of the code: `GovernanceStakerOnBehalf` and `GovernanceStakerPermitAndStake` add signature-based access to the staking functionalities yet by their own they will not work as the 2 before-mentioned functions will remain empty. This is because [they inherit from `GovernanceStaker`](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/extensions/GovernanceStakerPermitAndStake.sol#L14) and not from `GovernanceStakerDelegateSurrogateVotes`. + +## Impact + +The code design is not what it was intended. Tests only pass because the harness inherits all the contracts including a `GovernanceStakerDelegateSurrogateVotes` contract which actually overrides the functions. + +These functions are pivotal to the code as they are the ones who guarantee that staked tokens can still be used for voting. + +> ℹ️ **Note** 📘 I don't know how this kind of issue is judged on Sherlock. Implementation is wrong to the point of software design and it won't even compile. + +## Proof Of Concept + +Paste this contract on `src/extensions/`, your linter should warn you that virtual overriding functions are missing and won't compile: + +```solidity +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +import {GovernanceStakerPermitAndStake} from "./GovernanceStakerPermitAndStake.sol"; +import {IERC20Permit} from "openzeppelin/token/ERC20/extensions/IERC20Permit.sol"; +import {GovernanceStaker} from "src/GovernanceStaker.sol"; +import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; +import {IERC20Staking} from "src/interfaces/IERC20Staking.sol"; +import {IEarningPowerCalculator} from "src/interfaces/IEarningPowerCalculator.sol"; + + +contract ContractImplementing is GovernanceStakerPermitAndStake { + constructor( + IERC20 _rewardsToken, + IERC20Staking _stakeToken, + IEarningPowerCalculator _earningPowerCalculator, + uint256 _maxBumpTip, + address _admin, + string memory _name + ) + GovernanceStakerPermitAndStake(_stakeToken) + GovernanceStaker( + _rewardsToken, + _stakeToken, + _earningPowerCalculator, + _maxBumpTip, + _admin + ) + {} +} +``` + +## Recommendation + +Make the contracts inherit from `GovernanceStakerDelegateSurrogateVotes` instead of `GovernanceStaker`. + +Or make the 2 named virtual functions have the default implementation in `GovernanceStaker` and get rid of the `GovernanceStakerDelegateSurrogateVotes` contract. diff --git a/014.md b/014.md new file mode 100644 index 0000000..b06245b --- /dev/null +++ b/014.md @@ -0,0 +1,160 @@ +Expert Tawny Sawfish + +High + +# Complete DOS of core staking functionality + +## Summary + +A malicious staker/user can completely disrupt the staking functionality of the protocol by a grieifing attack and drain all of the gas in the protocol or raise the gas costs to a level where other stakers might find it difficult to stake. + +## Vulnerability Detail + +In the ```GovernanceStaker::stake()``` function there is no zero amount check which is a critical check. A malicious attacker can run an infinite/large loop to drain all of the gas while staking Zero amount . + +Each malicious stake permanently deploys a new surrogate contract. + +This leads to a complete DOS of the staking functionality of the protocol which is one of the core functionalities. + + +## Impact + +1. Complete DOS of the staking functionality. +2. Gas costs permanently increase for all other stakers. +3. Drains all of the gas in the protocol. +4. Makes the protocol economically unviable to use. + +## Code Snippet + +- [Staking functionalities](https://github.com/sherlock-audit/2024-11-tally/blob/043815089dfa4cb2ee3e4344839070c9c679ed52/staker/src/GovernanceStaker.sol#L348) + +```solidity + function stake(uint256 _amount, address _delegatee) + external + virtual + returns (DepositIdentifier _depositId) + { + _depositId = _stake(msg.sender, _amount, _delegatee, msg.sender); + } + + + /// @notice Internal convenience methods which performs the staking operations. + /// @dev This method must only be called after proper authorization has been completed. + /// @dev See public stake methods for additional documentation. + function _stake(address _depositor, uint256 _amount, address _delegatee, address _claimer) + internal + virtual + returns (DepositIdentifier _depositId) + { + _revertIfAddressZero(_delegatee); + _revertIfAddressZero(_claimer); + + //e Updates the rewardPerToken + _checkpointGlobalReward(); + + //e This is a deliberate design choice known as template method . This will be overriden by the + // child contracts' implementation + DelegationSurrogate _surrogate = _fetchOrDeploySurrogate(_delegatee); + _depositId = _useDepositId(); + + uint256 _earningPower = earningPowerCalculator.getEarningPower(_amount, _depositor, _delegatee); + + totalStaked += _amount; + totalEarningPower += _earningPower; + depositorTotalStaked[_depositor] += _amount; + depositorTotalEarningPower[_depositor] += _earningPower; + deposits[_depositId] = Deposit({ + balance: _amount.toUint96(), + owner: _depositor, + delegatee: _delegatee, + claimer: _claimer, + earningPower: _earningPower.toUint96(), + rewardPerTokenCheckpoint: rewardPerTokenAccumulatedCheckpoint, + scaledUnclaimedRewardCheckpoint: 0 //e No unclaimed rewards at the beggining + }); + + //q No tokens are being minted ..? + _stakeTokenSafeTransferFrom(_depositor, address(_surrogate), _amount); + emit StakeDeposited(_depositor, _depositId, _amount, _amount); + emit ClaimerAltered(_depositId, address(0), _claimer); + emit DelegateeAltered(_depositId, address(0), _delegatee); + } + + +``` + +## POC(Proof Of Concept) + +Add the following to [staker/test/GovernanceStaker.t.sol](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/test/GovernanceStaker.t.sol) + +```solidity + +function testFuzz_CanGriefStaking(address attacker,address depositor, uint256 legitimateAmount, address delegatee) public { + vm.assume(attacker != depositor); + vm.assume(attacker != address(0)); + vm.assume(depositor != address(0)); + + legitimateAmount = _boundMintAmount(legitimateAmount); + _mintGovToken(depositor,legitimateAmount); + + uint256 initialGas = gasleft(); + + vm.startPrank(attacker); + for(uint256 i=0;i<2000000;i++){ + address maliciousDelegatee = address(uint160(i+1)); + try govStaker.stake(0,maliciousDelegatee) { + + } catch { + break; + } + } + + vm.stopPrank(); + + // Recording gas consumed + uint256 gasConsumed = initialGas - gasleft(); + + // Now a legitimate depositor tries to stake + vm.startPrank(depositor); + vm.expectRevert(); + + govStaker.stake(legitimateAmount, delegatee); + vm.stopPrank(); + + // Assert the attack's impact + assertGt(gasConsumed, 1000000, "Attack should consume significant gas"); + + + console2.log("Gas consumed by attack:", gasConsumed); + + } + + + +``` + + + +## Tool used + +- Manual Review +- Foundry + +## Recommendation + +1. Add a zero amount validation to the ```GovernanceStaker.sol``` + +```solidity + + function stake(uint256 _amount, address _delegatee, address _claimer) + external + virtual + returns (DepositIdentifier _depositId) + { ++++ require(amount >0 , "Cannot stake zero amount") ; + _depositId = _stake(msg.sender, _amount, _delegatee, _claimer); + } + +``` + + diff --git a/015.md b/015.md new file mode 100644 index 0000000..d2e227f --- /dev/null +++ b/015.md @@ -0,0 +1,37 @@ +Swift Hickory Snail + +Medium + +# Lack of fee slippage leads to staker losing unexpected rewards + +## Summary + +Lack of slippage during claiming can lead to stakers unexpectedly losing extra rewards. + +## Vulnerability Detail + +The amount a staker receives when claiming is deducted a fixed fee. See [here](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L720). + +If the fees eat up all the staker's rewards the function just [`returns 0;` in the next line of code](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L721). Yet any unclaimed rewards tracking is updated and no fee transfer is made. This is fine as the staker has no rewards to claim. + +The problem comes when a user accidentally claims his rewards right after a fee update that will eat more than he desired yet not drain them to 0. The staker will lose more than he expected. + +We can discard the admin griefing the staker with fee front-run or MEV like that as admin is trusted. + +Yet we cannot discard the possibility of just accidentally a bad timing for the staker with some fee update where his tx ends up right after a fee increase update. Furthermore, in Arbitrum the mempool is not public so it is pretty difficult for a staker to avoid bad timing. + +On top of all this, rewards are also affected by bump fees, [see here](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L512). Multiple fees can be applied by system actions without giving the staker the chance to react on the slippage they generate. + +This is why a fee slippage is needed. + +## Impact + +Stakers can unnecessarily receive fewer rewards that expected due to fee slippage. Notice that the staker if he would've been protected from this he might have just not claimed and waited until the admin lowers down the fees in the future. Avoidable, unexpected loss of funds. + +## Tool used + +Manual Review + +## Recommendation + +Add an `expetedRewardsAfterFees` parameter set by the sender of the tx while claiming fees. diff --git a/016.md b/016.md new file mode 100644 index 0000000..81ba166 --- /dev/null +++ b/016.md @@ -0,0 +1,156 @@ +Modern Heather Sidewinder + +Medium + +# Unearned reward tokens can be locked in the `GovernanceStaker` contract + +## Summary + +Since depositors are unable to earn reward tokens when `totalEarningPower` is zero, some reward tokens may remain unallocated even after the reward duration has expired. + +And also due to rounding down, a dust of reward tokens may be accrued in the contract. + +By the way, there is no way to withdraw such reward tokens, and they are permanently locked within the contract. + +## Vulnerability Detail + +Reward tokens are not awarded to depositors when `totalEarningPower` is 0. + +[GovernanceStaker.sol#L303-L308](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L303-L308) + +```solidity + function rewardPerTokenAccumulated() public view virtual returns (uint256) { +@> if (totalEarningPower == 0) return rewardPerTokenAccumulatedCheckpoint; + + return rewardPerTokenAccumulatedCheckpoint + + (scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)) / totalEarningPower; + } +``` + +Being 0 `totalEarningPower` is fairly possible: +- when no depositors exists - OR - +- when each depositor's earning power is 0 + +Consequently, while `totalEarningPower` is 0, no reward tokens are awarded and remain in the contract. + +### Proof-Of-Concept + +Let's take an example scenario: + +1. A reward notifier starts a new reward period(30 days) with `30 ether` reward tokens. +2. The first deposit is performed after `10 days` later. +3. After `20 days` passes, and the depositor earns only `20 ether` reward tokens that are corresponding to `20 days`. The remaining `10 ether` reward tokens remained unallocated. +4. When next reward period begins, this `10 ether` is not added to the new reward amount because the duration of the old reward period has expired. + +Here is a test case to demonstrate the locked reward tokens due to 0 `totalEarningPower`. + +```solidity +contract GovernanceStakerPocTest is GovernanceStakerRewardsTest { + + function _logDetails(GovernanceStaker.DepositIdentifier _depositId) internal { + GovernanceStaker.Deposit memory _deposit = _fetchDeposit(_depositId); + console2.log("Deposit Balance", _deposit.balance); + console2.log("Deposit Earning Power", _deposit.earningPower); + console2.log("Unclaimed Reward", _deposit.scaledUnclaimedRewardCheckpoint / 1e36); + } + + function test_LockedRewardTokens() public { + address depositor0 = makeAddr("depositor0"); + address delegatee0 = makeAddr("delegatee0"); + + uint256 rewardAmount = 30 ether; + + { + console2.log("----------------- day 0 -----------------"); + _mintTransferAndNotifyReward(rewardAmount); + console2.log("Reward Balances:", rewardToken.balanceOf(address(govStaker))); + } + + GovernanceStaker.DepositIdentifier _depositId; + GovernanceStaker.Deposit memory _deposit; + uint256 stakeAmount = 1 ether; + + _jumpAhead(10 days); + { + console2.log(""); + console2.log("----------------- day 10 -----------------"); + _mintGovToken(depositor0, stakeAmount * 2); + + vm.startPrank(depositor0); + govToken.approve(address(govStaker), stakeAmount); + _depositId = govStaker.stake(stakeAmount, delegatee0, depositor0); + vm.stopPrank(); + + _logDetails(_depositId); + } + + _jumpAhead(20 days); + { + console2.log(""); + console2.log("----------------- day 30 -----------------"); + + vm.startPrank(depositor0); + govToken.approve(address(govStaker), stakeAmount / 10); + govStaker.stakeMore(_depositId, stakeAmount / 10); + vm.stopPrank(); + + _logDetails(_depositId); + + vm.prank(depositor0); + govStaker.claimReward(_depositId); + console2.log("Remaining Reward Balances", rewardToken.balanceOf(address(govStaker))); + } + + { + console2.log(""); + console2.log(">> Starting new reward duaration ..."); + _mintTransferAndNotifyReward(rewardAmount); + console2.log("Reward amount to be applied in the new duration", + govStaker.scaledRewardRate() * govStaker.REWARD_DURATION() / 1e36); + } + } +} +``` + +Here are the logs: +```bash +Ran 1 test for test/_poc/GovernanceStaker.poc.t.sol:GovernanceStakerPocTest +[PASS] test_LockedRewardTokens() (gas: 712934) +Logs: + ----------------- day 0 ----------------- + Reward Balances: 30000000000000000000 + + ----------------- day 10 ----------------- + Deposit Balance 1000000000000000000 + Deposit Earning Power 1000000000000000000 + Unclaimed Reward 0 + + ----------------- day 30 ----------------- + Deposit Balance 1100000000000000000 + Deposit Earning Power 1100000000000000000 + Unclaimed Reward 19999999999999999999 + Remaining Reward Balances 10000000000000000001 + + >> Starting new reward duration... + Reward amount to be applied in the new duration 29999999999999999999 +``` + +As can be seen from the logs, reward tokens are not distributed for the first 10 days. Therefore `10 ether` tokens are locked in the contract and not even added to the next reward amount. + +## Impact + +Unpaid reward tokens will be permanently locked in the contract. + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L32 + +## Tool used + +Manual Review + +## Recommendation + +To address such case, Synthetix adopted an owner-permissioned emergency function called `recoverERC20()`. [(StakingRewards.sol#L135-L139)](https://github.com/Synthetixio/synthetix/blob/de2b994cc8064301288e7619042287cddb7c6753/contracts/StakingRewards.sol#L135-L139) + +I suggest adding such a function to the `GovernanceStaker` contract to withdraw locked tokens. diff --git a/017.md b/017.md new file mode 100644 index 0000000..fd24688 --- /dev/null +++ b/017.md @@ -0,0 +1,78 @@ +Late Snowy Mockingbird + +Medium + +# Anyone can call bumpEarningPower and receive tip. + +## Summary + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L471 + +In GovernanceStaker.sol +The bumpEarningPower function has no any checking for msg.sender and transfers REWARD_TOKEN to _tipReceiver. +So anyone can call the function on any deposit and can receive _requestedTip. + +## Impact + +Anyone can call the function on any depositId. +So caller can receive tip if the EarningPowerCalculator changed their earning power. +Therefore anyone can try to call bumpEarningPower function on all deposits and get tip. + +## Code Snippet + +There is no any checking for sender. + +```Solidity +function bumpEarningPower( + DepositIdentifier _depositId, + address _tipReceiver, + uint256 _requestedTip + ) external virtual { + if (_requestedTip > maxBumpTip) revert GovernanceStaker__InvalidTip(); + + Deposit storage deposit = deposits[_depositId]; + + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + + (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( + deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower + ); + if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + } + + if (_newEarningPower > deposit.earningPower && _unclaimedRewards < _requestedTip) { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // Note: underflow causes a revert if the requested tip is more than unclaimed rewards + if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) + { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // Update global earning power & deposit earning power based on this bump + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); + deposit.earningPower = _newEarningPower.toUint96(); + + // Send tip to the receiver + SafeERC20.safeTransfer(REWARD_TOKEN, _tipReceiver, _requestedTip); + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_requestedTip * SCALE_FACTOR); + } +``` + +## Tool used + +Manual Review + +## Recommendation + +Need to add any checking for sender. \ No newline at end of file diff --git a/018.md b/018.md new file mode 100644 index 0000000..faa1539 --- /dev/null +++ b/018.md @@ -0,0 +1,135 @@ +Late Snowy Mockingbird + +Medium + +# The earningPower isn't increased for stake more after bumpEarningPower function. + +## Summary + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L471 + + +After call bumpEarningPower function, even though depositor stakes more or withdraws, the earningPower of the deposit is not changed. + +## Impact + +1. The depositor might not believe this system because the earningPower is not increased even though stake more. +2. The depositor can get reward with old earningpower because the earningPower is not decreased even though withdraw. + + +## Code Snippet + +```Solidity +function testFuzz_EarningPowerAfterBumpsEarning( + address _depositor, + address _delegatee, + uint256 _stakeAmount, + uint256 _rewardAmount, + address _bumpCaller, + address _tipReceiver, + uint256 _requestedTip, + uint96 _earningPowerIncrease + ) public { + vm.assume(_tipReceiver != address(0)); + _stakeAmount = _boundToRealisticStake(_stakeAmount); + _rewardAmount = _boundToRealisticReward(_rewardAmount); + _earningPowerIncrease = uint96(bound(_earningPowerIncrease, 1, type(uint48).max)); + + // A user deposits staking tokens + (, GovernanceStaker.DepositIdentifier _depositId) = + _boundMintAndStake(_depositor, _stakeAmount, _delegatee); + + _mintGovToken(_depositor, _boundMintAmount(_stakeAmount)); + // The contract is notified of a reward + _mintTransferAndNotifyReward(_rewardAmount); + // The full duration passes + _jumpAheadByPercentOfRewardDuration(101); + // Tip must be less than the max bump, but also less than rewards for the sake of this test + _requestedTip = bound(_requestedTip, 0, _min(maxBumpTip, govStaker.unclaimedReward(_depositId))); + + // The staker's earning power increases + earningPowerCalculator.__setEarningPowerForDelegatee( + _delegatee, _stakeAmount + _earningPowerIncrease + ); + // Bump earning power is called + vm.startPrank(_bumpCaller); + + govStaker.bumpEarningPower(_depositId, _tipReceiver, _requestedTip); + + (uint96 _newBalanceAfterBump,, uint96 _newEarningPowerAfterBump,,,,) = govStaker.deposits(_depositId); + assertEq(_newEarningPowerAfterBump, _stakeAmount + _earningPowerIncrease); + + vm.stopPrank(); + + vm.startPrank(_depositor); + govToken.approve(address(govStaker), _boundMintAmount(_stakeAmount)); + + govStaker.stakeMore( _depositId, _boundMintAmount(_stakeAmount/2)); + + (uint96 _newBalanceAfterMore,, uint96 _newEarningPowerAfterMore,,,,) = govStaker.deposits(_depositId); + + govStaker.withdraw( _depositId, _boundMintAmount(_stakeAmount * 3/2)); + + (uint96 _newBalanceAfterWithdraw,, uint96 _newEarningPowerAfterWithdraw,,,,) = govStaker.deposits(_depositId); + + vm.stopPrank(); + + + + console2.log("----Bump earning power-----"); + console2.log("new Balance after bump"); + console2.log(_newBalanceAfterBump); + console2.log("new Earning Power after bump"); + console2.log(_newEarningPowerAfterBump); + + console2.log("----After stake more-----"); + console2.log("new Balance after stake more"); + console2.log(_newBalanceAfterMore); + console2.log("new Earning Power after stake more"); + console2.log(_newEarningPowerAfterMore); + + console2.log("----After withdraw-----"); + console2.log("new Balance after withdraw"); + console2.log(_newBalanceAfterWithdraw); + console2.log("new Earning Power after withdraw"); + console2.log(_newEarningPowerAfterWithdraw); + + + assertGt(_newEarningPowerAfterBump, _newEarningPowerAfterMore); + } +``` + +This is test code. +The result is following. + +----Bump earning power----- + new Balance after bump + 24999999900000000000011555 + new Earning Power after bump + 24999999900000000000021462 + ----After stake more----- + new Balance after stake more + 37499999850000000000017332 + new Earning Power after stake more + 24999999900000000000021462 + ----After withdraw----- + new Balance after withdraw + 0 + new Earning Power after withdraw + 24999999900000000000021462 + +Encountered 1 failing test in test/GovernanceStaker.t.sol:BumpEarningPower +[FAIL: assertion failed: 24999999900000000000021462 <= 24999999900000000000021462; + + + +## Tool used + +Manual Review + +## Recommendation + +As the test code had used MockFullEarningPowerCalculator as EarningPowerCalculator, it causes this issue. +But if use BinaryEligibilityOracleEarningPowerCalculator as EarningPowerCalculation, the bump will not work. +So you need to make proper calculation for bump and need some code for processing after bump. + diff --git a/019.md b/019.md new file mode 100644 index 0000000..40ec259 --- /dev/null +++ b/019.md @@ -0,0 +1,53 @@ +Proud Tartan Lynx + +Medium + +# Cannot Claim The Rewards + +### Summary +After the user withdraws all of his/her funds and if the remaining rewards are less than the fees, these rewards cannot be claimed. + +### Root Cause +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L720 + +### Internal pre-conditions +N/A + +### External pre-conditions +N/A + +### Attack Path +N/A + +### Impact +Loss of funds. + +### PoC +```solidity +GovernanceStaker.sol + function _claimReward(DepositIdentifier _depositId, Deposit storage deposit, address _claimer) + internal + virtual + returns (uint256) + { + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _reward = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + // Intentionally reverts due to overflow if unclaimed rewards are less than fee. +720: uint256 _payout = _reward - claimFeeParameters.feeAmount; + ... + } + function bumpEarningPower( + DepositIdentifier _depositId, + address _tipReceiver, + uint256 _requestedTip + ) external virtual { + ... +488: if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + } + ... + } +``` +### Mitigation diff --git a/020.md b/020.md new file mode 100644 index 0000000..8f0773d --- /dev/null +++ b/020.md @@ -0,0 +1,50 @@ +Proud Tartan Lynx + +Medium + +# Rewards May be Locked. + +### Summary +This contract provides rewards even when `totalEarningPower` is zero. +These rewards will be locked in this contract. + +### Root Cause +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L294 +This contract provides rewards over time. +If `totalEarningPower` is zero, this contract still provide rewards, because the `lastCheckpointTime` is updated current time. +But there is no one to receive these rewards. +As a result, these rewards will be locked in this contract. + +### Internal pre-conditions +N/A + +### External pre-conditions +N/A + +### Attack Path +N/A + +### Impact +Rewards may be locked in this contract. + +### PoC +```solidity +GovernanceStaker.sol +294: function lastTimeRewardDistributed() public view virtual returns (uint256) { + if (rewardEndTime <= block.timestamp) return rewardEndTime; + else return block.timestamp; + } +748: function _checkpointGlobalReward() internal virtual { + rewardPerTokenAccumulatedCheckpoint = rewardPerTokenAccumulated(); + lastCheckpointTime = lastTimeRewardDistributed(); + } +``` + +### Mitigation +```diff +294: function lastTimeRewardDistributed() public view virtual returns (uint256) { ++ if (totalEarningPower == 0) return rewardEndTime; + if (rewardEndTime <= block.timestamp) return rewardEndTime; + else return block.timestamp; + } +``` \ No newline at end of file diff --git a/022.md b/022.md new file mode 100644 index 0000000..423eb30 --- /dev/null +++ b/022.md @@ -0,0 +1,28 @@ +Nice Burgundy Gerbil + +High + +# Protocol fee not deducted durning bumpEarningPower() + +## Summary +Every reward withdrawal on Tally should include protocol fee payment, but in ```bumpEarningPower()``` protocol fee was never deducted. + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L471 + +## Vulnerability Detail +If a user calls ```bumpEarningPower()``` to update the earning power of a staker, since there is no limit to when this call can be made, it is possible that this call being made when the max reward has been made available and caller request all of it. If all is sent to the caller, there will be nothing left for protocol to deduct reward fee from. + +## Impact +Protocol loses fee on rewards + +## Code Snippet +```solidity + uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; +``` + +## Tool used + +Manual Review + +## Recommendation +Deduct protocol fee from ```_unclaimedRewards``` in ```bumpEarningPower()``` and send fee to the recipient. \ No newline at end of file diff --git a/023.md b/023.md new file mode 100644 index 0000000..3712ab2 --- /dev/null +++ b/023.md @@ -0,0 +1,90 @@ +Proud Tartan Lynx + +Medium + +# Get Reward Without `earningPower` + +### Summary +Even if someone's new `earningPower` is zero and they have no rewards, they can still receive rewards amounting to `maxBumpTip - claimFeeParameters.feeAmount`. + +### Root Cause +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L497 + +### Internal pre-conditions +1. `maxBumpTip` > `claimFeeParameters.feeAmount`. + +### External pre-conditions +N/A + +### Attack Path +1. A malicious user has `n` stakes, each with an amount of `1`. +2. Wait untill all new `earningPower` is zero. +3. The user's unclaimedRewards will reach `n * maxBumpTip` soon after the `earningPower` of others updates to zero. +4. The user can claim rewards totaling`n * (maxBumpTip - claimFeeParameters.feeAmount)` + +### Impact +Users can obtain rewards without `earningPower`. +If the new `totalEarningPower` is zero, even if their deposit amount is `1`, they can soon receive rewards amounting to `maxBumpTip - claimFeeParameters.feeAmount`. +If there are many stakes with an amount of `1`, the protocol must send the rewards amounting to `maxBumpTip - claimFeeParameters.feeAmount` to each one. +This results in a loss for the protocols. + +### PoC +```solidity +GovernanceStaker.sol +471: function bumpEarningPower( + DepositIdentifier _depositId, + address _tipReceiver, + uint256 _requestedTip + ) external virtual { + if (_requestedTip > maxBumpTip) revert GovernanceStaker__InvalidTip(); + + Deposit storage deposit = deposits[_depositId]; + + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + + (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( + deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower + ); + if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + } + + if (_newEarningPower > deposit.earningPower && _unclaimedRewards < _requestedTip) { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // Note: underflow causes a revert if the requested tip is more than unclaimed rewards +497: if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) + { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // Update global earning power & deposit earning power based on this bump + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); + deposit.earningPower = _newEarningPower.toUint96(); + + // Send tip to the receiver + SafeERC20.safeTransfer(REWARD_TOKEN, _tipReceiver, _requestedTip); + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_requestedTip * SCALE_FACTOR); + } +``` +When the user's new `earningPower` is zero and `_unclaimedRewards` is less than `maxBumpTip`, the `bumpEarningPower` function always reverts. +Thus, the user's `_unclaimedRewards` can reach `maxBumpTip`, allowing them to claim `maxBumpTip - claimFeeParameters.feeAmount` + +### Mitigation + +```diff +794: function _setMaxBumpTip(uint256 _newMaxTip) internal virtual { + emit MaxBumpTipSet(maxBumpTip, _newMaxTip); ++ require(_newMaxTip <= claimFeeParameters.feeAmount,""); + maxBumpTip = _newMaxTip; + } +``` \ No newline at end of file diff --git a/024.md b/024.md new file mode 100644 index 0000000..9a67754 --- /dev/null +++ b/024.md @@ -0,0 +1,31 @@ +Recumbent Orange Wallaby + +Medium + +# Deposit owners exploit incentives, undermining third-party participation + +## **Summary** +The `bumpEarningPower` function is intended to reward third-party callers (bumpers) for triggering updates to a deposit's earning power when its conditions qualify for an update. However, due to unrestricted access, deposit owners or reward claimers can abuse this function to self-trigger updates at intervals of `updateEligibilityDelay`, effectively farming the tip incentive. This undermines the purpose of the incentive system, potentially leading to unfair distribution of rewards and discouragement of third-party participation. + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L471-L476 + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L154-L158 + +## **Vulnerability Detail** +The key issue lies in the design of the `bumpEarningPower` function, which does not restrict the caller to non-owners or third parties. As a result: + +1. **Mechanism Misuse:** The owner or the reward claimer of a deposit can repeatedly call `bumpEarningPower`, claiming the incentive for themselves. This incentivizes self-dealing rather than leveraging third-party assistance. +2. **Functionality Redundancy:** The `bumpEarningPower` function becomes effectively redundant for third parties, as they have no opportunity to earn the tip incentive due to owners monopolizing the calls. + +## **Impact** +- **Loss of Third-Party Participation:** The incentive mechanism fails to attract third-party bumpers. +- **Potential Centralization of Rewards:** Owners or claimants can monopolize the function, draining rewards at the expense of other participants. + +## **Recommendation** +**Restrict Caller Access:** + - Add a condition to ensure the caller of `bumpEarningPower` is neither the owner nor the reward claimer of the deposit. For example: + ```solidity + if (msg.sender == deposit.owner || msg.sender == deposit.claimer) { + revert GovernanceStaker__Unauthorized("Owner or claimer cannot bump", msg.sender); + } + ``` diff --git a/025.md b/025.md new file mode 100644 index 0000000..b0b982e --- /dev/null +++ b/025.md @@ -0,0 +1,61 @@ +Proud Tartan Lynx + +Medium + +# Signature Must Contain the ChainId + +### Summary +In `GovernanceStakerOnBehalf.sol`, the signature must include the ChainId. + +### Root Cause +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/extensions/GovernanceStakerOnBehalf.sol#L180 + +### Internal pre-conditions +N/A + +### External pre-conditions +N/A + +### Attack Path +N/A + +### Impact +If someone has a depositId on defferent chain, their signature could be valid on the other chain. +In particular, the deposit's claimer on one chain, which is using larger nonce, can claim the deposit's from the other chain. + +### PoC +```solidity +GovernanceStakerOnBehalf.sol +180: function alterClaimerOnBehalf( + DepositIdentifier _depositId, + address _newClaimer, + address _depositor, + uint256 _deadline, + bytes memory _signature + ) external virtual { + Deposit storage deposit = deposits[_depositId]; + _revertIfNotDepositOwner(deposit, _depositor); + _revertIfPastDeadline(_deadline); + _revertIfSignatureIsNotValidNow( + _depositor, + _hashTypedDataV4( + keccak256( + abi.encode( + ALTER_CLAIMER_TYPEHASH, + _depositId, + _newClaimer, + _depositor, + _useNonce(_depositor), + _deadline + ) + ) + ), + _signature + ); + + _alterClaimer(deposit, _depositId, _newClaimer); + } +``` + +### Mitigation +The Signature must contain the ChainId. \ No newline at end of file diff --git a/026.md b/026.md new file mode 100644 index 0000000..92d7d4e --- /dev/null +++ b/026.md @@ -0,0 +1,39 @@ +Nice Burgundy Gerbil + +Medium + +# Earning Power is not updated when rewards to claim is zero + +## Summary +Earning Power is not updated when rewards to claim is zero + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L710-L721 + +## Vulnerability Detail +Protocol attempts to update earning power on every user state changing calls, but fails to update the earning Power when Reward. is zero, this permits the user to keep accumulating rewards with previous earning power. Also, while there is an option for it to be updated by an third party, available reward is the incentive and user does not have. + +## Impact +Rewards accumulation with old earning power + +## Code Snippet +```solidity + function _claimReward(DepositIdentifier _depositId, Deposit storage deposit, address _claimer) + internal + virtual + returns (uint256) + { + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _reward = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + // Intentionally reverts due to overflow if unclaimed rewards are less than fee. + uint256 _payout = _reward - claimFeeParameters.feeAmount; + if (_payout == 0) return 0; +``` + +## Tool used + +Manual Review + +## Recommendation +Update earning pwer even where zero reward is available. \ No newline at end of file diff --git a/027.md b/027.md new file mode 100644 index 0000000..844e6d4 --- /dev/null +++ b/027.md @@ -0,0 +1,50 @@ +Noisy Pineapple Wasp + +Medium + +# There is no slippage protection for staking operations, which may result in users being unable to accumulate rewards. + +## Summary + +When a user stakes, they can select a delegatee contract and then start accumulating rewards by transferring tokens to that contract, as long as the delegatee is eligible (_isDelegateeEligible(delegatee) = true). However, when a user stakes, the delegatee may suddenly become unqualified, such as due to the score update from the orcale or a change to the threshold(delegateeEligibilityThresholdScore). This will cause the user's EarningPower to be 0, which means losing the ability to accumulate rewards. + +## Vulnerability Detail + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L558-L571 + +```solidity + function _stake(address _depositor, uint256 _amount, address _delegatee, address _claimer) + internal + virtual + returns (DepositIdentifier _depositId) + { + _revertIfAddressZero(_delegatee); + _revertIfAddressZero(_claimer); + + _checkpointGlobalReward(); + + DelegationSurrogate _surrogate = _fetchOrDeploySurrogate(_delegatee); + _depositId = _useDepositId(); + + uint256 _earningPower = earningPowerCalculator.getEarningPower(_amount, _depositor, _delegatee); +``` + +There is no doubt that users are motivated to obtain rewards through staking. However, when staking, users will not be able to accrue rewards due to the delegatee being disqualified (e.g., its score is below the threshold). + +## Impact + +Users are unable to accrue rewards. + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L558-L571 + +## Tool used + +Manual Review + +## Recommendation + +Add a slippage protection variable **withEarningPower**. If the user wants to get EarningPower when staking, it can be set to 1. Otherwise, if the user does not care, it can be set to 0. + +When withEarningPower=1, check whether the delegatee is qualified. If not, revert the TX. \ No newline at end of file diff --git a/028.md b/028.md new file mode 100644 index 0000000..566e2e4 --- /dev/null +++ b/028.md @@ -0,0 +1,44 @@ +Noisy Pineapple Wasp + +High + +# When the unclaimed rewards in the deposit are insufficient to pay the feeAmount, this part of rewards will be locked in the contract. + +## Summary + +When claiming rewards, users need to pay the claim fee. However, when the rewards are not enough to pay the claim fee, users cannot claim the rewards, and the protocol cannot charge fees. Finally, the rewards will be permanently locked in the contract. + +## Vulnerability Detail + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L710-L721 + +When the reward in the deposit is not enough to cover the claim fee (or is exactly equal to it) and the user has withdrawn all the staked tokens, this part of the reward will always be locked in the contract. + +## Impact + +Rewards will be locked in contract. + +## Code Snippet + +```solidity + function _claimReward(DepositIdentifier _depositId, Deposit storage deposit, address _claimer) + internal + virtual + returns (uint256) + { + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _reward = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + // Intentionally reverts due to overflow if unclaimed rewards are less than fee. + uint256 _payout = _reward - claimFeeParameters.feeAmount; + if (_payout == 0) return 0; +``` + +## Tool used + +Manual Review + +## Recommendation + +Add a method so that when a user withdraws all staked tokens and the remaining rewards are not enough to pay the claim fee, the contract owner can transfer the rewards to the feeCollector. \ No newline at end of file diff --git a/029.md b/029.md new file mode 100644 index 0000000..64c49a3 --- /dev/null +++ b/029.md @@ -0,0 +1,42 @@ +Recumbent Orange Wallaby + +Medium + +# Deposit owners can exploit `stakeMore` and `withdraw` to prevent incentives for Bumpers + +### **Summary** +The design of the `bumpEarningPower` function, which allows third-party bumpers to update the earning power of a `Deposit` and receive incentives, can be invalidated by the `Deposit` owner. The owner can exploit the `stakeMore` or `withdraw` functions with minimal amounts, disrupting the earning power update process and denying bumpers their intended incentives. This undermines the protocol's mechanism to encourage bumpers to call `bumpEarningPower`. + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L595-L606 + +--- + +### **Root Cause** +The following issues allow `Deposit` owners to bypass the protocol's intent: +1. **No Minimum Amount for `stakeMore` or `withdraw`**: + - The `stakeMore` and `withdraw` functions do not enforce a minimum limit on the amount of tokens that can be staked or withdrawn. + - As a result, the `Deposit` owner can call these functions with negligible amounts solely to trigger an earning power update. + +2. **Frequent Calls During `updateEligibilityDelay`**: + - The `updateEligibilityDelay` in `getNewEarningPower` enforces a delay before earning power can be updated again. + - The owner can reset this delay repeatedly by calling `stakeMore` or `withdraw` with minimal amounts, effectively blocking bumpers from calling `bumpEarningPower` during this period. + +### **Impact** +1. **Protocol Design Undermined**: + - The incentive mechanism for bumpers is rendered ineffective as owners block updates by exploiting small-value staking or withdrawals. + +2. **Loss of Bumper Participation**: + - Without incentives, bumpers are unlikely to participate, weakening the protocol's ability to update earning power efficiently. + +3. **Unfair Advantage for `Deposit` Owners**: + - Owners gain disproportionate control over the process, negating the intent of third-party interventions. + +--- + +### **Mitigation** +1. **Set Minimum Transaction Limits**: + - Introduce a minimum threshold for the amount of tokens that can be staked or withdrawn in `stakeMore` and `withdraw`. + +2. **Restrict Frequency of Updates**: + - Enforce a cooldown period for the `stakeMore` and `withdraw` functions to prevent repeated calls within a short timeframe. + \ No newline at end of file diff --git a/030.md b/030.md new file mode 100644 index 0000000..58f3808 --- /dev/null +++ b/030.md @@ -0,0 +1,45 @@ +Recumbent Orange Wallaby + +Medium + +# Claimers can suppress bumpers by exploiting `claimReward` to renew earning power + +### **Summary** +The `claimer` of a `Deposit` can suppress bumpers by exploiting the `claimReward` function. Since bumpers receive a portion of the `Deposit`'s unclaimed rewards as incentives, claimers can prevent bumpers from earning incentives by calling `claimReward` to renew their earning power. This action denies bumpers their expected rewards, undermining the protocol's incentive mechanism and shifting the loss to the claimer, as the funds used for bumper incentives are deducted from their own rewards. + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L721-L729 + +--- + +### **Root Cause** +The ability of the `claimReward` function to reset the earning power of a `Deposit` without restrictions allows claimers to interfere with the bumper incentive mechanism: +1. **Earning Power Reset in `claimReward`**: + - Each call to `claimReward` resets the earning power of a `Deposit` using the `earningPowerCalculator.getEarningPower` logic. + +2. **No Restriction on Reward Claims**: + - The function does not enforce a minimum claimable amount and only `returns` if the rewards are zero. + - This allows claimers to repeatedly call the function with negligible or no rewards purely to refresh their earning power. + +3. **Impact on Bumper Incentives**: + - Bumpers receive incentives from a portion of the `Deposit`'s unclaimed rewards. By claiming rewards frequently, the claimer can reduce or eliminate the unclaimed rewards before the bumper can act. + +--- + +### **Impact** +1. **Protocol Design Undermined**: + - The incentive mechanism for bumpers is compromised, discouraging participation and weakening the protocol's ability to update earning power effectively. + +2. **Financial Loss to Claimer**: + - While suppressing bumpers, the claimer sacrifices the protocol's intended earning power updates, potentially leading to reduced overall rewards. + +3. **Disincentivized Bumper Participation**: + - Bumpers, unable to reliably earn incentives, are discouraged from participating, further degrading the protocol's functionality. + +--- + +### **Mitigation** +1. **Restrict Frequent Calls to `claimReward`**: + - Introduce a cooldown period for the `claimReward` function to limit how often it can be called, ensuring bumpers have a fair opportunity to act. + +2. **Set a Minimum Claim Threshold**: + - Prevent earning power from being renewed by `revert` function `claimReward` if the claimable amount does not exceed the specified minimum. diff --git a/031.md b/031.md new file mode 100644 index 0000000..95d3640 --- /dev/null +++ b/031.md @@ -0,0 +1,74 @@ +Swift Hickory Snail + +Medium + +# Claiming fee can be skipped by dumping yourself + +## Summary + +Bumping yourself allows you to skip the claiming fee. + +## Vulnerability Detail + +When `bumpEarningPower()` a fee is taken from the unclaimed rewards and given to the bumper as an incentive to keep the system always updated. See [here](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L511). + +The bumper can perfectly be the very same depositor or claimer of the deposit. Thus, the fee taken from the unclaimed rewards is still transferred to them in that case. Effectively claiming their reward without having to call `claimReward()` which would have charged them a fee from their claiming reducing their overall earnings. + +If they do this process enough times they can claim all their rewards without ever having to pay the fee for it. + +## Impact + +As of the present calculator you can only skip your fee every time the oracle updates the score of your delegatee. + +It is reasonable to expect the score changing, in any kind of calculator, every time significant changes in voting power or voting token balance happen to a delegatee. Thus the depositor would only have to do the following to repeatedly claim their rewards without paying the fee: + +- Transfer voting power or tokens to delegatee ( delegatee can be the very same depositor, as the only condition against the delegatee address is, [this one](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L563)), triggering oracle update. +- Bump earning power, skip a fee. +- Transfer again. +- Bump earning power, skip a fee. +- And so on. + +## Proof of concept + +Bumping is always executed [if](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L476): + +```solidity + function bumpEarningPower(/*args*/) external virtual { +@> // 👁️🟢1️⃣ Bumper puts a lower tip than the max allowed. Then this does not revert. + if (_requestedTip > maxBumpTip) revert GovernanceStaker__InvalidTip(); + + // more code... + +@> // 👁️🟢2️⃣ New delegate got a different earning power. + (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( + deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower + ); + +@> // 👁️🟢3️⃣ Thus _isQualifiedForBump==true and new earning is different to the previous one. This does not revert. +@> // Even in the case of the current calculator where _isQualifiedForBump==false, the bumper just has to wait a delay and then execute. + if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + } + +@> // 👁️🟢4️⃣ New earning is higher and you just make the tip smaller than the already unclaimed rewards. This does not revert. + if (_newEarningPower > deposit.earningPower && _unclaimedRewards < _requestedTip) { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + +@> // 👁️🟢4️⃣ If the earning is lower due to the naughty transfer you can still tweak the tip so this doesnt revert. All executes. + if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) + { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // more code... + } +``` + +## Tool used + +Manual Review + +## Recommendation + +Do not allow claimer or depositor to bump themselves, or discount from the tip part of the claim fee if they are the bumpers. diff --git a/032.md b/032.md new file mode 100644 index 0000000..b99bf88 --- /dev/null +++ b/032.md @@ -0,0 +1,28 @@ +Ancient Denim Horse + +Medium + +# GovernanceStaker.sol#bumpEarningPower() - Paused or stale oracle could be abused to farm bumping tips + +## Summary +The `bumpEarningPower()` function provides an incentivizing way of keeping depositors' earning power up to date by "tipping" the bumper a portion of the deposit's unclaimed reward as an incentive. It does so by first checking if there was a change in the delegatee's eligibility to determine if a bump occurs and a tip should be paid out, if there are sufficient rewards to do so. +A mechanism that opens room for exploit is the way oracle staleness and pause are handled during a failsafe when earning power becomes based only on the staked amount and thus turns eligible, allowing for getting tips for bumping previously ineligible delegatees when a failsafe starts and when it finishes. + +## Vulnerability Detail +Currently the delegatee's eligibility is based on a threshold and the score is updated by the designated oracle. +Any address could technically be a delegatee, but the ones with most score would logically be community involved or to be involved addresses. Thus, the possibility of having a not-yet-qualified address be a delegatee to a number of deposits is not unlikely, their earning power will just become qualified later on. +This means that during a failsafe, all deposits delegating to a not-yet-qualified delegatee will become eligible for the duration of the failsafe, technically allowing bumpers to earn 2 tips - when the failsafe starts and when the failsafe ends and the delegatee becomes ineligible due to score again. +This technically creates a race condition for bumpers to earn tips during failsafe. + +## Impact +Bumpers earn tips not based on actual changes to the scores but due to edge-case contract and external state + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L146-L161 + +## Tool used + +Manual Review + +## Recommendation +Update the `getNewEarningPower()` function to return `false` as qualification boolean as the oracle's staleness/pausing should not be a subject for generating bumping tips out of unsuspecting user's rewards. A user should be aware that depositing into ineligible delegatee's could lead to them paying tips, but users do not control the oracle's state and should not be forced to pay when a failsafe occurs. \ No newline at end of file diff --git a/033.md b/033.md new file mode 100644 index 0000000..294032b --- /dev/null +++ b/033.md @@ -0,0 +1,41 @@ +Helpful Walnut Meerkat + +High + +# GovernanceStaker::notifyRewardAmount Does Not Account for Unclaimed Rewards + +# Summary + +The `notifyRewardAmount` function is responsible for notifying the contract about an additional amount of rewards sent to it, updating the total rewards available for distribution. However, certain checks within this function are incorrect. + +# Root Case + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L456-L458 + +```solidity +if ( + (scaledRewardRate * REWARD_DURATION) > (REWARD_TOKEN.balanceOf(address(this)) * SCALE_FACTOR) + ) revert GovernanceStaker__InsufficientRewardBalance(); +``` + +The issue lies in the fact that this condition does not consider already allocated but unclaimed rewards. Consequently, if a sufficient number of users have not claimed their rewards, this function could still be called, increasing the reward rate and potentially causing future payment failures. + +# Internal pre-conditions + +***No response*** + +# External pre-conditions + +This scenario could occur due to an incorrect call by `isRewardNotifier`. Additionally, if multiple isRewardNotifier entities exist, they might not be aware of each other's calls and could invoke the function twice. + +# Attack Path + +***No response*** + +# Impact + +Improper increase of the reward rate, leading to potential payment failures in the future. + +# Mitigation + +Adjust the logic to account for unclaimed rewards. \ No newline at end of file diff --git a/034.md b/034.md new file mode 100644 index 0000000..5aaa7b7 --- /dev/null +++ b/034.md @@ -0,0 +1,43 @@ +Swift Hickory Snail + +Medium + +# Claimer can be unnecessarily and unfairly griefed by bumper + +## Summary + +Updates from the calculator can be griefed by a bumper if the claimer is going to claim in the same block. + +## Vulnerability Detail + +The whole purpose of `bumpEarningPower()` is to have an incentive to properly and quickly update the systems' `totalEarningPower`. + +This is necessary because some deposits might be untouched for a long time and if they are not touched their earning power will remain the same even though it can be outdated and have already changed in the calculator. + +So all positions which had their earning power changed in the calculator and require an update should either be touched (any action triggers the update, [even changing the claimer](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L661)) or bumped to properly keep track of the `totalEarningPower`. + +It makes sense and it is fair to reward the bumper with a small part of the rewards accrued as it is necessary for a healthy functioning of the system. + +But, there is a scenario where it is unfair and griefs the depositor and/or claimer. This is when in the same block: + +- Calculator updates the earning power of the delegatee. +- Depositor is going to claim rewards. His tx will be placed after the notification, yet no time really passed from an inactive outdated deposit that must be updated. +- Bumper sees this and decides to front-run and bump him. + +This creates an unfair MEV from which the claimer should be protected, as per what tx ordering timing regards, he was going to update his position on time and keep the system healthy, he must not be punished for this and he should be protected. The MEV is the following: + +As the calculator sends a new earning score which will require an update of the `totalEarningPower` a depositor is going to `claimReward()` thus triggering that update. + +Yet a bumper sees this and front-runs the claimer to grief him from his rewards, sandwiching his transaction between the update and the claim in the same block. + +## Impact + +A claimer gets griefed by the bumper the bump tip from his rewards. + +## Tool used + +Manual Review + +## Recommendation + +Only allow any bumping after 1 timestamp unit passed since the time when the calculator updated the delegatee's score. diff --git a/035.md b/035.md new file mode 100644 index 0000000..4b00157 --- /dev/null +++ b/035.md @@ -0,0 +1,29 @@ +Gentle Chocolate Eel + +High + +# Missing _checkpointReward(deposit) in GovernanceStaker._stake() can lead to the reward State being Misaligned or Inconsistent + +## Summary +The bug arises from a misalignment of the reward state during the staking process, specifically in the _stake function. The issue occurs because the _stake function does not call `_checkpointReward(deposit)` to properly initialize the reward state for new deposits. As a result, when tokens are staked via _stake, the deposit's `scaledUnclaimedRewardCheckpoint` and `rewardPerTokenCheckpoint` are not set to the correct values, causing inconsistencies in reward calculations. + +## Vulnerability Detail +The [Stake](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L558-L590) function allows users perform staking Operations. But unlike the [_stakeMore](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L595-L619) function which adds more stake to an existing deposit., `_checkpointReward(deposit);` is not called. As a result, the reward state is incorrectly initialized for new deposits, creating a risk that rewards could be under-calculated. +In the `_stake` function, new deposits are created but the reward state is not updated (via `_checkpointReward(deposit)`). This means that the `scaledUnclaimedRewardCheckpoint` (which tracks the total unclaimed rewards for a deposit) and `rewardPerTokenCheckpoint` (which tracks the reward rate at the time of staking) are not synchronized with the global reward state. + +## Impact +Under calculation of rewards for users in their first deposit. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L558-L590 + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L595-L600 + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L759-L762 + +## Tool used + +Manual Review + +## Recommendation +Add the _checkpointReward(deposit); to the `stake function` \ No newline at end of file diff --git a/036.md b/036.md new file mode 100644 index 0000000..df10de4 --- /dev/null +++ b/036.md @@ -0,0 +1,146 @@ +Expert Tawny Sawfish + +Medium + +# Permit signature can be frontrun in GovernanceStakerPermitAndStake's permitAndStake() functions causing denial of service + +## Summary + +A frontrunning vulnerability exists in the GovernanceStakerPermitAndStake contract where an attacker can invalidate a user's EIP-2612(Inherited through IERC20Permit here) permit signature before their transaction executes, causing their staking operation to fail. + +## Vulnerability Detail + +The vulnerability stems from how EIP-2612 permit signatures work in combination with the contract's staking functions. Here's the internal working of EIP-2612 and how it leads to the vulnerability: + +1. EIP-2612's Internal working + +- Each user has a nonce counter starting at 0. +- A permit signature contains: + +```solidity + +struct Permit { + address owner; // Token owner + address spender; // Address getting approval + uint256 value; // Amount being approved + uint256 nonce; // Current nonce of owner + uint256 deadline; // Signature expiry +} +``` +2. When permit() is called, the contract: + +- Verifies the deadline hasn't passed +- Recovers signer from the signature +- Verifies signer is the owner +- Verifies nonce matches owner's current nonce +- Increments owner's nonce +- Sets allowance + +3. Attack Path: + +- User signs permit with their current nonce (N) +- Attacker sees the transaction in mempool +- Attacker extracts signature parameters +- Attacker frontruns by calling permit() directly +- User's nonce increments to N+1 +- User's original transaction fails as signature was for nonce N + + +The EIP-2612 says ```permit()``` could be frontrun . + +In the security section of the EIP-2612 they say: + +```HTML +Though the signer of a Permit may have a certain party in mind to submit their transaction, another party can always front run this transaction and call permit before the intended party. The end result is the same for the Permit signer, however. + + +``` + +Read about the Internal Working Of [EIP-2612 Here](https://eips.ethereum.org/EIPS/eip-2612#specification[relevant%20EIP) + +[Code Reference](https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/extensions/GovernanceStakerPermitAndStake.sol#L37) + +## Impact + +- Users' staking transactions can be forced to fail +- Gas costs are wasted +- Denial of service through signature invalidation +- No direct loss of funds but requires users to generate new signatures and retry transactions +- User frustration + + +## Code Snippet + +```solidity + +function permitAndStake( + uint256 _amount, + address _delegatee, + address _claimer, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s +) external virtual returns (DepositIdentifier _depositId) { + try IERC20Permit(address(STAKE_TOKEN)).permit( + msg.sender, address(this), _amount, _deadline, _v, _r, _s + ) {} catch {} + _depositId = _stake(msg.sender, _amount, _delegatee, _claimer); +} +``` + +## Tool used + +- Manual Review +- Foundry + +## Recommendation + +Several approaches could mitigate this vulnerability: + +1. Commit-Reveal Pattern + +```solidity + +struct Commitment { + bytes32 commitmentHash; + uint256 timestamp; +} +mapping(address => Commitment) public commitments; + +function commitStake(bytes32 commitmentHash) external { + commitments[msg.sender] = Commitment(commitmentHash, block.timestamp); +} + +function permitAndStake( + uint256 amount, + address delegatee, + address claimer, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s, + bytes32 salt +) external { + require(block.timestamp >= commitments[msg.sender].timestamp + MIN_COMMITMENT_DELAY); + require(keccak256(abi.encodePacked(salt, amount, delegatee, claimer)) == + commitments[msg.sender].commitmentHash); + // Rest of permitAndStake logic +} + +``` + +2. Custom Replay Protection: +Add a custom nonce system specific to the staking operation. + +3. Short Deadlines + + +```solidity + +function permitAndStake(...) external { + require(deadline <= block.timestamp + MAX_PERMIT_DELAY); + // Rest of function +} + +``` \ No newline at end of file diff --git a/037.md b/037.md new file mode 100644 index 0000000..1e4463a --- /dev/null +++ b/037.md @@ -0,0 +1,28 @@ +Brilliant Flint Sardine + +High + +# Uninitialized `MAX_CLAIM_FEE` in `GovernanceStaker` leads to Denial-of-Service in `_setClaimFeeParameters` + +## Summary +The `GovernanceStaker` contract includes an immutable variable `MAX_CLAIM_FEE`, but it is not initialized in the base contract. While the [`README`](https://audits.sherlock.xyz/contests/609?filter=questions) does not explicitly confirm or deny whether `GovernanceStaker` is a production contract, the scope requires that we consider it as such. + +## Vulnerability Detail +The [`_setClaimFeeParameters`](https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L799-L813) function in `GovernanceStaker` relies on `MAX_CLAIM_FEE` to validate fee parameters. However, because `MAX_CLAIM_FEE` is not initialized in `GovernanceStaker`, the comparison `_params.feeAmount > MAX_CLAIM_FEE` will always be true, and the function will always revert. + +## Impact +- **Denial of Service (DoS**): The admin cannot configure claim fees. +- **Revenue Loss**: Without claim fees, the protocol may be unable to cover operational costs. + +## Code Snippet +- **Vulnerable logic** : [_setClaimFeeParameters](https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L799C30-L803) +- **Uninitialized variable** : [MAX_CLAIM_FEE](https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L171) +## Tool used + +Manual Review + +## Recommendation +Add this line in the constructor: +```diff ++ MAX_CLAIM_FEE = 1e18; +``` \ No newline at end of file diff --git a/038.md b/038.md new file mode 100644 index 0000000..8735a21 --- /dev/null +++ b/038.md @@ -0,0 +1,60 @@ +Rich Smoke Cheetah + +High + +# Reward could be lost if there is no staker + +## Summary +If there is a distribution and all the stakers withdraw, then for the rest of the reward duration, if no one stakes, the reward will be stuck forever. + +## Vulnerability Detail +We can see here that the reward is distributed only if the totalEarningPower is higher than 0. +```solidity +function rewardPerTokenAccumulated() public view virtual returns (uint256) { + if (totalEarningPower == 0) return rewardPerTokenAccumulatedCheckpoint; + + return rewardPerTokenAccumulatedCheckpoint + + (scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)) / totalEarningPower; + } +``` +The problem is that if during a reward distribution all the stakers withdraw, then the funds will be stuck forever in the smart contract. +### Proof-Of-Concept +You can copy-paste this test in a contract of the GovernanceStaker.t.sol file and run `forge test --mt test_stuckBalancePOC`. +```solidity +function test_stuckBalancePOC() public { + address BOB = makeAddr("BOB"); + // Bob stake 1 token + (, GovernanceStaker.DepositIdentifier _depositId) = _boundMintAndStake(BOB, 1e18,BOB); + uint256 _rewardAmount = 1000e18; + //The protocol notify some rewards + _mintTransferAndNotifyReward( _rewardAmount); + //We jump the half of the reward duration + _jumpAheadByPercentOfRewardDuration(50); + //Bob claim his rewards + vm.prank(BOB); + govStaker.claimReward(_depositId); + //Bob the withdraw his staking balance + vm.prank(BOB); + govStaker.withdraw(_depositId,1e18); + //We jump the rest of the reward duration + _jumpAheadByPercentOfRewardDuration(50); + uint256 unclaimedReward= govStaker.unclaimedReward(_depositId); + uint256 rewardGovBalance = rewardToken.balanceOf(address(govStaker)); + // we can see that Bob have no unclaimed reward + assertEq(unclaimedReward, 0); + //There is some funds that are stuck in the contract + assertEq(rewardGovBalance, 500e18 +1); + } +``` + +## Impact +The rewards will be stuck forever. +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L303-L308 + +## Tool used + +Foundry + +## Recommendation +The protocol should implement a function to redistribute the undistributed rewards. \ No newline at end of file diff --git a/039.md b/039.md new file mode 100644 index 0000000..5033c82 --- /dev/null +++ b/039.md @@ -0,0 +1,79 @@ +Gentle Chocolate Eel + +High + +# scaledUnclaimedRewardCheckpoint is not properly reset to zero after the reward has been claimed in the GovernorStaker._claimReward() + +## Summary +`scaledUnclaimedRewardCheckpoint` is not properly reset to zero after the reward has been claimed + +## Vulnerability Detail +In the `GovernorStaker` contract, a comment is written by the protocol indicating that the value of `scaledUnclaimedRewardCheckpoint` is set to 0 when deposit rewards are claimed. +```solidity + /// @param scaledUnclaimedRewardCheckpoint Checkpoint of the unclaimed rewards earned by a given + /// deposit with the scale factor included. This value is stored any time an action is taken that + /// specifically impacts the rate at which rewards are earned by a given deposit. Total unclaimed + /// rewards for a deposit are thus this value plus all rewards earned after this checkpoint was + /// taken. This value is reset to zero when the deposit's rewards are claimed. +``` +However in the [_claimReward](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L710-L751) when deposits are claimed, the code currently only subtracts the reward from it. After this subtraction, theres not part in the snippet which set `scaledUnclaimedRewardCheckpoint` to 0, and this does not align with what above the comment says. + +```solidity + function _claimReward(DepositIdentifier _depositId, Deposit storage deposit, address _claimer) + internal + virtual + returns (uint256) + { + _checkpointGlobalReward(); + _checkpointReward(deposit); + + + uint256 _reward = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + // Intentionally reverts due to overflow if unclaimed rewards are less than fee. + uint256 _payout = _reward - claimFeeParameters.feeAmount; + if (_payout == 0) return 0; + + + // retain sub-wei dust that would be left due to the precision loss + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_reward * SCALE_FACTOR); + emit RewardClaimed(_depositId, _claimer, _payout); + + + uint256 _newEarningPower = + earningPowerCalculator.getEarningPower(deposit.balance, deposit.owner, deposit.delegatee); + + + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); + deposit.earningPower = _newEarningPower.toUint96(); + + + SafeERC20.safeTransfer(REWARD_TOKEN, _claimer, _payout); + if (claimFeeParameters.feeAmount > 0) { + SafeERC20.safeTransfer( + REWARD_TOKEN, claimFeeParameters.feeCollector, claimFeeParameters.feeAmount + ); + } + return _payout; // @audit>> scaledUnclaimedRewardCheckpoint is not set to 0 as comment says in ln:131-134 + } +``` + +## Impact +This may lead to incorrect reward calculations since the protocol has reasons for commenting that they would always reset the scaled Unclaimed Reward Checkpoint to 0 after deposit rewards are claimed. + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L131-L135 + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L710-L751 + +## Tool used + +Manual Review + +## Recommendation +`scaledUnclaimedRewardCheckpoint` should be reset to 0 as the comment says diff --git a/040.md b/040.md new file mode 100644 index 0000000..0095909 --- /dev/null +++ b/040.md @@ -0,0 +1,559 @@ +Spare Wool Mole + +Medium + +# `Bumpers` can update the `earning power` of `deposits` that should be `!_isQualifiedForBump` after a change in the `delegateeEligibilityThresholdScore` value. + +## Summary +`Bumpers` can update the `earning power` of `deposits` that should have become `!_isQualifiedForBump` because of a change in the `delegateeEligibilityThresholdScore` value and not enough time elapsed from that moment, so that `_isUpdateDelayElapsed == true`. This update can be done by `bumpers` calling the `GovernanceStaker::bumpEarningPower` sending this way, the `_requestedTip` from the `unclaimed rewards` of the `deposits` to their `_tipReceiver`. + +## Relevant GitHub Links +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L471-L514 +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L235-L243 +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L146-L161 + +## Vulnerability Detail +Whenever the `delegateeEligibilityThresholdScore` is changed some `delegatees` may pass (with the same `score`) from `_isDelegateeEligible` to `!_isDelegateeEligible` because of this change. +However, when a `delegatee` becomes `!_isDelegateeEligible` in this way, its `timeOfIneligibility[_delegatee]` is not updated with a `block.timestamp` of that moment. This prevent a proper calculation of the `_isUpdateDelayElapsed = (timeOfIneligibility[_delegatee] + updateEligibilityDelay) <= block.timestamp` in the `BinaryEligibilityOracleEarningPowerCalculator::getNewEarningPower` when is called by `bumpers` through the `GovernanceStaker::bumpEarningPower`. + +```solidity + function bumpEarningPower( + DepositIdentifier _depositId, + address _tipReceiver, + uint256 _requestedTip + ) external virtual { + if (_requestedTip > maxBumpTip) revert GovernanceStaker__InvalidTip(); + + Deposit storage deposit = deposits[_depositId]; + + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + +@> (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( + deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower + ); +@> if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + } + + if (_newEarningPower > deposit.earningPower && _unclaimedRewards < _requestedTip) { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // Note: underflow causes a revert if the requested tip is more than unclaimed rewards + if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) + { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // Update global earning power & deposit earning power based on this bump + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); + deposit.earningPower = _newEarningPower.toUint96(); + + // Send tip to the receiver + SafeERC20.safeTransfer(REWARD_TOKEN, _tipReceiver, _requestedTip); + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_requestedTip * SCALE_FACTOR); + } +``` +```solidity +function getNewEarningPower( + uint256 _amountStaked, + address, /* _staker */ + address _delegatee, + uint256 /* _oldEarningPower */ + ) external view returns (uint256, bool) { + if (_isOracleStale() || isOraclePaused) return (_amountStaked, true); + + if (!_isDelegateeEligible(_delegatee)) { +@> bool _isUpdateDelayElapsed = + (timeOfIneligibility[_delegatee] + updateEligibilityDelay) <= block.timestamp; + return (0, _isUpdateDelayElapsed); + } + + return (_amountStaked, true); + } + ``` +In this contest the `timeOfIneligibility[delegatee]` it's still set to `0` (considering a contest with no previous `ineligibilities` for that `delegatee`). Although the `updateEligibilityDelay` is not elapsed, `bumpers` can still use `GovernanceStaker::bumpEarningPower` on `deposits` having that `delegatee` which however, should be `!_isQualifiedForBump`. + +Moreover, considering a contest like this where a `delegatee` has become `!_isDelegateeEligibleligible` after changes made in the `delegateeEligibilityThresholdScore`, any further update in the `score` of `delegatee` which would not make it eligible again (either made with the `BinaryEligibilityOracleEarningPowerCalculator::updateDelegateeScore` or `BinaryEligibilityOracleEarningPowerCalculator::overrideDelegateeScore`) will not correctly update `timeOfIneligibility[_delegatee]` because there would be a situation where the `delegatee` would be `!_previouslyEligible && !_newlyEligible` that is not considered by the `BinaryEligibilityOracleEarningPowerCalculator_updateDelegateeScore`. + +```solidity +function _updateDelegateeScore(address _delegatee, uint256 _newScore) internal { + uint256 _oldScore = delegateeScores[_delegatee]; + bool _previouslyEligible = _oldScore >= delegateeEligibilityThresholdScore; + bool _newlyEligible = _newScore >= delegateeEligibilityThresholdScore; + emit DelegateeScoreUpdated(_delegatee, _oldScore, _newScore); + // Record the time if the new score crosses the eligibility threshold. +@> if (_previouslyEligible && !_newlyEligible) timeOfIneligibility[_delegatee] = block.timestamp; + delegateeScores[_delegatee] = _newScore; + } +``` + +## Impact +The impact of this vulnerability is twofold: + +1) Calling the `GovernanceStaker::bumpEarningPower` for `deposits` that should be `!_isQualifiedForBump`, `bumpers` can reduce their `unclaimed rewards`. + +2) The updates in the `score` of a `delegatee` (that has become `!_isDelegateeEligibleligible` because of a change made on the `delegateeEligibilityThresholdScore`) that would not make it `_isDelegateeEligibleligible` again, would leave `timeOfIneligibility[delegatee]` set to `0` breaking in this way a core mechanism of the contract. +## POC +```solidity +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +import {Vm, Test, stdStorage, StdStorage, console2, stdError} from "forge-std/Test.sol"; +import {GovernanceStaker, IERC20, IEarningPowerCalculator} from "src/GovernanceStaker.sol"; +import {IERC20Staking} from "src/interfaces/IERC20Staking.sol"; +import {DelegationSurrogate} from "src/DelegationSurrogate.sol"; +import {GovernanceStakerHarness} from "test/harnesses/GovernanceStakerHarness.sol"; +import {GovernanceStakerOnBehalf} from "src/extensions/GovernanceStakerOnBehalf.sol"; +import {ERC20VotesMock, ERC20Permit} from "test/mocks/MockERC20Votes.sol"; +import {IERC20Errors} from "openzeppelin/interfaces/draft-IERC6093.sol"; +import {ERC20Fake} from "test/fakes/ERC20Fake.sol"; +import {MockFullEarningPowerCalculator} from "test/mocks/MockFullEarningPowerCalculator.sol"; +import {PercentAssertions} from "test/helpers/PercentAssertions.sol"; + +import {Test, console, console2} from "forge-std/Test.sol"; +import { + BinaryEligibilityOracleEarningPowerCalculator as EarningPowerCalculator, + Ownable +} from "src/BinaryEligibilityOracleEarningPowerCalculator.sol"; + +contract EarningPowervsChangeThresholdTest is Test, PercentAssertions { + address public owner; + address public scoreOracle; + uint256 public staleOracleWindow; + address public oraclePauseGuardian; + uint256 public delegateeScoreEligibilityThreshold; + uint256 public updateEligibilityDelay; + address public delegatee1; + address public delegatee2; + address public depositor; + address public tipReceiver; + address public bumper; + EarningPowerCalculator public calculator; + + ERC20Fake rewardToken; + ERC20VotesMock govToken; + address admin; + address rewardNotifier; + GovernanceStakerHarness govStaker; + uint256 SCALE_FACTOR; + + // console2.log(uint(_domainSeparatorV4())) + bytes32 EIP712_DOMAIN_SEPARATOR = bytes32( + uint256( + 100_848_718_687_569_044_464_352_297_364_979_714_567_529_445_102_133_191_562_407_938_263_844_493_123_852 + ) + ); + uint256 maxBumpTip = 1e18; + + bytes32 constant PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + + event RewardNotifierSet(address indexed account, bool isEnabled); + event AdminSet(address indexed oldAdmin, address indexed newAdmin); + + mapping(DelegationSurrogate surrogate => bool isKnown) isKnownSurrogate; + mapping(address depositor => bool isKnown) isKnownDepositor; + + function setUp() public { + owner = makeAddr("owner"); + scoreOracle = makeAddr("scoreOracle"); + staleOracleWindow = 7 days; + oraclePauseGuardian = makeAddr("oraclePauseGuardian"); + delegateeScoreEligibilityThreshold = 50; + updateEligibilityDelay = 3 days; + + calculator = new EarningPowerCalculator( + owner, + scoreOracle, + staleOracleWindow, + oraclePauseGuardian, + delegateeScoreEligibilityThreshold, + updateEligibilityDelay + ); + + // Set the block timestamp to an arbitrary value to avoid introducing assumptions into tests + // based on a starting timestamp of 0, which is the default. + uint256 start = block.timestamp; + vm.warp(start + 2 days); + + rewardToken = new ERC20Fake(); + vm.label(address(rewardToken), "Reward Token"); + + govToken = new ERC20VotesMock(); + vm.label(address(govToken), "Governance Token"); + + rewardNotifier = address(0xaffab1ebeef); + vm.label(rewardNotifier, "Reward Notifier"); + + vm.label(address(calculator), "Full Earning Power Calculator"); + + admin = makeAddr("admin"); + + govStaker = new GovernanceStakerHarness( + rewardToken, govToken, calculator, maxBumpTip, admin, "GovernanceStaker" + ); + vm.label(address(govStaker), "GovStaker"); + + vm.prank(admin); + govStaker.setRewardNotifier(rewardNotifier, true); + + // Convenience for use in tests + SCALE_FACTOR = govStaker.SCALE_FACTOR(); + + delegatee1 = makeAddr("Delegatee1"); + delegatee2 = makeAddr("Delegatee2"); + depositor = makeAddr("Depositor"); + tipReceiver = makeAddr("tipReceiver"); + bumper = makeAddr("bumper"); + } + + function test_EligibilityvsThresholdChange() public { + GovernanceStaker.DepositIdentifier depositID1; + GovernanceStaker.DepositIdentifier depositID2; + deal(address(govToken), depositor, 100 ether); + deal(address(rewardToken), address(govStaker), 100 ether); + + vm.startPrank(address(scoreOracle)); + calculator.updateDelegateeScore(delegatee1, 60); + + vm.startPrank(depositor); + govToken.approve(address(govStaker), 20 ether); + depositID2 = govStaker.stake(10 ether, delegatee2); + depositID1 = govStaker.stake(10 ether, delegatee1); + + vm.startPrank(rewardNotifier); + govStaker.notifyRewardAmount(10 ether); + + vm.warp(block.timestamp + 2 days); + vm.startPrank(depositor); + govStaker.withdraw(depositID2, 5 ether); + + vm.warp(block.timestamp + 2 days); + vm.startPrank(owner); + calculator.setDelegateeScoreEligibilityThreshold(80); + + vm.startPrank(depositor); + uint256 unclaimedrew = govStaker.unclaimedReward(depositID1); + console.log("unclaimedReward:", unclaimedrew); + console.log("timeOfIneligibility[delegatee1]:", calculator.timeOfIneligibility(delegatee1)); + + vm.startPrank(bumper); + govStaker.bumpEarningPower(depositID1, tipReceiver, 1e17); + + vm.startPrank(scoreOracle); + calculator.updateDelegateeScore(delegatee1, 70); + console.log("timeOfIneligibility[delegatee1] after the score change:", calculator.timeOfIneligibility(delegatee1)); + + assertEq(rewardToken.balanceOf(tipReceiver), 1e17); + assertEq(calculator.timeOfIneligibility(delegatee1), 0); + + } + } +``` +
+Trace + +```solidity +Ran 1 test for test/4th05.t.sol:EarningPowervsChangeThresholdTest +[PASS] test_EligibilityvsThresholdChange() (gas: 1109274) +Logs: + unclaimedReward: 1333333333333333333 + timeOfIneligibility[delegatee1]: 0 + timeOfIneligibility[delegatee1] after the score change: 0 + +Traces: + [7107172] EarningPowervsChangeThresholdTest::setUp() + ├─ [0] VM::addr() [staticcall] + │ └─ ← [Return] owner: [0x7c8999dC9a822c1f0Df42023113EDB4FDd543266] + ├─ [0] VM::label(owner: [0x7c8999dC9a822c1f0Df42023113EDB4FDd543266], "owner") + │ └─ ← [Return] + ├─ [0] VM::addr() [staticcall] + │ └─ ← [Return] scoreOracle: [0xc1838b9ECEaBA710f564473f92419F38c906ad85] + ├─ [0] VM::label(scoreOracle: [0xc1838b9ECEaBA710f564473f92419F38c906ad85], "scoreOracle") + │ └─ ← [Return] + ├─ [0] VM::addr() [staticcall] + │ └─ ← [Return] oraclePauseGuardian: [0xD7a74ec423cF3e091f9efFb701a82FC63ca7Ba4e] + ├─ [0] VM::label(oraclePauseGuardian: [0xD7a74ec423cF3e091f9efFb701a82FC63ca7Ba4e], "oraclePauseGuardian") + │ └─ ← [Return] + ├─ [898650] → new Full Earning Power Calculator@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ ├─ emit OwnershipTransferred(previousOwner: 0x0000000000000000000000000000000000000000, newOwner: owner: [0x7c8999dC9a822c1f0Df42023113EDB4FDd543266]) + │ ├─ emit ScoreOracleSet(oldScoreOracle: 0x0000000000000000000000000000000000000000, newScoreOracle: scoreOracle: [0xc1838b9ECEaBA710f564473f92419F38c906ad85]) + │ ├─ emit OraclePauseGuardianSet(oldOraclePauseGuardian: 0x0000000000000000000000000000000000000000, newOraclePauseGuardian: oraclePauseGuardian: [0xD7a74ec423cF3e091f9efFb701a82FC63ca7Ba4e]) + │ ├─ emit DelegateeEligibilityThresholdScoreSet(oldThreshold: 0, newThreshold: 50) + │ ├─ emit UpdateEligibilityDelaySet(oldDelay: 0, newDelay: 259200 [2.592e5]) + │ └─ ← [Return] 3784 bytes of code + ├─ [0] VM::warp(172801 [1.728e5]) + │ └─ ← [Return] + ├─ [548670] → new Reward Token@0x2e234DAe75C793f67A35089C9d99245E1C58470b + │ └─ ← [Return] 2515 bytes of code + ├─ [0] VM::label(Reward Token: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], "Reward Token") + │ └─ ← [Return] + ├─ [1081652] → new Governance Token@0xF62849F9A0B5Bf2913b396098F7c7019b51A820a + │ └─ ← [Return] 5171 bytes of code + ├─ [0] VM::label(Governance Token: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], "Governance Token") + │ └─ ← [Return] + ├─ [0] VM::label(Reward Notifier: [0x00000000000000000000000000000aFFAB1eBEEf], "Reward Notifier") + │ └─ ← [Return] + ├─ [0] VM::label(Full Earning Power Calculator: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], "Full Earning Power Calculator") + │ └─ ← [Return] + ├─ [0] VM::addr() [staticcall] + │ └─ ← [Return] admin: [0xaA10a84CE7d9AE517a52c6d5cA153b369Af99ecF] + ├─ [0] VM::label(admin: [0xaA10a84CE7d9AE517a52c6d5cA153b369Af99ecF], "admin") + │ └─ ← [Return] + ├─ [4014230] → new GovStaker@0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9 + │ ├─ emit AdminSet(oldAdmin: 0x0000000000000000000000000000000000000000, newAdmin: admin: [0xaA10a84CE7d9AE517a52c6d5cA153b369Af99ecF]) + │ ├─ emit MaxBumpTipSet(oldMaxBumpTip: 0, newMaxBumpTip: 1000000000000000000 [1e18]) + │ ├─ emit EarningPowerCalculatorSet(oldEarningPowerCalculator: 0x0000000000000000000000000000000000000000, newEarningPowerCalculator: Full Earning Power Calculator: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f]) + │ ├─ emit ClaimFeeParametersSet(oldFeeAmount: 0, newFeeAmount: 0, oldFeeCollector: 0x0000000000000000000000000000000000000000, newFeeCollector: 0x0000000000000000000000000000000000000000) + │ └─ ← [Return] 19657 bytes of code + ├─ [0] VM::label(GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], "GovStaker") + │ └─ ← [Return] + ├─ [0] VM::prank(admin: [0xaA10a84CE7d9AE517a52c6d5cA153b369Af99ecF]) + │ └─ ← [Return] + ├─ [24278] GovStaker::setRewardNotifier(Reward Notifier: [0x00000000000000000000000000000aFFAB1eBEEf], true) + │ ├─ emit RewardNotifierSet(account: Reward Notifier: [0x00000000000000000000000000000aFFAB1eBEEf], isEnabled: true) + │ └─ ← [Stop] + ├─ [273] GovStaker::SCALE_FACTOR() [staticcall] + │ └─ ← [Return] 1000000000000000000000000000000000000 [1e36] + ├─ [0] VM::addr() [staticcall] + │ └─ ← [Return] Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313] + ├─ [0] VM::label(Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313], "Delegatee1") + │ └─ ← [Return] + ├─ [0] VM::addr() [staticcall] + │ └─ ← [Return] Delegatee2: [0xB370260ccED0E101398A032f6B365a5E682D2e1a] + ├─ [0] VM::label(Delegatee2: [0xB370260ccED0E101398A032f6B365a5E682D2e1a], "Delegatee2") + │ └─ ← [Return] + ├─ [0] VM::addr() [staticcall] + │ └─ ← [Return] Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A] + ├─ [0] VM::label(Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], "Depositor") + │ └─ ← [Return] + ├─ [0] VM::addr() [staticcall] + │ └─ ← [Return] tipReceiver: [0x69a6C849c70629d658fa38407f2Cd53f1E92BAd3] + ├─ [0] VM::label(tipReceiver: [0x69a6C849c70629d658fa38407f2Cd53f1E92BAd3], "tipReceiver") + │ └─ ← [Return] + ├─ [0] VM::addr() [staticcall] + │ └─ ← [Return] bumper: [0xC65829824821e0773dBEA7A496C7Bf010afd1F9e] + ├─ [0] VM::label(bumper: [0xC65829824821e0773dBEA7A496C7Bf010afd1F9e], "bumper") + │ └─ ← [Return] + └─ ← [Stop] + + [1109274] EarningPowervsChangeThresholdTest::test_EligibilityvsThresholdChange() + ├─ [2599] Governance Token::balanceOf(Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A]) [staticcall] + │ └─ ← [Return] 0 + ├─ [0] VM::record() + │ └─ ← [Return] + ├─ [599] Governance Token::balanceOf(Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A]) [staticcall] + │ └─ ← [Return] 0 + ├─ [0] VM::accesses(Governance Token: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a]) + │ └─ ← [Return] [0x7d11d99ef3ac0118b9aa1e88ab1942a2da3301d96a68a9e3bb56d2c399f08787], [] + ├─ [0] VM::load(Governance Token: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], 0x7d11d99ef3ac0118b9aa1e88ab1942a2da3301d96a68a9e3bb56d2c399f08787) [staticcall] + │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 + ├─ emit WARNING_UninitedSlot(who: Governance Token: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], slot: 56570644437300003375976572106532207474341764501783339481403678330086963054471 [5.657e76]) + ├─ [0] VM::load(Governance Token: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], 0x7d11d99ef3ac0118b9aa1e88ab1942a2da3301d96a68a9e3bb56d2c399f08787) [staticcall] + │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 + ├─ [599] Governance Token::balanceOf(Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A]) [staticcall] + │ └─ ← [Return] 0 + ├─ [0] VM::store(Governance Token: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], 0x7d11d99ef3ac0118b9aa1e88ab1942a2da3301d96a68a9e3bb56d2c399f08787, 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff) + │ └─ ← [Return] + ├─ [599] Governance Token::balanceOf(Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A]) [staticcall] + │ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77] + ├─ [0] VM::store(Governance Token: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], 0x7d11d99ef3ac0118b9aa1e88ab1942a2da3301d96a68a9e3bb56d2c399f08787, 0x0000000000000000000000000000000000000000000000000000000000000000) + │ └─ ← [Return] + ├─ emit SlotFound(who: Governance Token: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], fsig: 0x70a0823100000000000000000000000000000000000000000000000000000000, keysHash: 0x7d11d99ef3ac0118b9aa1e88ab1942a2da3301d96a68a9e3bb56d2c399f08787, slot: 56570644437300003375976572106532207474341764501783339481403678330086963054471 [5.657e76]) + ├─ [0] VM::load(Governance Token: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], 0x7d11d99ef3ac0118b9aa1e88ab1942a2da3301d96a68a9e3bb56d2c399f08787) [staticcall] + │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 + ├─ [0] VM::store(Governance Token: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a], 0x7d11d99ef3ac0118b9aa1e88ab1942a2da3301d96a68a9e3bb56d2c399f08787, 0x0000000000000000000000000000000000000000000000056bc75e2d63100000) + │ └─ ← [Return] + ├─ [599] Governance Token::balanceOf(Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A]) [staticcall] + │ └─ ← [Return] 100000000000000000000 [1e20] + ├─ [2561] Reward Token::balanceOf(GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ └─ ← [Return] 0 + ├─ [0] VM::record() + │ └─ ← [Return] + ├─ [561] Reward Token::balanceOf(GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ └─ ← [Return] 0 + ├─ [0] VM::accesses(Reward Token: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]) + │ └─ ← [Return] [0x6aabb5e3159f63e459180f984db14c38dd83524ae5f05f37086f1b3a53e50715], [] + ├─ [0] VM::load(Reward Token: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 0x6aabb5e3159f63e459180f984db14c38dd83524ae5f05f37086f1b3a53e50715) [staticcall] + │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 + ├─ emit WARNING_UninitedSlot(who: Reward Token: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], slot: 48248548136177130874595238330305211175626856694428216567256805355870241490709 [4.824e76]) + ├─ [0] VM::load(Reward Token: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 0x6aabb5e3159f63e459180f984db14c38dd83524ae5f05f37086f1b3a53e50715) [staticcall] + │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 + ├─ [561] Reward Token::balanceOf(GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ └─ ← [Return] 0 + ├─ [0] VM::store(Reward Token: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 0x6aabb5e3159f63e459180f984db14c38dd83524ae5f05f37086f1b3a53e50715, 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff) + │ └─ ← [Return] + ├─ [561] Reward Token::balanceOf(GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ └─ ← [Return] 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77] + ├─ [0] VM::store(Reward Token: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 0x6aabb5e3159f63e459180f984db14c38dd83524ae5f05f37086f1b3a53e50715, 0x0000000000000000000000000000000000000000000000000000000000000000) + │ └─ ← [Return] + ├─ emit SlotFound(who: Reward Token: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], fsig: 0x70a0823100000000000000000000000000000000000000000000000000000000, keysHash: 0x6aabb5e3159f63e459180f984db14c38dd83524ae5f05f37086f1b3a53e50715, slot: 48248548136177130874595238330305211175626856694428216567256805355870241490709 [4.824e76]) + ├─ [0] VM::load(Reward Token: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 0x6aabb5e3159f63e459180f984db14c38dd83524ae5f05f37086f1b3a53e50715) [staticcall] + │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 + ├─ [0] VM::store(Reward Token: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 0x6aabb5e3159f63e459180f984db14c38dd83524ae5f05f37086f1b3a53e50715, 0x0000000000000000000000000000000000000000000000056bc75e2d63100000) + │ └─ ← [Return] + ├─ [561] Reward Token::balanceOf(GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ └─ ← [Return] 100000000000000000000 [1e20] + ├─ [0] VM::startPrank(scoreOracle: [0xc1838b9ECEaBA710f564473f92419F38c906ad85]) + │ └─ ← [Return] + ├─ [38042] Full Earning Power Calculator::updateDelegateeScore(Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313], 60) + │ ├─ emit DelegateeScoreUpdated(delegatee: Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313], oldScore: 0, newScore: 60) + │ └─ ← [Stop] + ├─ [0] VM::startPrank(Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A]) + │ └─ ← [Return] + ├─ [24735] Governance Token::approve(GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 20000000000000000000 [2e19]) + │ ├─ emit Approval(owner: Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], spender: GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], value: 20000000000000000000 [2e19]) + │ └─ ← [Return] true + ├─ [306468] GovStaker::stake(10000000000000000000 [1e19], Delegatee2: [0xB370260ccED0E101398A032f6B365a5E682D2e1a]) + │ ├─ [61043] → new DelegationSurrogateVotes@0x5B0091f49210e7B2A57B03dfE1AB9D08289d9294 + │ │ ├─ [24735] Governance Token::approve(GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77]) + │ │ │ ├─ emit Approval(owner: DelegationSurrogateVotes: [0x5B0091f49210e7B2A57B03dfE1AB9D08289d9294], spender: GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], value: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77]) + │ │ │ └─ ← [Return] true + │ │ ├─ [22515] Governance Token::delegate(Delegatee2: [0xB370260ccED0E101398A032f6B365a5E682D2e1a]) + │ │ │ └─ ← [Stop] + │ │ └─ ← [Return] 63 bytes of code + │ ├─ emit SurrogateDeployed(delegatee: Delegatee2: [0xB370260ccED0E101398A032f6B365a5E682D2e1a], surrogate: DelegationSurrogateVotes: [0x5B0091f49210e7B2A57B03dfE1AB9D08289d9294]) + │ ├─ [3260] Full Earning Power Calculator::getEarningPower(10000000000000000000 [1e19], Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], Delegatee2: [0xB370260ccED0E101398A032f6B365a5E682D2e1a]) [staticcall] + │ │ └─ ← [Return] 0 + │ ├─ [26017] Governance Token::transferFrom(Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], DelegationSurrogateVotes: [0x5B0091f49210e7B2A57B03dfE1AB9D08289d9294], 10000000000000000000 [1e19]) + │ │ ├─ emit Transfer(from: Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], to: DelegationSurrogateVotes: [0x5B0091f49210e7B2A57B03dfE1AB9D08289d9294], value: 10000000000000000000 [1e19]) + │ │ └─ ← [Return] true + │ ├─ emit StakeDeposited(owner: Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], depositId: 0, amount: 10000000000000000000 [1e19], depositBalance: 10000000000000000000 [1e19]) + │ ├─ emit ClaimerAltered(depositId: 0, oldClaimer: 0x0000000000000000000000000000000000000000, newClaimer: Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A]) + │ ├─ emit DelegateeAltered(depositId: 0, oldDelegatee: 0x0000000000000000000000000000000000000000, newDelegatee: Delegatee2: [0xB370260ccED0E101398A032f6B365a5E682D2e1a]) + │ └─ ← [Return] 0 + ├─ [266458] GovStaker::stake(10000000000000000000 [1e19], Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313]) + │ ├─ [61043] → new DelegationSurrogateVotes@0xDD4c722d1614128933d6DC7EFA50A6913e804E12 + │ │ ├─ [24735] Governance Token::approve(GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77]) + │ │ │ ├─ emit Approval(owner: DelegationSurrogateVotes: [0xDD4c722d1614128933d6DC7EFA50A6913e804E12], spender: GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], value: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77]) + │ │ │ └─ ← [Return] true + │ │ ├─ [22515] Governance Token::delegate(Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313]) + │ │ │ └─ ← [Stop] + │ │ └─ ← [Return] 63 bytes of code + │ ├─ emit SurrogateDeployed(delegatee: Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313], surrogate: DelegationSurrogateVotes: [0xDD4c722d1614128933d6DC7EFA50A6913e804E12]) + │ ├─ [1250] Full Earning Power Calculator::getEarningPower(10000000000000000000 [1e19], Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313]) [staticcall] + │ │ └─ ← [Return] 10000000000000000000 [1e19] + │ ├─ [26017] Governance Token::transferFrom(Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], DelegationSurrogateVotes: [0xDD4c722d1614128933d6DC7EFA50A6913e804E12], 10000000000000000000 [1e19]) + │ │ ├─ emit Transfer(from: Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], to: DelegationSurrogateVotes: [0xDD4c722d1614128933d6DC7EFA50A6913e804E12], value: 10000000000000000000 [1e19]) + │ │ └─ ← [Return] true + │ ├─ emit StakeDeposited(owner: Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], depositId: 1, amount: 10000000000000000000 [1e19], depositBalance: 10000000000000000000 [1e19]) + │ ├─ emit ClaimerAltered(depositId: 1, oldClaimer: 0x0000000000000000000000000000000000000000, newClaimer: Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A]) + │ ├─ emit DelegateeAltered(depositId: 1, oldDelegatee: 0x0000000000000000000000000000000000000000, newDelegatee: Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313]) + │ └─ ← [Return] 1 + ├─ [0] VM::startPrank(Reward Notifier: [0x00000000000000000000000000000aFFAB1eBEEf]) + │ └─ ← [Return] + ├─ [68862] GovStaker::notifyRewardAmount(10000000000000000000 [1e19]) + │ ├─ [561] Reward Token::balanceOf(GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9]) [staticcall] + │ │ └─ ← [Return] 100000000000000000000 [1e20] + │ ├─ emit RewardNotified(amount: 10000000000000000000 [1e19], notifier: Reward Notifier: [0x00000000000000000000000000000aFFAB1eBEEf]) + │ └─ ← [Stop] + ├─ [0] VM::warp(345601 [3.456e5]) + │ └─ ← [Return] + ├─ [0] VM::startPrank(Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A]) + │ └─ ← [Return] + ├─ [56515] GovStaker::withdraw(0, 5000000000000000000 [5e18]) + │ ├─ [1260] Full Earning Power Calculator::getEarningPower(5000000000000000000 [5e18], Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], Delegatee2: [0xB370260ccED0E101398A032f6B365a5E682D2e1a]) [staticcall] + │ │ └─ ← [Return] 0 + │ ├─ [3714] Governance Token::transferFrom(DelegationSurrogateVotes: [0x5B0091f49210e7B2A57B03dfE1AB9D08289d9294], Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], 5000000000000000000 [5e18]) + │ │ ├─ emit Transfer(from: DelegationSurrogateVotes: [0x5B0091f49210e7B2A57B03dfE1AB9D08289d9294], to: Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], value: 5000000000000000000 [5e18]) + │ │ └─ ← [Return] true + │ ├─ emit StakeWithdrawn(owner: Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], depositId: 0, amount: 5000000000000000000 [5e18], depositBalance: 5000000000000000000 [5e18]) + │ └─ ← [Stop] + ├─ [0] VM::warp(518401 [5.184e5]) + │ └─ ← [Return] + ├─ [0] VM::startPrank(owner: [0x7c8999dC9a822c1f0Df42023113EDB4FDd543266]) + │ └─ ← [Return] + ├─ [6809] Full Earning Power Calculator::setDelegateeScoreEligibilityThreshold(80) + │ ├─ emit DelegateeEligibilityThresholdScoreSet(oldThreshold: 50, newThreshold: 80) + │ └─ ← [Stop] + ├─ [0] VM::startPrank(Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A]) + │ └─ ← [Return] + ├─ [2186] GovStaker::unclaimedReward(1) [staticcall] + │ └─ ← [Return] 1333333333333333333 [1.333e18] + ├─ [0] console::log("unclaimedReward:", 1333333333333333333 [1.333e18]) [staticcall] + │ └─ ← [Stop] + ├─ [2574] Full Earning Power Calculator::timeOfIneligibility(Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313]) [staticcall] + │ └─ ← [Return] 0 + ├─ [0] console::log("timeOfIneligibility[delegatee1]:", 0) [staticcall] + │ └─ ← [Stop] + ├─ [0] VM::startPrank(bumper: [0xC65829824821e0773dBEA7A496C7Bf010afd1F9e]) + │ └─ ← [Return] + ├─ [79828] GovStaker::bumpEarningPower(1, tipReceiver: [0x69a6C849c70629d658fa38407f2Cd53f1E92BAd3], 100000000000000000 [1e17]) + │ ├─ [3693] Full Earning Power Calculator::getNewEarningPower(10000000000000000000 [1e19], Depositor: [0x888d7213BfbfE01A4C88346Eec0381e8903fBa0A], Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313], 10000000000000000000 [1e19]) [staticcall] + │ │ └─ ← [Return] 0, true + │ ├─ [25046] Reward Token::transfer(tipReceiver: [0x69a6C849c70629d658fa38407f2Cd53f1E92BAd3], 100000000000000000 [1e17]) + │ │ ├─ emit Transfer(from: GovStaker: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], to: tipReceiver: [0x69a6C849c70629d658fa38407f2Cd53f1E92BAd3], value: 100000000000000000 [1e17]) + │ │ └─ ← [Return] true + │ └─ ← [Stop] + ├─ [0] VM::startPrank(scoreOracle: [0xc1838b9ECEaBA710f564473f92419F38c906ad85]) + │ └─ ← [Return] + ├─ [3242] Full Earning Power Calculator::updateDelegateeScore(Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313], 70) + │ ├─ emit DelegateeScoreUpdated(delegatee: Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313], oldScore: 60, newScore: 70) + │ └─ ← [Stop] + ├─ [574] Full Earning Power Calculator::timeOfIneligibility(Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313]) [staticcall] + │ └─ ← [Return] 0 + ├─ [0] console::log("timeOfIneligibility[delegatee1] after the score change:", 0) [staticcall] + │ └─ ← [Stop] + ├─ [561] Reward Token::balanceOf(tipReceiver: [0x69a6C849c70629d658fa38407f2Cd53f1E92BAd3]) [staticcall] + │ └─ ← [Return] 100000000000000000 [1e17] + ├─ [0] VM::assertEq(100000000000000000 [1e17], 100000000000000000 [1e17]) [staticcall] + │ └─ ← [Return] + ├─ [574] Full Earning Power Calculator::timeOfIneligibility(Delegatee1: [0x40040FAB876c6733d5781094F4f22aD993f88313]) [staticcall] + │ └─ ← [Return] 0 + ├─ [0] VM::assertEq(0, 0) [staticcall] + │ └─ ← [Return] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.62ms (1.63ms CPU time) +``` +
+ +## Tool used +Manual Review + +## Recommendation +This could be the solution addressing the 2 problems: + +```diff +function getNewEarningPower( + uint256 _amountStaked, + address, /* _staker */ + address _delegatee, + uint256 /* _oldEarningPower */ + ) external view returns (uint256, bool) { + if (_isOracleStale() || isOraclePaused) return (_amountStaked, true); + + if (!_isDelegateeEligible(_delegatee)) { ++ if(timeOfIneligibility[_delegatee] == 0){ ++ return (0, false); ++ } + bool _isUpdateDelayElapsed = + (timeOfIneligibility[_delegatee] + updateEligibilityDelay) <= block.timestamp; + return (0, _isUpdateDelayElapsed); + } + + return (_amountStaked, true); + } +``` + +```diff + function _updateDelegateeScore(address _delegatee, uint256 _newScore) internal { + uint256 _oldScore = delegateeScores[_delegatee]; + bool _previouslyEligible = _oldScore >= delegateeEligibilityThresholdScore; + bool _newlyEligible = _newScore >= delegateeEligibilityThresholdScore; + emit DelegateeScoreUpdated(_delegatee, _oldScore, _newScore); + // Record the time if the new score crosses the eligibility threshold. +- if (_previouslyEligible && !_newlyEligible) timeOfIneligibility[_delegatee] = block.timestamp; ++ if (_previouslyEligible && !_newlyEligible || !_previouslyEligible && !_newlyEligible && timeOfIneligibility[_delegatee] == 0) timeOfIneligibility[_delegatee] = block.timestamp; + delegateeScores[_delegatee] = _newScore; + } + ``` \ No newline at end of file diff --git a/041.md b/041.md new file mode 100644 index 0000000..c97cc6f --- /dev/null +++ b/041.md @@ -0,0 +1,208 @@ +Calm Burlap Mule + +Medium + +# Malicious Staker Can Exploit Outdated Earning Power to Unfairly Accrue or Steal Rewards up to `maxBumpTip` + +## Summary +A malicious staker can exploit the staking system to maintain and accrue rewards with an outdated earning power, even if their `delegatee` becomes ineligible for earning power. This allows the malicious staker to continue accruing rewards unfairly upto `maxBumpTip`. + +## Vulnerability Detail +#### [Score Oracle Stale/Paused -> Oracle State Restored](https://docs.google.com/document/d/1Mu5HMsmnNWhjAiISfnQPznJnErIuyp1kGfAKuK4-GC4/edit?pli=1&tab=t.0#heading=h.rusbnb52w5xb) +* When the oracle that updates scores is paused or stale, all `delegatees` are treated as eligible for earning power +* Stakers whose `delegatees` have not reached the eligibility threshold can now accrue rewards during this period +* When the oracle is restored, their earning power should revert to zero if the `delegatee` is ineligible for earning power +* However, a malicious staker can manage to keep the earning power and accrue rewards with the earning power upto `maxBumpTip` + +#### Attack Path +* When the oracle that updates scores is paused or stale, a malicious staker stakes and immediately [gets an earning power based off the staked amount](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L571) +* Just before the oracle state is restored, the malicious staker quickly claims all rewards for the elapsed period with the aim to make their `deposit.scaledUnclaimedRewardCheckpoint` (i.e. [unclaimed rewards](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L724-L725)) == 0 +* Oracle is restored and the malicious staker's earning power is expected to be bumped back to zero (if their `delegatee` is ineligible for earning power) by either searcher bots (anyone) or the next time the malicious staker interacts with the staking contract through `stakeMore`, `alterDelegatee`, `alterClaimer`, `withdraw`, and `claimReward` +* The malicious staker avoids interacting with the system to avoid the reset, although since searcher bots or anyone are incentivized to bump stakers earning power through `bumpEarningPower` function, they can try to update the malicious staker's earning power to zero +* However, since the malicious staker's `deposit.scaledUnclaimedRewardCheckpoint` == 0 and the [update is to decrease the earning power](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L497), any `bumpEarningPower` function call on the malicious staker's deposit will revert due to this check -> `if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip)` even if the `_unclaimedRewards` is enough to cover for the `_requestedTip` or even if the `_requestedTip` is zero +* The check ensures that searcher bots or anyone who wants to decrease the malicious staker's deposit earning power (i.e. back to zero in this case) will have to wait till the [malicious staker's unclaimed rewards - the `_requestedTip` is atleast `=> maxBumpTip`](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L497-L500) +* Therefore, this allows the malicious staker to accrue rewards with the outdated earning power to atleast `maxBumpTip` before searcher bots or anyone can successfully update i.e. decrease the malicious staker's earning power to zero +> Note: The amount accrued and gained due to this attack solely depends on the set `maxBumpTip` + + +#### Root Cause +* The root cause is this check in the [`bumpEarningPower` function](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L497-L500): +```javascript +if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) + { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } +``` +* The above check ensures that even if the `_unclaimedRewards` is enough to cover for the `_requestedTip` or the `_requestedTip` is zero, as long as the deposit's unclaimed rewards is not `=> maxBumpTip` then the update will always revert. Therefore, this gives the staker's deposit an edge case to always accrue unfair rewards atleast upto the set `maxBumpTip` + + +## Impact +* Theft of unclaimed rewards - Malicious stakers can unfairly continue accruing rewards upto `maxBumpTip` despite their `delegatees` being ineligible for earning power + +## Code Snippet +* [Earning Power Bump Function](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L471-L514) +```diff + function bumpEarningPower( + DepositIdentifier _depositId, + address _tipReceiver, + uint256 _requestedTip + ) external virtual { + if (_requestedTip > maxBumpTip) revert GovernanceStaker__InvalidTip(); + + + Deposit storage deposit = deposits[_depositId]; + + + _checkpointGlobalReward(); + _checkpointReward(deposit); + + + uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + + + (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( + deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower + ); + if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + } + + + if (_newEarningPower > deposit.earningPower && _unclaimedRewards < _requestedTip) { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + + // Note: underflow causes a revert if the requested tip is more than unclaimed rewards +- if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) +- { +- revert GovernanceStaker__InsufficientUnclaimedRewards(); +- } + + + // Update global earning power & deposit earning power based on this bump + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); + deposit.earningPower = _newEarningPower.toUint96(); + + + // Send tip to the receiver + SafeERC20.safeTransfer(REWARD_TOKEN, _tipReceiver, _requestedTip); + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_requestedTip * SCALE_FACTOR); + } +``` +* [Stake Function](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L558-L590) +```diff + function _stake(address _depositor, uint256 _amount, address _delegatee, address _claimer) + internal + virtual + returns (DepositIdentifier _depositId) + { + _revertIfAddressZero(_delegatee); + _revertIfAddressZero(_claimer); + + + _checkpointGlobalReward(); + + + DelegationSurrogate _surrogate = _fetchOrDeploySurrogate(_delegatee); + _depositId = _useDepositId(); + + +- uint256 _earningPower = earningPowerCalculator.getEarningPower(_amount, _depositor, _delegatee); + + + totalStaked += _amount; + totalEarningPower += _earningPower; + depositorTotalStaked[_depositor] += _amount; + depositorTotalEarningPower[_depositor] += _earningPower; + deposits[_depositId] = Deposit({ + balance: _amount.toUint96(), + owner: _depositor, + delegatee: _delegatee, + claimer: _claimer, +- earningPower: _earningPower.toUint96(), + rewardPerTokenCheckpoint: rewardPerTokenAccumulatedCheckpoint, + scaledUnclaimedRewardCheckpoint: 0 + }); + _stakeTokenSafeTransferFrom(_depositor, address(_surrogate), _amount); + emit StakeDeposited(_depositor, _depositId, _amount, _amount); + emit ClaimerAltered(_depositId, address(0), _claimer); + emit DelegateeAltered(_depositId, address(0), _delegatee); + } +``` +* [Earning Power Calculator Function](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L130-L137) +```diff + function getEarningPower(uint256 _amountStaked, address, /* _staker */ address _delegatee) + external + view + returns (uint256) + { +- if (_isOracleStale() || isOraclePaused) return _amountStaked; + return _isDelegateeEligible(_delegatee) ? _amountStaked : 0; + } +``` + +## Tool used +Manual Review + +## Recommendation +These [checks](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L492-L500) should be modified and simplified. The important check is to ensure the deposit's unclaimed rewards is enough to cover for the requested tip. + +```diff + function bumpEarningPower( + DepositIdentifier _depositId, + address _tipReceiver, + uint256 _requestedTip + ) external virtual { + if (_requestedTip > maxBumpTip) revert GovernanceStaker__InvalidTip(); + + + Deposit storage deposit = deposits[_depositId]; + + + _checkpointGlobalReward(); + _checkpointReward(deposit); + + + uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + + + (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( + deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower + ); + if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + } + +- if (_newEarningPower > deposit.earningPower && _unclaimedRewards < _requestedTip) { +- revert GovernanceStaker__InsufficientUnclaimedRewards(); +- } + + // Note: underflow causes a revert if the requested tip is more than unclaimed rewards +- if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) +- { +- revert GovernanceStaker__InsufficientUnclaimedRewards(); +- } + ++ if (_unclaimedRewards < _requestedTip) revert GovernanceStaker__InsufficientUnclaimedRewards(); + + // Update global earning power & deposit earning power based on this bump + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); + deposit.earningPower = _newEarningPower.toUint96(); + + + // Send tip to the receiver + SafeERC20.safeTransfer(REWARD_TOKEN, _tipReceiver, _requestedTip); + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_requestedTip * SCALE_FACTOR); + } +``` \ No newline at end of file diff --git a/042.md b/042.md new file mode 100644 index 0000000..4ca6de4 --- /dev/null +++ b/042.md @@ -0,0 +1,208 @@ +Magic Eggshell Stallion + +High + +# Oracle Staleness/Paused Defaulting to Full Earning Power Vulnerability + +## Summary + +https://github.com/withtally/staker/blob/90802966475239c3eb8aafc3dbf18edd5a0b6b1b/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L130-L137 +The **Oracle Staleness and Pause Exploit** vulnerability exists within the `BinaryEligibilityOracleEarningPowerCalculator` contract of the **Tally ARB Staker** protocol. This flaw allows an attacker to manipulate the system's earning power calculations by either pausing the oracle or causing it to become stale. When triggered, the contract defaults all delegatees to receive 100% earning power, irrespective of their actual eligibility scores. This undermines the protocol's fundamental mechanism of rewarding only eligible participants, enabling unauthorized entities to earn rewards disproportionately or indefinitely. If left unaddressed, this vulnerability can lead to significant financial losses, governance manipulation, and erosion of user trust. + +## Vulnerability Detail +### a. Detailed Explanation + +The `BinaryEligibilityOracleEarningPowerCalculator` contract determines a staker's earning power based on their delegatee's eligibility score. The core logic is as follows: + +```solidity +if (_isOracleStale() || isOraclePaused) return _amountStaked; +return _isDelegateeEligible(_delegatee) ? _amountStaked : 0; +``` + +**Mechanism Breakdown:** + +1. **Oracle Staleness Check (`_isOracleStale()`):** + - Evaluates whether the oracle has failed to update delegatee scores within a predefined window (`STALE_ORACLE_WINDOW`). + - If stale, the system defaults to granting full earning power (`_amountStaked`) to all delegatees, bypassing actual eligibility checks. + +2. **Oracle Pause Flag (`isOraclePaused`):** + - A boolean flag controlled by the `oraclePauseGuardian`. + - When set to `true`, the system similarly defaults to full earning power for all delegatees. + +**Vulnerability Path:** + +- **Exploitation via Oracle Pause:** + - An attacker gains control over the `oraclePauseGuardian` role. + - They set `isOraclePaused` to `true`, forcing the contract to grant full earning power universally. + +- **Exploitation via Oracle Staleness:** + - The attacker disrupts the oracle's functionality, preventing it from updating delegatee scores. + - Once the `STALE_ORACLE_WINDOW` elapses without updates, the system automatically grants full earning power to all delegatees. + +### b. Proof-of-Concept Code and Results + +**Test Code:** + +```solidity +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +import {Test} from "forge-std/Test.sol"; +import {BinaryEligibilityOracleEarningPowerCalculator} from "../src/BinaryEligibilityOracleEarningPowerCalculator.sol"; +import {ERC20VotesMock} from "./mocks/MockERC20Votes.sol"; + +contract OracleVulnerabilityTest is Test { + BinaryEligibilityOracleEarningPowerCalculator calculator; + address owner; + address scoreOracle; + address pauseGuardian; + address attacker; + uint256 staleWindow; + uint256 threshold; + ERC20VotesMock mockToken; + + function setUp() public { + owner = makeAddr("owner"); + scoreOracle = makeAddr("oracle"); + pauseGuardian = makeAddr("guardian"); + attacker = makeAddr("attacker"); + + staleWindow = 1 days; + threshold = 50; // Minimum score needed for eligibility + + vm.startPrank(owner); + calculator = new BinaryEligibilityOracleEarningPowerCalculator( + owner, + scoreOracle, + staleWindow, + pauseGuardian, + threshold, + 1 hours + ); + mockToken = new ERC20VotesMock(); + vm.stopPrank(); + } + + function testExploit_StalenessGrantsFullPower() public { + // Setup: Set initial score below threshold + vm.startPrank(scoreOracle); + calculator.updateDelegateeScore(attacker, 10); // Score well below threshold + vm.stopPrank(); + + // Initial check - should have zero earning power + assertEq( + calculator.getEarningPower(100, attacker, attacker), + 0, + "Should have zero earning power when score below threshold" + ); + + // Simulate oracle becoming stale + vm.warp(block.timestamp + staleWindow + 1); + + // Check after staleness - should now have full earning power + uint256 stakeAmount = 100; + assertEq( + calculator.getEarningPower(stakeAmount, attacker, attacker), + stakeAmount, + "Should have full earning power when oracle is stale" + ); + } + + function testExploit_PausedOracleGrantsFullPower() public { + // Setup: Set initial score below threshold + vm.startPrank(scoreOracle); + calculator.updateDelegateeScore(attacker, 10); + vm.stopPrank(); + + // Initial check + assertEq( + calculator.getEarningPower(100, attacker, attacker), + 0, + "Should have zero earning power when score below threshold" + ); + + // Pause oracle + vm.prank(pauseGuardian); + calculator.setOracleState(true); + + // Check after pause - should have full earning power + uint256 stakeAmount = 100; + assertEq( + calculator.getEarningPower(stakeAmount, attacker, attacker), + stakeAmount, + "Should have full earning power when oracle is paused" + ); + } +} +``` + +**Test Results:** + + +Ran 2 tests for test/OracleVulnerability.t.sol:OracleVulnerabilityTest +[PASS] testExploit_PausedOracleGrantsFullPower() (gas: 60889) +[PASS] testExploit_StalenessGrantsFullPower() (gas: 55424) +Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 38.21ms (8.45ms CPU time) + +Ran 1 test suite in 87.07ms (38.21ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests) + + +**Interpretation:** + +1. **`testExploit_StalenessGrantsFullPower()` Passed:** + - Verified that after the oracle becomes stale (i.e., no updates within `STALE_ORACLE_WINDOW`), a delegatee with a score below the threshold (`10 < 50`) is erroneously granted full earning power (`100` instead of `0`). + +2. **`testExploit_PausedOracleGrantsFullPower()` Passed:** + - Confirmed that when the oracle is explicitly paused by the `oraclePauseGuardian`, a delegatee with an insufficient score similarly receives full earning power. + +These results conclusively demonstrate that both exploitation vectors—oracle staleness and oracle pausing—effectively bypass the intended eligibility checks, validating the presence of the vulnerability. + +### c. Key Insights Demonstrating Vulnerability Validity + +- **Bypassing Eligibility Checks:** + - The contract's fallback mechanism prioritizes system availability over security, defaulting to full earning power when the oracle's state is compromised or inactive. + +- **Role Exploitation:** + - Control over the `oraclePauseGuardian` role or the ability to induce oracle staleness provides an attacker with the means to exploit the system's fallback logic. + +- **System Invariant Violation:** + - The protocol's invariant that only eligible delegatees should receive rewards is violated when earning power defaults to full, irrespective of actual delegatee scores. + +- **Impact on Core Functionality:** + - The vulnerability directly affects the reward distribution mechanism, a cornerstone of the staking protocol, leading to potential economic abuse and governance manipulation. + +## Impact +The **Oracle Staleness and Pause Exploit** has profound implications for the **Tally ARB Staker** protocol: + +1. **Financial Exploitation:** + - **Unlimited Reward Accumulation:** Attackers can continuously earn rewards by maintaining the oracle in a stale or paused state, disregarding actual delegatee eligibility. + - **Resource Drain:** Legitimate stakers with low or zero eligibility scores can unjustly siphon rewards meant for active, eligible participants. + +2. **Governance Manipulation:** + - **Distorted Voting Power:** Full earning power may inadvertently or deliberately grant disproportionate governance influence to ineligible or malicious delegates. + - **Erosion of Trust:** Users may lose confidence in the protocol's fairness and security, leading to reduced participation and potential exit. + +3. **System Integrity Compromise:** + - **Economic Imbalance:** The protocol's reward distribution becomes skewed, undermining the economic incentives designed to promote active governance participation. + - **Potential for Repeated Exploits:** Persistent toggling of the oracle state can create an ongoing vulnerability window, exacerbating the protocol's exposure. + +## Tool used + +Manual Review and Foundry + +## Recommendation +To mitigate the **Oracle Staleness and Pause Exploit**, the following actionable steps are recommended: + +### a. Adjust Fallback Logic + +- **Default to Zero Earning Power:** + - Modify the `getEarningPower` function to return zero earning power when the oracle is stale or paused, rather than granting full earning power. + + ```solidity + if (_isOracleStale() || isOraclePaused) return 0; + return _isDelegateeEligible(_delegatee) ? _amountStaked : 0; + ``` + +- **Conditional Reward Accrual:** + - Implement a mechanism to halt reward distribution entirely if the oracle is stale or paused, preventing any earning regardless of delegatee scores. + diff --git a/043.md b/043.md new file mode 100644 index 0000000..d2415da --- /dev/null +++ b/043.md @@ -0,0 +1,120 @@ +Magic Eggshell Stallion + +Medium + +# `bumpEarningPower` Flip-Flop Attack to Drain Unclaimed Rewards + +## Summary +https://github.com/withtally/staker/blob/90802966475239c3eb8aafc3dbf18edd5a0b6b1b/src/GovernanceStaker.sol#L471-L514 +The `bumpEarningPower` function within the **GovernanceStaker** contract is susceptible to a **Flip-Flop Attack** that can be exploited to drain unclaimed rewards from user deposits. This vulnerability arises when an attacker manipulates the eligibility status of a deposit's delegatee, causing the deposit's `earningPower` to oscillate between eligible and ineligible states. Each transition that qualifies for a bump allows the attacker to extract a predefined tip (`_requestedTip`) from the deposit's unclaimed rewards. + +While each individual extraction may be small, the cumulative effect of repeated exploit attempts can lead to significant drainage of a user's unclaimed rewards. This undermines the protocol's fairness and reliability, potentially eroding user trust and financial security within the staking ecosystem. Given that the unclaimed rewards are integral to the staking incentives, this vulnerability poses a medium-level risk that necessitates prompt mitigation to preserve the protocol's integrity and user confidence. + +The vulnerability stems from the `bumpEarningPower` function's ability to allow external actors to repeatedly extract tips from a deposit's unclaimed rewards whenever the deposit's `earningPower` transitions between eligible and ineligible states. This "flip-flop" behavior can be exploited to drain accumulated rewards over multiple cycles. + +#### **Code Snippet** + +```solidity +function bumpEarningPower( + DepositIdentifier _depositId, + address _tipReceiver, + uint256 _requestedTip +) external virtual { + // ... [other logic] + + (uint256 _newEarningPower, bool _isQualifiedForBump) = + earningPowerCalculator.getNewEarningPower( + deposit.balance, + deposit.owner, + deposit.delegatee, + deposit.earningPower + ); + + // ... [additional checks] + + // Subtract _requestedTip from deposit's unclaimed rewards + deposit.scaledUnclaimedRewardCheckpoint -= (_requestedTip * SCALE_FACTOR); + + // ... [transfer the tip to the bumper] +} +``` + +**Explanation:** + +1. **Earning Power Calculation**: The function retrieves the new earning power and determines if the bump qualifies (`_isQualifiedForBump`) based on the delegatee's current eligibility score. + +2. **Tip Extraction**: If qualified, the function subtracts the `_requestedTip` from the deposit's `scaledUnclaimedRewardCheckpoint`, effectively siphoning a portion of the user's unclaimed rewards. + +3. **Potential for Repeated Exploitation**: By toggling the delegatee's eligibility status, an attacker can repeatedly trigger qualifying conditions, each time extracting a tip from the unclaimed rewards. + +#### **Test Code Snippet** + +```solidity +function testFlipFlopAttack() public { + // Simulate passage of time to accumulate rewards + skip(10 days); + + // Toggle delegatee score below threshold to make deposit ineligible + vm.startPrank(admin); + calculator.updateDelegateeScore(address(222), 49); // Below eligibility threshold + vm.stopPrank(); + + // Malicious bumper calls bumpEarningPower to extract a tip + vm.startPrank(maliciousBumper); + uint256 requestedTip = 3e18; + staker.bumpEarningPower(depositId, maliciousBumper, requestedTip); + vm.stopPrank(); + + // Toggle delegatee score above threshold to make deposit eligible again + vm.startPrank(admin); + calculator.updateDelegateeScore(address(222), 51); // Above eligibility threshold + vm.stopPrank(); + + // Malicious bumper calls bumpEarningPower again to extract another tip + vm.startPrank(maliciousBumper); + staker.bumpEarningPower(depositId, maliciousBumper, requestedTip); + vm.stopPrank(); + + // Repeat the toggle and extraction multiple times + for (uint i = 0; i < 3; i++) { + vm.prank(admin); + calculator.updateDelegateeScore(address(222), 49); // Ineligible + vm.prank(maliciousBumper); + staker.bumpEarningPower(depositId, maliciousBumper, requestedTip); + + vm.prank(admin); + calculator.updateDelegateeScore(address(222), 51); // Eligible + vm.prank(maliciousBumper); + staker.bumpEarningPower(depositId, maliciousBumper, requestedTip); + } + + // Assert that the unclaimed rewards have been drained appropriately + // [Assertions would go here] +} +``` + +**Explanation:** + +1. **Reward Accumulation**: The test advances the blockchain time to allow the deposit to accrue rewards. + +2. **Delegatee Score Manipulation**: The admin toggles the delegatee's score below and above the eligibility threshold, making the deposit ineligible and then eligible. + +3. **Tip Extraction by Bumper**: Each time the deposit becomes eligible, the malicious bumper calls `bumpEarningPower` to extract a tip from the unclaimed rewards. + +4. **Repetition of the Process**: By looping the toggle and extraction process, the attacker can repeatedly drain the deposit's unclaimed rewards. + +--- + +### **Tools Used** +- **Manual Review** +- **Foundry** + +--- + +### **Recommendations** + +To mitigate the identified vulnerability and strengthen the protocol's security, the following measures are recommended: + +1. **Rate-Limiting Bumps**: + - **Implementation**: Introduce a restriction that limits the frequency at which `bumpEarningPower` can be called for a specific deposit. For instance, allow only one bump per defined time window (e.g., once every 24 hours). + - **Benefit**: Prevents attackers from rapidly extracting tips through frequent state toggling. diff --git a/044.md b/044.md new file mode 100644 index 0000000..699a074 --- /dev/null +++ b/044.md @@ -0,0 +1,95 @@ +Magic Eggshell Stallion + +Medium + +# Admin Arbitrary Overrides of User Rewards + +## Summary +https://github.com/withtally/staker/blob/90802966475239c3eb8aafc3dbf18edd5a0b6b1b/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L188-L192 + +https://github.com/withtally/staker/blob/90802966475239c3eb8aafc3dbf18edd5a0b6b1b/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L200-L203 +The **Tally ARB Staker** smart contract exhibits a vulnerability where privileged roles—**admin** and **owner**—possess the authority to arbitrarily manipulate user rewards. Specifically, these roles can: + +1. **Freeze or Inflate Rewards**: By using functions like `overrideDelegateeScore` and `setDelegateeScoreLock`, an admin can set a delegatee’s score to zero, effectively nullifying rewards for all stakers associated with that delegatee. Conversely, the admin can inflate delegatee scores to disproportionately increase rewards for certain users. + +2. **Impose Arbitrary Claim Fees**: Through `setClaimFeeParameters`, the admin can set exorbitant fee amounts, potentially confiscating all or a significant portion of users' accrued rewards during claim operations. + +3. **Enable Malicious Reward Notifiers**: Using `setRewardNotifier`, the admin can authorize malicious notifiers to manipulate the reward distribution schedule, either by extending it indefinitely with negligible increments or by introducing inconsistencies in reward allocations. + + +## Vulnerability Detail +The **Tally ARB Staker** system allows admins and owners to perform critical operations that can directly influence user rewards. The vulnerability arises from the lack of stringent restrictions and oversight on these privileged functions, enabling potential abuse that can disrupt the reward distribution mechanism. + +#### **Affected Functions and Code Snippets** + +1. **Override Delegatee Scores and Locking Mechanism** + + - **Function**: `overrideDelegateeScore(address _delegatee, uint256 _newScore)` + + ```solidity + function overrideDelegateeScore(address _delegatee, uint256 _newScore) public { + _checkOwner(); + _updateDelegateeScore(_delegatee, _newScore); + _setDelegateeScoreLock(_delegatee, true); + } + ``` + + - **Function**: `setDelegateeScoreLock(address _delegatee, bool _isLocked)` + + ```solidity + function setDelegateeScoreLock(address _delegatee, bool _isLocked) public { + _checkOwner(); + _setDelegateeScoreLock(_delegatee, _isLocked); + } + ``` + + **Issue**: These functions allow the owner to arbitrarily set and lock a delegatee's score. Setting a score to zero effectively nullifies the earning power of all stakers associated with that delegatee, while inflating scores can disproportionately increase rewards. + +2. **Imposing Arbitrary Claim Fees** + + - **Function**: `setClaimFeeParameters(ClaimFeeParameters memory _params)` + + ```solidity + function setClaimFeeParameters(ClaimFeeParameters memory _params) external virtual { + _revertIfNotAdmin(); + _setClaimFeeParameters(_params); + } + ``` + + **Issue**: This function enables the admin to set the `feeAmount` to any value, including excessively high amounts. If set higher than typical user rewards, it can effectively confiscate all or most of the rewards during the claim process. + +3. **Enabling Malicious Reward Notifiers** + + - **Function**: `setRewardNotifier(address _rewardNotifier, bool _isEnabled)` + + ```solidity + function setRewardNotifier(address _rewardNotifier, bool _isEnabled) external virtual { + _revertIfNotAdmin(); + isRewardNotifier[_rewardNotifier] = _isEnabled; + emit RewardNotifierSet(_rewardNotifier, _isEnabled); + } + ``` + + **Issue**: By enabling a malicious notifier, the admin can manipulate the reward distribution schedule. For instance, they can spam `notifyRewardAmount(0)` to indefinitely extend the reward period with negligible increments, disrupting fair reward distribution. + +## Impact +- **User Trust Erosion**: Such capabilities allow the admin to undermine the fairness and reliability of the staking rewards system, leading to loss of user trust. + +- **Economic Disruption**: Arbitrary adjustments to rewards and fees can destabilize the reward economy, affecting user incentives and overall protocol sustainability. + +- **Potential for Abuse**: If the admin's private keys are compromised or if the admin acts maliciously, users could suffer significant financial losses without recourse. + +## Tools Used + +- **Manual Review** + +- **Foundry** +--- + +## Recommendations + +To mitigate the identified vulnerability and enhance the security posture of the **Tally ARB Staker** protocol, the following measures are recommended: + +1. **Implement Multi-Signature Governance for Privileged Actions**: + - **Description**: Require multiple signatures or a decentralized governance mechanism (e.g., DAO votes) for executing critical admin functions such as `overrideDelegateeScore`, `setClaimFeeParameters`, and `setRewardNotifier`. + - **Benefit**: Reduces the risk of unilateral malicious actions by a single admin, ensuring that multiple parties must approve sensitive changes. diff --git a/045.md b/045.md new file mode 100644 index 0000000..41abb58 --- /dev/null +++ b/045.md @@ -0,0 +1,84 @@ +Magic Eggshell Stallion + +Medium + +# Oracle Pause Guardian Indefinitely Grants All Stakers Full Rewards + +## Summary and Impact +https://github.com/withtally/staker/blob/90802966475239c3eb8aafc3dbf18edd5a0b6b1b/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L130-L137 +This vulnerability allows the `oraclePauseGuardian` role to forcefully pause the oracle logic, thereby triggering fallback behavior that grants *every* staker 100% earning power—regardless of their delegate’s real governance score. While the Tally ARB Staker is designed to distribute rewards only to stakers whose delegatees meet a certain eligibility threshold, pausing the oracle or allowing it to become stale bypasses this mechanism entirely. + +By keeping the system perpetually paused (or failing to unpause), malicious or negligent parties undermine the primary incentive structure of the protocol. This effectively cancels out any differential rewards intended to encourage active governance participation. Although this scenario requires specific role access (i.e., the `oraclePauseGuardian` must be compromised, malicious, or coerced), the resulting system failure is severe enough to warrant a **medium** classification: the exploit is straightforward to carry out but somewhat limited by role-based privileges. + +## Vulnerability Detail +The core contracts (notably `BinaryEligibilityOracleEarningPowerCalculator`) rely on the following logic to calculate staker eligibility: + +```solidity +function getEarningPower( + uint256 _amountStaked, + address, + address _delegatee +) external view returns (uint256) { + if (_isOracleStale() || isOraclePaused) return _amountStaked; + return _isDelegateeEligible(_delegatee) ? _amountStaked : 0; +} +``` + +Here, `_isOracleStale()` is: + +```solidity +function _isOracleStale() internal view returns (bool) { + return block.timestamp - lastOracleUpdateTime > STALE_ORACLE_WINDOW; +} +``` + +And pausing is done by the `oraclePauseGuardian` via: + +```solidity +function setOracleState(bool _pauseOracle) public { + if (msg.sender != oraclePauseGuardian) { + revert BinaryEligibilityOracleEarningPowerCalculator__Unauthorized( + "not oracle pause guardian", msg.sender + ); + } + emit OraclePausedStatusUpdated(isOraclePaused, _pauseOracle); + isOraclePaused = _pauseOracle; +} +``` + +**Problematic Code Path** +1. **Pausing**: `isOraclePaused = true;` +2. **No Score Updates**: With the oracle paused (or stale past `STALE_ORACLE_WINDOW`), `_isOracleStale()` returns `true`. +3. **Everyone Eligible**: `getEarningPower()` subsequently returns `_amountStaked` in all scenarios. + +**Test Code Snippet** (from the exploit demonstration) + +```solidity +// 2. Malicious "pauseGuardian" forcibly pauses the oracle. +vm.prank(pauseGuardian); +calc.setOracleState(true); + +// 3. Move forward in time > STALE_ORACLE_WINDOW => oracle becomes "stale". +vm.warp(block.timestamp + STALE_ORACLE_WINDOW + 1); + +// 4. Everyone gets full earning power, ignoring real scores. +uint256 badEarningAfterPause = stakingCaller.getEarningPower( + 100 ether, stakerWithBadDelegate, address(0xBadDelegatee) +); +// badEarningAfterPause == 100 ether +``` + +Invariants dictate that **“only stakers whose delegatees meet a certain eligibility score can earn rewards.”** By making the system stale or paused indefinitely, this invariant is violated: *all* stakers are treated as meeting the threshold. This effectively defeats the protocol’s core governance-based reward distribution design. + +## Tool used + +Manual Review and foundry + +## Recommendation + + +1. **Restrict or Decentralize Pause Authority** + - Require a multisig, DAO vote, or time-delayed mechanism for pausing. This ensures no single address can permanently subvert the protocol. + +1. **Restrict or Decentralize Pause Authority** + - Require a multisig, DAO vote, or time-delayed mechanism for pausing. This ensures no single address can permanently subvert the protocol. diff --git a/046.md b/046.md new file mode 100644 index 0000000..a62ac31 --- /dev/null +++ b/046.md @@ -0,0 +1,45 @@ +Helpful Walnut Meerkat + +High + +# Attackers Can Call GovernanceStaker::bumpEarningPower and Extract All _unclaimedRewards + +# Summary + +The `bumpEarningPower` function allows a bumper to update a deposit's earning power when a qualifying change in earning power is determined by the earning power calculator. To incentivize bumpers to trigger these updates, a portion of the deposit's `_unclaimedRewards` is sent to the bumper. However, this portion is not properly limited, allowing abuse. + +# Root Case + +Relevant code snippets: + +* https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L492 + +* https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L497 + +These restrictions are not correctly implemented, enabling cases such as: + +* _newEarningPower > deposit.earningPower && _unclaimedRewards === _requestedTip + +* _newEarningPower < deposit.earningPower && _requestedTip > maxBumpTip + +This allows attackers to extract more tip than intended by the protocol. + +# Internal pre-conditions + +A deposit qualifies as `_isQualifiedForBump`. + +# External pre-conditions + +***No response*** + +# Attack Path + +***No response*** + +# Impact + +Users could lose a significant portion—or even all—of their unclaimed rewards. + +# Mitigation + +Ensure that the `tip` must always be less than or equal to `maxBumpTip`. \ No newline at end of file diff --git a/047.md b/047.md new file mode 100644 index 0000000..904fa53 --- /dev/null +++ b/047.md @@ -0,0 +1,47 @@ +Feisty Opaque Vulture + +Medium + +# Fees are not transferred if the fee amount is equal to the reward amount for a deposit + +## Summary + +When a staker calls `GovernanceStaker::claimReward()` to claim the rewards for a deposit, but the reward amount equals the fee amount, there are no rewards to claim, and the fee is also not transferred to the `feeCollector`. This results in the reward amount being stuck in the contract and the fees remaining untransferred if the deposit never accumulates rewards again due to having no staked balance. + +## Vulnerability Detail + +Rewards can be claimed when either the owner or the claimer of a given deposit calls `GovernanceStaker::claimReward()`. Within `_claimReward()`, the `feeAmount` is deducted from the rewards associated with the deposit. + +```solidity + function _claimReward(DepositIdentifier _depositId, Deposit storage deposit, address _claimer) + internal + virtual + returns (uint256) + { + ... ... + uint256 _reward = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + // Intentionally reverts due to overflow if unclaimed rewards are less than fee. + uint256 _payout = _reward - claimFeeParameters.feeAmount; + if (_payout == 0) return 0; + ... ... + } +``` + +However, when the `feeAmount` equals the reward amount, the function returns `0`, and the fees are not transferred. +This results in the protocol losing potential fees if the deposit does not accumulate additional rewards in the future due to all funds being withdrawn. The protocol will only receive a portion of the originally intended fees if the `feeAmount` is reduced. + +## Impact + +The protocol will either lose out on the whole fee amount or at least a portion of it. + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L720-L721 + +## Tool used + +Manual Review + +## Recommendation + +The fee amount should still be transferred even when it equals the reward amount. \ No newline at end of file diff --git a/048.md b/048.md new file mode 100644 index 0000000..e8b8ae3 --- /dev/null +++ b/048.md @@ -0,0 +1,39 @@ +Helpful Walnut Meerkat + +Medium + +# Users Can Exploit STALE_ORACLE_WINDOW to Obtain Maximum Possible EarningPower + +# Summary + +The `GovernanceStaker::_stake` function fetches the `_earningPower` from the `earningPowerCalculator`. However, when the `STALE_ORACLE_WINDOW` is exceeded, the function assumes and returns the maximum possible `_earningPower`. In scenarios with low activity or a very short `STALE_ORACLE_WINDOW`, this behavior can be exploited. + +# Root Case + +* https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L571 + +* https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L152 + +The logic shows that when the `STALE_ORACLE_WINDOW` is exceeded, the maximum possible `_earningPower` is assumed and returned. + +# Internal pre-conditions + +* A very short STALE_ORACLE_WINDOW. + +* Low protocol activity. + +# External pre-conditions + +***No response*** + +# Attack Path + +***No response*** + +# Impact + +Users could exploit these conditions to obtain the maximum `EarningPower` from delegates who do not meet the `delegateeEligibilityThresholdScore` minimum requirements. + +# Mitigation + +The `_isDelegateeEligible` check should be enforced to prevent abuse. \ No newline at end of file diff --git a/049.md b/049.md new file mode 100644 index 0000000..f0f9de0 --- /dev/null +++ b/049.md @@ -0,0 +1,39 @@ +Helpful Walnut Meerkat + +Medium + +# Users Are Susceptible to Slippage in _earningPower When Calling GovernanceStaker::stake + +# Summary + +The `GovernanceStaker` contract is designed for use via EOAs (Externally Owned Accounts). However, it does not account for scenarios where, due to the delay between the transaction submission and execution, users might experience unexpected distortions. This could result in receiving less—or even no—`_earningPower` from their staking. + +# Root Case + +* https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L332 + +* https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L348 + +* https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L362 + +None of the external calls in these lines account for such variations, leaving users vulnerable to slippage or even the complete loss of rewards during their staking. + +# Internal pre-conditions + +***No response*** + +# External pre-conditions + +***No response*** + +# Attack Path + +***No response*** + +# Impact + +* Users could earn significantly less or even no rewards from their staking due to slippage. + +# Mitigation + +Implement a minimum earningPower check to validate the transaction and ensure predictable outcomes for users. \ No newline at end of file diff --git a/050.md b/050.md new file mode 100644 index 0000000..528a231 --- /dev/null +++ b/050.md @@ -0,0 +1,74 @@ +Rich Smoke Cheetah + +Medium + +# Rewards are underestimated because of a rounding error + +## Summary +There is a rounding issue in the way rewards are computed that underestimates the rewards and can lead to a loss of funds. +## Vulnerability Detail +Whenever the state of a deposit is modified, two functions are called. The first one is _checkpointGlobalReward: +```solidity + function _checkpointGlobalReward() internal virtual { + rewardPerTokenAccumulatedCheckpoint = rewardPerTokenAccumulated(); + lastCheckpointTime = lastTimeRewardDistributed(); + } +``` +This function updates the rewardPerTokenAccumulatedCheckpoint variable: + +```solidity +function rewardPerTokenAccumulated() public view virtual returns (uint256) { + if (totalEarningPower == 0) return rewardPerTokenAccumulatedCheckpoint; + + return rewardPerTokenAccumulatedCheckpoint + + (scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)) / totalEarningPower; + } +``` +The problem is that rewardPerTokenAccumulatedCheckpoint represents the value of one earning power in reward tokens. +After that, the function _checkpointReward updates the reward of the deposit: +```solidity + function _checkpointReward(Deposit storage deposit) internal virtual { + deposit.scaledUnclaimedRewardCheckpoint = _scaledUnclaimedReward(deposit); + deposit.rewardPerTokenCheckpoint = rewardPerTokenAccumulatedCheckpoint; + } +``` +The function `_scaledUnclaimedReward` updates the scaledUnclaimedRewardCheckpoint by calling _scaledUnclaimedReward, as seen here: +```solidity +function _scaledUnclaimedReward(Deposit storage deposit) internal view virtual returns (uint256) { + return deposit.scaledUnclaimedRewardCheckpoint + + (deposit.earningPower * (rewardPerTokenAccumulated() - deposit.rewardPerTokenCheckpoint)); + } +``` +The function _scaledUnclaimedReward updates the scaledUnclaimedRewardCheckpoint by calling `_scaledUnclaimedReward`, as seen here: +```solidity +``` + +## Impact +The rewards will be underestimated. +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L748-L751 + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L759-L762 + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L522-L525 +## Tool used + +Foundry + +## Recommendation +The function `rewardPerTokenAccumulated` should be updated as follows: +```solidity +function rewardPerTokenAccumulated() public view virtual returns (uint256) { + if (totalEarningPower == 0) return rewardPerTokenAccumulatedCheckpoint; + + return rewardPerTokenAccumulatedCheckpoint + + (scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)); + } +``` +And the function `_scaledUnclaimedReward` should be updated as follows: +```solidity +function _scaledUnclaimedReward(Deposit storage deposit) internal view virtual returns (uint256) { + return deposit.scaledUnclaimedRewardCheckpoint + + (deposit.earningPower * (rewardPerTokenAccumulated() - deposit.rewardPerTokenCheckpoint))/totalEarningPower; + } +``` \ No newline at end of file diff --git a/051.md b/051.md new file mode 100644 index 0000000..05d97d3 --- /dev/null +++ b/051.md @@ -0,0 +1,58 @@ +Feisty Opaque Vulture + +Medium + +# Ineligible deposit continues to accumulate rewards due to maxBumpTip constraint in `bumpEarningPower()` + +## Summary + +Keepers are incentivized to update a deposit's earning power in exchange for a fee. If the earning power is reduced to `0`, it is ensured that the remaining unclaimed rewards are not less than the `maxBumpTip`. This prevents keepers from reducing a deposit's earning power to `0`, resulting in the deposit continuing to accumulate rewards even though it should not, as its earning power is supposed to be `0`. + +## Vulnerability Detail + +In `GovernanceStaker::bumpEarningPower()`, if the new earning power is less than the old one, it is ensured that the remaining unclaimed rewards are not less than the `maxBumpTip` that a keeper can request for bumping. + +```solidity + function bumpEarningPower( + DepositIdentifier _depositId, + address _tipReceiver, + uint256 _requestedTip + ) external virtual { + ... ... + uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + + (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( + deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower + ); + if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + ... ... + // Note: underflow causes a revert if the requested tip is more than unclaimed rewards + if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) + { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + ... ... + } +``` +However, this means the earning power of a deposit cannot be updated to `0` as long as the deposit's unclaimed rewards are less than `maxBumpTip`. This is even more likely because a keeper can claim all of the unclaimed rewards when the earning power is increased. Consequently, if the deposit has not accumulated enough rewards during the `updateEligibilityDelay` to allow the earning power to be reduced to `0`, the deposit will continue to accumulate rewards. + +Although a deposit does not accumulate rewards when its earning power is `0`, meaning the remaining rewards could be `0` and there would be no incentive to increase the earning power, the constraint is unnecessary. +The owner of the deposit would still be incentivized to, for example, stake an amount of `0` to increase the earning power again as soon as possible, allowing the deposit to continue accumulating rewards. + +## Impact + +Deposits will continue to accumulate rewards even though their earning power should be `0`, effectively stealing rewards from other stakers. + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L497-L500 + +## Tool used + +Manual Review + +## Recommendation + +Consider changing the constraint so that the remaining rewards are not required to at least equal the `maxBumpTip` when the earning power is reduced. +Additionally, consider applying the constraint when the earning power is increased instead, to ensure sufficient incentive remains for reducing the earning power when necessary. \ No newline at end of file diff --git a/052.md b/052.md new file mode 100644 index 0000000..511957f --- /dev/null +++ b/052.md @@ -0,0 +1,35 @@ +Helpful Walnut Meerkat + +High + +# earningPower Breaks Key Invariants + +# Summary + +The reward accounting system in `GovernanceStaker` operates under the assumption that the weight of users' staked positions can only change through their own interactions with specific functions, such as `stake` and `withdraw`. However, the current implementation links the `earningPower` of delegatees to multiple users. This causes `earningPower` changes to affect all linked users collectively, rather than adjusting individually for each user linked to a delegatee. + +# Root Case + +* https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L168 + +This function modifies the score of delegatees, which directly should impacts the weight of multiple users' positions. As a result, desynchronization occurs in the rates at which users should earn rewards. + +# Internal pre-conditions + +***No response*** + +# External pre-conditions + +***No response*** + +# Attack Path + +***No response*** + +# Impact + +* Some users could earn higher rewards than intended, while others might earn significantly less or no rewards at all to offset those gains. + +# Mitigation + +Change the approach to ensure that these adjustments occur automatically through user-initiated interactions. \ No newline at end of file diff --git a/053.md b/053.md new file mode 100644 index 0000000..e233d89 --- /dev/null +++ b/053.md @@ -0,0 +1,89 @@ +Stale Tangelo Puppy + +High + +# Malicious users can steal rewards for a certain period of time + +## Summary + +Malicious users can steal rewards for a certain period of time. + +## Vulnerability Detail + +Assume that Bob (a large staker) has a special agreement with his delegetee that he will never change the delegetee under any circumstance. + +At some point later, his deletegee does some bad thing, and his deletegee's score will be reduced by the oracle. However, due to the agreement, Bob still has to continue forwarding his voting power to the same delegetee, but he does not want to lose any rewards. Thus, he can perform the trick to obtain as many rewards as possible for a period of time until his earning power gets reset to zero eventually. + +Assume that Bob has unclaimed rewards of 500 ARB and an earning power of 100. He observed that his delegatee score would be updated to below the eligibility threshold in the incoming `updateDelegateeScore` transaction, which will cause his earning power to drop to zero if someone bumped his earning power via the `bumpEarningPowier` function. + +Bob front-runs the TX and claims all the unclaimed rewards. Thus, the Bob's `_unclaimedRewards` becomes zero. + +Subsequently, the `updateDelegateeScore` transaction gets executed, and Bob's delegetee becomes ineligible. + +Alice (another staker in the system) wants to reset Bob's earning power to zero so that Bob is not "stealing" her portion of the total rewards. This is because Bob still has a large stake in the system, which diverts significant rewards streamed from others. Bob should not be entitled to these rewards because his earning power technically should be zero now due to ineligible delegatee. Thus, Bob's earning power is actually inflated and outdated. + +Alice executes `bumpEarningPower` function against Bob's account, but the transaction will revert because Bob's `_unclaimedRewards` ( = zero) is below `maxBumpTip`, and the condition at Line 497 below will evaluated as `False`. + +Let the time duration for Bob's unclaimed rewards increase from zero to 0.01 ARB be $D$ seconds. Thus, Bob continues to earn rewards that he is not supposed to for a period of time ($D$) until the `maxBumpTip` (0.01 ARB) is reached. Note that any caller to the `bumpEarningPower` function has to wait for $D$ seconds due to the condition (`(_unclaimedRewards - _requestedTip) < maxBumpTip`) in Line 497 below, which will always revert until `maxBumpTip` is reached. + +This constitutes an issue since it is effectively stealing from other stakers for a duration of $D$ seconds. The loss will be significant and aggravated if: + +- Bob's earning power is significant. In this scenario, this is true because Bob is a large staker. +- The current reward rate is high, which allows Bob to steal more rewards within $D$ seconds. +- The block interval on the chain is long (e.g., 12 seconds on Ethereum). As a result, each unsuccessful bump allows Bob to delay for a longer time and claim more rewards before his earning power gets reset by other users. + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L471 + +```solidity +File: GovernanceStaker.sol +471: function bumpEarningPower( +472: DepositIdentifier _depositId, +473: address _tipReceiver, +474: uint256 _requestedTip +475: ) external virtual { +476: if (_requestedTip > maxBumpTip) revert GovernanceStaker__InvalidTip(); +477: +478: Deposit storage deposit = deposits[_depositId]; +479: +480: _checkpointGlobalReward(); +481: _checkpointReward(deposit); +482: +483: uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; +484: +485: (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( +486: deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower +487: ); +488: if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { +489: revert GovernanceStaker__Unqualified(_newEarningPower); +490: } +491: +492: if (_newEarningPower > deposit.earningPower && _unclaimedRewards < _requestedTip) { +493: revert GovernanceStaker__InsufficientUnclaimedRewards(); +494: } +495: +496: // Note: underflow causes a revert if the requested tip is more than unclaimed rewards +497: if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) +498: { +499: revert GovernanceStaker__InsufficientUnclaimedRewards(); +500: } +``` + +## Impact + +Loss of assets for other stakers in the system, as mentioned in the report. + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L471 + +## Tool used + +Manual Review + +## Recommendation + +Consider letting the users decide if they will want to proceed to execute `bumpEarningPower` function even if there is no tip in return because users will be incentivized to do so even without any tip under certain scenarios. + +The callers of the `bumpEarningPower` function do not always call this function for the sake of getting a small portion of the reward tokens as fees/tips. This is especially true if the target staker to be bumped holds a significant stake in the system, and resetting their earning power will allow the callers to earn more rewards. In this scenario, the profit from earning more rewards if the target staker's earning power is reset to zero will be much more than the gas price they paid for executing the `bumpEarningPower` function. + +This is even more true in L2 environment where the gas fee for executing `bumpEarningPower` function costs almost nothing, but the rewards that can be earned will be worth much more. \ No newline at end of file diff --git a/054.md b/054.md new file mode 100644 index 0000000..19c4335 --- /dev/null +++ b/054.md @@ -0,0 +1,40 @@ +Able Boysenberry Koala + +Medium + +# Delayed Rewards Due To Lack Of Bumping Incentives + +## Summary + +In the current system, bots are responsible for activating users' earning power once it reaches the eligibility threshold. However, there is no incentive for bots to perform this task, because there are no rewards to be claimed leading to delays in users becoming eligible for rewards. + +For example, if Bob stakes assets but his earning power is initially below the threshold, he won’t be eligible for rewards right away. He would need to manually bump his status to become eligible, which could take hours or even days. This delay means Bob misses out on rewards that were supposed to be distributed over time. + +Bots, which should ideally automate this process, are not motivated to bump bob because they aren't rewarded for doing so. + +To solve this, the system could allow future claimable rewards for bots. This would incentivize bots to trigger the eligibility transition by offering them a reasonable tip. The bot’s reward would be deducted from the user’s claim later (e.g., when Bob claims his rewards after a month). This would ensure users can receive rewards on time and make the system more efficient. + +## Vulnerability Detail +lack of financial incentive (tip) for bots to perform bumping action from not eligible to eligible due to no claimable rewards for newly staked users. + +## Impact + +**Missed Rewards**: Users, like Bob, may miss out on rewards if there is a significant delay between the time their earning power becomes eligible and the time they manually trigger the transition. This undermines the intent of the reward system, where users are supposed to earn rewards continuously. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L471-L514 +### PoC +- Bob has 0 earning power and stakes for 1e18. +- The oracle sets Bob’s earning power to eligible, above the threshold.  +- However, since bots don’t have incentives to bump Bob to eligible status, Bob has to do this himself.  +- This delay can be hours or days, meaning he might miss out on rewards he should have earned immediately. +- It’s impractical for users like Bob to do this manually, and the system should ideally be handled by searching bots. + + +## Tool used + +Manual Review + +## Recommendation + +Implement future claimable rewards. This way, a bot could automatically bump Bob’s earning power from ineligible to eligible, and be compensated with a tip, which would be subtracted from Bob’s reward when he claims it later (e.g., a month after). \ No newline at end of file diff --git a/055.md b/055.md new file mode 100644 index 0000000..280188c --- /dev/null +++ b/055.md @@ -0,0 +1,59 @@ +Urban Coffee Nuthatch + +Medium + +# Precision Loss in `notifyRewardAmount` Function Causes Unclaimable RewardToken + +## Summary +The `notifyRewardAmount` function suffers from precision loss when calculating the reward rate, leading to some rewards being locked and unclaimable. + + +## Vulnerability Detail +there is a precision loss in the `notifyRewardAmount` function when calculating `scaledRewardRate`, which results in some of the reward funds being locked in the contract and not being available for distribution. This leads to economic loss. + +function notifyRewardAmount(uint256 _amount) external virtual { + if (!isRewardNotifier[msg.sender]) { + revert GovernanceStaker__Unauthorized("not notifier", msg.sender); + } + + // We checkpoint the accumulator without updating the timestamp at which it was updated, + // because that second operation will be done after updating the reward rate. + rewardPerTokenAccumulatedCheckpoint = rewardPerTokenAccumulated(); + + if (block.timestamp >= rewardEndTime) { + scaledRewardRate = (_amount * SCALE_FACTOR) / REWARD_DURATION; + } else { + uint256 _remainingReward = scaledRewardRate * (rewardEndTime - block.timestamp); + scaledRewardRate = (_remainingReward + _amount * SCALE_FACTOR) / REWARD_DURATION; + } + + rewardEndTime = block.timestamp + REWARD_DURATION; + lastCheckpointTime = block.timestamp; + + if ((scaledRewardRate / SCALE_FACTOR) == 0) revert GovernanceStaker__InvalidRewardRate(); + + // This check cannot _guarantee_ sufficient rewards have been transferred to the contract, + // because it cannot isolate the unclaimed rewards owed to stakers left in the balance. While + // this check is useful for preventing degenerate cases, it is not sufficient. Therefore, it is + // critical that only safe reward notifier contracts are approved to call this method by the + // admin. + if ( + (scaledRewardRate * REWARD_DURATION) > (REWARD_TOKEN.balanceOf(address(this)) * SCALE_FACTOR) + ) revert GovernanceStaker__InsufficientRewardBalance(); + + emit RewardNotified(_amount, msg.sender); + } + + + +## Impact +`notifyRewardAmount` function results in a portion of the reward funds being locked in the contract and unavailable for distribution. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L430 +## Tool used + +Manual Review + +## Recommendation +Add an admin function to extract the reward tokens that remain undistributed due to precision loss. \ No newline at end of file diff --git a/056.md b/056.md new file mode 100644 index 0000000..9c46828 --- /dev/null +++ b/056.md @@ -0,0 +1,147 @@ +Able Boysenberry Koala + +High + +# Oracle Earning Score Can Be Bypassed, Allowing Ineligible Users To Receive Rewards + +## Summary + +When a **inelegible** user with a score below the threshold, alters the delegatee to another user that is **elegible**, it will calculate his new earning power based on the score of the other delegatee. Meaning return the balance of the deposit as earning power OR if that delegatee is **inelegible** return 0. +[code](https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L624-L648) + +```solidity +uint256 _newEarningPower = + earningPowerCalculator.getEarningPower(deposit.balance, deposit.owner, _newDelegatee); +``` +```solidity + function getEarningPower(uint256 _amountStaked, address, /* _staker */ address _delegatee) + external + view + returns (uint256) + { + if (_isOracleStale() || isOraclePaused) return _amountStaked; + return _isDelegateeEligible(_delegatee) ? _amountStaked : 0; + } +``` + +Afterwards it sets the new earning power to this deposit. + +```solidity +deposit.earningPower = _newEarningPower.toUint96(); +``` + +Additionally this earning power cannot be bumped down. +due to the condition `_newEarningPower == deposit.earningPower` in `bumpEarningPower` being true causing a revert. + +## Vulnerability Detail +While a user is **not elegible** to receive rewards, it can delegate to another user to receive rewards which undermines entire functionality of a earning score. + +## Impact +Other users receive lower rewards while a person that in **not elegible** receive rewards. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L624-L648 + +## POC + +Add the following contract to the existing test file GovernanceStaker.t.sol +and run it with `forge test --mt testByPassOracle` +```solidity +contract JoeMamaTest is GovernanceStakerTest { + + address owner = makeAddr("owner"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address joe = makeAddr("joe"); + + address scoreOracle = makeAddr("scoreOracle"); + uint256 staleOracleWindow = 7 days; + address oraclePauseGuardian = makeAddr("oraclePauseGuardian"); + uint256 delegateeScoreEligibilityThreshold = 400; + uint256 updateEligibilityDelay = 1 days; + + BinaryEligibilityOracleEarningPowerCalculator calculator = new BinaryEligibilityOracleEarningPowerCalculator( + owner, + scoreOracle, + staleOracleWindow, + oraclePauseGuardian, + delegateeScoreEligibilityThreshold, + updateEligibilityDelay + ); + + function testByPassOracle( + + ) public { + + _mintGovToken(alice, 1e18); + _mintGovToken(bob, 1e18); + _mintGovToken(joe, 1e18); + + rewardToken.mint(rewardNotifier, 1e18); + + vm.prank(admin); + govStaker.setEarningPowerCalculator(address(calculator)); + + // set alice to elegible + vm.prank(scoreOracle); + calculator.updateDelegateeScore(alice, 500); + + // set bob to elegible + vm.prank(scoreOracle); + calculator.updateDelegateeScore(bob, 500); + + // set joe is not elegible + vm.prank(scoreOracle); + calculator.updateDelegateeScore(joe, 0); + + // have an active oracle. + vm.prank(scoreOracle); + calculator.updateDelegateeScore(address(0), 0); + + GovernanceStaker.DepositIdentifier _depositIdBob = _stake(bob, 1e18, bob); + GovernanceStaker.DepositIdentifier _depositIdAlice = _stake(alice, 1e18, alice); + GovernanceStaker.DepositIdentifier _depositIdJoe = _stake(joe, 1e18, joe); + + // distribute 1e18 over the 1 month period. + vm.startPrank(rewardNotifier); + rewardToken.transfer(address(govStaker), 1e18); + govStaker.notifyRewardAmount(1e18); + vm.stopPrank(); + + // have an active oracle. + vm.prank(scoreOracle); + calculator.updateDelegateeScore(address(0), 0); + + vm.prank(joe); + govStaker.alterDelegatee(_depositIdJoe,bob); + + skip(30 days); + vm.roll(block.timestamp + 30); + + // have an active oracle. + vm.prank(scoreOracle); + calculator.updateDelegateeScore(address(0), 0); + + vm.prank(bob); + uint256 rewardBob = govStaker.claimReward(_depositIdBob); + assertEq(rewardBob,333333333333333333); + + vm.prank(alice); + uint256 rewardALice = govStaker.claimReward(_depositIdAlice); + assertEq(rewardALice, 333333333333333333); + + // this should be 0 because Joe has no voting power. + vm.prank(joe); + uint256 rewardJoe = govStaker.claimReward(_depositIdJoe); + assertEq(rewardJoe, 333333333333333333); + + } +} +``` + +## Tool used + +Manual Review + +## Recommendation +To prevent users from bypassing the oracle it should not increase the deposits earning power while altering delegatee. \ No newline at end of file diff --git a/057.md b/057.md new file mode 100644 index 0000000..7945946 --- /dev/null +++ b/057.md @@ -0,0 +1,112 @@ +Rough Brick Meerkat + +High + +# alterDelegatee affecting the rewards calculation of all stakers + +## Summary +When the staker changes the governance delegate using function alterDelegatee, it affects the rewards of all stakers. +## Vulnerability Detail +The function alterDelegatee in GovernanceStaker contract is called to change the address to which governance voting power is assigned. In this function, call is made to function getEarningPower in the file BinaryEligibilityOracleEarningPowerCalculator.sol to get the earning power of _newDelegatee. As the delegateeScores[_newDelegatee] has not been updated, this value will be ‘0’. This value will make the _newDelegatee ineligible (because delegateeScores[_newDelegatee] < delegateeEligibilityThresholdScore). + +This affects the deposit.earningPower and the totalEarningPower thereby not only affecting the reward for the concerned deposit but also for all the other stakes. + +```solidity + function alterDelegatee(DepositIdentifier _depositId, address _newDelegatee) external virtual { + Deposit storage deposit = deposits[_depositId]; + _revertIfNotDepositOwner(deposit, msg.sender); + _alterDelegatee(deposit, _depositId, _newDelegatee); + } +``` + +```solidity + function _alterDelegatee( + Deposit storage deposit, + DepositIdentifier _depositId, + address _newDelegatee + ) internal virtual { + _revertIfAddressZero(_newDelegatee); + _checkpointGlobalReward(); + _checkpointReward(deposit); + + + DelegationSurrogate _oldSurrogate = surrogates(deposit.delegatee); + uint256 _newEarningPower = + earningPowerCalculator.getEarningPower(deposit.balance, deposit.owner, _newDelegatee); + + + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); +``` + +```solidity + function getEarningPower(uint256 _amountStaked, address, /* _staker */ address _delegatee) + external + view + returns (uint256) + { + if (_isOracleStale() || isOraclePaused) return _amountStaked; + return _isDelegateeEligible(_delegatee) ? _amountStaked : 0; + } +``` + +```solidity + function _isDelegateeEligible(address _delegatee) internal view returns (bool) { + return delegateeScores[_delegatee] >= delegateeEligibilityThresholdScore; + } +``` + +## Impact +Affects negatively for the staker who is changing the governance delegate and positively for all other stakers. +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L374-L378 + +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L624-L641 + +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L130-L137 + +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L282-L284 +## Tool used + +Manual Review + +## Recommendation +In the GovernanceStaker contract, a) The function alterDelegatee should be called with one additional parameter _oldDelegatee. b) The internal function _alterDelegatee should be called with one additional parameter _oldDelegatee. c) _newScore = delegateeScores[_oldDelegatee]; d) call the internal function _updateDelegateeScore(address _newDelegatee, uint256 _newScore) prior to _newEarningPower calculation at #L634 in GovernanceStaker contract. + +The modified code may look like +```solidity + function alterDelegatee(DepositIdentifier _depositId, address _oldDelegatee, address _newDelegatee) external virtual { //modified + Deposit storage deposit = deposits[_depositId]; + _revertIfNotDepositOwner(deposit, msg.sender); + _alterDelegatee(deposit, _depositId, _newDelegatee); + } +``` + +```solidity + function _alterDelegatee( + Deposit storage deposit, + DepositIdentifier _depositId, + address _oldDelegatee, //added + address _newDelegatee + ) internal virtual { + _revertIfAddressZero(_newDelegatee); + _checkpointGlobalReward(); + _checkpointReward(deposit); + + + DelegationSurrogate _oldSurrogate = surrogates(deposit.delegatee); + _newScore = delegateeScores[_oldDelegatee]; //added + _updateDelegateeScore(address _newDelegatee, uint256 _newScore); //added + uint256 _newEarningPower = + earningPowerCalculator.getEarningPower(deposit.balance, deposit.owner, _newDelegatee); + + + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); +``` \ No newline at end of file diff --git a/058.md b/058.md new file mode 100644 index 0000000..9c8f420 --- /dev/null +++ b/058.md @@ -0,0 +1,134 @@ +Brilliant Menthol Jaguar + +High + +# "Critical Reward Precision Loss: Vulnerability in Calculating Tiny Rewards for Large Stakes + + + +## Summary + +This report outlines a critical **Reward Precision Loss** vulnerability in the smart contract’s reward distribution mechanism. When extremely small rewards are computed for very large staked amounts, integer arithmetic in Solidity causes a non-trivial loss of precision. The root issue is that multiplying a tiny reward by a large stake, then dividing by the total stake, can truncate fractional components. Although the deviation may appear insignificant in a single payout, these inaccuracies can accumulate over time, potentially leading to substantial discrepancies in rewards and undermining user confidence. + +## Vulnerability Detail + +1. **Mechanics of the Vulnerability** + - **Integer Arithmetic Truncation:** In Solidity, operations on `uint256` discard any fractional components. Therefore, if a small reward (e.g., `1 GWEI`) is multiplied by a large stake (e.g., `10,000,000 ETH`) and then divided by the total stake, the result can be significantly lower than the intended reward. + - **No Native Floating-Point:** Solidity lacks floating-point math, so fractional parts are lost during multiplication/division unless the developer implements a fixed-point or scaling approach. + +2. **Where It Occurs in the Contract** + + a. [_scaledUnclaimedReward](https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L522-L525)() + ```solidity + function _scaledUnclaimedReward(Deposit storage deposit) internal view virtual returns (uint256) { + return deposit.scaledUnclaimedRewardCheckpoint + + (deposit.earningPower * (rewardPerTokenAccumulated() - deposit.rewardPerTokenCheckpoint)); + } + ``` + - Here, `deposit.earningPower` (a `uint96`) is multiplied by the difference between two large integers (`rewardPerTokenAccumulated()` and `deposit.rewardPerTokenCheckpoint`). Because this is integer arithmetic, any fractional remainder is discarded. + - If `earningPower` is very large or if the difference in reward accumulators is relatively small (e.g., due to a tiny reward), the truncated fractions can accumulate to a noticeable shortfall over multiple distributions. + + b. **`rewardPerTokenAccumulated()`** + ```solidity + function rewardPerTokenAccumulated() public view virtual returns (uint256) { + if (totalEarningPower == 0) return rewardPerTokenAccumulatedCheckpoint; + + return rewardPerTokenAccumulatedCheckpoint + + (scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)) + / totalEarningPower; + } + ``` + - This function calculates the global reward per token. + - The expression `(scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)) / totalEarningPower` uses integer division. If `scaledRewardRate` is small (due to a tiny reward) and `totalEarningPower` is extremely large, the division may truncate much of the fractional component, leading to understated rewards. + + c. **`notifyRewardAmount()`** + ```solidity + if (block.timestamp >= rewardEndTime) { + scaledRewardRate = (_amount * SCALE_FACTOR) / REWARD_DURATION; + } else { + uint256 _remainingReward = scaledRewardRate * (rewardEndTime - block.timestamp); + scaledRewardRate = (_remainingReward + _amount * SCALE_FACTOR) / REWARD_DURATION; + } + ``` + - Although a `SCALE_FACTOR` (1e36) is applied to improve precision, a very small `_amount` can make `scaledRewardRate` still too low compared to a huge `totalEarningPower`. Later divisions by `totalEarningPower` can further reduce the reward portion. + - The contract attempts to maintain precision by scaling, but integer arithmetic can still lose fractions once you divide by large numbers. + +3. **Example of Failure** + - **Expected Reward:** `1 GWEI` + - **Actual Reward:** `0.9999999 GWEI` (or even less, depending on scaling and integer truncation) + + Repeated daily or across many participants, these rounding gaps can add up to a substantial overall discrepancy. + +## Impact + +1. **Incremental Financial Losses** + - **Underpayment of Stakers:** With each reward cycle, users may receive slightly less than expected. Over a long period or with frequent rewards, these underpayments accumulate into significant losses. + - **Potential Exploit:** A sophisticated actor could theoretically create transactions timed to maximize truncation, gradually siphoning reward shortfalls. Although it’s not a straightforward exploit, the risk remains non-negligible in edge cases. + +2. **Reputational Risks** + - **Trust Erosion:** Users noticing persistent shortfalls in rewards will lose confidence in the protocol’s fairness and accuracy. Repeated precision errors can also raise questions about the contract’s overall integrity. + +3. **System Instability** + - **Verification Failures:** Testing frameworks (e.g., Forge) rely on `assertApproxEqAbs` or similar checks. As soon as the result falls below the acceptance threshold, tests break. This signals the system may not meet its design specifications for precise reward distribution. + - **Inter-Contract Dependencies:** Other contracts or libraries expecting exact reward amounts might behave unpredictably if those expectations are not fulfilled, causing broader ecosystem issues. + +## Code Snippet + +Below is a distilled version of a test scenario demonstrating the discrepancy. Here, the assertion fails because the calculated reward is smaller than `tinyReward`: + +```solidity +uint256 tinyReward = 1e9; // 1 GWEI +uint256 largeStake = 10_000_000e18; // 10M ETH +uint256 totalStake = largeStake; + +uint256 reward = (tinyReward * largeStake) / totalStake; + +// Test assertion fails due to precision loss +assertApproxEqAbs(reward, tinyReward, 1, "Precision issue for small reward"); +``` + +A comparable logic path is seen in the actual contract code (`_scaledUnclaimedReward()` and `rewardPerTokenAccumulated()`), where integer operations similarly truncate fractions. + +## Tool Used + +**Manual Review** +- The testing was performed by minting tokens, simulating reward calculations with large stakes, and then comparing expected vs. actual results. +- A thorough review of the math in `_scaledUnclaimedReward()`, `rewardPerTokenAccumulated()`, and `notifyRewardAmount()` confirmed that integer division discards fractional components, producing under-rewards in edge scenarios. + +## Recommendation + +1. **Utilize Fixed-Point or High-Precision Libraries** + - **Libraries:** Integrate libraries such as [[ABDKMathQuad](https://github.com/abdk-consulting/abdk-libraries-solidity)](https://github.com/abdk-consulting/abdk-libraries-solidity) or [[PRBMath](https://github.com/PaulRBerg/prb-math)](https://github.com/PaulRBerg/prb-math), which preserve fractional components in critical multiplications/divisions. + - **Methodology:** Replace standard `uint256` math with fixed-point math to avoid integer truncation. + +2. **Adopt a Scaling Strategy** + - **Scale Up, Then Scale Down:** Multiply all relevant values (e.g., `tinyReward`, `stakerStake`, etc.) by a large constant (e.g., `1e18`) before dividing, then scale down at the end. + - **Example Implementation:** + ```solidity + function calculateReward(uint256 tinyReward, uint256 stakerStake, uint256 totalStake) + public + pure + returns (uint256) + { + require(totalStake > 0, "Total stake cannot be zero"); + uint256 scaledReward = (tinyReward * 1e18 * stakerStake) / totalStake; // Scale up + return scaledReward / 1e18; // Scale down + } + ``` + - This ensures you capture and preserve the fractional value until the final step. + +3. **Minimum Reward Threshold** + - **Threshold Setting:** If the calculated reward is below a certain cutoff (e.g., `1e5` Wei), accumulate it until it reaches a more meaningful amount. This avoids repeated micro-truncation for very tiny distributions. + - **Benefits:** Dramatically reduces the frequency of round-off events for negligible rewards. + +4. **Comprehensive Testing and Fuzzing** + - **Edge Cases:** + - Extremely small reward amounts (1 GWEI or less). + - Extremely large stake totals (10M ETH or more). + - Large participant pools that amplify tiny rounding errors. + - **Fuzz Testing:** Randomly vary stakes and reward sizes to detect unanticipated rounding errors under diverse conditions. + +5. **Clear Documentation** + - **Transparency:** Document known limitations around integer-based calculations and highlight potential rounding errors. + - **Versioning & Audits:** Track code changes impacting reward calculation logic. Periodic external audits help verify that any modifications do not reintroduce precision loss issues. + diff --git a/059.md b/059.md new file mode 100644 index 0000000..15dcae2 --- /dev/null +++ b/059.md @@ -0,0 +1,42 @@ +Teeny Orange Otter + +Medium + +# A small amount of protocol fee can not be paid + +## Summary +Inappropriate protocol fee can cause stakers unable to pay fee and claim rewards + +## Vulnerability Detail +In the function `GovernanceStaker::_claimReward()`, the payout for staker is the claimable amount minus fee. +```solidity + function _claimReward(DepositIdentifier _depositId, Deposit storage deposit, address _claimer) + internal + virtual + returns (uint256) + { + _checkpointGlobalReward(); + _checkpointReward(deposit); + +@> uint256 _reward = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + // Intentionally reverts due to overflow if unclaimed rewards are less than fee. +@> uint256 _payout = _reward - claimFeeParameters.feeAmount; +@> if (_payout == 0) return 0; + ... +``` +In case the staker's unclaimed rewards is small enough, such that `_reward <= claimFeeParameters.feeAmount`, the staker can not pay fee and claim any rewards token even though there is unclaimed rewards existed. This can be a problem when the [`claimFeeParameters.feeAmount` is set to be a relative high amount](https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L799-L813). + +## Impact +- There can be a small amount of fee/rewards can not be paid + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L710-L721 + +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L799-L813 + +## Tool used + +Manual Review + +## Recommendation +Either send the small amount of rewards to user, or take it as fee \ No newline at end of file diff --git a/060.md b/060.md new file mode 100644 index 0000000..5b2eeb1 --- /dev/null +++ b/060.md @@ -0,0 +1,32 @@ +Deep Metal Wren + +Medium + +# Incorrect Check for Tip Amount in `bumpEarningPower` Function + +## Description: +The `bumpEarningPower` function allows a third party (bumper) to update a deposit's earning power and receive a tip from the deposit's unclaimed rewards. However, the logic for ensuring the requested tip does not exceed the unclaimed rewards is flawed. Specifically, the following condition: +```solidity +if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) { + revert GovernanceStaker__InsufficientUnclaimedRewards(); +} +``` +is incorrectly designed and does not effectively ensure that `_requestedTip` is less than or equal to `_unclaimedRewards`. + +## code snippet: +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L496-L500 + +## Vulnerability Details: +#### Logical Error: +The condition `(_unclaimedRewards - _requestedTip) < maxBumpTip` does not directly validate whether `_requestedTip` exceeds `_unclaimedRewards`. It instead checks the result of subtracting `_requestedTip` from `_unclaimedRewards` against `maxBumpTip`. which would cause it to revert if the `_unclaimedRewards` is barely >`_requestedTip` + +## Impact: +The incorrect check would cause valid `_unclaimedRewards` and `_requestedTip` to fail. + +## Mitigation: +Replace the flawed condition with an explicit check that ensures `_requestedTip` does not exceed `_unclaimedRewards`. This prevents underflow and ensures correct validation of the tip amount. +```solidity +if (_newEarningPower < deposit.earningPower && _requestedTip > _unclaimedRewards) { + revert GovernanceStaker__InsufficientUnclaimedRewards(); +} +``` \ No newline at end of file diff --git a/061.md b/061.md new file mode 100644 index 0000000..ba3fbef --- /dev/null +++ b/061.md @@ -0,0 +1,66 @@ +Rough Brick Meerkat + +Medium + +# Error in the calculation of scaledRewardRate. + +## Summary +In the function notifyRewardAmount, calculation of scaledRewardRate is in error when the rewards are notified prior to the scheduled rewardEndTime. +## Vulnerability Detail +Refer to the function notifyRewardAmount in the GovernanceStaker contract. When the authorized rewards notifier notifies the staking contract that a newreward has been transferred to it prior to the scheduled rewardEndTime, the function computes _remainingReward using the stale scaledRewardRate for the remaining period. So this computed _remainingReward is in error. This _remainingReward is added to the scaled reward _amount for the computation of the new scaledRewardRate, which also becomes erroneous. + +The correct approach is to calculate the new scaledRewardRate is to use the available data itself. Please refer to the timeline below + +|--------------------------------------------|----------| + +where the three vertical lines above are labelled 1, 2 and 3 starting from the left hand side and correspond to +1 = Previous reward notification time +2 = timestamp of notification (block.timestamp) +3 = End time (rewardEndTime) + +time difference between 1 to 3 gives REWARD_DURATION + +The notification is received at timeline 2. Thus the notified 'amount' corresponds to the interval between timeline points 1 and 2. This interval only should be used in the calculation of the scaledRewardRate as is done in the 'if' case in the code at lines #439 to #440. Calculation of this interval follows + +Previous reward notification time + REWARD_DURATION = End time; + +Previous reward notification time = End time - REWARD_DURATION; + +time elapsed since Previous reward notification time = timestamp of notification - Previous reward notification time + +time elapsed since Previous reward notification time = timestamp of notification - (End time - REWARD_DURATION) + +time elapsed since Previous reward notification time = timestamp of notification + REWARD_DURATION - End time; + +It is this value of time elapsed since Previous reward notification time which should be used for the computation of scaledRewardRate. The quantity of reward tokens the staking contract is being notified of (_amount) corresponds to this period of time. Thus, + +scaledRewardRate = (_amount * SCALE_FACTOR) / (timestamp of notification + REWARD_DURATION - End time); + + +```solidity + if (block.timestamp >= rewardEndTime) { + scaledRewardRate = (_amount * SCALE_FACTOR) / REWARD_DURATION; + } else { + uint256 _remainingReward = scaledRewardRate * (rewardEndTime - block.timestamp); + scaledRewardRate = (_remainingReward + _amount * SCALE_FACTOR) / REWARD_DURATION; + } +``` + +## Impact +Depends on the time gap from the previous notification of rewards and the numbers of stake token either being added or withdrawn since previous notification (referred as Previous reward notification time in the Vulnerability Detail section). So the impact can vary from nil to very high. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L439-L444 +## Tool used + +Manual Review + +## Recommendation +As explained in the finding, the modified code can be : +```solidity + if (block.timestamp >= rewardEndTime) { + scaledRewardRate = (_amount * SCALE_FACTOR) / REWARD_DURATION; + } else { + scaledRewardRate = (_amount * SCALE_FACTOR) / (block.timestamp + REWARD_DURATION – rewardEndTime); + } +``` \ No newline at end of file diff --git a/062.md b/062.md new file mode 100644 index 0000000..8813cf0 --- /dev/null +++ b/062.md @@ -0,0 +1,100 @@ +Rough Brick Meerkat + +Medium + +# Rewards computation affected by setting a new delegatee score eligibility threshold + +## Summary +The call to function setDelegateeScoreEligibilityThreshold in the BinaryEligibilityOracleEarningPowerCalculator contract should be accompanied by updating each and every Delegatee’s Score, eligibility and the earning power as all these are linked to the threshold. Not doing these causes the contract to keep running with the stale values of these thereby affecting the rewards also. +## Vulnerability Detail +The call to function setDelegateeScoreEligibilityThreshold in the BinaryEligibilityOracleEarningPowerCalculator contract should be accompanied by updating each and every Delegatee’s Score, eligibility and the earning power as all these are linked to the threshold. Not doing these causes the contract to keep running with the stale values of these thereby affecting the rewards also. + +The function setDelegateeScoreEligibilityThreshold can only be called by the contract owner to set a new delegatee score eligibility threshold. + +```solidity + function _setDelegateeScoreEligibilityThreshold(uint256 _newDelegateeScoreEligibilityThreshold) + internal + { + emit DelegateeEligibilityThresholdScoreSet( + delegateeEligibilityThresholdScore, _newDelegateeScoreEligibilityThreshold + ); + delegateeEligibilityThresholdScore = _newDelegateeScoreEligibilityThreshold; + } +} +``` +This value is used to determine the eligibility of the delegatee for the earning power. Only when the delegatee score is equal or above this threshold, the delegate will get 100% earning power, otherwise the earning power is ‘0’. i.e. + +Earning power = staked amount if delegatee score >= delegateeEligibilityThresholdScore + +and + +Earning power = 0 if delegatee score < delegateeEligibilityThresholdScore + +Refer +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L130-L137 +```solidity + function getEarningPower(uint256 _amountStaked, address, /* _staker */ address _delegatee) + external + view + returns (uint256) + { + if (_isOracleStale() || isOraclePaused) return _amountStaked; + return _isDelegateeEligible(_delegatee) ? _amountStaked : 0; + } +``` +and +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L282-L284 +```solidity + function _isDelegateeEligible(address _delegatee) internal view returns (bool) { + return delegateeScores[_delegatee] >= delegateeEligibilityThresholdScore; + } +``` +The earning power of the delegate and the totalEarningPower are used in the reward calculations as well. +Refer, for example, +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L303-L308 +```solidity + function rewardPerTokenAccumulated() public view virtual returns (uint256) { + if (totalEarningPower == 0) return rewardPerTokenAccumulatedCheckpoint; + + + return rewardPerTokenAccumulatedCheckpoint + + (scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)) / totalEarningPower; + } +``` +and +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L522-L525 +```solidity + function _scaledUnclaimedReward(Deposit storage deposit) internal view virtual returns (uint256) { + return deposit.scaledUnclaimedRewardCheckpoint + + (deposit.earningPower * (rewardPerTokenAccumulated() - deposit.rewardPerTokenCheckpoint)); + } +``` +## Impact +Assume the case where +delegateeEligibilityThresholdScore = 300 eth; +a. Number of stakers with staked amount 70 eth = 500; Total amount 35000 eth +b. Number of stakers with staked amount 150 eth = 250; Total amount 37500 eth +c. Number of stakers with staked amount 225 eth = 100; Total amount 22500 eth +d. Number of stakers with staked amount 350 eth = 600; Total amount 210000 eth +e. Number of stakers with staked amount 500 eth = 300; Total amount 150000 eth + +Under this condition(not using the scalefactors), eventhough totalStaked = 455000 eth, +totalEarningPower = 360000 as only the stakes at ‘d’ and ‘e’ qualify for the earning power. For the stakes at a, b and c, earning power = 0. + +If the delegateeEligibilityThresholdScore, which is presently 300 eth is altered to 200 eth or 400 eth, it will affect the earning power of some of these stakes, and consequently the rewards due. + +In the cases where the delegateeEligibilityThresholdScore is reduced, Bumper(s) may call function bumpEarningPower in the GovernanceStaker contract and collect fees for it. Alternatively, the stakers themselves have to call the function updateDelegateeScore in the BinaryEligibilityOracleEarningPowerCalculator.sol to update their earning power. The stakers who become ineligible when the delegateeEligibilityThresholdScore is increased, will not take any action as it impacts them negatively. + +The process can be easily automated in the contract with fair deal to every staker in the event of either increase or decrease of the delegateeEligibilityThresholdScore. +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L325-L333 +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L130-L137 +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L282-L284 +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L303-L308 +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L522-L525 +## Tool used + +Manual Review + +## Recommendation +The call to function setDelegateeScoreEligibilityThreshold should be accompanied by updating each and every Delegatee’s Score, eligibility and the earning power as all these are linked to the threshold. \ No newline at end of file diff --git a/063.md b/063.md new file mode 100644 index 0000000..08483fa --- /dev/null +++ b/063.md @@ -0,0 +1,40 @@ +Broad Umber Eagle + +Medium + +# Data parallels structs + +## Summary + +The `GovernanceStaker.sol` contract presents a security issue related to parallel data structures. The mappings `depositorTotalStaked` and `depositorTotalEarningPower` store redundant information, increasing the risk of inconsistencies and incorrect reward calculations, which could lead to potential exploits. + +## Vulnerability Details + +The `GovernanceStaker.sol` contract uses the mappings `depositorTotalStaked`and `depositorTotalEarningPower` to track the total staked balance and the total earning power for each depositor, respectively. These parallel data structures reflect similar information but serve distinct purposes. Their updates occur across several functions, such as `_stake`, `_stakeMore`, `_alterDelegatee`, `_withdraw`, and `_claimReward`, increasing the complexity of the logic and the likelihood of synchronization errors. + +Reward distribution relies on earning power, which in turn depends on synchronization with the total staked balance, resulting in incorrect reward calculations. Discrepancies between the data structures could be manipulated to gain undue advantages or disrupt the system. + +## Impact + +Failing to maintain consistency between the parallel data structures may result in unequal reward distribution, compromising the system’s integrity and user trust. In extreme cases, an attacker could exploit this vulnerability to drain funds from the contract. + +## Code Snippet + +[GovernanceStaker.sol](https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L195-L199) + +```Solidity +/// @notice Tracks the total staked by a depositor across all unique deposits. +mapping (address depositor => uint256 amount) public depositorTotalStaked; + +/// @notice Tracks the total earning power by a depositor across all unique deposits. +mapping (address depositor => uint256 earningPower) public depositorTotalEarningPower; +``` + +## Recommendation + +To mitigate this issue, it is recommended to eliminate redundancy and centralize update logic. +- Remove parallel mappings: Eliminate depositorTotalStaked and depositorTotalEarningPower. Instead, store the staked balance and earning power directly within the Deposit structure, simplifying the logic and reducing the risk of errors. +- Create a unified update function: Implement a single function responsible for updating the staked balance and earning power within the Deposit structure. This function should be called by all methods that modify the deposit state. +- Implement rigorous testing: Develop comprehensive tests to validate data consistency and reward calculation correctness across various scenarios. + +By simplifying the data structure and centralizing update logic, the protocol becomes more robust, secure, and less prone to errors and exploits. Rigorous testing ensures code quality and increases confidence in the system’s correctness. \ No newline at end of file diff --git a/064.md b/064.md new file mode 100644 index 0000000..147c77d --- /dev/null +++ b/064.md @@ -0,0 +1,67 @@ +High Licorice Jellyfish + +High + +# Malicious Users Can Block Earning Power to keep the high reword + +## Summary +Users can **Stop** their earning power from being reduced by manipulating **unclaimed rewards** to block the **bump mechanism**. By **front-running bump transactions** in Ethereum and maintaining a specific **unclaimed reward balance**, users can keep reword accumulating with high earning powr . + +## Vulnerability Detail +The vulnerability arises because **bumper** tips are taken from the user's `accumulatedrewards`. When a user becomes ineligible and their earning power drops to 0,when the `oracles` change the earning power or admin set new `calculator` . can prevent this by manipulating their unclaimed reward balance.In `GovernanceStaker.sol`.a user could use the underflow to revert the function +```solidity + function bumpEarningPower( + DepositIdentifier _depositId, + address _tipReceiver, + uint256 _requestedTip + ) external virtual { + + // underflow causes a revert if the requested tip is more than unclaimed rewards + if (_newEarningPower < deposit.earningPower && @>>>(_unclaimedRewards - _requestedTip) < maxBumpTip) + { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } +//code ..... + } +``` +Using this attack : +User earning power: 1000 (should reduce to 0) +Accumulated rewards: 100 +Required bumper tip: 15 +1. User monitors mempool for bump attempts +2. When a bump is detected, front-run with the claim. +3. Claim most rewards, and leave less than the required tip. + +```solidity +function _claimReward(DepositIdentifier _depositId, Deposit storage deposit, address _claimer) { + uint256 _reward = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + + uint256 _payout = _reward - 10; // Leave only 10 tokens + + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_reward * SCALE_FACTOR); +} +``` +-Bump transaction fails, +```solidity +// underflow causes a revert if the requested tip is more than unclaimed rewards + if (_newEarningPower < deposit.earningPower && @>>>(_unclaimedRewards - _requestedTip) < maxBumpTip) + { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } +``` +User keeps original high earning power, Continues earning at 1000 power instead of 0, the user could reapaet this prosses until the end of the reword duration . + +## Impact +Users maintain a high earning power indefinitely, Continue earning rewards at elevated rates even when ineligible,drain protocol rewards unfairly from legitimate users. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L471-L514 +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L710-L745 +## Tool used + +Manual Review + +## Recommendation + +For earning power decreases (when _newEarningPower < deposit.earningPower), remove the tip. \ No newline at end of file diff --git a/065.md b/065.md new file mode 100644 index 0000000..5b6665f --- /dev/null +++ b/065.md @@ -0,0 +1,85 @@ +Petite Slate Orangutan + +High + +# Potential Front-running and Back-running Vulnerabilities in the BinaryEligibilityOracleEarningPowerCalculator Contract + +## Summary +Front-running could occur when an attacker observes a pending transaction that will affect a delegatee's score and uses this knowledge to submit a transaction with a higher priority to manipulate their earning power before the original transaction is mined. + +Back-running may happen if an attacker waits for a transaction that updates delegatee scores and then submits a competing transaction right after it, exploiting the new state of the contract to gain an advantage in earning power calculations. + +## Vulnerability Details +https://github.com/sherlock-audit/2024-11-tally/blob/main/govstaking/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L167-L179 +Vulnerability: If an attacker is aware of an imminent score update, they may attempt to front-run the update to benefit from a higher earning power, or back-run by reacting to the public state change to manipulate the system. + +https://github.com/sherlock-audit/2024-11-tally/blob/main/govstaking/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L234-L242 +An attacker may adjust their strategy based on the new score and eligibility status (especially if scores are updated frequently). Back-running can be done by reacting quickly after a legitimate user’s score is updated and using this knowledge to adjust earning power eligibility. + +https://github.com/sherlock-audit/2024-11-tally/blob/main/govstaking/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L129-L136 +Vulnerability: Front-running can occur if an attacker knows when a delegatee’s eligibility is about to change, allowing them to stake and benefit from the delegatee's status before the update. + +https://github.com/sherlock-audit/2024-11-tally/blob/main/govstaking/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L145-L160 +Vulnerability: An attacker could back-run a transaction to adjust earning power eligibility just before the update, gaining an advantage by staking right after an eligibility threshold is reached. + +https://github.com/sherlock-audit/2024-11-tally/blob/main/govstaking/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L248-L256 +Vulnerability: If the oracle pause state can be exploited, an attacker could prevent legitimate updates from being processed, leading to delayed eligibility changes and possible back-running opportunities. + +https://github.com/sherlock-audit/2024-11-tally/blob/main/govstaking/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L300-L303 +Vulnerability: If a delegatee’s score can be locked by an attacker or someone with privileged access, it could be used to front-run or prevent changes to eligibility, especially in cases where the score is supposed to change based on new information. + +https://github.com/sherlock-audit/2024-11-tally/blob/main/govstaking/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L324-L331 +Vulnerability: A privileged actor, such as the owner, could change the threshold, leading to manipulations in eligibility, which could allow an attacker to gain full earning power unfairly. + +## PoC +Assumptions +Attack Context: A user updates the score of a delegatee via the updateDelegateeScore function, and an attacker seeks to manipulate the earning power calculation by submitting a competing transaction before or after the legitimate update. + +Vulnerable Function: The contract relies on the delegatee’s score to determine eligibility for full earning power, making it susceptible to attacks if the delegatee’s score is updated in a predictable manner. + +Front-running PoC +Initial Setup: + +User (victim) wants to update a delegatee's score using the updateDelegateeScore function. +Attacker watches for this transaction in the mempool and knows the delegatee’s score is about to be updated. +The attacker submits a transaction to manipulate the delegatee’s score before the victim's transaction is mined. +Steps for Front-running: + +Attacker observes the mempool for transactions that will call updateDelegateeScore. +The attacker submits a competing transaction to manipulate the delegatee’s score before the victim’s transaction. +Since the contract does not have any nonce-based checks or a commitment phase, the attacker's transaction can be mined first, giving them an advantage. + +```solidity + +// Front-run attacker transaction +function attackFrontRunning(address victim, address delegatee, uint256 manipulatedScore) external { + // Assume we can observe the victim's update in the mempool + // Manipulate the delegatee's score before the victim's transaction is mined + binaryEligibilityOracleEarningPowerCalculator.updateDelegateeScore(delegatee, manipulatedScore); +} +``` +Outcome: +The attacker's score manipulation is processed before the legitimate score update from the victim, potentially leaving the attacker in a better position for earning power calculation. +Back-running PoC +Initial Setup: +The victim successfully updates the delegatee’s score via the updateDelegateeScore function. +The attacker waits for the victim’s transaction to be mined and then submits a new transaction to manipulate the delegatee's score right after the legitimate update. +Steps for Back-running: +The attacker monitors the blockchain for the victim’s transaction and waits for it to be mined. +Once the score is updated, the attacker submits a new transaction to modify the score to a new value, affecting the earning power calculation in their favor. +```solidity + +// Back-run attacker transaction +function attackBackRunning(address victim, address delegatee, uint256 manipulatedScore) external { + // Assume we observe the mining of the victim's transaction + // Manipulate the delegatee's score immediately after the victim's transaction is mined + binaryEligibilityOracleEarningPowerCalculator.updateDelegateeScore(delegatee, manipulatedScore); +} +``` +Outcome: +The attacker's transaction is mined immediately after the legitimate score update, allowing them to exploit the new state of the contract and potentially gain an advantage in the earning power calculation. + +## Recommendations +Commit-Reveal Scheme +Introduce randomness or custom transaction ordering +Block Confirmation Delay diff --git a/066.md b/066.md new file mode 100644 index 0000000..88460f4 --- /dev/null +++ b/066.md @@ -0,0 +1,51 @@ +Wonderful Pebble Sawfish + +High + +# A surrogate owner acts as a bumper and passes the surrogate as `_tipReceiver` to increase their voting power through a portion of the deposit's unclaimed rewards. + +## Summary + +A surrogate owner acts as a bumper and passes the DelegationSurrogate as `_tipReceiver` to increase their voting power through a portion of the deposit's unclaimed rewards. By utilizing this mechanism, the voting power of the delegatee can be boosted without staking additional tokens, leveraging unclaimed rewards as a source of unfair governance weight. + + +## Vulnerability Detail + + +The owner of the DelegationSurrogate can call the `bumpEarningPower` function and pass the `DelegationSurrogate` address as the `_tipReceiver`. This action will send all unclaimed rewards to DelegationSurrogate and increase the voting power of the `delegatee`, Since the ARB token serves as both the stake token and the reward token in Protocol. This can be unfairly exploited by the surrogate owner to gain an unfair advantage in the voting process without any stake tokens. + + +```solidity + function bumpEarningPower( + DepositIdentifier _depositId, +@> address _tipReceiver, + uint256 _requestedTip + ) external virtual { + + //... + + // Send tip to the receiver +@>> SafeERC20.safeTransfer(REWARD_TOKEN, _tipReceiver, _requestedTip); + + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_requestedTip * SCALE_FACTOR); + } +``` + +## Impact + +Anyone call bumpEarningPower because they claim a portion of deposit's unclaimed rewards, If surrogate owner call bumpEarningPower with `_tipReceiver` is the DelegationSurrogate this will send unclaimed rewards to the `DelegationSurrogate` with that action delegatee is increase governance voting weight. The surrogate owner repeats this process to continue amplifying voting power and gain significant governance power without staking additional tokens. + + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L471-L514 + +## Tool used + +Manual Review + +## Recommendation + +Separate staking and reward Tokens because this prevents the same token from being used to artificially boost voting power. + diff --git a/067.md b/067.md new file mode 100644 index 0000000..9bd7a1d --- /dev/null +++ b/067.md @@ -0,0 +1,71 @@ +Skinny Mocha Dragon + +High + +# Loss of Unclaimed Rewards Due to Unchecked Eligibility Changes + +## Summary +The `BinaryEligibilityOracleEarningPowerCalculator` contract allows admin functions to change eligibility criteria without checkpointing accumulated rewards, leading to permanent loss of unclaimed rewards for users when their earning power suddenly drops to zero,specialty when the processes happen in edge cases make the bumpers can't react . +## Vulnerability Detail +The contract has multiple admin functions that can instantly change a delegatee's eligibility status: +1. `setDelegateeScoreEligibilityThreshold()` +2. `setDelegateeScoreLock()` +3. `setOracleState()` + +When any of these functions are called and cause a delegatee to become ineligible, the earning power of all their stakers immediately drops to zero: + +```solidity +function getEarningPower(...) external view returns (uint256) { + if (_isOracleStale() || isOraclePaused) return _amountStaked; + return _isDelegateeEligible(_delegatee) ? _amountStaked : 0; +} +``` + +However, these functions don't trigger a checkpoint of accumulated rewards. The rewards calculation in GovernanceStaker depends on earning power: + +```solidity +function rewardPerTokenAccumulated() public view returns (uint256) { + if (totalEarningPower == 0) return rewardPerTokenAccumulatedCheckpoint; + + return rewardPerTokenAccumulatedCheckpoint + + (scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)) / totalEarningPower; +} +``` + +1. Users stake with a delegatee (earning power > 0) +2. Rewards accumulate over time +3. Admin increases eligibility threshold or triggers other eligibility changes +4. Delegatee becomes ineligible +5. All stakers' earning power drops to 0 +6. Unclaimed rewards between last checkpoint and eligibility change are permanently lost + +## Impact +Users can permanently lose their unclaimed rewards when their delegatee becomes ineligible through admin actions. This affects: +- All stakers of a delegatee simultaneously +- Any unclaimed rewards since last checkpoint +- No warning or time to claim rewards +- Multiple admin functions can trigger this +## Code Snippet +```solidity +// BinaryEligibilityOracleEarningPowerCalculator.sol +function setDelegateeScoreEligibilityThreshold(uint256 _newDelegateeScoreEligibilityThreshold) public { + _checkOwner(); + _setDelegateeScoreEligibilityThreshold(_newDelegateeScoreEligibilityThreshold); + // No checkpoint triggered +} + +function getEarningPower(uint256 _amountStaked, address, address _delegatee) + external + view + returns (uint256) +{ + if (_isOracleStale() || isOraclePaused) return _amountStaked; + return _isDelegateeEligible(_delegatee) ? _amountStaked : 0; +} +``` +## Tool used + +Manual Review + +## Recommendation +Implement automatic checkpointing for all eligibility-affecting functions. diff --git a/068.md b/068.md new file mode 100644 index 0000000..2c701aa --- /dev/null +++ b/068.md @@ -0,0 +1,67 @@ +Skinny Mocha Dragon + +High + +# Rewards Can Be Permanently Locked If No Active Stakers + + +## Summary +Reward tokens can become permanently locked in the `GovernanceStaker` contract if rewards are notified when there are no active stakers (`totalEarningPower == 0`). The protocol lacks a mechanism to recover these tokens, leading to a permanent loss of funds. + +## Vulnerability Detail +The vulnerability exists in the reward notification flow: + +1. The notifier transfers reward tokens to the contract +2. Then calls `notifyRewardAmount()` +3. If `totalEarningPower == 0` (no active stakers): +```solidity +function notifyRewardAmount(uint256 _amount) external virtual { + if (!isRewardNotifier[msg.sender]) { + revert GovernanceStaker__Unauthorized("not notifier", msg.sender); + } + + // No check for totalEarningPower > 0 + rewardPerTokenAccumulatedCheckpoint = rewardPerTokenAccumulated(); + + if (block.timestamp >= rewardEndTime) { + scaledRewardRate = (_amount * SCALE_FACTOR) / REWARD_DURATION; + } +} +``` + +The issue arises because: +- The contract requires rewards to be transferred **before** notification +- No validation of `totalEarningPower > 0` before accepting rewards +- No mechanism to recover tokens if they get stuck + +## Impact +**HIGH**. If rewards are notified when no one is staking: +- Reward tokens become permanently locked in the contract +- No users can claim these rewards (even future stakers) +- No recovery mechanism exists +- Loss of funds for the protocol + +## Code Snippet +```solidity +function rewardPerTokenAccumulated() public view virtual returns (uint256) { + if (totalEarningPower == 0) return rewardPerTokenAccumulatedCheckpoint; // @audit rewards lost + + return rewardPerTokenAccumulatedCheckpoint + + (scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)) / totalEarningPower; +} + +function notifyRewardAmount(uint256 _amount) external virtual { + if (!isRewardNotifier[msg.sender]) { + revert GovernanceStaker__Unauthorized("not notifier", msg.sender); + } + // @audit no check for totalEarningPower > 0 + rewardPerTokenAccumulatedCheckpoint = rewardPerTokenAccumulated(); +} +``` + +## Tool used +Manual Review + + +## Recommendation +implement a recovery mechanism \ No newline at end of file diff --git a/069.md b/069.md new file mode 100644 index 0000000..5f185ea --- /dev/null +++ b/069.md @@ -0,0 +1,68 @@ +Wonderful Pebble Sawfish + +Medium + +# Incompatibility of contracts with Ethereum ARB token. + +## Summary + + +In the README mention that the contracts are deployed in the below chains + +> Ethereum, Arbitrum, Rari Chain, zkSync Mainnet, Base, Polygon, OP Mainnet + + +Additionally, ARB is used as both a staked and reward token; however, the issue here is that ARB tokens exist on the Ethereum blockchain and some others, according to the [documentation](https://docs.arbitrum.foundation/deployment-addresses). + + +## Vulnerability Detail + +So when we check the ARB token contract implementation in the [Ethereum](https://etherscan.io/address/0xad0c361ef902a7d9851ca7dcc85535da2d3c6fc7#code), it becomes evident that it is no compatibile with IERC20Delegates. Therefore, these contracts are not deployed on the Ethereum blockchain with the ARB Governance token. + +```solidity +contract L1ArbitrumToken is + INovaArbOneReverseToken, + Initializable, + ERC20Upgradeable, + ERC20PermitUpgradeable, + TransferAndCallToken +{ +``` + +In the GovernanceStakerDelegateSurrogateVotes contract, the constructor includes the following check: + + +```solidity + constructor(IERC20Delegates _votingToken) { + if (address(STAKE_TOKEN) != address(_votingToken)) { + revert GovernanceStakerDelegateSurrogateVotes__UnauthorizedToken(); + } + } +``` + +In the Ethereum chain, the ARB token contract implementation does not have a `delegate` function, so this will revert. + +```solidity +contract DelegationSurrogateVotes is DelegationSurrogate { + /// @param _token The governance token that will be held by this surrogate + /// @param _delegatee The address of the would-be voter to which this surrogate will delegate its + /// voting weight. 100% of all voting tokens held by this surrogate will be delegated to this + /// address. + constructor(IERC20Delegates _token, address _delegatee) DelegationSurrogate(_token) { +@>> _token.delegate(_delegatee); + } +``` + +## Impact +The `ARB` token exists on Ethereum, but the contracts are incompatible with the implementation and won't be deployed. As a result, this breaks the invariants stated in the README. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/DelegationSurrogateVotes.sol#L26-L28 + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/extensions/GovernanceStakerDelegateSurrogateVotes.sol#L24-L28 + +## Tool used + +Manual Review + +## Recommendation \ No newline at end of file diff --git a/070.md b/070.md new file mode 100644 index 0000000..d946e4b --- /dev/null +++ b/070.md @@ -0,0 +1,62 @@ +Skinny Mocha Dragon + +Medium + +# Deployment Will Fail on ZKSync Due to EVM Incompatibility + +## Summary +The `GovernanceStakerDelegateSurrogateVotes` contract uses direct contract creation (`new` keyword) for surrogate deployment, which is incompatible with ZKSync's modified EVM. The protocol has explicitly stated that it must be compatible with any EVM chain, but the current implementation will cause critical functionality failures if deployed on ZKSync. + +## Vulnerability Details +In `GovernanceStakerDelegateSurrogateVotes.sol`, the contract deploys surrogate contracts using the `new` keyword: + +```solidity +function _fetchOrDeploySurrogate(address _delegatee) internal virtual override returns (DelegationSurrogate _surrogate) { + _surrogate = storedSurrogates[_delegatee]; + + if (address(_surrogate) == address(0)) { + // @audit This will fail on ZKSync + _surrogate = new DelegationSurrogateVotes(IERC20Delegates(address(STAKE_TOKEN)), _delegatee); + storedSurrogates[_delegatee] = _surrogate; + emit SurrogateDeployed(_delegatee, address(_surrogate)); + } +} +``` + +The issue arises because of ZKSync's unique contract deployment mechanism: + +1. On ZKSync Era, contract deployment is performed using the hash of the bytecode +2. The `factoryDeps` field of EIP712 transactions contains the bytecode +3. The actual deployment occurs by providing the contract's hash to the ContractDeployer system contract +4. For `create/create2` to work correctly, the compiler must know the bytecode in advance +5. The compiler interprets calldata arguments as incomplete input for ContractDeployer + +As stated in the [ZKSync documentation](https://docs.zksync.io/build/developer-reference/ethereum-differences/evm-instructions): +> "The compiler interprets the calldata arguments as incomplete input for ContractDeployer, as the remaining part is filled in by the compiler internally. The Yul datasize and dataoffset instructions have been adjusted to return the constant size and bytecode hash rather than the bytecode itself." + +## Impact +**HIGH**. If deployed on ZKSync: +- Surrogate contract deployment will completely fail +- Users cannot delegate their voting power +- Core staking functionality becomes unusable +- Protocol's cross-chain compatibility requirement is violated +- The entire protocol becomes non-functional on ZKSync + +## Code Snippet +```solidity +// In GovernanceStakerDelegateSurrogateVotes.sol +function _fetchOrDeploySurrogate(address _delegatee) internal virtual override returns (DelegationSurrogate _surrogate) { + if (address(_surrogate) == address(0)) { + // This fails on ZKSync due to incompatible deployment mechanism + _surrogate = new DelegationSurrogateVotes( + IERC20Delegates(address(STAKE_TOKEN)), + _delegatee + ); + } +} +``` + +## Tool used +Manual Review + +## Recommendation \ No newline at end of file diff --git a/071.md b/071.md new file mode 100644 index 0000000..5b18959 --- /dev/null +++ b/071.md @@ -0,0 +1,33 @@ +Skinny Shadow Sparrow + +Medium + +# Issue with Delegatee Eligibility Check in _stake Function + +## Summary +The _stake function in the contract processes staking operations and calculates earningPower using the earningPowerCalculator.getEarningPower() function. However, the current implementation does not validate if the _delegatee is eligible before performing other operations in the function. If the _delegatee is ineligible, subsequent operations should not occur. + +This issue could lead to unintended side effects, such as updates to state variables and incorrect deposit creation, even when the _delegatee is ineligible. + +## Impact +Causes Uneligible delegate to totalEarningPower and totalStaked to be incorrectly updated !!!! +1. totalStaked and totalEarningPower are updated incorrectly: These global variables will reflect a higher staking value than what is actually eligible, leading to inaccurate accounting. +Per-depositor tracking is also affected: Variables like depositorTotalStaked and depositorTotalEarningPower will overstate the depositor's contribution, even though the delegatee is ineligible. +2. Unnecessary Token Transfers +The _stakeTokenSafeTransferFrom call will still execute, transferring tokens from the depositor to the DelegationSurrogate. + +Funds get locked: Since the delegatee is ineligible, the staked tokens are effectively locked with no earning power or rewards, resulting in user dissatisfaction. +Gas Wastage: Users will incur gas fees for a transaction that ultimately does not fulfill their intent. +3. Misleading Deposit Records +An entry is added to the deposits mapping even though the _delegatee is ineligible: + +Misleading Data: The system records a deposit as valid, but the associated earning power will be 0 if _delegatee is ineligible. This creates a false impression that the staking process succeeded. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L470-L502 +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L81-L88 +## Tool used +Manual Review + +## Recommendation + diff --git a/072.md b/072.md new file mode 100644 index 0000000..76e63ae --- /dev/null +++ b/072.md @@ -0,0 +1,82 @@ +Petite Slate Orangutan + +High + +# Front-Running Attack on Signature Process in GovernanceStakerOnBehalf.sol + +## Summary +If the transaction is not yet mined, an attacker could monitor the mempool and detect the staking request. By submitting a competing transaction with a higher gas fee, the attacker could stake tokens before the legitimate transaction is processed +https://github.com/sherlock-audit/2024-11-tally/blob/main/govstaking/src/extensions/GovernanceStakerOnBehalf.sol#L71-L99 + +## Vulnerability Detail +Front-Running Attack Scenario: +If the attacker has access to the signature data, they could reproduce the signature's contents and stake before the legitimate transaction executes. + +Specifically, this line: + +```solidity + +_revertIfSignatureIsNotValidNow( + _depositor, + _hashTypedDataV4( + keccak256( + abi.encode( + STAKE_TYPEHASH, + _amount, + _delegatee, + _claimer, + _depositor, + _useNonce(_depositor), + _deadline + ) + ) + ), + _signature +); +``` +allows the attacker to see the components of the signature (amount, delegatee, claimer, etc.) before the transaction is mined, enabling them to create their own front-running transaction. +An attacker monitoring the network for incoming transactions can detect when a legitimate staking transaction is pending but has not yet been mined. This transaction will contain the signature, which provides the attacker with knowledge of the intended staking details. +The attacker can then craft a competing transaction with the same parameters (amount, delegatee, claimer, etc.) but with higher gas fees. This allows the attacker to prioritize their transaction and execute it before the legitimate transaction, effectively "front-running" the original user's stake. +## Impact +The attacker could steal the staking action if they submit the transaction first with a higher gas fee, using the same signature and parameters, but gaining the governance influence or rewards that were intended for the legitimate depositor. +Governance manipulation: If the tokens staked are associated with governance voting, the attacker could skew voting outcomes. +Reward accrual manipulation: By staking before the original transaction, the attacker may receive rewards meant for the legitimate user. + +## PoC +PoC Code Snippet +```solidity + +// Attacker's transaction that front-runs the legitimate staking request +function attackFrontRun( + address _stakingContract, + uint256 _amount, + address _delegatee, + address _claimer, + address _depositor, + uint256 _deadline, + bytes memory _signature +) external { + // The attacker submits the transaction to stake tokens before the legitimate depositor + GovernanceStakerOnBehalf(_stakingContract).stakeOnBehalf( + _amount, + _delegatee, // Attacker assigns themselves as delegatee + _claimer, // Attacker claims the rewards + _depositor, // Original depositor's address (victim) + _deadline, // Same deadline as the legitimate transaction + _signature // Signature from the victim (exploited in this PoC) + ); +} +``` +## Tool used +Manual Review + +## Recommendation +Mitigations to Consider +Non-repudiation and Signature Management: + +Time-Limited Approvals: + +Delay Before Execution: + +Transaction Ordering Protection: + diff --git a/073.md b/073.md new file mode 100644 index 0000000..c8313c0 --- /dev/null +++ b/073.md @@ -0,0 +1,65 @@ +Long Rouge Jay + +Medium + +# When the deposit owner claims it, the reward is sent to the deposit owner instead of the designated deposit claimer. + +## Summary +When the deposit owner calls `claimReward`, the reward is sent to the deposit owner instead of the designated deposit claimer, which is not as expected and causes the deposit claimer to lose of rewards. + +## Vulnerability Detail +According to the `deposit` function, when a user stakes tokens into a new deposit, he can designates a `_claimer` address that will accrue rewards for the stake. As indicated in [`GovernanceStaker.sol:L344`](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L344), the rewards for the deposit should be sent to the claimer. +```solidity + /// @notice Method to stake tokens to a new deposit. The caller must pre-approve the staking + /// contract to spend at least the would-be staked amount of the token. + /// @param _amount Quantity of the staking token to stake. + /// @param _delegatee Address to assign the governance voting weight of the staked tokens. + /// @param _claimer Address that will accrue rewards for this stake. +344:/// @return _depositId Unique identifier for this deposit. + /// @dev Neither the delegatee nor the claimer may be the zero address. The deposit will be + /// owned by the message sender. + function stake(uint256 _amount, address _delegatee, address _claimer) + external + virtual + returns (DepositIdentifier _depositId) + { + _depositId = _stake(msg.sender, _amount, _delegatee, _claimer); + } +``` +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L325-L338 + +According to the `claimReward` function, to claim the rewards, the caller needs to be either the deposit claimer or the deposit owner. Following the authorization check, the `_claimReward` function will be invoked to calculate the accumulated rewards and send them to the `msg.sender` ([`GovernanceStaker.sol:L412`](https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L412)). If it is called by the deposit owner, then `msg.sender` will be the deposit owner. +```solidity + function claimReward(DepositIdentifier _depositId) external virtual returns (uint256) { + Deposit storage deposit = deposits[_depositId]; +409: if (deposit.claimer != msg.sender && deposit.owner != msg.sender) { + revert GovernanceStaker__Unauthorized("not claimer or owner", msg.sender); + } +412: return _claimReward(_depositId, deposit, msg.sender); + } +``` +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L407-L413 + +Therefore, if the deposit owner claims the rewards, the rewards will be sent to the deposit owner instead of the deposit claimer, which is inconsistent with the description of the `stake` function, and causes the deposit claimer to lose of rewards. + +## Impact +When deposit owner claims the rewards, the rewards will be sent to the deposit owner instead of the deposit claimer, which is inconsistent with the role of the deposit claimer, and causes the deposit claimer to lose of rewards. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L412 + +## Tool used + +Manual Review + +## Recommendation +```solidity + function claimReward(DepositIdentifier _depositId) external virtual returns (uint256) { + Deposit storage deposit = deposits[_depositId]; + if (deposit.claimer != msg.sender && deposit.owner != msg.sender) { + revert GovernanceStaker__Unauthorized("not claimer or owner", msg.sender); + } +- return _claimReward(_depositId, deposit, msg.sender); ++ return _claimReward(_depositId, deposit, deposit.claimer); + } +``` \ No newline at end of file diff --git a/074.md b/074.md new file mode 100644 index 0000000..7f15d97 --- /dev/null +++ b/074.md @@ -0,0 +1,23 @@ +Droll Shamrock Jaguar + +Medium + +# Signature replay possible in GovernanceStakerOnBehalf, as chainID is not added to the signature + +## Summary +As the protocol is going to be deployed on multiple chains "Ethereum, Arbitrum, Rari Chain, zkSync Mainnet, Base, Polygon, OP Mainnet", this could mean that signatures could be reused to stake/stake more on behalf of a user. + +## Vulnerability Detail +There is no `chain.id` in the signed data + +## Impact +If a malicious user does a `stakeOnBehalf`, ` stakeMoreOnBehalf`, chainId is missing which means that the same stake can be replayed on a different chain for the same account. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/extensions/GovernanceStakerOnBehalf.sol#L82-L96 + +## Tool used +Manual Review + +## Recommendation +Include the `chain.id` in what's hashed diff --git a/075.md b/075.md new file mode 100644 index 0000000..995c638 --- /dev/null +++ b/075.md @@ -0,0 +1,21 @@ +Large Brown Hippo + +High + +# Malicious Users can withdraw more than they should thereby stealing funds + +## Summary +Malicious Users can withdraw more than they should thereby stealing funds +## Vulnerability Detail +The `withdraw` function lacks check to prevent a malicious from withdrawing more amount than they should . This will result to loss of funds +## Impact +Loss of Funds +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L680-L704 +## Tool used + +Manual Review + +## Recommendation +Users deposit should be greater than amount withdrawn. There should be a check regarding this in the `withdraw` function +`require depositorTotalStaked[deposit.owner] > _amount` \ No newline at end of file diff --git a/076.md b/076.md new file mode 100644 index 0000000..8a9fb3b --- /dev/null +++ b/076.md @@ -0,0 +1,38 @@ +Skinny Shadow Sparrow + +High + +# Unauthorized Delegation of Voting Power Leading to Loss of Token Control + +## Summary +Alice deposits her governance tokens into a pool contract. +The pool contract deploys a surrogate contract for Alice, which holds Alice's tokens and delegates the voting power to Bob. +Now Bob can use Alice's voting power in governance, as the surrogate has delegated 100% of Alice's tokens to Bob. +However, if Bob creates another surrogate contract and delegates Alice's voting power to someone else (e.g., Alex), Alice's tokens are still tied to Bob's surrogate contract. This means that if Alice decides to reclaim her tokens, Bob (or Alex) would still have the voting power, and Alice can't reclaim her tokens unless Bob revokes his delegation. +Issue: Bob Can Delegate to Someone Else Without Alice's Knowledge +The problem here is that Bob can effectively transfer voting power by creating a new surrogate contract and delegating Alice's tokens to someone else (e.g., Alex), but there is no tracking mechanism that ties Bob's delegatee (or the chain of delegations) back to Alice's tokens. This means: + +Alice has no direct way to reclaim her voting power unless Bob explicitly revokes the delegation. +If Alice tries to reclaim her tokens, Bob still holds the voting power via the surrogate, but the tokens are locked up until Bob either revokes the delegation or returns the power to Alice. +Solution to the Problem: +You need to track delegations and ensure that Alice can reclaim her tokens regardless of whether Bob delegates them further. This can be done by recording delegatee chains and preventing Bob from arbitrarily transferring Alice's voting power to someone else. + + + +## Impact +Depositor not being able to withdraw his funds + +## Code Snippet + +## Tool used + +Manual Review + +## Recommendation +You should maintain a delegation chain for each token holder (i.e., Alice) in the surrogate contract. This chain can track where Alice's voting power is being delegated, ensuring it’s clear who has the power at any given time. +Reclaim Tokens with Delegation Reversal: + +When Alice attempts to reclaim her tokens, the system should check the current delegatee (Bob or anyone else) and ensure that the tokens are either returned directly to Alice or Bob (whoever has the final delegation). +Prevent Bob from Transferring Voting Power Freely: + +Bob should not be allowed to freely delegate voting power without updating the surrogate contract's records. Instead, Bob’s ability to delegate the power should be constrained by the contract, so Alice can always reclaim her tokens from the surrogate once they are no longer delegated. \ No newline at end of file diff --git a/077.md b/077.md new file mode 100644 index 0000000..d838c39 --- /dev/null +++ b/077.md @@ -0,0 +1,30 @@ +Slow Ocean Seahorse + +Medium + +# CREATE opcode works differently in the `zkSync` chain + +## Summary +`zkSync mainnet` chain has differences in the usage of the create opcode compared to the EVM. + +According to the mentioned details, the protocol can be deployed in zkSync mainnet. +> Ethereum, Arbitrum, Rari Chain, zkSync Mainnet, Base, Polygon, OP Mainnet. + +## Vulnerability Detail +The zkSync Era docs explain how it differs from Ethereum. + +Check the description of CREATE ([zkSynce Era Docs](https://docs.zksync.io/zksync-protocol/differences/contract-deployment#ethereum-zksync-differences-in-contract-deployment)) and it is also stated that Create cannot be used for arbitrary code unknown to the compiler. + +## Impact +zkSync uses a different compilation and deployment process than Ethereum. +Deployment might fail. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/extensions/GovernanceStakerDelegateSurrogateVotes.sol#L45 + +## Tool used + +Manual Review + +## Recommendation +use `create2` instead. \ No newline at end of file diff --git a/078.md b/078.md new file mode 100644 index 0000000..258c059 --- /dev/null +++ b/078.md @@ -0,0 +1,27 @@ +Attractive Tin Coyote + +Medium + +# Uninitialized immutable variable `MAX_CLAIM_FEE` prevents having a fee collector that receives fees + +## Summary +Uninitialized immutable variable `MAX_CLAIM_FEE` prevents admin from setting `feeAmount` to a certain amount `> 0`. `feeCollector` will never receive fees in this contract. + +## Vulnerability Detail +Uninitialized immutable variable `MAX_CLAIM_FEE` will always be 0 in this contract. Thus, `feeAmount` will always be `0`. If admin tries to set `feeAmount` to an amount `> 0` the `setClaimFeeParameters(...)` function will revert. It can only be `0` making `feeCollector` useless. + +## Impact +`feeCollector` will never receive fees in this contract. + +## Code Snippet +The root cause making `MAX_CLAIM_FEE` == 0 always: +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L171 +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L228 +The check preventing `feeAmount` from being > 0: +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L801 +## Tool used + +Manual Review + +## Recommendation +Initialize the `MAX_CLAIM_FEE` immutable variable in the constructor. \ No newline at end of file diff --git a/079.md b/079.md new file mode 100644 index 0000000..e6d4ae0 --- /dev/null +++ b/079.md @@ -0,0 +1,42 @@ +Hidden Tweed Elephant + +High + +# Non-Deposit Owners will not be able to claim rewards. + +## Summary +As the `GovernanceStaker.sol` allows user to stake token by creating a deposit and user can select any claimer address who can claim the rewards for that deposit But there is no way for claimers who is not the owner of deposit to claim rewards. + +## Vulnerability Detail +When user creates Stake there is two `function stake()` one where the claimer will be the owner only and other is to specify separate claimer address so someone else can claim reward for that deposit. +And claimer can claim rewards anytime But when the claimer tries to claim reward by calling the `function claimReward()` there is a if statement :: It checks that msg.sender should be deposit.claimer and also the deposit.owner so when the claimer is not the owner it will revert the call and Hence other than the owner No other claimer is eligible to claimReward for this stake of deposit. +```solidity + if (deposit.claimer != msg.sender && deposit.owner != msg.sender) { + revert GovernanceStaker__Unauthorized("not claimer or owner", msg.sender); + } + return _claimReward(_depositId, deposit, msg.sender); +``` + +## Impact +**Severity :: HIGH** +**Likelihood :: HIGH** +Only deposit owners can claim rewards , None of the claimer who is not the owner of the deposit can claim rewards + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L403-L413 + +## Tool used + +Manual Review + +## Recommendation +Remove the if statement and add a require statement +```diff +- if (deposit.claimer != msg.sender && deposit.owner != msg.sender) { +- revert GovernanceStaker__Unauthorized("not claimer or owner", msg.sender); +- } +- return _claimReward(_depositId, deposit, msg.sender); + ++ require(deposit.claimer == msg.sender, "If the msg.sender is not claimer than revert") + +``` \ No newline at end of file diff --git a/080.md b/080.md new file mode 100644 index 0000000..b6e6bb6 --- /dev/null +++ b/080.md @@ -0,0 +1,32 @@ +Hidden Tweed Elephant + +High + +# Staker can himself call the `bumpEarningPower` as a `bumper` and earn incentive on his own deposit. + +## Summary +In `GovernanceStaker.sol` bumper can call `function bumpEarningPower()` to update deposit's earning power and they can earn incentives for performing this activity .So the deposit.owner himself will call this function and can claim incentives for his own stake which should not be allowed. + +## Vulnerability Detail +When the deposit is eligible to update it's earningPower bumper can call the `function bumpEarningPower()` to update it But there is no check that `msg.sender` is the deposit.owner or not which allows anyone to call this function So the owner of that deposit is also eligible to call this function and He can earn incentives on his own deposit which should not be accepted otherwise every staker will call this function himself and can take the incentives by his own. +This way the user will not have to give any tip or incentive to someone else from the unclaimed Reward Because he himself will earn that incentive from the unclaimedReward for his own deposit. + +## Impact +**Severity :: HIGH** +**Likelihood :: HIGH** +Bumpers will not able to earn incentive as staker himself only will call this function. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L463-L514 + +## Tool used + +Manual Review + +## Recommendation +Try adding checks in the function which will not allow deposit.owner to call this function +```diff ++ if(msg.sender == deposit.owner) { + revert NotAllowedToCall(); +} +``` \ No newline at end of file diff --git a/081.md b/081.md new file mode 100644 index 0000000..536e614 --- /dev/null +++ b/081.md @@ -0,0 +1,45 @@ +Puny Tan Millipede + +Medium + +# Incorrect validation check for tipping amount + +## Summary + +The `bumpEarningPower` function contains an incorrect validation check for tip amounts against unclaimed rewards, potentially causing valid transactions to fail due to a flawed comparison with `maxBumpTip`. + +## Vulnerability Detail +The current validation logic is flawed in its implementation: + +- The function uses (`_unclaimedRewards` - `_requestedTip`) < `maxBumpTip` to validate tips +- This check compares the remaining rewards against `maxBumpTip` instead of directly validating if `_requestedTip` exceeds `_unclaimedRewards` +- The condition could cause transactions to fail even when there are sufficient unclaimed rewards to cover the requested tip +- The issue occurs specifically when `_newEarningPower` < `deposit.earningPower` +- +## Impact +- Valid transactions may be incorrectly reverted +- Bumpers might be unable to claim legitimate tips even when sufficient unclaimed rewards exist +- Function behavior does not align with its intended purpose of ensuring tips don't exceed available rewards +- Could discourage legitimate bumpers from participating in the system + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L496-L500 + +## Tool used + +Manual Review + +## Recommendation + +Replace the current condition with a direct comparison between `_requestedTip` and `_unclaimedRewards`: + +```solidity + +if (_newEarningPower < deposit.earningPower && _requestedTip > _unclaimedRewards) { + revert GovernanceStaker__InsufficientUnclaimedRewards(); +} + +``` + +This will properly validate that requested tips don`t exceed available rewards, to prevent incorrect transactions reversions. \ No newline at end of file diff --git a/082.md b/082.md new file mode 100644 index 0000000..a247355 --- /dev/null +++ b/082.md @@ -0,0 +1,38 @@ +Rich Smoke Cheetah + +Medium + +# A malicious user can pay less fees when he claim rewards + +## Summary +Because the feeAmount to pay when a user claims fees is a constant, a user may wait to claim their rewards in order to pay fees only once and limit the total fees paid. + +## Vulnerability Detail +In the _claimReward function, the fees paid is a variable set by the admin and deducted from the reward claimable, as we can see here: +```solidity + function _claimReward(DepositIdentifier _depositId, Deposit storage deposit, address _claimer) + internal + virtual + returns (uint256) + { + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _reward = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + // Intentionally reverts due to overflow if unclaimed rewards are less than fee. + uint256 _payout = _reward - claimFeeParameters.feeAmount; + if (_payout == 0) return 0; +``` +The problem is that the fee isn’t a portion of the rewards, meaning that whatever the rewards, the same fee will be paid, which is unfair for all stakers. Moreover, a user could be incentivized to avoid claiming their rewards multiple times in order to limit the fees paid. + +## Impact +Users could pay fewer fees by claiming their rewards only once, thereby limiting the fees paid, which would result in a loss for the protocol. Moreover the fees are unfair since everyone pay exactly the same fees. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L710-L721 +## Tool used + +Foundry + +## Recommendation +The protocol should implement fees that adapt to the rewards received. \ No newline at end of file diff --git a/083.md b/083.md new file mode 100644 index 0000000..402cc22 --- /dev/null +++ b/083.md @@ -0,0 +1,45 @@ +Broad Umber Eagle + +Medium + +# Inaccurate Reward Timestamp Calculation + +## Summary + +The `lastTimeRewardDistributed` function in `GovernanceStaker.sol` has a logical flaw that can result in incorrect reward distribution timestamps. This issue can lead to miscalculated rewards and an unfair distribution of funds among stakers. + +## Vulnerability Details + +The `lastTimeRewardDistributed` function is intended to return the last timestamp when rewards were distributed. Its current logic is if the current time (block.timestamp) is greater than or equal to the reward period end time (rewardEndTime), it returns `rewardEndTime`, otherwise, it returns the current time (block.timestamp). + +This approach can fail in scenarios where the reward period is extended: +1. A new reward period starts with rewardEndTime set 30 days into the future. +2. Midway through this period (e.g., after 15 days), the notifyRewardAmount function is called to add more rewards, extending the reward period by updating rewardEndTime. +3. In this case, lastTimeRewardDistributed would return the current block.timestamp, which does not accurately reflect the last time rewards were distributed. + +## Impact + +- Incorrect Reward Calculations: using an inaccurate timestamp from lastTimeRewardDistributed can result in incorrect reward amounts being distributed to stakers. +- Unequal Distribution of Rewards: stakers may receive more or fewer rewards than they are entitled to, depending on the timing of their interactions with the contract. + +## Code Snippet + +[code](https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L294-L297) + +```Solidity +function lastTimeRewardDistributed() public view virtual returns (uint256) { + if (rewardEndTime <= block.timestamp) return rewardEndTime; + else return block.timestamp; + } +``` + +## Recommendation + +To address this issue, a new state variable should be introduced to explicitly store the last reward distribution timestamp. This variable must be updated whenever notifyRewardAmount is called to ensure accuracy. + +Suggested Fix: + +1. Introduce a State Variable: add a new variable lastRewardDistributionTime to track the actual last reward distribution time. +2. Update in notifyRewardAmount: ensure this variable is updated whenever the reward period is modified. +3. Refactor lastTimeRewardDistributed: modify the function to return the value of lastRewardDistributionTime for accurate tracking. + diff --git a/084.md b/084.md new file mode 100644 index 0000000..6309d5d --- /dev/null +++ b/084.md @@ -0,0 +1,47 @@ +Rich Smoke Cheetah + +Medium + +# Lack of incentives to call bumpEarningPower for small rewards + +## Summary +Since the tip that will be received in the `bumpEarningPower` function must be less or equal to the minimum between the unclaimed fees and the maxBumpTip, if the rewards are too small, there is a lack of incentives to call this function. + +## Vulnerability Detail +Here we can see that the tip that will be received in the `bumpEarningPower` must be less or equal to the minimum between the unclaimed fees and the maxBumpTip in the bumpEarningPower function. + +```solidity + uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + + (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( + deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower + ); + if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + } + + if (_newEarningPower > deposit.earningPower && _unclaimedRewards < _requestedTip) { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // Note: underflow causes a revert if the requested tip is more than unclaimed rewards + if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) + { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + +``` +Otherwise, the call will revert because of an underflow or a custom error. But if the reward is very small, then there is a clear lack of incentives to call this function. + + +## Impact +Some deposits’ earning power will not be updated by bumpers, which can lead to problems in the protocol’s accounting. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L483-L500 +## Tool used + +Manual Review + +## Recommendation +In order to mitigate this issue, the protocol should implement a function that batches the protocol IDs in order to bump the earning power of many deposits at the same time and share the tip paid proportionally. \ No newline at end of file diff --git a/085.md b/085.md new file mode 100644 index 0000000..006551f --- /dev/null +++ b/085.md @@ -0,0 +1,106 @@ +Petite Slate Orangutan + +High + +# Replay Attack Vulnerability in GovernanceStakerOnBehalf.sol + +## Summary +The stakeOnBehalf function in GovernanceStakerOnBehalf.sol is susceptible to replay attacks due to its reliance on signature validation without adequate safeguards. +## Vulnerability Detail +https://github.com/sherlock-audit/2024-11-tally/blob/main/govstaking/src/extensions/GovernanceStakerOnBehalf.sol#L71-L99 +Breakdown of the Issue: + +Signature Replay: +The function uses a user-provided signature to verify the legitimacy of the transaction. However, without incorporating mechanisms like a nonce, a unique transaction ID, or a time-based constraint beyond the _deadline, the signature can be replayed by an attacker. +The absence of specific protections means that an attacker who observes the signed request can submit a similar transaction using the same signature, effectively "replaying" it. +There is : +Lack of Nonce or Unique Identifier +Timestamp Reliance on _deadline +Governance Risk + +Attack Scenario: +Attack Flow: +A user signs a staking transaction and sends it to the contract for execution via the stakeOnBehalf function. +An attacker monitors the mempool and notices the transaction, including the signature. +Before the legitimate transaction is processed by the blockchain, the attacker replays the signature by submitting a similar transaction with a higher gas price, ensuring that theirs is mined first. +The attacker successfully stakes the tokens, hijacking the governance power or rewards that were meant for the legitimate user. +Potential Consequences: +Unauthorized staking of tokens. +Altered governance decisions due to shifted voting power. +Loss of rewards intended for the legitimate user. +Erosion of trust in the staking system due to the vulnerability. + +## Impact +High: This vulnerability poses a significant risk, especially in decentralized governance and staking systems. The potential to steal tokens, manipulate voting power, and disrupt rewards systems makes this vulnerability critical to address in order to maintain the integrity and trust of the protocol. +## PoC +Assumptions: +The Attacker's Visibility: The attacker can observe a valid stakeOnBehalf transaction in the mempool before it is mined (e.g., using a mempool scanner). +Unchanged Parameters: The attacker knows the exact parameters (_amount, _delegatee, _claimer, _depositor) used in the original transaction. +The Signature: The attacker has access to the signature that validates the staking request and can re-use it. +PoC Implementation: +```solidity + +// Pseudo-code for the attacker (simplified) + +pragma solidity ^0.8.0; + +interface GovernanceStakerOnBehalf { + function stakeOnBehalf( + uint256 _amount, + address _delegatee, + address _claimer, + address _depositor, + uint256 _deadline, + bytes memory _signature + ) external returns (uint256 _depositId); +} + +contract Attacker { + GovernanceStakerOnBehalf public governanceStaker; + address public victim; // Victim address whose tokens are being stolen + + constructor(address _governanceStaker) { + governanceStaker = GovernanceStakerOnBehalf(_governanceStaker); + } + + // Function to exploit the front-running vulnerability + function frontRunStakeOnBehalf( + uint256 _amount, + address _delegatee, + address _claimer, + address _depositor, + uint256 _deadline, + bytes memory _signature + ) external { + // Attacker mimics the parameters of the original staking request + // and submits the transaction with a higher gas price to front-run it + + // Mimic the original staking request with the same parameters + uint256 depositId = governanceStaker.stakeOnBehalf( + _amount, + _delegatee, + _claimer, + _depositor, + _deadline, + _signature + ); + + // Log the depositId or take action (in real scenario, attacker might further manipulate governance) + emit FrontRunSuccess(depositId); + } + + // Event to log a successful front-run + event FrontRunSuccess(uint256 depositId); +} +``` + +## Tool used + +Manual Review + +## Recommendation +To prevent replay attacks, the contract should: + +Include a nonce mechanism to uniquely identify each staking transaction. +Use timestamp-based checks (beyond just the deadline) or randomized delays to make front-running more difficult. +Implement a unique transaction ID that ties each stake to a specific instance, preventing its reuse. \ No newline at end of file diff --git a/086.md b/086.md new file mode 100644 index 0000000..9957f56 --- /dev/null +++ b/086.md @@ -0,0 +1,39 @@ +Feisty Opaque Vulture + +Medium + +# `lastCheckpointTime` is updated even when the `totalEarningPower` is zero and causes reward tokens to be stuck in the contract + +## Summary + +Stakers accumulate rewards monotonically over time. But when the `totalEarningPower` equals `0`, the `lastCheckpointTime` is updated even though the `rewardPerTokenAccumulatedCheckpoint` stays the same. Consequently, the total amount of reward tokens that has been sent to the contract cannot be accumulated and will remain in the contract. + +## Vulnerability Detail + +When `rewardPerTokenAccumulated()` is called but the `totalEarningPower` equals `0`, it is not updated. However, the `lastCheckpointTime` is updated. + +```solidity + function _checkpointGlobalReward() internal virtual { + rewardPerTokenAccumulatedCheckpoint = rewardPerTokenAccumulated(); + lastCheckpointTime = lastTimeRewardDistributed(); + } +``` + +As a result, reward tokens will remain in the contract and cannot be accumulated in the future. + +## Impact + +Reward tokens which have not been accumulated will remain in the contract. If noone staked during the whole reward duration, the whole reward amount remains in the contract. + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L439-L441 +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L304 + +## Tool used + +Manual Review + +## Recommendation + +Consider implementing a withdraw functionality so that leftover reward tokens can be withdrawn. \ No newline at end of file diff --git a/087.md b/087.md new file mode 100644 index 0000000..dca961f --- /dev/null +++ b/087.md @@ -0,0 +1,82 @@ +Able Pastel Lion + +Medium + +# The `bumpEarningPower` function can't change the depositId's earning power + +## Summary + +## Vulnerability Detail + + +In the GovernanceStaker contract, users can stake their tokens and set a `_delegatee` address that earns rewards based on that. However, after some time, the `_delegatee` may become ineligible for earning power. When a user claims their rewards, the `_delegatee` may become eligible again. The problem is that the `bumpEarningPower` function cannot change this depositId's earning power because `deposit.scaledUnclaimedRewardCheckpoint` will always be zero. This means that the delegatee has the voting power, but the staker cannot earn rewards. This breaks the invariant of bumpEarningPower that states that when a qualifying change in the earning power is returned by the earning power calculator, the deposit's earning power should be updated. + + +```solidity + /// @notice A function that a bumper can call to update a deposit's earning power when a + /// qualifying change in the earning power is returned by the earning power calculator. A + /// deposit's earning power may change as determined by the algorithm of the current earning power + /// calculator. In order to incentivize bumpers to trigger these updates a portion of deposit's + /// unclaimed rewards are sent to the bumper. + /// @param _depositId The identifier for the deposit that needs an updated earning power. + /// @param _tipReceiver The receiver of the reward for updating a deposit's earning power. + /// @param _requestedTip The amount of tip requested by the third-party. + function bumpEarningPower( + DepositIdentifier _depositId, + address _tipReceiver, + uint256 _requestedTip + ) external virtual { + if (_requestedTip > maxBumpTip) revert GovernanceStaker__InvalidTip(); + + Deposit storage deposit = deposits[_depositId]; + + _checkpointGlobalReward(); + _checkpointReward(deposit); + +@>> uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + + (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( + deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower + ); + if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + } + +@>> if (_newEarningPower > deposit.earningPower && _unclaimedRewards < _requestedTip) { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // Note: underflow causes a revert if the requested tip is more than unclaimed rewards + if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) + { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // Update global earning power & deposit earning power based on this bump + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); + deposit.earningPower = _newEarningPower.toUint96(); + + // Send tip to the receiver + SafeERC20.safeTransfer(REWARD_TOKEN, _tipReceiver, _requestedTip); + deposit.scaledUnclaimedRewardCheckpoint = + deposit.scaledUnclaimedRewardCheckpoint - (_requestedTip * SCALE_FACTOR); + } +``` + +## Impact + +The delegatee has the voting power, but the staker cannot earn rewards. + +## Code Snippet + +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L462-L514 + +## Tool used + +Manual Review + +## Recommendation \ No newline at end of file diff --git a/088.md b/088.md new file mode 100644 index 0000000..ea20bd5 --- /dev/null +++ b/088.md @@ -0,0 +1,22 @@ +Skinny Shadow Sparrow + +Medium + +# Lack of Approval for Token Transfer between Surrogates + +## Summary +When ```alterDelegatee``` is called it changes the delegates but also calls ```_fetchOrDeploySurrogate``` which deploys a new contract surrogate. Then we proceed with a safeTransfer from the old one to the new one. This will always fail since the old one didnt approved the new one. Broken Logic + + +## Impact +Broken Logic. ```alterDelegate``` will always fail and revert since token transfer from old one to the new one wont be able to transfer +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L374-L378 +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L624-L648 + +## Tool used +Manual Review + +## Recommendation + +add Approve \ No newline at end of file diff --git a/089.md b/089.md new file mode 100644 index 0000000..c6cb1a4 --- /dev/null +++ b/089.md @@ -0,0 +1,48 @@ +Rich Smoke Cheetah + +High + +# A mallicious bumper can take all the rewards of a user + +## Summary +If the rewards are less than the `maxBumpTip` the the bumper can take absolutely all the rewards. + +## Vulnerability Detail +Here we can see that the tip that will be received in the bumpEarningPower must be less or equal to the minimum between the unclaimed fees and the maxBumpTip in the bumpEarningPower function. + +```solidity + uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / SCALE_FACTOR; + + (uint256 _newEarningPower, bool _isQualifiedForBump) = earningPowerCalculator.getNewEarningPower( + deposit.balance, deposit.owner, deposit.delegatee, deposit.earningPower + ); + if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + } + + if (_newEarningPower > deposit.earningPower && _unclaimedRewards < _requestedTip) { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } + + // Note: underflow causes a revert if the requested tip is more than unclaimed rewards + if (_newEarningPower < deposit.earningPower && (_unclaimedRewards - _requestedTip) < maxBumpTip) + { + revert GovernanceStaker__InsufficientUnclaimedRewards(); + } +``` +Otherwise, the call will revert because of an underflow or a custom error. But if the reward is less than the maxBumpTip and the user gain earning power then the bumper could request all his reward. +### attack path +Bob is a depositor, and his earning power continuously increases. +Alice is a malicious user who wants to steal all of Bob’s rewards. +Alice calls bumpEarningPower every time Bob gains some rewards in order to continuously take all the rewards. +As a result, Bob will never gain his rewards. +## Impact +The Bumper will steal all the depositor's reward. +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L483-L500 +## Tool used + +Manual Review + +## Recommendation +The tip should be a portion of the rewards and then becoming a constant in order to avoid this type of attack. \ No newline at end of file diff --git a/090.md b/090.md new file mode 100644 index 0000000..a835394 --- /dev/null +++ b/090.md @@ -0,0 +1,26 @@ +Hidden Tweed Elephant + +High + +# Reward can be lost if the Staker withdraws deposit without claiming reward + +## Summary +When the staker deposit he can provide delegatee and claimer address other than himself but when the staker allots someone else as a claimer. He will not know that the claimer has claimed the reward for his deposit or not and if he mistakenly withdraws his stake then it will result in loosing out on rewards. + +## Vulnerability Detail +When the claimer has not called the reward for the deposit and the staker withdraws the stake then the claimer will loose out on rewards because there is no check which stops the staker from withdrawing its stake before claiming the reward. Suppose there is a reward which is being generated for the deposit which is being staked by the user and user has alloted the reward to some other claimer But because of some conditions user wants to withdraw its stake so he call the withdraw function and it will withdraw his stake and that will make the claimer loose out on rewards which is being generated for the deposit. + +## Impact +**Severity :: HIGH** +**Likelihood :: MEDIUM** +Claimer loosing the rewards because of the stake withdrawal by the staker before claiming the reward + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L392-L401 + +## Tool used + +Manual Review + +## Recommendation +Add a check which will check the deposit has the pending reward which should be claimed before withdrawing the stake \ No newline at end of file diff --git a/091.md b/091.md new file mode 100644 index 0000000..44aa6fc --- /dev/null +++ b/091.md @@ -0,0 +1,33 @@ +Skinny Shadow Sparrow + +Medium + +# Redundant Calculation of Total Earning Power in Delegatee Change Process + +## Summary +When trying to change delegatees we do a uselles calculation + +```solidity + totalEarningPower = + _calculateTotalEarningPower(deposit.earningPower, _newEarningPower, totalEarningPower); + depositorTotalEarningPower[deposit.owner] = _calculateTotalEarningPower( + deposit.earningPower, _newEarningPower, depositorTotalEarningPower[deposit.owner] + ); +``` + +## Vulnerability Detail +In the _alterDelegatee function, the calculation of totalEarningPower and depositorTotalEarningPower[deposit.owner] is performed unnecessarily when the delegatee is changed. This is redundant because the deposit.earningPower is already updated with the new delegatee's earning power, and recalculating the total earning power at this level is inefficient and unnecessary. + +## Impact +Efficiency Loss: The redundant calculation of totalEarningPower and depositorTotalEarningPower introduces unnecessary computational overhead, increasing gas costs and reducing the efficiency of the function. +Potential for Errors: By performing redundant operations, there's a higher risk of introducing inconsistencies or errors in the future if the logic is modified, especially if these totals are updated elsewhere in the system. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/GovernanceStaker.sol#L374-L378 + +## Tool used + +Manual Review + +## Recommendation +remove this calculation \ No newline at end of file diff --git a/092.md b/092.md new file mode 100644 index 0000000..bd8b83e --- /dev/null +++ b/092.md @@ -0,0 +1,23 @@ +Real Glossy Rhino + +Medium + +# Anyone can Claim reward On Behalf of depositor or claimer via `claimRewardOnBehalf` + +## Summary +The `claimRewardOnBehalf` function in the contract allows anyone to claim rewards on behalf of a deposit owner or claimer if they possess a valid `_signature`. However, there are no restrictions requiring the caller of the function to match the `deposit.owner` or `deposit.claimer`. This creates a significant vulnerability, as anyone with access to a valid _signature can claim rewards without being `deposit.owner` or `deposit.claimer`. + +## Vulnerability Detail + +## Impact +Anyone with access to a valid _signature can claim rewards without being the deposit owner or claimer. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/extensions/GovernanceStakerOnBehalf.sol#L250-L274 + +## Tool used + +Manual Review, Foundry + +## Recommendation +Only `deposit.owner` or `deposit.claimer` should be able to call `claimRewardOnBehalf` \ No newline at end of file diff --git a/093.md b/093.md new file mode 100644 index 0000000..d8c41b2 --- /dev/null +++ b/093.md @@ -0,0 +1,125 @@ +Lively Arctic Robin + +Medium + +# Lack of Delegatee Eligibility Check May Result in Successful Staking but Immediately No Rewards due to no Update in Earning Power + +## Summary + +The current implementation of the `_stake` and `_stakeMore`cfunction in the smart contract does not verify whether the delegatee is eligible for staking before proceeding with the staking operation. This oversight can lead to users being unaware that their stake has been processed without earning any rewards if the delegatee is ineligible. Additionally, the `bumpEarningPower` function, which is used to update the earning power of deposits, checks delegatee eligibility and prevents updates for unqualified delegatees. This creates a mismatch between the two functions—staking is allowed without validation, but earning power cannot be updated if the delegatee is unqualified. The report recommends separating the `getEarningPower` (used for querying) and `getNewEarningPower` (used for staking and earning power updates) to improve clarity and functionality. + + +## Vulnerability Detail + +The existing code has two main issues: + +1. Staking without Delegatee Eligibility Check: + - The `_stake` and `_stakeMore` function does not validate whether the delegatee is eligible before proceeding with the staking operation. This can result in successful staking but without earning any rewards if the delegatee’s earning power is zero. + +2. Mismatch Between Staking and Earning Power Update Logic: + - The `bumpEarningPower` function checks if the delegatee is eligible using the `getNewEarningPower` function and requires the delegatee to be qualified before updating the earning power. However, the staking function does not perform this check. This discrepancy means that while staking can be performed with an unqualified delegatee, updates to the earning power via `bumpEarningPower` will be blocked if the delegatee is unqualified. + +## Impact + + - Users may stake their tokens with an ineligible delegatee and later find that they are not earning rewards. This leads to confusion as the transaction appears successful, but no rewards are generated. + - There is a mismatch in the system’s behavior. While the `bumpEarningPower` function prevents updates for unqualified delegatees, staking does not. This inconsistency can create frustration for users who attempt to update their earning power after staking with an invalid delegatee. + +## Code Snippet + +Current implementation: + +[staker/src/GovernanceStaker.sol:_stake#L571-L73](https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L571C1-L573C28) +```solidity +function _stake(...) { + // ... other checks + + // @audit Just calculates earning power without checking delegatee eligibility + uint256 _earningPower = earningPowerCalculator.getEarningPower( + _amount, + _depositor, + _delegatee + ); + + // @audit Directly proceed with staking operation +totalStaked += _amount; + // ... more logic +} +``` + +[staker/src/GovernanceStaker.sol:_stakeMore#L605-L610](https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L605C1-L610C28) +```solidity +function _stakeMore(...) { + + // @audit Just calculates earning power without checking delegatee eligibility + uint256 _newEarningPower = earningPowerCalculator.getEarningPower( + _newBalance, + deposit.owner, + deposit.delegatee + ); + + totalEarningPower = _calculateTotalEarningPower( + deposit.earningPower, + _newEarningPower, + totalEarningPower + ); +// @audit Directly proceed with staking operation and earningPower without + totalStaked += _amount; + +``` + + +The following code snippet shows the current behavior of the `bumpEarningPower` function, which checks delegatee eligibility before updating earning power: + +[staker/src/GovernanceStaker.sol:bumpEarningPower#L485-L490](https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/GovernanceStaker.sol#L485C1-L490C6) +```solidity +function bumpEarningPower( + DepositIdentifier _depositId, + address _tipReceiver, + uint256 _requestedTip +) external virtual { + if (_requestedTip > maxBumpTip) revert GovernanceStaker__InvalidTip(); + + Deposit storage deposit = deposits[_depositId]; + + _checkpointGlobalReward(); + _checkpointReward(deposit); + + uint256 _unclaimedRewards = deposit.scaledUnclaimedRewardCheckpoint / + SCALE_FACTOR; + + ( + uint256 _newEarningPower, + bool _isQualifiedForBump + ) = earningPowerCalculator.getNewEarningPower( + deposit.balance, + deposit.owner, + deposit.delegatee, + deposit.earningPower + ); + +// @audit check Earning power could not be updated due to unqualified delegatee + if (!_isQualifiedForBump || _newEarningPower == deposit.earningPower) { + revert GovernanceStaker__Unqualified(_newEarningPower); + } + + // Further checks and updates for earning power... +} +``` + +The above code prevents updates if the delegatee is not qualified (`_isQualifiedForBump`), which causes an issue when the delegatee is unqualified during staking, especially stakers just finish their staking . + + +## Tool Used + +Manual Review + +## Recommendation + +To address these issues and enhance the functionality of the staking system, we recommend the following changes: + +1. Delegatee Eligibility Check on Staking: + - Implement a delegatee eligibility check within the `_stake` function before proceeding with the staking operation. This will ensure that only qualified delegatees can receive stakes and avoid situations where the earning power is zero due to an unqualified delegatee. + +2. Clarify Usage of `getEarningPower` and `getNewEarningPower`: + - `getEarningPower` should be used for querying the current earning power of a deposit, as it does not modify the state and provides a simple snapshot of the rewards a user can earn. + - `getNewEarningPower` should be used for staking and earning power update operations. This function already checks delegatee eligibility and should be called only after ensuring the delegatee is eligible. This will prevent inconsistencies in how earning power is updated. \ No newline at end of file diff --git a/094.md b/094.md new file mode 100644 index 0000000..6b9d186 --- /dev/null +++ b/094.md @@ -0,0 +1,34 @@ +Dazzling Coral Sheep + +Medium + +# the `if (_isOracleStale() || isOraclePaused)` check doesnt revert when oracle is stale or paused + +## Summary +the `if (_isOracleStale() || isOraclePaused)` check return is wrong +## Vulnerability Detail +in `getEarningPower` and `getNewEarningPower` we can see there is the `if (_isOracleStale() || isOraclePaused)` check doesnt revert when oracle is stale or paused and returns `_amountStaked` if oracle is stale or paused but the problem is it should't return the `_amountStaked` because this already going to be returned if `_isDelegateeEligible(_delegatee)` and not when oracle is stale or paused +## Impact +the function doesnt revert when the oracle is stale or paused, hence oracle being stale or paused makes no changes +## Code Snippet +```solidity + function getEarningPower(uint256 _amountStaked, address, /* _staker */ address _delegatee) + external + view + returns (uint256) + { + if (_isOracleStale() || isOraclePaused) return _amountStaked; //@audit why both returns amountstaked even when oracle stale and is eligble? + return _isDelegateeEligible(_delegatee) ? _amountStaked : 0; //@note calculates nothing here + } + +``` + +as you saw it doesnt makes any sense at all that this check doesnt prevent anything at all +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/BinaryEligibilityOracleEarningPowerCalculator.sol#L130-L158 +## Tool used + +Manual Review + +## Recommendation + +- consider modiying the contracts to the way that it reverts when oracle is stale or paused as intended \ No newline at end of file diff --git a/095.md b/095.md new file mode 100644 index 0000000..0dbb2d1 --- /dev/null +++ b/095.md @@ -0,0 +1,24 @@ +Real Glossy Rhino + +Medium + +# Anyone can Withdraw staked tokens from an existing deposit on behalf of a user via `withdrawOnBehalf` + +## Summary +The `withdrawOnBehalf` function allows anyone to withdraw staked tokens on behalf of a user (`_depositor`) if they possess a valid `_signature`. However, the function lacks proper validation to restrict the caller (`msg.sender`) from initiating withdrawals unless they are authorized by the depositor. This creates a critical vulnerability as malicious actors can exploit valid signatures to withdraw funds from deposits without the user’s consent. + +## Vulnerability Detail + + +## Impact +Anyone with access to a valid _signature can withdraw funds from deposits without the user’s consent. + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/main/staker/src/extensions/GovernanceStakerOnBehalf.sol#L218-L241 + +## Tool used + +Manual Review, Foundry + +## Recommendation +Only `deposit.owner` or `deposit.claimer` should be able to call `withdrawOnBehalf` \ No newline at end of file diff --git a/096.md b/096.md new file mode 100644 index 0000000..16201f8 --- /dev/null +++ b/096.md @@ -0,0 +1,44 @@ +Rough Brick Meerkat + +Medium + +# EIP-2612 permit functionality not respected + +## Summary + +## Vulnerability Detail +In the functions ‘permitAndStake’ and ‘permitAndStakeMore’ in the file GovernanceStakerPermitAndStake.sol, even when the stake token support EIP-2612 permit functionality, these functions ignores the permit call failures (e.g., due to a signature mismatch or expired deadline) and continues execution without reverting as it uses try / catch with the permit call. In the instance, the basic purpose and advantage of permit call is defeated. + +```solidity + function permitAndStake( + uint256 _amount, + address _delegatee, + address _claimer, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external virtual returns (DepositIdentifier _depositId) { + try IERC20Permit(address(STAKE_TOKEN)).permit( + msg.sender, address(this), _amount, _deadline, _v, _r, _s + ) {} catch {} + _depositId = _stake(msg.sender, _amount, _delegatee, _claimer); + } +``` +Under the condition of any failure in the try/catch of permit call, the staker has to separately give ERC20 approval for the desired amount to be staked, which will be required prior to the execution of +```solidity + _depositId = _stake(msg.sender, _amount, _delegatee, _claimer); +``` + +## Impact + +## Code Snippet +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/extensions/GovernanceStakerPermitAndStake.sol#L37-L50 + +https://github.com/sherlock-audit/2024-11-tally/blob/b125d1f2b52170a3789b1060a52fc6609e6e2262/staker/src/extensions/GovernanceStakerPermitAndStake.sol#L63-L79 +## Tool used + +Manual Review + +## Recommendation +Better to revert on the failure of permit call and informing the staker the reason of failure. This will enable the staker to take informed decision. \ No newline at end of file diff --git a/invalid/.gitkeep b/invalid/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/invalid/021.md b/invalid/021.md new file mode 100644 index 0000000..f043055 --- /dev/null +++ b/invalid/021.md @@ -0,0 +1,48 @@ +Unique Denim Worm + +Invalid + +# [H-1] Claimer can only claim rewards for deposits that they are BOTH the claimer and owner + +## Summary + +in `GovernanceStaker::claimReward` claimers who are not BOTH the claimer and owner of a deposit can never claim their rewards. The only claimers that can claim rewards are those who are BOTH the designated claimer and owner of a deposit - when it is clear that `claimers` are entitled to claim rewards even if they are not the owner of the deposit. + +## Vulnerability Detail + +Several places within the documentation state `claimer account has the right to withdraw rewards` - there is specific functionality that allows and facilitates the `claimer` address to be different from the `owner` address of a deposit. + +The `claimReward` function has documentation that states `msg.sender` must be the claimer address of the deposit` - but even if they are, they cannot withdraw / claim the rewards that they are entitled to unless they are ALSO the owner of the deposit - which is not the intended functionality. This is because of the following check: + +` if (deposit.claimer != msg.sender && deposit.owner != msg.sender) { + revert GovernanceStaker__Unauthorized("not claimer or owner", msg.sender); + }` + +## Impact + +Claimers who are not also the owner of the deposit will not be able to claim rewards. Not only that, any deposits that have a different `claimer` and `owner` will never be able to have their rewards claimed. Those rewards will never be able to be claimed. + +## Code Snippet + +`function claimReward(DepositIdentifier _depositId) external virtual returns (uint256) { + Deposit storage deposit = deposits[_depositId]; + if (deposit.claimer != msg.sender && deposit.owner != msg.sender) { + revert GovernanceStaker__Unauthorized("not claimer or owner", msg.sender); + } + return _claimReward(_depositId, deposit, msg.sender); + }` + + +## Tool used + +Manual Review + +## Recommendation + +There are 2 different solutions that the protocol can take: + +1. If the `owner` of the deposit can claim rewards for the claimer (the claimer will still be the receiving address) : +- Change the check to `||` instead of `&&`. Which will allow rewards to be claimed if the `msg.sender` is either the `claimer` or `owner`. + +2. If only the claimer is allowed to claim rewards: +- Change the check to only check if `msg.sender` is the `claimer` \ No newline at end of file