Platform: Code4rena
Start Date: 12/08/2021
Pot Size: $30,000 USDC
Total HM: 9
Participants: 8
Period: 3 days
Judge: ghoulsol
Total Solo HM: 7
Id: 25
League: ETH
Rank: 1/8
Findings: 7
Award: $14,292.10
🌟 Selected for report: 9
🚀 Solo Findings: 4
🌟 Selected for report: cmichel
cmichel
The CompositeMultiOracle.peek/get
functions seem to return wrong prices.
It's unclear what decimals source.decimals
refers to in this case. Does it refer to source.source
token decimals?
It chains the price arguments through _peek
function calls and a single price is computed as:
(priceOut, updateTimeOut) = IOracle(source.source).peek(base, quote, 10 ** source.decimals); // Get price for one unit // @audit shouldn't this divide by 10 ** IOracle(source.source).decimals() instead? priceOut = priceIn * priceOut / (10 ** source.decimals);
Assume all oracles use 18 decimals (oracle.decimals()
returns 18) and source.decimals
refers to the token decimals of source.source
.
Then going from USDC -> DAI -> USDT
(path = [DAI]
) starts with a price of 1e18
in peek
:
_peek(USDC, DAI, 1e18)
: Gets the price of 1e6 USDC
(as USDC has 6 decimals) in DAI with 18 decimals precision (because all oracle precision is set to 18): priceOut = priceIn * 1e18 / 1e6 = 1e18 * 1e18 / 1e6 = 1e30
_peek(DAI, USDT, 1e30)
: Gets the price of 1e18 DAI
(DAI has 18 decimals) with 18 decimals precision: priceOut = priceIn * 1e18 / 1e18 = priceIn = 1e30
It then uses 1e30
as the price to go from USDC
to USDT
: value = price * amount / 1e18 = 1e30 * (1.0 USDC) / 1e18 = 1e30 * 1e6 / 1e18 = 1e18 = 1e12 * 1e6 = 1_000_000_000_000.0 USDT
. Inflating the actual USDT
amount.
The issue is that peek
assumes that the final price is in 18 decimals in the value = price * amount / 1e18
division by 1e18
.
But _peek
(and _get
) don't enforce this.
_peek
should scale the prices to 1e18
by doing:
(priceOut, updateTimeOut) = IOracle(source.source).get(base, quote, 10 ** source.decimals); // priceOut will have same decimals as priceIn if we divide by oracle decimals priceOut = priceIn * priceOut / (10 ** IOracle(source.source).decimals());
It does not need to divide by the source.source
token precision (source.decimals
), but by the oracle precision (IOracle(source.source).decimals()
).
#0 - alcueca
2021-08-14T04:56:10Z
It's confusing to deal with all these decimals, I should at least comment the code better, and try to make it easier to understand.
It's unclear what decimals source.decimals refers to in this case. Does it refer to source.source token decimals?
CompositeMultiOracle takes IOracle contracts as sources, so source.decimals
refers to the token decimals of the oracle, not of the data source one level below.
It does not need to divide by the source.source token precision (source.decimals), but by the oracle precision (IOracle(source.source).decimals()).
The source.source token precision would be IChainlinkAggregatorV3(source.source()).decimals()
, the source oracle precision is source.decimals()
. CompositeMultiOracle cannot make an assumption on any fields present on source.source
, and must work only with the underlying source
IOracles.
I'm still not disputing this finding. I need to dig further to make sure the decimals are right when different IOracle sources have different decimals, and I've hardcoded a few 1e18
in there. Those are code smells.
#1 - alcueca
2021-08-18T06:17:44Z
Sent me into a wild goose chase to support IOracle of multiple decimals as sources to CompositeMultiOracle, only to realize that we create all IOracles and we always create them with 18 decimals, converting from the underlying data source if needed.
Ended up making CompositeMultiOracle require that underlying oracles have 18 decimals. Done.
#2 - alcueca
2021-09-17T05:06:53Z
Further refactored all oracles so that decimals are handled properly, and work on taking an amount of base
as input, and returning an amount of quote
as output. Our oracles don't have decimals themselves anymore as a state variable, since the return values are in the decimals of quote
. This means that CompositeMultiOracle is agnostic with regards to decimals, and doesn't even need to know about them.
🌟 Selected for report: cmichel
3907.8568 USDC - $3,907.86
cmichel
The ERC20Rewards._updateRewardsPerToken
function exits without updating rewardsPerToken_.lastUpdated
if totalSupply
is zero, i.e., if there are no tokens initially.
This leads to an error if there is an active rewards period but not tokens have been minted yet.
Example: rewardsPeriod.start: 1 month ago
, rewardsPeriod.end: in 1 month
, totalSupply == 0
.
The first mint leads to the user (mintee) receiving all rewards for the past period (50% of the total rewards in this case).
_mint
is called, calls _updateRewardsPerToken
which short-circuits. rewardsPerToken.lastUpdated
is still set to rewardsPeriod.start
from the constructor. Then _updateUserRewards
is called and does not currently yield any rewards. (because both balance and the index diff are zero). User is now minted the tokens, totalSupply
increases and user balance is set.claim
: _updateRewardsPerToken
is called and timeSinceLastUpdated = end - rewardsPerToken_.lastUpdated = block.timestamp - rewardsPeriod.start = 1 month
. Contract "issues" rewards for the past month. The first mintee receives all of it.The first mintee receives all pending rewards when they should not receive any past rewards.
This can easily happen if the token is new, the reward period has already been initialized and is running, but the protocol has not officially launched yet.
Note that setRewards
also allows setting a date in the past which would also be fatal in this case.
The rewardsPerToken_.lastUpdated
field must always be updated in _updateRewardsPerToken
to the current time (or end
) even if _totalSupply == 0
. Don't return early.
#0 - alcueca
2021-08-14T05:15:11Z
You are right, that's a great finding. For the record, I think that this is what this line in Unipool.sol does:
function rewardPerToken() public view returns (uint256) { if (totalSupply() == 0) { return rewardPerTokenStored; }
I'll apply the mitigation step suggested, with a conditional to not do the rewardsPerToken_.accumulated
math that would revert.
Now I know the feeling of the devs that fork a known project and leave a pesky conditional out, thanks again :D
#1 - alcueca
2021-08-16T09:39:15Z
🌟 Selected for report: cmichel
3907.8568 USDC - $3,907.86
cmichel
The setRewards
function allows setting a different token.
Holders of a previous reward period cannot all be paid out and will receive their old reward amount in the new token.
This leads to issues when the new token is more (less) valuable, or uses different decimals.
Example: Assume the first reward period paid out in DAI
which has 18 decimals. Someone would have received 1.0 DAI = 1e18 DAI
if they called claim
now. Instead, they wait until the new period starts with USDC
(using only 6 decimals) and can claim
their 1e18
reward amount in USDC which would equal 1e12 USDC
, one trillion USD.
Changing the reward token only works if old and new tokens use the same decimals and have the exact same value. Otherwise, users that claim too late/early will lose out.
Disallow changing the reward token, or clear user's pending rewards of the old token. The second approach requires more code changes and keeping track of what token a user last claimed.
#0 - alcueca
2021-08-14T05:02:27Z
Maybe I should have used stronger language:
// If changed in a new rewards program, any unclaimed rewards from the last one will be served in the new token
The issue is known, but you are right in pointing it out. There are few situations in which changing the rewards token would make sense (such as replacing a faulty rewards token by a fixed one). I think it would be best to just disallow changing the token.
#1 - alcueca
2021-08-16T09:31:59Z
316.5364 USDC - $316.54
cmichel
The claim
function performs an ERC20 transfer rewardsToken.transfer(to, claiming);
but does not check the return value, nor does it work with all legacy tokens.
Some tokens (like USDT) don't correctly implement the EIP20 standard and their transfer
/transferFrom
function return void
instead of a success boolean. Calling these functions with the correct EIP20 function signatures will always revert.
The ERC20.transfer()
and ERC20.transferFrom()
functions return a boolean value indicating success. This parameter needs to be checked for success.
Some tokens do not revert if the transfer failed but return false
instead.
Tokens that don't actually perform the transfer and return false
are still counted as a correct transfer and tokens that don't correctly implement the latest EIP20 spec, like USDT, will be unusable in the protocol as they revert the transaction because of the missing return value.
We recommend using OpenZeppelin’s SafeERC20
versions with the safeTransfer
and safeTransferFrom
functions that handle the return value check as well as non-standard-compliant tokens.
#0 - alcueca
2021-08-14T05:23:28Z
True, thanks for spotting it!
#1 - alcueca
2021-08-16T09:30:36Z
🌟 Selected for report: cmichel
1172.3571 USDC - $1,172.36
cmichel
The TimeLock.schedule
function reverts if the same targets
and data
fields are used as the txHash
will be the same.
This means one cannot schedule the same transactions multiple times.
Imagine the delay is set to 30 days, but a contractor needs to be paid every 2 weeks. One needs to wait 30 days before scheduling the second payment to them.
Also include eta
in the hash. (Compound's Timelock does it as well.) This way the same transaction data can be used by specifying a different eta
.
#0 - alcueca
2021-08-14T05:41:09Z
Funny, BoringCrypto was quite negative about including the eta in the txHash. At the time I couldn't think of a reason to repeat the same call with the same data, but you are right that sometimes it might make sense, and storing off-chain the expected eta of each timelocked transaction is something you should do anyway.
I'll confirm this issue, and will bring it for public discussion once the contest is over.
#1 - alcueca
2021-09-17T05:02:12Z
I ended up refactoring the Timelock so that the eta
is not included in the parameters, but repeated proposals are allowed.
175.8536 USDC - $175.85
cmichel
The Strategy.Invest
event is not fired.
Off-chain scripts that rely on this event won't work.
Emit this event in startPool
.
#0 - alcueca
2021-08-16T11:29:25Z
🌟 Selected for report: cmichel
cmichel
The CTokenMultiOracle._peek/_get
function does the following computation on unsigned integers which reverts when source.decimals > 18
:
price = uint(rawPrice) * 10 ** (18 - source.decimals);
Instead, perform this price = uint(rawPrice) * 10 ** 18 / 10 ** source.decimals;
.
Note that this leads to a loss of precision and the price could end up being 0
.
#0 - alcueca
2021-08-18T15:18:04Z
🌟 Selected for report: cmichel
390.7857 USDC - $390.79
cmichel
The ERC20Rewards
contract assumes that enough rewardsToken
are in the contract to pay out when claim
is called.
This value is never checked.
Claiming rewards can fail.
When setting new rewards periods, make sure that enough rewardsToken
s are in the contract to cover the entire period.
#0 - alcueca
2021-08-14T05:29:10Z
This is a known issue, which we prefer to leave as it is.
Users can check if there are rewards tokens in the contract to cover the whole period, if they wish. ERC20Rewards.sol is intended to be inherited, so it depends on the implementation of the child contract whether that user check (or the proposed mitigation) could be trusted.
In our intended use of ERC20Rewards, it is a governance action to make sure that there are funds to cover rewards at all times, which is easy to do since they are evenly distributed over time.
🌟 Selected for report: cmichel
84.2697 USDC - $84.27
cmichel
TimeLock.setDelay
reads storage variable for event which produces an SLOAD
. It should use emit DelaySet(_delay)
instead of emit DelaySet(delay)
#0 - alcueca
2021-08-16T09:58:05Z
37.9213 USDC - $37.92
cmichel
The return value is never used and should be removed to save some gas.
#0 - alcueca
2021-08-16T09:31:11Z