Asymmetry contest - lukris02's results

A protocol to help diversify and decentralize liquid staking derivatives.

General Information

Platform: Code4rena

Start Date: 24/03/2023

Pot Size: $49,200 USDC

Total HM: 20

Participants: 246

Period: 6 days

Judge: Picodes

Total Solo HM: 1

Id: 226

League: ETH

Asymmetry Finance

Findings Distribution

Researcher Performance

Rank: 16/246

Findings: 5

Award: $503.71

QA:
grade-a
Gas:
grade-a

🌟 Selected for report: 1

🚀 Solo Findings: 0

Awards

58.9366 USDC - $58.94

Labels

bug
3 (High Risk)
satisfactory
duplicate-703

External Links

Lines of code

https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L73-L74 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L91 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L115 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L118

Vulnerability details

Impact

It is highly possible that one (or more) of derivatives protocols will be hacked: functions stop working as expected or the assets will be drained. Asymmetry Finance seeks to diversify liquid staking derivatives, which means new derivative contracts will be added, which will only increase this risk. In this case, all funds of all users of the protocol will be locked. Since the stake() and unstake() functions call the functions of all derivative contracts in a loop, and a revert from one derivative will block the execution of these functions.
As stated in the discord chat, derivatives can be removed by setting the weight to 0, but this will not solve the problem, since it is not checked before all calls that the weight is not equal to 0.

Proof of Concept

For example, 1 out of 3 derivative protocols loses all assets, balance of derivative protocol is 0. Here, in the unstake() function, derivatives[i].balance() returns the amount written in the _balances mapping of derivative protocol (it will be > 0), so derivatives[i].withdraw(derivativeAmount) will be called. But the attempt to withdraw funds will fail, because in fact there are not enough funds on the balance. Unstake() function will constantly revert, no one can return staked funds.
Also, if for some reason the balanceOf() functions or the functions called in .deposit() stop working as expected, then the stake() function may start to malfunction or constantly revert.

Tools Used

Manual review.

Implement a function, which really removes a derivative protocol from the mapping derivatives. For example, put the last derivative info in place of removed derivative, delete data from the last index, and decrease derivativeCount by one.
Also, this function should make an attempt to withdraw funds and, in case of success, reallocate funds to other derivatives, but in case of failure, do not revert, since it is better to lose only part of the funds, and not all the funds placed in other protocols.

#0 - c4-pre-sort

2023-04-04T17:29:12Z

0xSorryNotSorry marked the issue as duplicate of #703

#1 - c4-judge

2023-04-21T15:05:33Z

Picodes marked the issue as satisfactory

Awards

58.9366 USDC - $58.94

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-703

External Links

Lines of code

https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L71-L75 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L84-L86 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L113-L119 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L140-L143

Vulnerability details

Impact

There is no way to remove a derivative from the derivatives mapping. Although it is possible to set the weight to 0, the loops still make the number of iterations equal to derivativeCount (derivativeCount cannot be decreased). It is also not possible to replace a derivative in the derivatives mapping with another one (for example, if the weight of some derivative was set to 0 and it is no longer used). This can cause out-of-gas error in functions that iterate over all derivatives. For example, the stake() function may be permanently blocked due to the persistent revert().

Proof of Concept

For example, in stake() function, the functions derivatives[i].ethPerDerivative() (See Note below) and derivatives[i].balance() (twice) are called each time, although some derivatives may no longer be used. Link:

for (uint i = 0; i < derivativeCount; i++) underlyingValue += (derivatives[i].ethPerDerivative(derivatives[i].balance()) * derivatives[i].balance()) / 10 ** 18;

and here, weight and derivative are read from the storage each time, and only then follows the check (if weight == 0):

for (uint i = 0; i < derivativeCount; i++) { uint256 weight = weights[i]; IDerivative derivative = derivatives[i]; if (weight == 0) continue;

NOTE: derivatives[i].ethPerDerivative() in turn, can make an unlimited number of calls to external contracts. For example, in Reth.sol ethPerDerivative makes in total 10 external calls.

Tools Used

Manual review.

Implement the function removeDerivative(), which deletes a derivative protocol from the mapping derivatives. For example, put the last derivative info in place of removed derivative, delete data from the last index, and decrease derivativeCount by one. Or, implement the function adjustDerivative(), which allows changing derivative contract address.

#0 - c4-pre-sort

2023-04-04T17:32:05Z

0xSorryNotSorry marked the issue as duplicate of #703

#1 - c4-judge

2023-04-21T15:06:32Z

Picodes marked the issue as satisfactory

#2 - c4-judge

2023-04-24T19:36:09Z

Picodes changed the severity to 3 (High Risk)

Findings Information

🌟 Selected for report: lukris02

Also found by: Bauer, HollaDieWaldfee, RedTiger, T1MOH, dec3ntraliz3d, joestakey, koxuan, qpzm, rbserver, reassor

Labels

bug
3 (High Risk)
primary issue
satisfactory
selected for report
H-04

Awards

307.4323 USDC - $307.43

External Links

Lines of code

https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/SfrxEth.sol#L115-L116 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/SfrxEth.sol#L74-L75 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L73 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L92

Vulnerability details

Impact

In the ethPerDerivative(), the calculated frxAmount is multiplied by (10 ** 18) and divided by price_oracle, but it must be multiplied by price_oracle and divided by (10 ** 18).

The impact is severe as ethPerDerivative() function is used in stake(), one of two main functions a user will interact with. The value returned by ethPerDerivative() affects the calculations of mintAmount. The incorrect calculation may over or understate the amount of safEth received by the user.

ethPerDerivative() is also used in the withdraw() function when calculating minOut. So, incorrect calculation of ethPerDerivative() may increase/decrease slippage. This can cause unexpected losses or function revert. If withdraw() function reverts, the function unstake() is unavailable => assets are locked.

Proof of Concept

We need to calculate: (10 ** 18) sfrxEth = X Eth.
For example, we convertToAssets(10 ** 18) and get frxAmount = 1031226769652703996. price_oracle returns 998827832404234820. So, (10 ** 18) frxEth costs 998827832404234820 Eth. Thus, (10 ** 18) sfrxEth costs frxAmount * price_oracle / 10 ** 18 = 1031226769652703996 * 998827832404234820 / 10 ** 18 Eth (1030017999049431492 Eth).

But this function:

function ethPerDerivative(uint256 _amount) public view returns (uint256) { uint256 frxAmount = IsFrxEth(SFRX_ETH_ADDRESS).convertToAssets( 10 ** 18 ); return ((10 ** 18 * frxAmount) / IFrxEthEthPool(FRX_ETH_CRV_POOL_ADDRESS).price_oracle()); }

calculates the cost of sfrxEth as 10 ** 18 * frxAmount / price_oracle = 10 ** 18 * 1031226769652703996 / 998827832404234820 Eth (1032436958800480269 Eth). The current difference ~ 0.23% but it can be more/less.

Tools Used

Manual review.

Change these lines:

return ((10 ** 18 * frxAmount) / IFrxEthEthPool(FRX_ETH_CRV_POOL_ADDRESS).price_oracle());

to:

return (frxAmount * IFrxEthEthPool(FRX_ETH_CRV_POOL_ADDRESS).price_oracle() / 10 ** 18);

#0 - c4-pre-sort

2023-04-04T16:48:10Z

0xSorryNotSorry marked the issue as duplicate of #698

#1 - c4-judge

2023-04-21T15:34:33Z

Picodes marked the issue as not a duplicate

#2 - c4-judge

2023-04-21T15:34:38Z

Picodes marked the issue as primary issue

#3 - c4-judge

2023-04-21T15:34:43Z

Picodes marked the issue as satisfactory

#4 - c4-judge

2023-04-21T15:34:47Z

Picodes marked the issue as selected for report

Awards

4.5426 USDC - $4.54

Labels

bug
3 (High Risk)
satisfactory
duplicate-588

External Links

Lines of code

https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/WstEth.sol#L86-L88 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L73 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L92

Vulnerability details

Impact

function ethPerDerivative() should calculate the price of derivative "in terms of ETH" but actually the function returns the price of WstEth in terms of stEth.

The impact is severe as ethPerDerivative() function is used in stake(), one of two main functions a user will interact with. The value returned by ethPerDerivative() affects the calculations of mintAmount. The incorrect calculation may over or understate the amount of safEth received by the user.

Although the ratio of stEth to eth will be 1:1 after Shanghai, this is not the case at the moment.

Proof of Concept

The function IWStETH(WST_ETH).getStETHByWstETH() only converts WstEth to stEth. Thus, ethPerDerivative() returns the value of WstEth in terms of stEth, not eth:

function ethPerDerivative(uint256 _amount) public view returns (uint256) { return IWStETH(WST_ETH).getStETHByWstETH(10 ** 18); }

Tools Used

Manual review.

In ethPerDerivative(), add a stEth to eth conversion using the curve pool.

#0 - c4-pre-sort

2023-04-04T17:21:27Z

0xSorryNotSorry marked the issue as duplicate of #588

#1 - c4-judge

2023-04-21T17:09:14Z

Picodes marked the issue as satisfactory

#2 - c4-judge

2023-04-22T09:03:59Z

Picodes marked the issue as partial-50

#3 - c4-judge

2023-04-24T20:45:30Z

Picodes marked the issue as satisfactory

QA Report for Asymmetry contest

Overview

During the audit, 12 low and 12 non-critical issues were found.

TitleRisk RatingInstance Count
L-1Use the two-step-transfer of ownershipLow4
L-2Critical changes should use two-step procedureLow6
L-3Owner can renounce ownershipLow4
L-4Add a timelock to critical functionsLow6
L-5Misleading comment about ownerLow3
L-6Add return parameter in stake()Low1
L-7Check mintAmountLow1
L-8Check user token balanceLow1
L-9Add more information in the eventsLow5
L-10Set minMinAmountLow1
L-11Make separate eventsLow2
L-12Unused variable in the ethPerDerivative() is misleadingLow2
NC-1Missing leading underscoresNon-Critical4
NC-2Order of FunctionsNon-Critical11
NC-3Typo in event nameNon-Critical1
NC-4Constants may be usedNon-Critical17
NC-5Make event names more consistentNon-Critical2
NC-6Emit events in initialize() functionsNon-Critical4
NC-7Put deposit() before withdraw()Non-Critical3
NC-8Concatenate multiple lines of codeNon-Critical11
NC-9Incorrect commentNon-Critical1
NC-10Make commentss more consistentNon-Critical2
NC-11Natspec is incompleteNon-Critical6
NC-12Inconsistency when using uint and uint256Non-Critical2

Low Risk Findings(12)

L-1. Use the two-step-transfer of ownership

Description

If the owner accidentally transfers ownership to an incorrect address, protected functions may become permanently inaccessible.

Instances
Recommendation

Consider using a two-step-transfer of ownership: the current owner would nominate a new owner, and to become the new owner, the nominated account would have to approve the change, so that the address is proven to be valid.

L-2. Critical changes should use two-step procedure

Description

Lack of two-step procedure for critical operations leaves them error-prone.

Instances
Recommendation

Consider adding two step procedure on the critical functions.

L-3. Owner can renounce ownership

Description

Openzeppelin's OwnableUpgradeable.sol implements renounceOwnership() function which leaves the contract without an owner and removes any functionality that is only available to them.

Instances
Recommendation

Consider reimplementing the function to disable it.

L-4. Add a timelock to critical functions

Description

Giving users time to react and adjust to critical changes in protocol provides more guarantees and increases the transparency of the protocol.

Instances
Recommendation

Consider adding a timelock.

L-5. Misleading comment about owner

Description

Comment for the initialize() function says that parameter owner is a "owner of the contract which handles stake/unstake", although in fact the SafEth contract itself must be specified as the owner.

Instances
Recommendation

Change the comments to "address of SafEth contract"

L-6. Add return parameter in stake()

Description

Since msg.value is not the same as the received number of tokens, for more convenience and clarity, it is better to add the return parameter in the stake() function.

Instances
Recommendation

Add at the end of the function: return mintAmount;

L-7. Check mintAmount

Description

To prevent cases when the user pays eth but gets nothing, it should be checked that they receive at least 1 SafEth. Such cases are possible if the protocol lowers the minimum amount of Ether for staking in the future.

Instances
Recommendation

require(mintAmount > 0, "no tokens");. Or even better, add a parameter so that users can specify the minimum number of tokens they want to receive.

L-8. Check user token balance

Description

At the beginning of the unstake() function, it is necessary to check that the user has enough tokens on the balance.

Instances
Recommendation

require(balanceOf(msg.sender) >= _safEthAmount, "not enough tokens");

L-9. Add more information in the events

Description

Some events are missing important information.

Instances

L-10. Set minMinAmount

Description

Set the minimum possible minAmount to ensure that the owner cannot set a value that is too small, which could negatively affect the received tokens due to roundings. This will enhance the reliability and security of the protocol.

Instances
Recommendation

Add a constant minMinAmount, and in the setMinAmount(), require(_minAmount >= minMinAmount, "too small value");

L-11. Make separate events

Description

It is better to make separate events "StakingPaused" and "StakingUnpaused" ("UnstakingPaused" and "UnstakingUnpaused") because the meaning of events will be more obvious, and there will be no need to remember what 0 and 1 means.

Instances

L-12. Unused variable in the ethPerDerivative() is misleading

Description

2 out of 3 derivatives do not use the amount parameter in the ethPerDerivative function, while the third derivative does. This can be confusing because, without documentation, it is not immediately obvious that the function calculates the price in Ether for only one unit of the derivative, and not for a given amount.

Instances
Recommendation

For clarity, add comments that clearly state what the amount passed in is used for, and that the price is calculated for only one unit.

Non-Critical Risk Findings(12)

NC-1. Missing leading underscores

Description

Private functions should have a leading underscore.

Instances
Recommendation

Add leading underscores where needed.

NC-2. Order of Functions

Description

According to Style Guide, ordering helps readers identify which functions they can call and to find the constructor and fallback definitions easier.
Functions should be grouped according to their visibility and ordered:

  1. constructor
  2. receive function (if exists)
  3. fallback function (if exists)
  4. external
  5. public
  6. internal
  7. private
Instances

receive() function should be placed right after constructor:

public functions should be placed after all external:

external and public functions should be placed before private:

Recommendation

Reorder functions where possible.

NC-3. Typo in event name

Instances

NC-4. Constants may be used

Description

Constants may be used instead for repeating values.

Instances

10 ** 18:

500:

Recommendation

Define constant variables for repeated values.

NC-5. Make event names more consistent

Description

Change to “MinAmountChanged” and “MaxAmountChanged”.

Instances
Recommendation

Consider renaming events.

NC-6. Emit events in initialize() functions

Instances
Recommendation

For off-chain monitoring and transparency, consider emitting an event in initialize() function.

NC-7. Put deposit() before withdraw()

Description

It is unusual and a little confusing that the deposit() function is placed after the withdraw() function.

Instances
Recommendation

To make the code more well-structured, place the deposit() function before withdraw().

NC-8. Concatenate multiple lines of code

Description

This complicates and lengthens the code when several small pieces of code are placed on several lines.

Instances
Recommendation

If you combine the specified lines with the previous ones, then they will not become too long.
For example, it looks better:

(bool sent, ) = address(msg.sender).call{value: address(this).balance}("");

than

(bool sent, ) = address(msg.sender).call{value: address(this).balance}( "" );

NC-9. Incorrect comment

Description

The comment for the adjustWeight() function says that it "Adds new derivative to the index fund", which is not true.

Instances
@notice - Adds new derivative to the index fund @dev - Weights are only in regards to each other, total weight changes with this function @dev - If you want exact weights either do the math off chain or reset all existing derivates to the weights you want @dev - Weights are approximate as it will slowly change as people stake @param _derivativeIndex - index of the derivative you want to update the weight @param _weight - new weight for this derivative. */ function adjustWeight(
Recommendation

Change to @notice - Sets new weight for a certain derivative index

NC-10. Make commentss more consistent

Description

To make comments for all functions with the onlyOwner modifier more consistent, change the two comments.

Instances

NC-11. NatSpec is incomplete

Description

NatSpec does not have parameter descriptions for some functions.

Instances

NC-12. Inconsistency when using uint and uint256

Description

Some variables is declared as uint and some as uint256.

Instances

There are 63 instances using uint256 and 16 instances using uint.

Recommendation

Stick to one style.

#0 - c4-sponsor

2023-04-10T16:06:32Z

toshiSat marked the issue as sponsor confirmed

#1 - c4-judge

2023-04-24T17:29:04Z

Picodes marked the issue as grade-a

Gas Optimizations Report for Asymmetry contest

Overview

During the audit, 12 gas issues were found.

TitleInstance CountSaved
G-1Cache state variables instead of reading them from storage multiple times161600
G-2Use a function parameter instead of a storage variable4400
G-3Use local variable cache instead of accessing mapping or array multiple times7700
G-4Break the if-statement Require1400
G-5Unnecessary loop in adjustWeight()1400
G-6Unnecessary loop in addDerivative()1400
G-7Use unchecked blocks for subtractions where underflow is impossible135
G-8Implement a function for removing a derivative7
G-9Do not call balance() functions twice2
G-10Some expressions can be transformed2
G-11Do not multiply and divide by the same number1
G-12Use uint256(1) and uint256(2) for pausing2

Gas Optimizations Findings(12)

G-1. Cache state variables instead of reading them from storage multiple times

Description

Memory read is much cheaper than storage read.

Instances
Saved

This saves ~100.
So, ~100*16 = 1600

G-2. Use a function parameter instead of a storage variable

Description

Memory read is much cheaper than storage read.

Instances
Saved

This saves ~100.
So, ~100*4 = 400

G-3. Use local variable cache instead of accessing mapping or array multiple times

Description

It saves gas due to not having to perform the same key’s keccak256 hash and/or offset recalculation.

Instances
Saved

This saves ~100.
So, ~100*7 = 700

G-4. Break the if-statement

Description

Break this condition into 2 parts. if (ethAmountToRebalance == 0) break; to do not iterate the loop for no reason.

Instances
Recommendation

Change to:

if (ethAmountToRebalance == 0) break; for (uint i = 0; i < derivativeCount; i++) { if (weights[i] == 0) continue; ...
Saved

This saves ~400 (if we take derivativeCount = 3).

G-5. Unnecessary loop in adjustWeight()

Description

There is no need for the loop.

Instances
Recommendation

Instead of loop, consider just calculating the difference between previous and new weight. Change to:

if (_weight > oldWeight) totalWeight += _weight; else totalWeight -= _weight;
Saved

This saves ~400 (if we take derivativeCount = 3).

G-6. Unnecessary loop in addDerivative()

Description

There is no need for the loop.

Instances
Recommendation

Instead of loop, consider just calculating adding new weight to the totalWeight. Change to:

totalWeight+=_weight;
Saved

This saves ~400 (if we take derivativeCount = 3).

G-7. Use unchecked blocks for subtractions where underflow is impossible

Description

In Solidity 0.8+, there’s a default overflow and underflow check on unsigned integers. When an overflow or underflow isn’t possible (after require or if-statement), some gas can be saved by using unchecked blocks.

Instances
Saved

This saves ~35.

G-8. Implement a function for removing a derivative

Description

Consider implementing a function for removing a derivative from the derivatives mapping. Some functions will continue to call derivative contracts which are not longer used (when set weight = 0, and all assets are withdrawn). The reason is that many functions loop through the derivativeCount iterations, i.e. all derivatives.

Instances

G-9. Do not call balance() functions twice

Description

The value returned by derivatives[i].balance() can be cached.

Instances

G-10. Some expressions can be transformed

Instances
Recommendation

Can be changed to:

uint256 minOut = ((ethPerDerivative(_amount) * _amount * (10 ** 18 - maxSlippage)) / 10 ** 36;

and to:

uint256 minOut = (((rethPerEth * msg.value) * (10 ** 18 - maxSlippage)) / 10 ** 36);

G-11. Do not multiply and divide by the same number

Instances
Recommendation

Change to else return poolPrice(); //(poolPrice() * 10 ** 18) / (10 ** 18)

G-12. Use uint256(1) and uint256(2) for pausing

Description

Booleans are more expensive than uint256 or any type that takes up a full word because each write operation emits an extra SLOAD to first read the slot's contents, replace the bits taken up by the boolean, and then write back. This is the compiler's defense against contract upgrades and pointer aliasing, and it cannot be disabled.

Instances
Recommendation

Use uint256(1) and uint256(2) for true/false to avoid a Gwarmaccess (100 gas), and to avoid Gsset (20000 gas) when changing from ‘false’ to ‘true’, after having been ‘true’ in the past.

#0 - c4-sponsor

2023-04-07T21:51:00Z

toshiSat marked the issue as sponsor confirmed

#1 - c4-judge

2023-04-23T14:56:27Z

Picodes marked the issue as grade-a

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