Yield micro contest #1 - cmichel's results

Fixed-rate borrowing and lending on Ethereum

General Information

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

Yield

Findings Distribution

Researcher Performance

Rank: 1/8

Findings: 7

Award: $14,292.10

🌟 Selected for report: 9

🚀 Solo Findings: 4

Findings Information

🌟 Selected for report: cmichel

Labels

bug
3 (High Risk)
Oracles

Awards

3907.8568 USDC - $3,907.86

External Links

Handle

cmichel

Vulnerability details

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.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
3 (High Risk)
sponsor confirmed
ERC20Rewards

Awards

3907.8568 USDC - $3,907.86

External Links

Handle

cmichel

Vulnerability details

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.
  • User performs a 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.

Impact

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

Findings Information

🌟 Selected for report: cmichel

Labels

bug
3 (High Risk)
sponsor confirmed
ERC20Rewards

Awards

3907.8568 USDC - $3,907.86

External Links

Handle

cmichel

Vulnerability details

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.

Impact

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

Findings Information

🌟 Selected for report: cmichel

Also found by: JMukesh, hickuphh3

Labels

bug
2 (Med Risk)
sponsor confirmed
ERC20Rewards

Awards

316.5364 USDC - $316.54

External Links

Handle

cmichel

Vulnerability details

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.

Impact

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

Findings Information

🌟 Selected for report: cmichel

Labels

bug
2 (Med Risk)
sponsor confirmed
Timelock

Awards

1172.3571 USDC - $1,172.36

External Links

Handle

cmichel

Vulnerability details

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.

Impact

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.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter