Platform: Code4rena
Start Date: 23/02/2024
Pot Size: $92,000 USDC
Total HM: 0
Participants: 47
Period: 10 days
Judge: 0xTheC0der
Id: 336
League: ETH
Rank: 14/47
Findings: 1
Award: $694.30
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: CodeWasp
Also found by: 0xdice91, 0xlemon, Aamir, Al-Qa-qa, AlexCzm, BAHOZ, Bauchibred, Breeje, DadeKuma, Fassi_Security, PetarTolev, Shield, SpicyMeatball, Trust, ZanyBonzy, cheatc0d3, gesha17, haxatron, imare, jesjupyter, kutugu, lsaudit, marchev, merlinboii, nnez, osmanozdemir1, peanuts, radev_sw, twicek, visualbits
694.2987 USDC - $694.30
abi.encode
instead of abi.encodePacked
when computing type hashesI conducted a manual code review, meticulously examining each line against a checklist tailored for identifying nuanced and low-severity issues in Solidity contracts. This process involved a deep dive into the contract's logic, external calls, and handling of user inputs, allowing for the swift identification of common vulnerabilities and areas requiring further scrutiny.
The UniStaker contract uses a simple admin check for critical functionalities like setting the admin and toggling reward notifiers. However, broader access control mechanisms, such as role-based access control (RBAC) for different levels of permissions, are not implemented. This can limit the flexibility and security of managing different administrative operations.
function setAdmin(address _newAdmin) external { _revertIfNotAdmin(); _setAdmin(_newAdmin); }
function setRewardNotifier(address _rewardNotifier, bool _isEnabled) external { _revertIfNotAdmin(); isRewardNotifier[_rewardNotifier] = _isEnabled; emit RewardNotifierSet(_rewardNotifier, _isEnabled); }
function _revertIfNotAdmin() internal view { if (msg.sender != admin) revert UniStaker__Unauthorized("not admin", msg.sender); }
permitAndStake
could benefit from ensuring the deadline is respected beyond the permit call itself.
function permitAndStake( uint256 _amount, address _delegatee, address _beneficiary, uint256 _deadline, uint8 _v, bytes32 _r, bytes32 _s ) external { require(block.timestamp <= _deadline, "Transaction expired"); // Existing permit and stake logic... }
It's advisable to check that the current block timestamp is less than the deadline provided in the permitAndStakeMore function. This ensures the permit has not expired at the time of execution.
function permitAndStakeMore( DepositIdentifier _depositId, uint256 _amount, uint256 _deadline, uint8 _v, bytes32 _r, bytes32 _s ) external { require(block.timestamp <= _deadline, "Permit: signature expired"); Deposit storage deposit = deposits[_depositId]; _revertIfNotDepositOwner(deposit, msg.sender); STAKE_TOKEN.permit(msg.sender, address(this), _amount, _deadline, _v, _r, _s); _stakeMore(deposit, _depositId, _amount); }
Ensure that functions dealing with value transfers or balances check for non-zero amounts.
function stake(uint256 _amount, address _delegatee) external { require(_amount > 0, "Amount must be greater than 0"); }
In scenarios with high transaction volumes, significant fluctuations in staking behavior, or a large number of stakers, the existing mechanisms might not scale efficiently. This could lead to challenges in timely updating reward rates, calculating individual rewards, and maintaining the contract's performance and accuracy.
Global Reward Rate Updates:
The contract recalculates the global reward rate upon notification of new rewards, adjusting for the remaining reward duration or initiating a new duration. This calculation is critical for subsequent individual reward allocations.
```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; } ```
Individual Reward Calculations:
The mechanism for recalculating each staker's reward entitlement based on their share of the total staked amount. This calculation relies on the updated global reward rate and individual staker's data, which could become less efficient under conditions of frequent large-scale changes.
solidity function unclaimedReward(address _beneficiary) public view returns (uint256) { return unclaimedRewardCheckpoint[_beneficiary] + (earningPower[_beneficiary] * (rewardPerTokenAccumulated() - beneficiaryRewardPerTokenCheckpoint[_beneficiary]) ) / SCALE_FACTOR; }
Inefficiencies in recalculating rewards could lead to delays in updating stakers' reward entitlements, affecting the timeliness of reward distribution.
Offload part of the calculation workload to off-chain systems, with on-chain verification to ensure the integrity and fairness of reward distributions.
abi.encode
instead of abi.encodePacked
when computing type hashesThe problem with using abi.encode
in this context is that it adds padding to the encoded data, which can lead to different hash values being computed for the same type string across different clients or implementations.
See links above.
bytes32 public constant STAKE_TYPEHASH = keccak256( abi.encode( "Stake(uint256 amount,address delegatee,address beneficiary,address depositor,uint256 nonce)" ) );
use abi.encodePacked
instead of abi.encode
when computing the type hashes. abi.encodePacked
does not add any padding to the encoded data, ensuring that the same type string will always result in the same hash value, regardless of the client or implementation.
The contract lacks a built-in mechanism to slash staked assets or withhold rewards in the case of staker misbehavior. This omission could potentially be exploited by participants who act maliciously or fail to meet predefined conditions essential for the protocol's integrity or operational efficiency, without any repercussions on their staked assets or earned rewards.
Introduce smart contract logic to slash a portion of the staked assets or withhold rewards for stakers found to be acting maliciously or failing to meet specific protocol responsibilities. These conditions should be clearly defined and understood by all participants.
Failure to protect user assets in emergency situations can severely damage the reputation of the protocol and erode trust within the community.
Integrate an emergency withdrawal feature that allows users to retrieve their staked assets independently, without interacting with potentially compromised contract functions.
Implementing a nonce expiry mechanism can further protect against replay attacks, where a nonce becomes invalid after a certain period or event, ensuring that old transactions cannot be replayed indefinitely.
See links above
The notifyRewardAmount function lacks sufficient checks to ensure that the _amount parameter matches the actual amount of tokens transferred to the contract before calling this function. This could potentially lead to discrepancies between the expected and actual reward amounts.
function notifyRewardAmount(uint256 _amount) external { if (!isRewardNotifier[msg.sender]) revert UniStaker__Unauthorized("not notifier", msg.sender); // ... if ( (scaledRewardRate * REWARD_DURATION) > (REWARD_TOKEN.balanceOf(address(this)) * SCALE_FACTOR) ) revert UniStaker__InsufficientRewardBalance(); // Existing logic... }
The require statement ensures that the underlying uint value of nextDepositId is less than the maximum value a uint256 can hold before incrementing. This prevents overflow errors. Given Solidity 0.8's inherent overflow checks, this might be more about explicit safety and clarity than a strict necessity.
function _useDepositId() internal returns (DepositIdentifier _depositId) { require(DepositIdentifier.unwrap(nextDepositId) < type(uint256).max, "DepositId overflow"); _depositId = nextDepositId; nextDepositId = DepositIdentifier.wrap(DepositIdentifier.unwrap(_depositId) + 1); }
Emitting events upon the use of a nonce can provide transparency and allow users and off-chain services to track nonce usage, making unexpected behavior or attacks more visible.
See links above
Consider using additional layers of security like session keys or time-bound operations (where a signature is only valid within a certain timeframe), especially for sensitive operations.
See links above (any function taking signature parameter can benefit)
Incorporating an event like BeneficiaryChanged enhances the contract's transparency by providing an auditable trail of beneficiary changes, which can be vital for governance and in scenarios where stakeholders need to track the history of such changes.
event BeneficiaryChanged( DepositIdentifier indexed depositId, address indexed oldBeneficiary, address indexed newBeneficiary ); function alterBeneficiary(DepositIdentifier _depositId, address _newBeneficiary) external { Deposit storage deposit = deposits[_depositId]; _revertIfNotDepositOwner(deposit, msg.sender); // Emit an event before making the change emit BeneficiaryChanged(_depositId, deposit.beneficiary, _newBeneficiary); _alterBeneficiary(deposit, _depositId, _newBeneficiary); }
_revertIfSignatureIsNotValidNow( _depositor, _hashTypedDataV4( keccak256( abi.encode( ALTER_DELEGATEE_TYPEHASH, _depositId, _newDelegatee, _depositor, _useNonce(_depositor), // Additional context like contract address and a unique operation identifier could be included here address(this), block.timestamp ) ) ), _signature );
Explicitly checking for a non-zero stake amount at the function's entry point can enhance clarity and prevent unnecessary contract calls. This makes the function fail fast if the condition is not met, saving gas for the caller and preventing unnecessary state changes or event emissions.
function stake(uint256 _amount, address _delegatee) external returns (DepositIdentifier _depositId) { require(_amount > 0, "Amount must be greater than 0"); _depositId = _stake(msg.sender, _amount, _delegatee, msg.sender); }
Add a requirement at the beginning of the permitAndStake function to ensure that the amount being staked is greater than 0. This early check ensures that no further operations are performed if the stake amount does not meet this basic criterion.
function permitAndStake( uint256 _amount, address _delegatee, address _beneficiary, uint256 _deadline, uint8 _v, bytes32 _r, bytes32 _s ) external returns (DepositIdentifier _depositId) { require(_amount > 0, "Amount must be greater than 0"); STAKE_TOKEN.permit(msg.sender, address(this), _amount, _deadline, _v, _r, _s); _depositId = _stake(msg.sender, _amount, _delegatee, _beneficiary); }
function stakeOnBehalf( uint256 _amount, address _delegatee, address _beneficiary, address _depositor, bytes memory _signature ) external returns (DepositIdentifier _depositId) { // Validate the signature _revertIfSignatureIsNotValidNow( _depositor, _hashTypedDataV4( keccak256( abi.encode( STAKE_TYPEHASH, _amount, _delegatee, _beneficiary, _depositor, _useNonce(_depositor) ) ) ), _signature ); // Ensure the amount to be staked is greater than 0 require(_amount > 0, "Amount must be greater than 0"); // Proceed with staking _depositId = _stake(_depositor, _amount, _delegatee, _beneficiary); }
Add an additional require statement checks whether the caller (msg.sender) is authorized to deploy a new DelegationSurrogate contract.
// Add an authorization check or a condition to limit who can trigger the deployment of new surrogates function _fetchOrDeploySurrogate(address _delegatee) internal returns (DelegationSurrogate _surrogate) { _surrogate = surrogates[_delegatee]; if (address(_surrogate) == address(0)) { // Ensure that the caller is authorized to deploy a new surrogate require(isAuthorizedDeployer(msg.sender), "Unauthorized to deploy surrogate"); _surrogate = new DelegationSurrogate(STAKE_TOKEN, _delegatee); surrogates[_delegatee] = _surrogate; emit SurrogateDeployed(_delegatee, address(_surrogate)); } }
A check can be added to ensure that the deposit exists before allowing more stake to be added. This can prevent unintended or malicious interactions with non-existent deposits.
function stakeMore(DepositIdentifier _depositId, uint256 _amount) external { require(DepositIdentifier.unwrap(_depositId) < DepositIdentifier.unwrap(nextDepositId), "Deposit does not exist"); Deposit storage deposit = deposits[_depositId]; _revertIfNotDepositOwner(deposit, msg.sender); _stakeMore(deposit, _depositId, _amount); }
The _alterBeneficiary function changes the beneficiary address for a given deposit. It updates who receives the rewards without necessarily ensuring that the new beneficiary is aware or has consented to this change. This could lead to situations where rewards are directed to an unintended address due to user error or malicious activity. You could also use Openzeppelin's Ownable2step here.
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; }
If the contract doesn't ensure nonces are used sequentially without gaps, there might be vulnerabilities. For example, if a user can skip a nonce, it might allow an old signature to be replayed if the skipped nonce is used.
While the _useNonce
function likely increments the nonce, explicitly checking that the provided nonce matches the expected nonce for the user adds clarity and ensures no gaps are allowed.
// For staking on behalf of a user function stakeOnBehalf( uint256 _amount, address _delegatee, address _beneficiary, address _depositor, bytes memory _signature ) external returns (DepositIdentifier _depositId) { _revertIfSignatureIsNotValidNow( _depositor, _hashTypedDataV4( keccak256( abi.encode( STAKE_TYPEHASH, _amount, _delegatee, _beneficiary, _depositor, _useNonce(_depositor) // <-- Nonce usage here ) ) ), _signature ); _depositId = _stake(_depositor, _amount, _delegatee, _beneficiary); }
There is no explicit mechanism in the contract to clear the beneficiaryRewardPerTokenCheckpoint
for a beneficiary after they have claimed their rewards. This means that if a beneficiary claims their rewards and later earns more rewards by staking more tokens or due to a new reward notification, the calculation of their new unclaimedReward
may be incorrect because the beneficiaryRewardPerTokenCheckpoint
is not reset.
function _claimReward(address _beneficiary) internal { _checkpointGlobalReward(); _checkpointReward(_beneficiary); uint256 _reward = unclaimedRewardCheckpoint[_beneficiary]; if (_reward == 0) return; unclaimedRewardCheckpoint[_beneficiary] = 0; emit RewardClaimed(_beneficiary, _reward); SafeERC20.safeTransfer(REWARD_TOKEN, _beneficiary, _reward); }
The contract should reset the beneficiaryRewardPerTokenCheckpoint
for a beneficiary after they have claimed their rewards. This can be done by adding a line in the _claimReward
function to reset the checkpoint.
function _claimReward(address _beneficiary) internal { _checkpointGlobalReward(); _checkpointReward(_beneficiary); uint256 _reward = unclaimedRewardCheckpoint[_beneficiary]; if (_reward == 0) return; unclaimedRewardCheckpoint[_beneficiary] = 0; beneficiaryRewardPerTokenCheckpoint[_beneficiary] = rewardPerTokenAccumulatedCheckpoint; // Reset checkpoint emit RewardClaimed(_beneficiary, _reward); SafeERC20.safeTransfer(REWARD_TOKEN, _beneficiary, _reward); }
The RewardNotifierSet event should be extended to include the admin's address as an event parameter. And the function emitting this event would include the msg.sender in the emitted event:
event RewardNotifierSet(address indexed account, bool isEnabled);
emit RewardNotifierSet(msg.sender, _rewardNotifier, _isEnabled);
The lastTimeRewardDistributed
function can be optimized for better readability by using a ternary operator, which simplifies the code without changing its logic. This is more of a stylistic preference that enhances code readability and conciseness.
function lastTimeRewardDistributed() public view returns (uint256) { return rewardEndTime <= block.timestamp ? rewardEndTime : block.timestamp; }
This version adds comments for clarity and breaks down the calculation into more steps, which can help with readability and debugging.
function rewardPerTokenAccumulated() public view returns (uint256) { // If no tokens are staked, no new rewards have been accumulated. if (totalStaked == 0) { return rewardPerTokenAccumulatedCheckpoint; } // Calculate new rewards per token since last checkpoint. uint256 timeSinceLastCheckpoint = lastTimeRewardDistributed() - lastCheckpointTime; uint256 rewardAccumulation = scaledRewardRate * timeSinceLastCheckpoint; uint256 rewardsPerTokenSinceLastCheckpoint = rewardAccumulation / totalStaked; return rewardPerTokenAccumulatedCheckpoint + rewardsPerTokenSinceLastCheckpoint; }
The emit RewardClaimed(_beneficiary, _reward); event is emitted before the actual transfer of rewards. In the standard practice of smart contract development, events are usually emitted after state changes to reflect the final state accurately.
function _claimReward(address _beneficiary) internal { _checkpointGlobalReward(); _checkpointReward(_beneficiary); uint256 _reward = unclaimedRewardCheckpoint[_beneficiary]; if (_reward == 0) return; unclaimedRewardCheckpoint[_beneficiary] = 0; emit RewardClaimed(_beneficiary, _reward); SafeERC20.safeTransfer(REWARD_TOKEN, _beneficiary, _reward); }
In this report I gathered all low severity and informational bugs I could find in the code together with relevant code snippets, links and recommendations. The main focus of my QA analysis was the Unistaker.sol contract. As mentioned earlier I used an extensive custom-tailored checklist of common issues affecting solidity smart contracts to base my analysis. I also checked for compliance issues with the relevant EIPs the contract is implementing, which I was able to find some issues in light of this. For a high level analysis of my findings including high and medium severity issues refer to the analysis report and individual bug reports.
#0 - c4-judge
2024-03-14T14:06:33Z
MarioPoneder marked the issue as grade-b