The review was three weeks of the v2 of the Floor DAO codebase. The goal of this code is to automate more of the onchain actions the DAO currently preforms via multisig and to increase transparency regarding votes and yield from the NFT LP positions. Notably the contracts still contain strong governance control points which would allow some addresses to remove the DAO assets.
During the course of the review I have discovered 7 critical bugs, 5 high bugs, 5 medium bugs and 6 low bugs, plus various notes on gas and edge cases. These rating are judged against a matrix defined by how likely a bug is and how much damage the bug could cause. The status of the reviewed remediation is indicated along side each entry in my log.
The commit hash at the start of review was: 74bec48f0dcdd08cdb2fcee89f24284a58124442
The ending commit:
Please note that no security review can be exhaustive, and I'm a human being who can and does miss bugs. This review only serves as a way for the team to spot check the codebase and to develop security methodology.
Delete Trigger Bricks Epoch Manager: the epoc manager's deletion function sets addresses to zero, but doesn't resize the trigger array. When calling endEpoc a call to zero is forced and will revert. Future updates cannot remove the zero value as they use array.push() which cannot overwrite elements.
Gas Exhaustion in processAction: The treasury's processAction function takes an array of actions and does approvals corresponding to each. In it a for loop is missing an assignment of the loop index and so will run forever.
Reentrancy in sweepEpoch: The sweepEpoc function checks that the epoc has not been swept by checking a storage variable 'completed' has not been set to true. However it then calls the sweeper with arbitrary data, and some sweepers may allow external calls. This lets a malicious user with 5000 floor potentially execute sweeps many times by having the sweeper call back into the sweep function, this may buy many more NFTs than intended and drain the weth in treasury.
Infinite Vote for collection in NewCollectionWar: If a user has already voted the vote function only adds the delta of their new votes since the last vote to the new collection. The revoke function removes all votes from the current collection in an unchecked block. In this case the subtract will underflow and the collection votes will be set to on the order of 2**255 which garuntees the collection will win.
Persistent Access for Treasury Manager Role: If a user has the process action role they can maintain access to ERC20 and ER721 even if the role is removed. They can do so by calling process action and approving a metamorphic contract which is self destructed at the end of the call. Since the contract does not exist calls to IAction(target).execute() will fail, so the approvals cannot be decremented by other admins. However they can still access assets by redeploying the contract and calling 'transferFrom'.
GemSweeper Funds Theft: The GemSweeper contract can be used by a malicious sweeper to steal sweep proceeds. The GemSweeper decodes the address for gem from the user provided calldata and then calls it with the sweep value. This function can be called by the treasury with the sweepEpoc function, provided a user has 5k floor and it was not executed by the team earlier. Since the GemSweeper contract is approved the user can call it with the sweep value and then call their own contract and keep the funds.
EarlyWithdraw reverts after 24 epocs: The early withdraw function calculates 'epositor.epochCount - depositor.epochStart' where start epoch is monotonically increasing (weekly in planned deployment) but epochCount < 24. Since this is in a checked block any underflow here will revert and so each early withdraw after 24 weeks reverts.
Early withdraw penalty is calculated incorrectly: The early withdraw penalty is intended to be calculated as a percent of the user's lock remaining, so for example in a 2 week lock on week 1 the withdraw percent is 50%. In the calculation however the code assumes the user has the maximum lock length so in this scenario the withdraw percent as written is 5%. Users are unlikely to take real loss as the code only allows a certain max percent loss, and the function will just revert.
Prices are calculated wrong in _calculateFloorPrice: The function takes in two price ratios (floor to eth, token to eth) and then reverse engineers the price for floor by dividing. But it does the check priceFloor > priceToken and flips the division based on the outcome. This will generate incorrect results when floorPrice < tokenPrice. There is low impact because this function is not used anywhere else in the codebase.
Prices are not updated when a collection is not approved: The register sweep trigger calls the price calculation function on an array of approved collections from the collection manager and then stores the result in a mapping. If a collection is approved and then un-approved the prices stop updating but the mapping still holds price data so any strategies which yield that token will return incorrect yield amounts. This also affects tokens which are never approved which might be created by the revenue strategies. Namely if a strategy is approved which is not producing yield in terms of the the approved collections is always credited as 0 eth.This has an impact in practice as when the liqudiation trigger liqudiates a collection it produces wETH and puts the wETH rewards into a distributed revenue strategy based on wETH. If the rewards are queried for this strategy since the wETH field of the tokenEthPrice is never set the rewards will always read as zero.
Missing Decimal Factor in RegisterSweep: The price executor returns prices as an 18 point decimal representation with the units of (eth/token). When we multiply by price in this line we should also be dividing by the decmials of our token, otherwise the rewards will be inflated by 10^{token decimals}. This causes very large incorrect reports of sweep values to the treasury and would brick the system in production.
Missing User State Update in NewCollectionWars Vote: Currently the user's vote in new collection wars is checked by checking the mapping userCollectionVote[warUser] however when the user votes this entry is never set and so the user cannot change votes or the manager revoke their votes.
Wrong collection's votes decremented in NewFloorWar: In the NewCollectionWar's voting function first it hashes the user provided collection with the war index to get a constant warCollection
this constant is entirely derived from the user inputs. The contract decrements via collectionVotes[warCollection] -= userVotes[warUser];
and so the system does not reduce votes from the user's currently voted collection but rather the one for which they are now trying to vote. This means the user's vote totals are unchanged as those votes are then added again to collectionVotes[warCollection]
a few lines later. In theory this should change what contract the userCollectionVote
is [dependant on the remediation of another bug]. If however the vote manager roles were to revoke votes from that account it would decrement the wrong collection and possibly cause an underflow. So given the following steps a user could create infinite votes: vote for collection A, then vote for collection B, then withdraw from veFloor paying the early withdraw fee which triggers a revoke of their vote in new collection war contract.
Uniswap Sandwich on Liquidate: The liquidate function requires the trade to return zero results from swapping tokens to wETH. Uniswap v3 in particular has cases where this is very dangerous as the liquidity could be concentrated in a range and a sandwich could remove sale liquidity and add LP for this trade at a very low price netting the pool very close to zero. They could then trade the output back into the pool and steal the whole value of the liquidated amount. The code in the liquidation trigger is triggered by an non-permissioned call from the epoch manager meaning it is quite easy to execute this.
Uniswap TWAP can return manipulated data: In the uniswap pricing executor the code attempts to load the data with a 30 minute lag, and if it can it will then try to calculate the most recent data it can. In some configurations of the pools this is as recent as the same block or 1 block ago. Such data is easy to manipulate in the current MEV environment, and may be fairly low cost given that the amount volume needed to perturb a pool is not constant across price regimes in v3. I would also consider using a longer TWAP for assets which may show low volume or in active trading. Manipulation could result in larger sweeps for some collections.
Manual Sweeper makes ETH unretrievable: The manual sweeper is called by the treasury contract with an amount of eth which is set by the sweep execution trigger. The manual sweeper function contains no way to retrieve this eth and so if it is called the ETH is burned.
Vote Revoking Edge Cases allow double voting: In the SweepWars contract the votes are allowed for either collections in the approved collections mapping or for the constant address 0x1. The revoke function however only revokes votes for the the collections in the approvedCollections array, which does not contain 0x1. Therefore a user can vote for 0x1 and then withdraw from vFloor and retain this vote. In another case if a collection is unapproved all of the votes are frozen and all users can withdraw and then redeposit, and vote for that collection again. If this was executed as coordinated attack a collection could be made permanently the higest voted collection.
Locking Vulnerability in UniswapStrategy: The uniswap strategy calls the position manager element of uniswap's periphery contracts. This contract returns the current amount of liquidity and the amount of each token consumed by this deposit. The contract sets all three values into a positions mapping, and the positions mapping is decremented when a user withdraws. If two users deposit and the second one deposits only very small amount all withdraws are blocked of the first user's assets, because they would subtract a larger number from a smaller number in a checked arithmetic block. All previous deposits become unrecoverable.
Re-entrancy In UniswapStrategy: The wETH refund mechanic can trigger execution in the caller of deposit again. This user could then callback in and cause the following problem: deposit stores the total liquidity tokens of the pool after the refund mechanic. Since that value is calculated before and stored after the re-entrant call you can use the reentrancy to store totalLiqudityTokens < realBalance. There's no reason to do this because the value isn't stored on chain but if future changes are made to this contract care should be made to avoid relying on that property.
UniswapStrategy Deposit Always Reverts for some Expected Inputs: The UniwswapStrategy deposit function uses the TokenUtils library which accepts uint.max as a value to mean "use the current token balance". The function deposits to uniswap via the position manager which returns the amounts used. The function the calls withdraw with "amountDesired - amountUsed" which will be almost 2^256, and the safe transfer will always revert. This prevents access to an intended code path but is not a major affect as that path is not the primary way to use this function.
Vote Duplication in Sweep Wars: The sweep wars contract records a user's voting power based on the ratio at the beginning of their vote. When votes are removed the contract calculates the user's current voting power based on their current locking ratio and then either removes those votes if the vote is for for a collection or adds them if the vote is against a collection. The consequence of this is if a user can either increase or reduce their voting power after voting some remainder will be left when they withdraw at the end because the voting power ratio at the end of the locking period is not equal to what it was at the beginning. This allows vote duplication via the following steps:
Optimal Voting for Large Voting power collections: If a collection will have greater than 50% of the net votes in favor of it then voters for it can engage in a manipulation attack. This is because the snapshot method and the liquidation method both use the net voting power from the voting contract, meaning you can reduce the numerator. In the extremal case if 75% of a vote is for a collection and 25% against it can optimize votes to get 100% of rewards [by voting 50% in favor and 25% against all other collections].
Vote Miscount in NewFloorWars: The new floor wars contract only calculates the user's balance but not their voting power. This means that both users who have locked for the max duration and min duration get the same votes if they deposited the same number of floor tokens.
Rewards Miscount in DistributedRevenueStaking Strategy: The available() function returns the currently withdrawable total plus lifetime rewards. When it is called by the total rewards function the lifetime rewards are added again. Therefore when snapshot calls total rewards the function loads the currently claimable rewards plus two times lifetime rewards. This causes the strategy to payout twice the intended rewards as it will pay them out once when they are pending and then again when they are doubled in lifetime rewards.
Full Withdraws from NFTX strategies Result in Deposit Locking: The deposits state mapping is decreased in an unchecked block on all withdraws from the NFTXInventoryStakingStrategy and NFTXLiqudityPoolStakingStrategy however it is increased only in the ERC20 deposit methods (in a checked block) and so if NFTs are deposited and then withdrawn the deposits mapping can underflow and then if future ERC20 deposits will revert as the overflow would revert in the checked block. This is considered unlikely to have an impact as it must be fully withdrawn in the course of normal use while having NFT deposits to lock only one method.