Platform: Code4rena
Start Date: 13/02/2024
Pot Size: $24,500 USDC
Total HM: 5
Participants: 84
Period: 6 days
Judge: 0xA5DF
Id: 331
League: ETH
Rank: 22/84
Findings: 3
Award: $227.96
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: 0xloscar01
Also found by: 0xAadi, 0xpiken, BowTiedOriole, Breeje, Fassi_Security, JohnSmith, Limbooo, SpicyMeatball, Tendency, Topmark, ZanyBonzy, aslanbek, atoko, jesjupyter, matejdb, max10afternoon, n0kto, peanuts, pkqs90, rouhsamad, thank_you, zhaojohnson
80.5583 USDC - $80.56
The reward entitlement calculated for the users uses the total supply of the LIERC20 token.
// Calculate the entitlement per token held uint256 supply = this.totalSupply(); for (uint i = 0; i < distributableERC20s.length; i++) { uint256 balance = IERC20(distributableERC20s[i]).balanceOf( address(this) ); uint256 entitlement = balance / supply; erc20EntitlementPerUnit.push(entitlement); }
And upon distribution, amount sent uses the holder's LIERC20 token balance. Note also that disapproved holders are not sent rewards.
for (i = nextDistributionRecipient; i < limit; i++) { address recipient = holders[i]; if (isApprovedHolder(recipient)) { uint256[] memory receipts = new uint256[]( distributableERC20s.length ); for (uint j = 0; j < distributableERC20s.length; j++) { IERC20 toDistribute = IERC20(distributableERC20s[j]); uint256 entitlement = erc20EntitlementPerUnit[j] * this.balanceOf(recipient); if (toDistribute.transfer(recipient, entitlement)) { receipts[j] = entitlement; } } emit Distribution(recipient, distributableERC20s, receipts); } }
This model is not very effective, as it doesn't take into consideration that certain token holders may not be approved to receive rewards. So the rewards that should be distributed to the approved holders is reduced due to the existence of disapproved users. As long as these disapproved holders still hold the tokens, the rewards distributed to other users will always be less and there will always be leftover rewards tokens in the protocol.
This is because, this model is inherently dependent on all holders being approved to fully distribute the rewards. As far as a user is not approved, and has not burned or transferred his token to an approved user, there will always be tokens left over. Hence, rewards will not be fairly distributed to users and undistributed rewards will be left.
This also causes the disapproval of holders to not be effective as so as not to lose the rewards, the protocol will have to reapprove these holders to be able to effectively handle reward sharing. This defeats the purpose of disapproval as they have will still have to be reapproved to balance out the reward sharing, so as not to lose the tokens.
Considering that the amount of reward tokens can be as much as the protcol owners desire, a large amount of tokens can be left in the protocol, undistributed and unattainable. This model doesn't seem the best to subscribe to.
The balance of reward token is 900;
User A, User B and User C all have LIERC20 token balances of 30 each, causing that the total supply is 90;
User C gets disapproved (maybe for being a bad actor);
The entitlement is calculated as 900/90 == 10;
Upon rewards distribution, User A and User B get 10 * 30 tokens, each get 300 tokens, rather than 450 (900/60 * 30) tokens;
The reward token balance left is 300. Stays in limbo, rather than being shared between User A and B;
A new batch of 900 reward tokens is released into the contract. So the reward token balance is now 900 + 300 == 1200;
User C is still disapproved and LIERC20 supply is still 90;
The entitlement is now 1200/90 == 40/3;
Upon rewards distribution, User A and B get 40/3 * 30 tokens, getting 400 tokens each, rather than another 450 (900/60*30) as previous 300 no longer exists;
The reward token balance left now is 400, which is still not shared between User A and B; and so on.
So, as far as User C still hold those 30 tokens, without burning or transferring them, the corresponding rewards share between User A and B will be less, which is unfair, and there will always be leftover rewards from distributions accumulating in the contract, while they cannot be claimed. User C has to be reapproved, transfer his tokens to an approved holder, or burn his tokens so that these reward tokens are not lost.
Manual code review
Three ways to fix this,
Other
#0 - c4-pre-sort
2024-02-22T04:24:12Z
0xRobocop marked the issue as duplicate of #703
#1 - c4-judge
2024-03-04T14:40:48Z
0xA5DF marked the issue as satisfactory
🌟 Selected for report: ZanyBonzy
Also found by: 0xSmartContractSamurai, DarkTower, MrPotatoMagic, SpicyMeatball, TheSavageTeddy, jesjupyter, lsaudit, peanuts, zhaojohnson
108.7242 USDC - $108.72
Lines of code*
The releaseManagedNFT
function transfers the nft to a selected address. It however doesn't check or withdraw the ERC20 token balances in the nft before transferring. Doing this can lead to loss of these tokens if they had not been withdrawn from the nft before transfer.
function releaseManagedNFT( address nftContract, address to ) public onlyOwner nonReentrant { LiquidInfrastructureNFT nft = LiquidInfrastructureNFT(nftContract); nft.transferFrom(address(this), to, nft.AccountId()); // Remove the released NFT from the collection for (uint i = 0; i < ManagedNFTs.length; i++) { address managed = ManagedNFTs[i]; if (managed == nftContract) { // Delete by copying in the last element and then pop the end ManagedNFTs[i] = ManagedNFTs[ManagedNFTs.length - 1]; ManagedNFTs.pop(); break; } } // By this point the NFT should have been found and removed from ManagedNFTs require(true, "unable to find released NFT in ManagedNFTs"); emit ReleaseManagedNFT(nftContract, to); }
Consider adding a check for the withdrawERC20s
balance and a call to the withdrawFromManagedNFTs
function before transferring the nft.
numWithdrawals
when withdrawing from NFTsLines of code*
The withdrawFromManagedNFTs
function withdraws token from the managed NFTs to the liquidInfrastructureErc20 token contract. It however lacks a check for 0 numWithdrawals
which can lead to the function running without actually doing anything.
function withdrawFromManagedNFTs(uint256 numWithdrawals) public { require(!LockedForDistribution, "cannot withdraw during distribution"); if (nextWithdrawal == 0) { emit WithdrawalStarted(); } uint256 limit = Math.min( numWithdrawals + nextWithdrawal, ManagedNFTs.length ); uint256 i; for (i = nextWithdrawal; i < limit; i++) { LiquidInfrastructureNFT withdrawFrom = LiquidInfrastructureNFT( ManagedNFTs[i] ); (address[] memory withdrawERC20s, ) = withdrawFrom.getThresholds(); withdrawFrom.withdrawBalancesTo(withdrawERC20s, address(this)); emit Withdrawal(address(withdrawFrom)); } nextWithdrawal = i; if (nextWithdrawal == ManagedNFTs.length) { nextWithdrawal = 0; emit WithdrawalFinished(); } }
Consider adding a 0 check and reverting if there are no nft to withdraw from.
erc20EntitlementPerUnit
Upon distribution start and end, the erc20EntitlementPerUnit
is deleted, making one of the checks redudndant. Consider deleting one of them, preferable the one in the _endDistribution
function.
function _beginDistribution() internal { require( !LockedForDistribution, "cannot begin distribution when already locked" ); LockedForDistribution = true; // clear the previous entitlements, if any if (erc20EntitlementPerUnit.length > 0) { delete erc20EntitlementPerUnit; }
function _endDistribution() internal { require( LockedForDistribution, "cannot end distribution when not locked" ); delete erc20EntitlementPerUnit; LockedForDistribution = false; LastDistribution = block.number; emit DistributionFinished(); }
distributeToAllHolders
function should be removed.The distributeToAllHolders
function makes a check to see if number of holders is more than zero, upon which it calls the distribute
function.
function distributeToAllHolders() public { uint256 num = holders.length; if (num > 0) { distribute(holders.length); } }
The check is not needed as the distribute
function also checks for non zero numnumDistributions
.
function distribute(uint256 numDistributions) public nonReentrant { require(numDistributions > 0, "must process at least 1 distribution"); ...
Consider removing the check and passing the values in directly.
addManagedNFT
and constructor should include a check for duplicate NFTsMinor impact is that gas will be burned during zero amount transfers.
Major impact is a DOS upon calling the withdrawFromManagedNFTs
function after the NFT is released.
The addManagedNFT
function allows the owner to add the LiquidInfrastructureNFTs to the list of managed NFTs, upon which the NFT is added to the list of the protocols's NFTs. The function and the constructor lacks sanity checks, which can cause that nfts can be duplicated.
function addManagedNFT(address nftContract) public onlyOwner { LiquidInfrastructureNFT nft = LiquidInfrastructureNFT(nftContract); address nftOwner = nft.ownerOf(nft.AccountId()); require( nftOwner == address(this), "this contract does not own the new ManagedNFT" ); ManagedNFTs.push(nftContract); emit AddManagedNFT(nftContract); }
constructor( string memory _name, string memory _symbol, address[] memory _managedNFTs, address[] memory _approvedHolders, uint256 _minDistributionPeriod, address[] memory _distributableErc20s ) ERC20(_name, _symbol) Ownable() { ManagedNFTs = _managedNFTs; LastDistribution = block.number; for (uint i = 0; i < _approvedHolders.length; i++) { HolderAllowlist[_approvedHolders[i]] = true; } MinDistributionPeriod = _minDistributionPeriod; distributableERC20s = _distributableErc20s; emit Deployed(); }
For the minor impact, upon calling the withdrawFromAllManagedNFTs
/withdrawFromManagedNFTs
function, the withdrawBalancesTo
function is called in the LiquidInfrastructureNFT
contract.
function withdrawFromManagedNFTs(uint256 numWithdrawals) public { ... for (i = nextWithdrawal; i < limit; i++) { LiquidInfrastructureNFT withdrawFrom = LiquidInfrastructureNFT( ManagedNFTs[i] ); (address[] memory withdrawERC20s, ) = withdrawFrom.getThresholds(); withdrawFrom.withdrawBalancesTo(withdrawERC20s, address(this)); emit Withdrawal(address(withdrawFrom)); }
function _withdrawBalancesTo( address[] calldata erc20s, address destination ) internal { uint256[] memory amounts = new uint256[](erc20s.length); for (uint i = 0; i < erc20s.length; i++) { address erc20 = erc20s[i]; uint256 balance = IERC20(erc20).balanceOf(address(this)); if (balance > 0) { bool result = IERC20(erc20).transfer(destination, balance); require(result, "unsuccessful withdrawal"); amounts[i] = balance; } } emit SuccessfulWithdrawal(destination, erc20s, amounts); }
After the withdrawal from the first duplicate, the ERC20 balance is 0, so the withdrawals from the second/subsequent duplicates are skipped.
For the more major impact, first the releaseManagedNFT
is called. This first transfers the NFT, and then deletes the NFT from the array of managedNFTs. However, due to the use of the break
functionality, it doesn't a loop through the entire array of NFTs, but rather stops at the first discovered NFT, i.e , the first duplicate. Leaving the remaining duplicates in the managedNFTs array.
function releaseManagedNFT( address nftContract, address to ) public onlyOwner nonReentrant { LiquidInfrastructureNFT nft = LiquidInfrastructureNFT(nftContract); nft.transferFrom(address(this), to, nft.AccountId()); // Remove the released NFT from the collection for (uint i = 0; i < ManagedNFTs.length; i++) { address managed = ManagedNFTs[i]; if (managed == nftContract) { // Delete by copying in the last element and then pop the end ManagedNFTs[i] = ManagedNFTs[ManagedNFTs.length - 1]; ManagedNFTs.pop(); break; } } // By this point the NFT should have been found and removed from ManagedNFTs require(true, "unable to find released NFT in ManagedNFTs"); emit ReleaseManagedNFT(nftContract, to); }
Now, important to note that, the NFT transfer transfers the ownership to the other address, causing that the LiquidInfrastructureERC20 contract is no longer the token owner.
Upon calling withdrawFromAllManagedNFTs
/withdrawFromManagedNFTs
function, the withdrawBalancesTo
function is called in the LiquidInfrastructureNFT
contract. The withdrawBalancesTo
function can only be called by the owner or an approved user, hence the LiquidInfrastructureERC20
contract will not be able to call this contract, thus dossing the nft balance withdrawal process.
function withdrawBalancesTo( address[] calldata erc20s, address destination ) public virtual { require( _isApprovedOrOwner(_msgSender(), AccountId), "caller is not the owner of the Account token and is not approved either" ); _withdrawBalancesTo(erc20s, destination); }
This duplicate token can also not be removed by calling the releaseManagedNFT
function, as the transferFrom
function has to be called by either the owner or an approved user.
Ultimately, this depends on the owner mistake, but can have serious effects in the protcol, so I'd leave this up to the judge.
Consider adding checks for duplications, or removing the break
functionality from the releaseManagedNFT
function, to allow for full looping and full removal of all duplicates.
releaseNFT
functionThis check is redundant and does not serve any purpose as it always evaluates to true. It should ideally check if the NFT was successfully removed from ManagedNFTs, but it is incorrectly written.
function releaseManagedNFT( address nftContract, address to ) public onlyOwner nonReentrant { ... require(true, "unable to find released NFT in ManagedNFTs"); emit ReleaseManagedNFT(nftContract, to); }
Consider refactoring to something like this
function releaseManagedNFT( address nftContract, address to ) public onlyOwner nonReentrant { LiquidInfrastructureNFT nft = LiquidInfrastructureNFT(nftContract); nft.transferFrom(address(this), to, nft.AccountId()); bool foundAndRemoved = false; // Remove the released NFT from the collection for (uint i = 0; i < ManagedNFTs.length; i++) { address managed = ManagedNFTs[i]; if (managed == nftContract) { // Delete by copying in the last element and then pop the end ManagedNFTs[i] = ManagedNFTs[ManagedNFTs.length - 1]; ManagedNFTs.pop(); foundAndRemoved = true; break; } } // By this point the NFT should have been found and removed from ManagedNFTs require(true, "unable to find released NFT in ManagedNFTs"); emit ReleaseManagedNFT(nftContract, to); }
withdrawERC20s
are not set as distributableERC20s
Lines of code*
The constructor and the setDistributableERC20s
allows the owner of the LiquidInfrastructureERC20 contract to set the ERC20 reward tokens to distribute.
function setDistributableERC20s( address[] memory _distributableERC20s ) public onlyOwner { distributableERC20s = _distributableERC20s; }
constructor( string memory _name, string memory _symbol, address[] memory _managedNFTs, address[] memory _approvedHolders, uint256 _minDistributionPeriod, address[] memory _distributableErc20s ) ERC20(_name, _symbol) Ownable() {
The owner of the LiquidInfrastructureNFT contract also has the ability to set the ERC20s as thresholds
function setThresholds( address[] calldata newErc20s, uint256[] calldata newAmounts ) public virtual onlyOwnerOrApproved(AccountId) { require( newErc20s.length == newAmounts.length, "threshold values must have the same length" ); // Clear the thresholds before overwriting delete thresholdErc20s; delete thresholdAmounts; for (uint i = 0; i < newErc20s.length; i++) { thresholdErc20s.push(newErc20s[i]); thresholdAmounts.push(newAmounts[i]); } emit ThresholdsChanged(newErc20s, newAmounts); }
which can later be withdrawn into the LiquidInfrastructureERC20 contract.
function withdrawFromManagedNFTs(uint256 numWithdrawals) public { require(!LockedForDistribution, "cannot withdraw during distribution"); if (nextWithdrawal == 0) { emit WithdrawalStarted(); } uint256 limit = Math.min( numWithdrawals + nextWithdrawal, ManagedNFTs.length ); uint256 i; for (i = nextWithdrawal; i < limit; i++) { LiquidInfrastructureNFT withdrawFrom = LiquidInfrastructureNFT( ManagedNFTs[i] ); (address[] memory withdrawERC20s, ) = withdrawFrom.getThresholds(); withdrawFrom.withdrawBalancesTo(withdrawERC20s, address(this)); emit Withdrawal(address(withdrawFrom)); } nextWithdrawal = i; if (nextWithdrawal == ManagedNFTs.length) { nextWithdrawal = 0; emit WithdrawalFinished(); } }
As there's no specific check, or enforcements that the withdrawERC20s
are also set as parts of the distributableERC20s
, these tokens being withdrawn into the LiquidInfrastructureERC20 stand the risk of being stranded if they're not made distributable, as there's no other way to retrieve tokens from the contract.
Consider introducing a sweep function in the LiquidInfrastructureERC20 contract, or adding an using the getThresholds
function to get the array of withdrawERC20s
and enforcing them as part of the distributableERC20s
The LiquidInfrastructureERC20
has a version and judging by the comment, it seems the contracts are intended to be upgradeable, so as to seamlessly make updates to the contract while introducing a new version.
/** * @notice This is the current version of the contract. Every update to the contract will introduce a new * version, regardless of anticipated compatibility. */ uint256 public constant Version = 1;
But the contract doesn't inherit any upgradeable OZ contracts, nor storage gaps.
import "@openzeppelin/contracts/utils/math/Math.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "./LiquidInfrastructureNFT.sol";
If upgradeability is truly desired, inherit the upgradeable OZ contracts, and add storage gaps.
#0 - c4-pre-sort
2024-02-22T16:49:40Z
0xRobocop marked the issue as sufficient quality report
#1 - 0xA5DF
2024-03-08T11:41:59Z
+L from #62
#2 - 0xA5DF
2024-03-08T13:45:36Z
L R R R L L L R
5L, 4R
#3 - c4-judge
2024-03-08T14:25:19Z
0xA5DF marked the issue as grade-a
#4 - c4-judge
2024-03-09T08:04:11Z
0xA5DF marked the issue as selected for report
#5 - 0xA5DF
2024-03-09T10:07:42Z
#524 was downgraded to QA
🌟 Selected for report: 0xAadi
Also found by: 0xbrett8571, DarkTower, JcFichtner, JrNet, MrPotatoMagic, PoeAudits, TheSavageTeddy, ZanyBonzy, clara
38.6789 USDC - $38.68
Scope and Architecture Overview
Phase 1: Protocol Familiarization: The review began by analyzing the protocol documentation, including the readMe, AthleaLI blog and whitepaper. followed by a preliminary examination of the relevant contracts and their tests.
Phase 2: Deep Contract Review: A meticulous, line-by-line review of the in-scope contracts was conducted, meticulously following the expected interaction flow.
Phase 3: Issue Discussion and Resolution: Potential pitfalls identified during the review were discussed with the developers to clarify whether they were genuine issues or intentional features of the protocol.
Phase 4: Reporting: Audit reports were finalized based on the findings.
Reward distribution - To go through the distribution of rewards to users holding the LiquidInfrastructure token, users call the distribute
or distributeToAllHolders
functions. This only works if the minimum distribution time is passed, upon which the contract is locked from transfers, mints and burns to ensure users get their fair share and prevent gaming the rewards process. Reward distirbution also occurs when the users mint or burn tokens. The owner calls the mintAndDistribute
function to do this. Users call the burnAndDistribute
and burnFromAndDistribute
which distributes the rewards before burning the user's tokens.
NFT management - LiquidInfrastructureNFT contract can be added to the collection of managed NFTs by calling the addManagedNFT
from which the token balances from the NFTs can be wihtdrawn into the custody of the ERC20 token contract by calling the withdrawFromManagedNFTs
function. The owner can decide to send the NFT to another address by calling the releaseManagedNFT
function.
Balance withdrawal - Users call the withdrawBalances
function to withdraw their ERC20 balance to their accounts or any account of their choosing.
Account recovery - Users who have lost the device thier account is attached to, can call the recoverAccount
function which starts the Liquid Infrastructure Account recovery process. It emits the TryRecover
event for the x/microtx module to detect. The x/microtx module converts all tokens in the account to be converted to ERC20s and sent to this contract, and emits the SuccessfulRecovery
event which confirms that the recovery is successful. After success, the users can then call the withdrawBalances
function to send the recover the tokens.
ownerOf
and _isApprovedOrOwner
implementations which check if the caller is the owner, an approved operator or an approved spender. If the caller doesn't fall into any of these categories, the function reverts.Audit Information - For the purpose of the security review, the Althea Liquid Infrastructure consists of three smart contracts totaling 377 SLoC. Its core design principle is inheritance, enabling efficient and flexible integration. It is scheduled to be deployed on the cosmos chain, giving each user the freedom to transact in and across all platforms. It operates independently of oracles and sidechains.
Documentation and NatSpec - The codebase provides important resources in form of blogs and whitepaper documentation. These documents provide a general overview of the the protocol and its functionality. The contracts are well commented, not strictly to NatSpec but each function was well explained. It made the audit process easier.
Gas Optimization - The codebase isn't very well optimized for gas usage. A number of basic gas saving techniques were ignored. No Unchecked
in loops and subtractions, an older non gas efficient solidity version is used, custom errors are not implemented and so on.
Error handling and Event Emission - Require errors with error strings is used in the codebase. Events are well handled, emitted for important parameter changes, although in some cases, they seemed to not follow the checks-effects-interaction patterns.
Testability - Test coverage is about 95% which is very gooed. The implemented tests are mosty unit tests which tests the basic functionalities of the functions and helped with basic bugs. The more complex parts of the codebase are however not fuzz tested.
Token Support - The protocol works with the LiquidInfrastructure ERC721 token, LiquidInfrastructure ERC20 token and other ERC20 tokens. Owners are allowed to set their desired token, although the protocol doesn't aim to support non-standard tokens. The contracts implementations is also not suitable for non-standard ERC20 tokens.
Attack Vectors - For a protcool of this size, various points of attack are to be considered, most important of which are gaming rewards distribution, bypassing disapprovals to gain rewards, stealing rewardds from other users and so on.
Like any protocol that incorporates admin functions, the actions of a malicious admin can leave the protcol vulnerable. So of them include:
The codebase should be sanitized, and the comments should also be brought up to date to conform with NatSpec.
While the protocol explicitly allows the owners to choose whatever tokens they're interested in, using unsafe trransfer methods for token transfers is not advisable. Consider using safer methods instead;
In the same vein, using ordinary transferFrom
and mint
methods to handle the LiquidityInfrastructureNFT carries the risk of losing the the token if sent to an account that cannot handle ERC721 tokens. Updating to safe methods is also recommended.
The current reward entitlement model doesn't take into consideration that disapproved holders do not get tokens distributed to them. It is calculated as a function of the total LiquidInfrastructureERC20 token supply, rather than the supply belonging to the approved users. This unfairly reduces rewards given to approved users and should be improved upon.
Disapproved holders who still hold the LiquidInfrastructure tokens are still allowed to transfer them. They can bypass this by using a thrid party approved holder, transferring the tokens to them, and receiving a reward through them in return. Consider fixing this by preventing disapproved holders from sending tokens, just as they can't receive tokens.
The calculation for the user's entitlement doesn't account for different token decimals, hence, for certain token types, little or no rewards will be distributed due to rounding errors. Consider inplementing a multiplier to prevent these rounding errors.
Two step variable and address updates should be implemented. Most of the setter functions implement the changes almost immediately which can be jarring for the contracts/users. Adding these fixes can help protect from mistakes and unexepected behaviour.
A proper pause function can be implemented to protect users in cases of turbulent market situations and black swan events. It also helps prevent malicious activities or bugs from causing significant damage while the team investigates the potential issues without affecting users' funds.
If the LiquidInfrastructureERC20 contract is desired to be upgradeable, judging by presence of a version, then consider importing upgradeable OpenZeppelin contracts instead of currently unupgradeable contracts. Introducing storage gaps are also needed.
Testing should be improved, including invariant and fuzzing tests;
Solidity and OpenZeppelin contract versions should be updated to the latest as they provide lots of benefits in security and optimization;
In general, the codebase is compact, of small size but not very well-designed. As is the reason for the audit, the identified risks need to be fixed. Recommended measures should be implemented to protect the protocol from potential attacks. Timely audits and sanitizations should be conducted to keep the codebase fresh and up to date with evolving security times.
018 hours
#0 - c4-pre-sort
2024-02-22T16:48:54Z
0xRobocop marked the issue as sufficient quality report
#1 - c4-judge
2024-03-08T14:32:27Z
0xA5DF marked the issue as grade-b