31592 - [SC - Insight] Collection of other important issues
Submitted on May 21st 2024 at 15:27:42 UTC by @Minato7namikazi for Boost | Alchemix
Report ID: #31592
Report type: Smart Contract
Report severity: Insight
Target: https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/RevenueHandler.sol
Impacts:
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Contract fails to deliver promised returns, but doesn't lose value
Description
Brief/Intro
Collection of other important issues
Important
take a look first at the attachments .. i put the same first 4 issues but in more structured way
1. require(treasuryPct <= BPS) implemented wrong in the constructor
In the constructor of RevenueHandler
, there is a logical check for the treasury percentage (treasuryPct
) that is intended to ensure it does not exceed a certain threshold (100%, or BPS
which is 10,000 basis points) ,there is a logical error in the order of operations for setting the treasuryPct
. Here's the specific constructor code:
The check require(treasuryPct <= BPS, "treasury pct too large");
is performed before treasuryPct
is actually set to _treasuryPct
. At the point of the check, treasuryPct
is still its default value (which is 0 since it's a state variable). Therefore, this check is essentially redundant and does not validate _treasuryPct
. - The correct value _treasuryPct
is only assigned to treasuryPct
after the check, meaning any value could be set
<------------------------------------------------------------------->
2. in poke function it doesn't check if the token is expired
the only way to call internal _vote() function in the voter contract is via either of vote() or poke()
vote() implement a check that the token is not expired in this part before it call the internal _vote() function
but poke() doesn't!
<------------------------------------------------------------------->
3. missed check of isgauge alive in _updatefor in voter.sol
in the function
When the killGauge is invoked, the claimable gauge distributions are frozen. This means that when a gauge is terminated, the value associated with the claimable[_gauge] key is cleared. This is because any rewards received by the Voter contract are indexed and distributed proportionally to the weight of each pool. As a result, the claimable amount becomes permanently locked within the contract and can no longer be accessed or claimed.
So the Recommendation: Instead of permanently locking the claimable amount within the contract, consider returning it to the Minter contract. This would ensure that the funds are not lost or inaccessible. Additionally, it may be beneficial to wipe the existing votes on the killed gauge. This is because the killed gauge will continue to accumulate rewards from the Minter contract, even though it is no longer active. Wiping the votes would prevent this from happening and ensure a clean slate after the gauge is terminated
so edit the function before it apply claimable[_gauge] += _share;
to check first if the gauge is alive .... the updated function part should be
<------------------------------------------------------------------->
4. Inconsistent between balanceOfToken() and balanceOfTokenAt() functions
the balanceOfToken() function implements a flash-loan protection that returns zero voting balance if ownershipChange[_tokenId] == block.number
,
this was not consistently applied to the balanceOfTokenAt()
As a consequence, when external protocols or alchemix call the balanceOfToken and balanceOfTokenAt functions, they will receive different voting balances for the same veALCXs, depending on which function they use. Additionally, the internal _balanceOfTokenAt function, which lacks flash-loan protection, is called by the VotingEscrow.getVotes function to calculate the voting balance of an account. While the VotingEscrow.getVotes function does not appear to be used in any in-scope contracts, it might be utilized by external protocols or off-chain components to tally the votes. If that is the case, a malicious user could exploit this by flash-loaning the veALCXs to artificially inflate their voting balance.
Recommendation: If the requirement is to have all newly transferred veALCXs (where ownershipChange[_tokenId] == block.number) have a zero voting balance to prevent someone from flash-loaning veALCXs to increase their voting power, the flash-loan protection should be consistently implemented across all the related functions.
<------------------------------------------------------------------->
5. All rewards will be lost until Gauge or Bribe deposits are non-zero
Because the rewards are emitted over DURATION, if no deposit has happened and notifyRewardAmount() is called with a non-zero value, all rewards will be forfeited until totalSupply is non-zero as nobody will be able to claim them.
Recommendation: Document this risk to end users and tell them to deposit before voting on a gauge.
<------------------------------------------------------------------->
6. Difference in getPastTotalSupply and propose
in alchemix governor
The getPastTotalSupply() function currently uses block.timestamp, but OpenZeppelin's propose() function will use votes from block.number - 1 as seen here
This could enable
• Front-run and increase total supply to cause proposer to be unable to propose(). • Require higher tokens than expected if total supply can grow within one block. Proposals could be denied as long as a whale is willing to lock more tokens to increase the total supply and thereby increase the proposal threshold. Recommendation: Consider computing total supply and votes values in the same block.
<------------------------------------------------------------------->
7. RewardDistributor caching totalSupply leading to incorrect reward calculation
RewardDistributor distributes newly minted ALCX tokens to users who locks the tokens in VotingEscrow. Since the calculation of past supply is costly, the rewardDistributor cache the supply value in uint256[1000000000000000] public veSupply. The RewardDistributor._checkpointTotalSupply function would iterate from the last updated time util the latest epoch time, fetches totalSupply from votingEscrow, and store it. Assume the following scenario when a transaction is executed at the beginning of an epoch.
The totalSupply is X.
The user calls checkpointTotalSupply. The rewardDistributor save the totalSupply = X.
The user creates a lock with 2X the amount of tokens. The user has balance = 2X and the totalSupply becomes 3X.
Fast forward to when the reward is distributed. The user claims the tokens, reward is calculated by total reward * balance / supply and user gets 2x of the total rewards.
Recommendation: The quick fix would be to stop rewardDistributor caching totalSupply when it can still increase.
8. Ownable
uses single-step ownership transfer
Ownable
uses single-step ownership transferLikelihood: Low, because it requires an error on the admin side
Impact: High, because important protocol functionality will be bricked
the ownable in revenueHandler contract
the commonly used Openzeppelin ownable implementation has a shortcoming that it allows the owner to transfer ownership to a non-existent or mistyped address.
It is a best practice to use Ownable2Step because it's safer than Ownable for smart contracts because the owner cannot accidentally transfer smart contract ownership to a mistyped address. Rather than directly transferring to the new owner, the transfer only completes when the new owner accepts ownership.
So it's important to consider using the OpenZeppelin's new Ownable2Step contract
Proof of Concept
Last updated