Platform: Code4rena
Start Date: 21/12/2023
Pot Size: $90,500 USDC
Total HM: 10
Participants: 39
Period: 18 days
Judge: LSDan
Total Solo HM: 5
Id: 315
League: ETH
Rank: 20/39
Findings: 2
Award: $323.60
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: IllIllI
Also found by: 0x11singh99, 0xA5DF, 0xMilenov, 0xTheC0der, 7ashraf, Bauchibred, EV_om, Kaysoft, Sathish9098, SpicyMeatball, cheatc0d3, erebus, hash, imare, immeas, joaovwfreire, lil_eth, lsaudit, oakcobalt, para8956, peanuts, rvierdiiev, slvDev, trachev
271.1429 USDC - $271.14
ID | Title | Severity |
---|---|---|
L1 | Guard and owners can't be changed in CM | Low |
L2 | CM can replace the guard while paused | Low |
L3 | Users might burn their tokens by mistake | Low |
L4 | Users can exhaust/spam the system by starting the governorCheckProposalId | Low |
L5 | CM guard shouldn’t be allowed to call other contracts by default | Low |
L6 | There’s no check that the sum of the top up fractions isn’t less than 100 | Low |
L7 | treasury doesn't allow to withdraw tokens that were sent directly to the contract | Low |
L8 | getUserPoint can revert when index is out of bound error | Low |
L9 | ultiple tokens might share the same tokenURI() | Low |
L10 | Treasury.receive() function would revert on simple ETH transfer | Low |
L11 | getCurrentPriceLP() underestimates the price of the LP | Low |
L12 | OLAS price might change after product creation | Low |
R1 | getCurrentPriceLP() should revert in case that none of the tokens are olas | Refactor |
R2 | ComponentRegistry._getSubComponents() doesn't assert that the UnitType is component | Refactor |
R3 | ComponentRegistry doesn’t implement calculateSubComponents | Refactor |
NC1 | GnosisSafeSameAddressMultisig.create() function name doesn’t reflect what the function does | NC |
Gnosis safe guard/owners change are done by an external call made by the Safe to itself. The GuardCM prevents any calls to self. This makes sense as it prevents the CM owners from changing the guard on their own, but there's no alternative way to change the guard or owners when needed.
As mitigation - consider adding mechanism for the governance to approve self calls when needed.
While the GuardCM is paused the CM can run any function, including the call to self to replace the guard. That means the governance would lose the ability to unpause the guard later on.
The OLAS token allows anybody to burn their tokens, this function might lead to users burning their own tokens by mistake.
function burn(uint256 amount) external { _burn(msg.sender, amount); }
The governorCheckProposalId
is a proposal ID that'll be used to check if the governance is still 'alive'. If the proposal doesn't pass then
quorum
for votesgovernorCheckProposalId
The guard checks the calls only if they are to the timelock or CM addresses, calls to other addresses are allowed and aren't checked. While I can't point to a practical security risk that seems to stem from that it's best to reject calls to other addresses by default (If there's any other address that the CM needs to call then that should be added to the list of allowed addresses, but the rest should be prohibited).
When incentive fractions are changed there's a check that the fractions aren't greater than 100. But there's no check to ensure they're not less than 100. In case that their sum is less than 100 then the tokenomics contract wouldn't be using the full potential of top ups.
// Same check for top-up fractions if (_maxBondFraction + _topUpComponentFraction + _topUpAgentFraction > 100) { revert WrongAmount(_maxBondFraction + _topUpComponentFraction + _topUpAgentFraction, 100); }
The Treasury allows the owner to withdraw the tokens
} else { // Insufficient amount of LP tokens revert LowerThan(tokenAmount, reserves); }
getUserPoint
can revert when index is out of bound errorIt seems that wveOLAS.getUserPoint()
tries to wrap the call to veOLAS
and prevent out of bond index error.
However, it fails to do so. It checks that userNumPoints > 0
instead of checking that idx < userNumPoints
.
In case that idx
is greater than userNumPoints
the check might pass and the call would revert with an index out of bound error.
/// @dev Gets the checkpoint structure at number `idx` for `account`. /// @notice The out of bound condition is treated by the default code generation check. /// @param account User wallet address. /// @param idx User point number. /// @return uPoint The requested user point. function getUserPoint(address account, uint256 idx) public view returns (PointVoting memory uPoint) { // Get the number of user points uint256 userNumPoints = IVEOLAS(ve).getNumUserPoints(account); if (userNumPoints > 0) { uPoint = IVEOLAS(ve).getUserPoint(account, idx); } }
tokenURI()
The tokenURI()
is based on the unitHash
which isn't unique for the tokens, meaning the tokenURI()
isn't unique either.
This goes against the ERC721 standard which requires it to be distinct for each asset:
A distinct Uniform Resource Identifier (URI) for a given asset.
Mitigation - keep track of the hashes, and don’t allow the same hash to be created twice. Alternately, find a way to add the unitId to the tokenURI
Treasury.receive()
function would revert on simple ETH transferWhen ETH is sent to a contract without any additional gas (e.g. via Solidity's trasnfer()
or send()
) then the gas limit for the receive()
function is 2300 gas.
The Treasury.receive()
function uses more than that (it sload
s 2 storage variable, and then sstore
s 1), meaning the function would revert in those cases.
(users can still send ETH using call()
with sufficient gas, in that case it won't revert)
getCurrentPriceLP()
underestimates the price of the LPgetCurrentPriceLP()
calculates the price based only on the OLAS part of the LP token, it doesn't take into account the value of the other token used in the pool.
This results in a price estimation that's low by 50% than the actual price. If the results of this function are fed into the Depository
when creating a product then it'd result in a price that's too low and LP holders won't deposit.
GenericBondCalculator.sol#L70-L92
The LP price of a product is set during its creation and isn't changed afterwards. In reality the price of the LP can change (due to OLAS price change) which will end up in buying LP in a higher price than the actual price (loss for the protocol) or the price being too low for the LPs to sell their LP tokens.
getCurrentPriceLP()
should revert in case that none of the tokens are olasgetCurrentPriceLP()
states that there should never be a case where none of the tokens are olas.
The function checks that, but it doesn't revert if the check fails. Instead it does nothing (which will return zero as a value).
It makes more sense to revert in case of an error rather than return zero.
GenericBondCalculator.sol#L81-L83
// token0 != olas && token1 != olas, this should never happen if (token0 == olas || token1 == olas) { // If OLAS is in token0, assign its reserve to reserve1, otherwise the reserve1 is already correct
ComponentRegistry._getSubComponents()
doesn't assert that the UnitType is componentThe function ignores UnitType
and seems to assume that it'll always be a component (since components can't hold subcomponent of other types).
While this is expected to be the case, it might be worth to assert that.
/// @dev Gets subcomponents of a provided component Id. /// @notice For components this means getting the linearized map of components from the local map of subcomponents. /// @param componentId Component Id. /// @return subComponentIds Set of subcomponents. function _getSubComponents(UnitType, uint32 componentId) internal view virtual override returns (uint32[] memory subComponentIds) { subComponentIds = mapSubComponents[uint256(componentId)]; }
calculateSubComponents
calculateSubComponents
is part of the IRegistry interface but isn’t implemented in ComponentRegistry (there's an internal function with a similar name, but not a public/external)
GnosisSafeSameAddressMultisig.create()
function name doesn’t reflect what the function doesThe function doesn’t seem to create any new instance, but just to verify an existing instance.
#0 - c4-pre-sort
2024-01-10T14:42:44Z
alex-ppg marked the issue as sufficient quality report
#1 - alex-ppg
2024-01-10T14:47:34Z
L2 dupe of #440 L6 dupe of #278 L12 dupe of #334 R1 dupe of #118
#2 - c4-judge
2024-01-20T13:07:03Z
dmvt marked the issue as grade-b
#3 - c4-judge
2024-01-20T18:37:01Z
dmvt marked the issue as grade-a
🌟 Selected for report: 0xAnah
Also found by: 0x11singh99, 0xA5DF, IllIllI, JCK, Raihan, Sathish9098, alphacipher, c3phas, hunter_w3b, naman1778
52.4645 USDC - $52.46
ID | Title | Gas saved |
---|---|---|
G1 | A more efficient way to assert that this is a delegate call | 2100 |
G2 | Token1 isn’t used in some case | 2400 |
G3 | Use assembly to trim arrays | 10800 |
G4 | Add the unit ID at _calculateSubComponents() | 6000 |
G5 | Assign only the required field from storage to memory | 2100 |
SUM | 23,400 |
Gas saved: 2.1K per tx
Current check to ensure a delegate-call-only uses an sload which costs ~2.1K per tx. There's a much simpler and cheaper way to do that, see OZ's docs
assembly { implementation := sload(PROXY_TOKENOMICS) } // Check if there is any address in the PROXY_TOKENOMICS address slot if (implementation == address(0)) { revert DelegatecallOnly(); }
OZ's code:
abstract contract OnlyDelegateCall { /// @custom:oz-upgrades-unsafe-allow state-variable-immutable address private immutable self = address(this); function checkDelegateCall() private view { require(address(this) != self); } modifier onlyDelegateCall() { checkDelegateCall(); _; } }
Gas saved: 2.4 per tx (sload + call)
In getCurrentPriceLP()
token1
isn't needed when token0 == olas
.
Not reading that can save us an sload
to a cold key inside the call + the call itself.
if (totalSupply > 0) { address token0 = pair.token0(); address token1 = pair.token1(); uint256 reserve0; uint256 reserve1; // requires low gas (reserve0, reserve1, ) = pair.getReserves(); // token0 != olas && token1 != olas, this should never happen if (token0 == olas || token1 == olas) { // If OLAS is in token0, assign its reserve to reserve1, otherwise the reserve1 is already correct if (token0 == olas) { reserve1 = reserve0; } // Calculate the LP price based on reserves and totalSupply ratio multiplied by 1e18 // Inspired by: https://github.com/curvefi/curve-contract/blob/master/contracts/pool-templates/base/SwapTemplateBase.vy#L262 priceLP = (reserve1 * 1e18) / totalSupply; } }
Gas saved: 10.8K = ~300 units per element * a sum 36 elements on avg
See PoC I tested the PoC with 20 elements, it saved about 6K gas units.
There are a few cases in GuardCM
where we trim an array by copying it to a new array.
However, this can be done by assembly, given that the old array isn't used anymore in the code.
Estimated avg # of elements: 12 (min is 8 according to the code)
for (uint256 i = 0; i < payload.length; ++i) { payload[i] = data[i + 4]; }
bytes memory bridgePayload = new bytes(mediatorPayload.length - SELECTOR_DATA_LENGTH); for (uint256 i = 0; i < bridgePayload.length; ++i) { bridgePayload[i] = mediatorPayload[i + SELECTOR_DATA_LENGTH]; }
bytes memory payload = new bytes(data.length - SELECTOR_DATA_LENGTH); for (uint256 i = 0; i < payload.length; ++i) { payload[i] = data[i + SELECTOR_DATA_LENGTH]; }
bytes memory payload = new bytes(data.length - SELECTOR_DATA_LENGTH); for (uint256 i = 0; i < payload.length; ++i) { payload[i] = data[i + 4]; }
This one has one instance Estimated average # of elements: 4
GnosisSafeMultisig.sol#L79-L81
for (uint256 i = 0; i < payloadLength; ++i) { payload[i] = data[i + DEFAULT_DATA_LENGTH]; }
Here we're not trimming the beginning of the array but the end of it, but the same optimization can be applied. Estimated average # of elements: 20
subComponentIds = new uint32[](counter); for (uint32 i = 0; i < counter; ++i) { subComponentIds[i] = allComponents[i]; }
About this instance I'm not 100% sure it's safe since it's adding an element, so I won't count that (see an alternative way to save the gas here in the next section)
if (unitType == UnitType.Component) { uint256 numSubComponents = subComponentIds.length; uint32[] memory addSubComponentIds = new uint32[](numSubComponents + 1); for (uint256 i = 0; i < numSubComponents; ++i) { addSubComponentIds[i] = subComponentIds[i]; } // Adding self component Id addSubComponentIds[numSubComponents] = uint32(unitId); subComponentIds = addSubComponentIds; }
_calculateSubComponents()
Gas saved: 6K (300 per element * 20 elements on avg)
Instead of copying the array after it was created by _calculateSubComponents()
it'd be much more efficient to refactor the code so that _calculateSubComponents()
would add it on it's own when needed.
if (unitType == UnitType.Component) { uint256 numSubComponents = subComponentIds.length; uint32[] memory addSubComponentIds = new uint32[](numSubComponents + 1); for (uint256 i = 0; i < numSubComponents; ++i) { addSubComponentIds[i] = subComponentIds[i]; } // Adding self component Id addSubComponentIds[numSubComponents] = uint32(unitId); subComponentIds = addSubComponentIds; }
Gas saved: 2.1K (sload to a cold slot)
This one is similar to G40 in the bot report, but it wasn't caught by it.
The Unit struct contains also the unitHash
field, but this one isn't used here.
Therefore, it'd be cheaper to make unit
a storage variable, this way we aren't reading the unitHash
field from storage.
function getDependencies(uint256 unitId) external view virtual returns (uint256 numDependencies, uint32[] memory dependencies) { Unit memory unit = mapUnits[unitId]; return (unit.dependencies.length, unit.dependencies); }
#0 - c4-pre-sort
2024-01-10T13:41:33Z
alex-ppg marked the issue as insufficient quality report
#1 - c4-judge
2024-01-20T12:54:32Z
dmvt marked the issue as grade-b