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: 4/36
Findings: 6
Award: $5,894.80
🌟 Selected for report: 4
🚀 Solo Findings: 0
gpersoon
The function recoverTokens() gives all excess tokens to the "streamCreator", however it doesn't take in account the fees for flashloans: "depositTokenFlashloanFeeAmount"
If you call claimFees() after you have called recoverTokens() then it will revert because the tokens for depositTokenFlashloanFeeAmount are no longer present. This means you also cannot claim the remaining rewardTokenFeeAmount tokens
Note: normally you would call claimFees() first and later on call recoverTokens(). However the flashloan functionally can keep accumulating depositTokenFlashloanFeeAmount and rewardTokenFeeAmount, as long as not everyone has called claimDepositTokens(). If you want to claim these via a second claimFees(), this will not work due to the difference between the remaining tokens and the administration of these tokens.
function flashloan(address token, address to, uint112 amount, bytes memory data) public lock { .... if (token == depositToken) { depositTokenFlashloanFeeAmount += feeAmt; require(preDepositTokenBalance + feeAmt <= postDepositTokenBalance, "f1"); ...
function recoverTokens(address token, address recipient) public lock { require(msg.sender == streamCreator, "!creator"); if (token == depositToken) { ... uint256 excess = ERC20(token).balanceOf(address(this)) - (depositTokenAmount - redeemedDepositTokens); // can claim too many tokens, also the depositTokenFlashloanFeeAmount tokens ERC20(token).safeTransfer(recipient, excess); ... }
function claimFees(address destination) public lock externallyGoverned { ... fees = depositTokenFlashloanFeeAmount; if (fees > 0) { .. ERC20(depositToken).safeTransfer(destination, fees); // will revert if there are not enough tokens left in the contract
Account for depositTokenFlashloanFeeAmount in the function recoverTokens()
#0 - 0xean
2022-01-14T20:52:58Z
dupe of #241
gpersoon
The contract Stream uses 2 tokens: depositToken and rewardToken, which are specified in the constructor. Suppose (accidentally) the same addresses are supplied, such that depositToken == rewardToken. Most of the code will still work, however the function recoverTokens() will give problems.
It sends excess tokens to the streamCreator, but when depositToken == rewardToken then: If you retrieve the excess "depositToken"s, you will get the tokens reserved as rewardToken If you retrieve the excess "rewardToken"s, you will get the tokens reserved as depositToken
Depending on the endDepositLock and endRewardLock time you will enter one or two of the if statements.
Ergo it is safer to make sure depositToken!= rewardToken as it doesn't seem logical to use the same token for depositToken and rewardToken.
contract Stream is LockeERC20, ExternallyGoverned { ... constructor(.... address _rewardToken, address _depositToken...) { ... // set tokens depositToken = _depositToken; rewardToken = _rewardToken; }
function recoverTokens(address token, address recipient) public lock { require(msg.sender == streamCreator, "!creator"); if (token == depositToken) { require(block.timestamp > endDepositLock, "time"); uint256 excess = ERC20(token).balanceOf(address(this)) - (depositTokenAmount - redeemedDepositTokens); // will get too many tokens if depositToken==rewardToken ERC20(token).safeTransfer(recipient, excess); .. ... } if (token == rewardToken) { require(block.timestamp > endRewardLock, "time"); uint256 excess = ERC20(token).balanceOf(address(this)) - (rewardTokenAmount + rewardTokenFeeAmount); // will get too many tokens if depositToken==rewardToken ERC20(token).safeTransfer(recipient, excess); .... }
Check if there is a usecase for haveing depositToken and rewardToken as the same token. If so, adapt recoverTokens. Otherwise add something like to following to the constructor: require(depositToken != rewardToken,"The same");
#0 - 0xean
2022-01-14T21:05:22Z
dupe of #215
3626.1684 USDC - $3,626.17
gpersoon
Suppose someone stakes some tokens and then withdraws all of his tokens (he can still withdraw). This will result in ts.tokens being 0.
Now after some time he stakes some tokens again. At the second stake updateStream() is called and the following if condition is false because ts.tokens==0
if (acctTimeDelta > 0 && ts.tokens > 0) {
Thus ts.lastUpdate is not updated and stays at the value from the first withdraw. Now he does a second withdraw. updateStream() is called an calculates the updated value of ts.tokens. However it uses ts.lastUpdate, which is the time from the first withdraw and not from the second stake. So the value of ts.token is calculated incorrectly. Thus more tokens can be withdrawn than you are supposed to be able to withdraw.
function stake(uint112 amount) public lock updateStream(msg.sender) { ... uint112 trueDepositAmt = uint112(newBal - prevBal); ... ts.tokens += trueDepositAmt;
function withdraw(uint112 amount) public lock updateStream(msg.sender) { ... ts.tokens -= amount;
function updateStreamInternal(address who) internal { ... uint32 acctTimeDelta = uint32(block.timestamp) - ts.lastUpdate; if (acctTimeDelta > 0 && ts.tokens > 0) { // some time has passed since this user last interacted // update ts not yet streamed ts.tokens -= uint112(acctTimeDelta * ts.tokens / (endStream - ts.lastUpdate)); ts.lastUpdate = uint32(block.timestamp); }
Change the code in updateStream() to:
if (acctTimeDelta > 0 ) { // some time has passed since this user last interacted // update ts not yet streamed if (ts.tokens > 0) ts.tokens -= uint112(acctTimeDelta * ts.tokens / (endStream - ts.lastUpdate)); ts.lastUpdate = uint32(block.timestamp); // always update ts.lastUpdate (if time has elapsed) }
Note: the next if statement with unstreamed and lastUpdate can be changed in a similar way to save some gas
#0 - brockelmore
2021-12-06T16:35:29Z
Nice catch :)
🌟 Selected for report: harleythedog
Also found by: WatchPug, csanuragjain, gpersoon, hubble
gpersoon
The function stake() increases unstreamed, however the function withdraw(), that does the inverse of stake() doesn't decrease unstreamed. The function withdraw() does update all the other relevant variables so this seems to be an omission.
Thus the value of unstreamed will not be accurate after a withdraw(). It doesn't seem to have negative consequences for the protocol, but the user interface & management interfaces will show misleading values.
function stake(uint112 amount) public lock updateStream(msg.sender) { ... unstreamed += trueDepositAmt; function withdraw(uint112 amount) public lock updateStream(msg.sender) { // unstreamed not updated
Update unstreamed in withdraw()
#0 - 0xean
2022-01-16T00:27:58Z
dupe of #118
🌟 Selected for report: gpersoon
gpersoon
On several locations in the code a multiplication by 106 and then a division by 106 is used to prevent rounding errors, see examples below.
However in a similar situation in updateStreamInternal(), when calculating ts.tokens, this construction is not used. This might lead to some (probably minor) rounding errors.
function updateStreamInternal(address who) internal { ... ts.tokens -= uint112(acctTimeDelta * ts.tokens / (endStream - ts.lastUpdate)); ... uint256 globalStreamingSpeedPerSecond = (uint256(unstreamed) * 10**6)/ (endStream - lastUpdate); unstreamed -= uint112((uint256(tdelta) * globalStreamingSpeedPerSecond) / 10**6);
function dilutedBalance(uint112 amount) internal view returns (uint256) { ... uint32 timeRemaining = endStream - uint32(block.timestamp); return ((uint256(streamDuration) * amount * 10**6) / timeRemaining) / 10**6;
Change the code to
ts.tokens -= uint112(acctTimeDelta * ts.tokens * 10**6 / (endStream - ts.lastUpdate) / 10**6 );
🌟 Selected for report: gpersoon
Also found by: GiveMeTestEther, Meta0xNull, bitbopper, hack3r-0m, pauliax, pedroais, wuwe1
gpersoon
The function updateStreamInternal(address who) has a parameter "who", which isn't used. However "TokenStream storage ts = tokensNotYetStreamed[msg.sender];" uses msg.sender, where probably "who" should be used.
Luckily in the current code this doesn't pose any problems however it is misleading and future code updates could introduce a high risk issue due to this. Because "who" isn't used it is safer and cheaper to remove it. ==> thus I have classified this as a gas optimization
function updateStreamInternal(address who) internal { require(block.timestamp < endStream , "!stream"); TokenStream storage ts = tokensNotYetStreamed[msg.sender];
modifier updateStream(address who) { // save bytecode space by making it a jump instead of inlining at cost of gas updateStreamInternal(who); _; }
Remove "who" from updateStreamInternal(), as well as the modifier updateStream() and the functions that use the modier updateStream
#0 - 0xean
2022-01-16T13:30:29Z
Going to move this to low-risk as the incorrect parameter can lead to confusion and function being incorrect to spec
1 — Low: Low: Assets are not at risk. State handling, function incorrect as to spec, issues with comments.
🌟 Selected for report: gpersoon
gpersoon
The function fundStream() stores a calculated value in amount. It saves some gas to use the already present variable "amt"
function fundStream(uint112 amount) public lock { ... uint112 amt; ... amount = uint112(newBal - prevBal); // cheaper to store in amt
Use amt instead of amount and adapt the rest of the code accordingly.