Platform: Code4rena
Start Date: 30/11/2021
Pot Size: $100,000 USDC
Total HM: 15
Participants: 36
Period: 7 days
Judge: 0xean
Total Solo HM: 4
Id: 62
League: ETH
Rank: 12/36
Findings: 4
Award: $2,728.11
🌟 Selected for report: 5
🚀 Solo Findings: 0
611.7761 USDC - $611.78
cmichel
The Streaming
contract allows the deposit
and reward
tokens to be the same token.
I believe this is intended, think Sushi reward on Sushi as is the case with
xSushi
.
The reward and deposit balances are also correctly tracked independently in depositTokenAmount
and rewardTokenAmount
.
However, when recovering tokens this leads to issues as the token is recovered twice, once for deposits and another time for rewards:
function recoverTokens(address token, address recipient) public lock { // NOTE: it is the stream creators responsibility to save // tokens on behalf of their users. require(msg.sender == streamCreator, "!creator"); if (token == depositToken) { require(block.timestamp > endDepositLock, "time"); // get the balance of this contract // check what isnt claimable by either party // @audit-info depositTokenAmount updated on stake/withdraw/exit, redeemedDepositTokens increased on claimDepositTokens uint256 excess = ERC20(token).balanceOf(address(this)) - (depositTokenAmount - redeemedDepositTokens); // allow saving of the token ERC20(token).safeTransfer(recipient, excess); emit RecoveredTokens(token, recipient, excess); return; } if (token == rewardToken) { require(block.timestamp > endRewardLock, "time"); // check current balance vs internal balance // // NOTE: if a token rebases, i.e. changes balance out from under us, // most of this contract breaks and rugs depositors. this isn't exclusive // to this function but this function would in theory allow someone to rug // and recover the excess (if it is worth anything) // check what isnt claimable by depositors and governance // @audit-info rewardTokenAmount increased on fundStream uint256 excess = ERC20(token).balanceOf(address(this)) - (rewardTokenAmount + rewardTokenFeeAmount); ERC20(token).safeTransfer(recipient, excess); emit RecoveredTokens(token, recipient, excess); return; } // ...
Given recoverTokens == depositToken
, Stream
creator calls recoverTokens(token = depositToken, creator)
.
token
balance is the sum of deposited tokens (minus reclaimed) plus the reward token amount. ERC20(token).balanceOf(address(this)) >= (depositTokenAmount - redeemedDepositTokens) + (rewardTokenAmount + rewardTokenFeeAmount)
if (token == depositToken)
executes, the excess
from the deposit amount will be the reward amount (excess >= rewardTokenAmount + rewardTokenFeeAmount
). This will be transferred.if (token == rewardToken)
executes, the new token balance is just the deposit token amount now (because the reward token amount has been transferred out in the step before). Therefore, ERC20(token).balanceOf(address(this)) >= depositTokenAmount - redeemedDepositTokens
. If this is non-negative, the transaction does not revert and the creator makes a profit.Example:
depositTokenAmount - redeemedDepositTokens = 1000
rewardTokenAmount
(plus rewardTokenFeeAmount
fees): rewardTokenAmount + rewardTokenFeeAmount = 500
Creator receives 1500 - 1000 = 500
excess deposit and 1000 - 500 = 500
excess reward.
When using the same deposit and reward token, the stream creator can steal tokens from the users who will be unable to withdraw their profit or claim their rewards.
One needs to be careful with using .balanceOf
in this special case as it includes both deposit and reward balances.
Add a special case for recoverTokens
when token == depositToken == rewardToken
and then the excess should be ERC20(token).balanceOf(address(this)) - (depositTokenAmount - redeemedDepositTokens) - (rewardTokenAmount + rewardTokenFeeAmount);
#0 - brockelmore
2021-12-08T22:23:39Z
duplicates: #223, #208, #184, #172, #105
🌟 Selected for report: cmichel
Also found by: GeekyLumberjack, hyh, kenzo, pedroais
1057.3907 USDC - $1,057.39
cmichel
The Streaming
contract allows recovering the reward token by calling recoverTokens(rewardToken, recipient)
.
However, the excess amount is computed incorrectly as ERC20(token).balanceOf(address(this)) - (rewardTokenAmount + rewardTokenFeeAmount)
:
function recoverTokens(address token, address recipient) public lock { if (token == rewardToken) { require(block.timestamp > endRewardLock, "time"); // check what isnt claimable by depositors and governance // @audit-issue rewardTokenAmount increased on fundStream, but never decreased! this excess underflows uint256 excess = ERC20(token).balanceOf(address(this)) - (rewardTokenAmount + rewardTokenFeeAmount); ERC20(token).safeTransfer(recipient, excess); emit RecoveredTokens(token, recipient, excess); return; } // ...
Note that rewardTokenAmount
only ever increases (when calling fundStream
) but it never decreases when claiming the rewards through claimReward
.
However, claimReward
transfers out the reward token.
Therefore, the rewardTokenAmount
never tracks the contract's reward balance and the excess cannot be computed that way.
Assume no reward fees for simplicity and only a single user staking.
1000
reward tokens through fundStream(1000)
. Then rewardTokenAmount = 1000
block.timestamp > endRewardLock
1000
reward tokens by calling claimReward()
. The reward contract balance is now 0
but rewardTokenAmount = 1000
excess = balance - rewardTokenAmount = 0
Reward token recovery does not work.
The claimed rewards need to be tracked as well, just like the claimed deposits are tracked.
I think you can even decrease rewardTokenAmount
in claimReward
because at this point rewardTokenAmount
is not used to update the cumulativeRewardPerToken
anymore.
#0 - brockelmore
2021-12-08T22:40:22Z
duplicates: #212, #165, #87
🌟 Selected for report: cmichel
805.8152 USDC - $805.82
cmichel
The recoverTokens
function's comment states that the excess deposit tokens are balance - depositTokenAmount
:
* 1. if its deposit token: * - DepositLock is fully done * - There are excess deposit tokens (balance - depositTokenAmount)
But it is balance - (depositTokenAmount - redeemedDepositTokens)
where (depositTokenAmount - redeemedDepositTokens)
is the outstanding redeemable amount.
The code is correct.
Fix the comment.
cmichel
The Stream.dilutedBalance
scales by 10**6
first and then divides by it again. I don't think this scaling leads to an improvement in precision:
return ((uint256(streamDuration) * amount * 10**6) / timeRemaining) / 10**6; // can just do this here to save gas return (uint256(streamDuration) * amount) / timeRemaining;
#0 - 0xean
2022-01-17T13:11:20Z
dupe of #188
🌟 Selected for report: cmichel
cmichel
The unstreamed
storage variable is redundant and the system can already track everything without this storage variable.
Note that this variable is never read and only written to.
(The system essentially tracks deposit_balance * deposit_duration
for each user and pays out the fair share compared to the total cumulative balance times duration once at the end.)
Removing this variable saves a lot of gas as every updateStreamInternal
call has to do fewer computations.
🌟 Selected for report: cmichel
cmichel
The Stream.constructor
performs a check on a storage variable instead of the parameter: require(feePercent < 10000, "fee")
. This will do an unnecessary sload
operation. Consider doing the check on _feePercent
instead.