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: 6/84
Findings: 3
Award: $478.06
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: BowTiedOriole
Also found by: 0x0bserver, 0xAadi, 0xJoyBoy03, 0xlamide, 0xlemon, 0xpiken, Babylen, Breeje, Brenzee, CodeWasp, DanielArmstrong, DarkTower, Fassi_Security, Fitro, Honour, JohnSmith, Krace, MrPotatoMagic, Myrault, ReadyPlayer2, SovaSlava, SpicyMeatball, TheSavageTeddy, Tigerfrake, atoko, cryptphi, csanuragjain, d3e4, gesha17, kinda_very_good, krikolkk, matejdb, max10afternoon, miaowu, n0kto, nuthan2x, parlayan_yildizlar_takimi, peanuts, petro_1912, pontifex, psb01, pynschon, rouhsamad, shaflow2, slippopz, spark, turvy_fuzz, web3pwn, zhaojohnson
7.1828 USDC - $7.18
The vulnerability has been identified in the LiquidInfrastructureERC20
contract, where an attacker can exploit zero-token transfers to manipulate/inflate the holder array. This attack involves sending zero-token transactions to an approved holder whose balance is zero. Despite not transferring any actual tokens, this action results in the recipient's address being added to the holder array multiple times.
The consequences of this vulnerability include Reward Theft, Gas Griefing, Permanent Denial of Service (DOS). This vulnerability poses a significant risk to the LiquidInfrastructureERC20
contract's reward distribution system and its overall operational integrity, threatening the equitable allocation of rewards and the contract's usability.
Exploitation of Reward Distribution: An attacker with an approved holder address could exploit the lack of checks on 0 token transfers to inflate the holders
array with multiple entries of their own address. During the execution of the distribute
function, this could result in the attacker receiving a disproportionate amount of the distributed tokens, effectively stealing a significant portion of the rewards intended for legitimate holders. This manipulation undermines the fairness and integrity of the reward distribution process.
Increased Gas Costs and Risk of DOS: The inflated holders
array can lead to increased gas costs for functions that iterate over the array, which can be exploited for gas griefing. This affects several key functions within the contract:
distribute
function iterate over the holders
array to distribute rewards. An inflated array could significantly increase the cost of these distributions and potentially cause them to fail due to out-of-gas errors if the array is too large. Subsequently, this affects the following functions that call the distribute
function: distributeToAllHolders
, mintAndDistribute
, burnAndDistribute
, and burnFromAndDistribute
._beforeTokenTransfer
and _afterTokenTransfer
functions are called during token transfers and may iterate over the holders
array to update the list of holders. An excessively large holders
array could make transfers more expensive and even cause them to fail._beforeTokenTransfer
and _afterTokenTransfer
functions may need to iterate over the holders
array to add or remove addresses. A large holders
array could increase the cost of these operations and risk transaction failures, potentially leading to a permanent denial of service (DOS) for these critical functions.Temporary DOS During Distribution and Ineffective distributeToAllHolders
Function: The LockedForDistribution
flag prevents certain actions while a distribution is in progress. An inflated holders
array could prolong the distribution process, extending the lock period and temporarily disabling operations like token transfers, minting, burning and withdrawals from the ManagedNFTs collection.
Additionally, the distributeToAllHolders
function becomes less useful and more costly to execute because it attempts to distribute rewards to all entries in the holders
array, including any duplicates. If the distribute
function cannot complete due to the size of the holders
array, the contract could remain locked, and no further distributions, mints, burns, transfers or withdrawals from the ManagedNFTs collection could occur until the issue is resolved.
Compromised Contract Functionality: The overall functionality and reliability of the contract are compromised, as the core mechanisms for distribution and token management are disrupted by the inflated holders
array.
The vulnerability arises from the following code in the _beforeTokenTransfer
function:
142: bool exists = (this.balanceOf(to) != 0); 143: if (!exists) { 144: holders.push(to); 145: }
This code adds the to
address to the holders
array if it does not already have a balance, without checking if the transfer amount is greater than zero.
Once the holder
array is inflated too much it can cause DOS or gas griefing on the following lines
144: holders.push(to); 171: for (uint i = 0; i < holders.length; i++) { 214: for (i = nextDistributionRecipient; i < limit; i++) {
A DOS attack targeting the distribute()
function can be mitigated by limiting the number of distributions processed in a single function call, as determined by the function's input parameter.
However, this approach requires the user or contract owner executing distribute()
to potentially incur substantial gas costs. Depending on the size of the holders
array and the current gas prices, these costs may become prohibitively expensive, making it economically infeasible for users to initiate the distribution at times. Subsequently the mintAndDistribute
, burnAndDistribute
and burnFromAndDistribute
become expensive.
Please refer to the distribute
function for details on how distributions are processed:
function distribute(uint256 numDistributions) public nonReentrant { // ... code omitted for brevity ... uint256 limit = Math.min( nextDistributionRecipient + numDistributions, holders.length ); uint i; 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); } } // ... code omitted for brevity ... }
The distribute
function is designed to iterate over the holders
array and transfer a share of the distributableERC20s
tokens to each approved holder based on their entitlement. The entitlement is calculated as a product of the holder's balance and a pre-calculated entitlement per unit for each distributable ERC20 token.
Due to this design, distribute()
might take significant time to process all the distributions, and during this period, the transfers, mints, and burns of the token will be prevented until all rewards have been paid out.
to
address being added to the holders
array, despite no actual tokens being transferred, due to the lack of a check for the transfer amount in the _beforeTokenTransfer
function.distribute
function is called, it includes the to
address multiple times in its reward calculation, proportional to the number of times the address appears in the holders
array.holders
array with redundant entries, significantly increasing its size without any actual change in token ownership.holders
array leads to increased gas costs for all contract functions that iterate over it, such as distribute
, _beforeTokenTransfer
, and _afterTokenTransfer
.holders
array becomes very large.The following script explains how an attacker can inflate holders
array. Place this file under test
folder and run using npm run test
import chai from "chai"; import { ethers } from "hardhat"; import { deployContracts, deployLiquidERC20 } from "../test-utils"; import { LiquidInfrastructureERC20 } from "../typechain-types/contracts"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; const { expect } = chai; // This test makes assertions about the LiquidInfrastructureERC20 contract by running it on hardhat async function liquidErc20Fixture() { const signers = await ethers.getSigners(); const nftAccount1 = signers[0]; const nftAccount2 = signers[1]; const nftAccount3 = signers[2]; const erc20Owner = signers[3]; const holder1 = signers[4]; const holder2 = signers[5]; const holder3 = signers[6]; const holder4 = signers[7]; const badSigner = signers[8]; // Deploy several ERC20 tokens to use as revenue currencies const { testERC20A, testERC20B, testERC20C } = await deployContracts( erc20Owner ); const erc20Addresses = [ await testERC20A.getAddress(), await testERC20B.getAddress(), await testERC20C.getAddress(), ]; // Deploy the LiquidInfra ERC20 token with no initial holders nor managed NFTs const infraERC20 = await deployLiquidERC20( erc20Owner, "Infra", "INFRA", [], [], 500, erc20Addresses ); return { infraERC20, testERC20A, testERC20B, testERC20C, signers, nftAccount1, nftAccount2, nftAccount3, erc20Owner, holder1, holder2, holder3, holder4, badSigner, }; } // Attack function async function attack( infraERC20: LiquidInfrastructureERC20, holder: HardhatEthersSigner, attacker: HardhatEthersSigner ) { // 1. Approve holder 1 to receive tokens await expect(infraERC20.approveHolder(holder.address)).to.not.be.reverted; const infraERC20Attacker = infraERC20.connect(attacker); const maxCount = 100; // 2. Attacker transferring 0 token to holder 1 for (let i = 0; i <= maxCount; i++) { await infraERC20Attacker.transfer(holder.address, 0); } // 3. Log the elements of holder array, Here you can see that the holder array is get inflated console.log("\nLet's see the duplication in holder array\n"); console.log("0'th address in holder array: ", await infraERC20.holders(0)); console.log("1'st address in holder array: ", await infraERC20.holders(1)); console.log("."); console.log("."); console.log( `${maxCount - 1}'th address in holder array:`, await infraERC20.holders(maxCount - 1) ); } describe("LiquidInfrastructureERC20 Holder Inflation Attack", function () { it.only("Attack holders", async function () { const { infraERC20, holder1, holder2, badSigner } = await liquidErc20Fixture(); attack(infraERC20, holder1, badSigner); }); });
Output
</details>LiquidInfrastructureERC20 Holder Inflation Attack ✔ Attack holders (2230ms) Let's see the duplication in holder array 0'th address in holder array: 0x2fFd013AaA7B5a7DA93336C2251075202b33FB2B 1'st address in holder array: 0x2fFd013AaA7B5a7DA93336C2251075202b33FB2B . . 99'th address in holder array: 0x2fFd013AaA7B5a7DA93336C2251075202b33FB2B 1 passing (10s)
Manual review and hardhat test
_beforeTokenTransfer
function to include a check that ensures the transfer amount is greater than zero before adding an address to the holders
array. This prevents the array from being inflated with entries for zero-token transfers.bool exists = (this.balanceOf(to) != 0); - if (!exists) { + if (!exists && amount > 0) { holders.push(to); }
DoS
#0 - c4-pre-sort
2024-02-20T06:37:51Z
0xRobocop marked the issue as duplicate of #77
#1 - c4-judge
2024-03-04T13:10:09Z
0xA5DF marked the issue as satisfactory
🌟 Selected for report: BowTiedOriole
Also found by: 0x0bserver, 0xAadi, 0xJoyBoy03, 0xlamide, 0xlemon, 0xpiken, Babylen, Breeje, Brenzee, CodeWasp, DanielArmstrong, DarkTower, Fassi_Security, Fitro, Honour, JohnSmith, Krace, MrPotatoMagic, Myrault, ReadyPlayer2, SovaSlava, SpicyMeatball, TheSavageTeddy, Tigerfrake, atoko, cryptphi, csanuragjain, d3e4, gesha17, kinda_very_good, krikolkk, matejdb, max10afternoon, miaowu, n0kto, nuthan2x, parlayan_yildizlar_takimi, peanuts, petro_1912, pontifex, psb01, pynschon, rouhsamad, shaflow2, slippopz, spark, turvy_fuzz, web3pwn, zhaojohnson
7.1828 USDC - $7.18
The LiquidInfrastructureERC20
contract exhibits a critical flaw where the zero address (address(0)
) is automatically added to the holders
array during every token burn transaction. This occurs due to a lack of proper validation in the _beforeTokenTransfer
function, which should prevent the zero address from being included in the holder tracking mechanism. The presence of address(0)
in the holders
array can lead to unnecessary complications and inefficiencies within the contract's operations, particularly in functions that rely on iterating over the array of token holders.
While minting new tokens by the owner can remove most instances of address(0)
from the holders
array due to cleanup logic in the _afterTokenTransfer
function, this mitigation is ineffective if a Denial of Service (DOS) condition has already occurred. In such a state, the holders
array cannot be cleared, and the contract's functionality is severely compromised.
An attacker can exploit this vulnerability to inflate the holders
array by conducting burn transactions with zero tokens (An approved or disapproved token holder can do this by burning a negligible amount of tokens, such as 1 wei). This action causes the zero address to be repeatedly added to the array, which can significantly increase its size. The inflated holders
array may lead to a Denial of Service (DOS) attack, as critical functions that iterate over the array could fail due to gas constraints.
Furthermore, this vulnerability can be used for gas griefing, where the attacker intentionally raises transaction costs for legitimate users and the contract owner, thereby hindering the contract's usability and disrupting its intended operations.
holders
array increases gas costs for functions that iterate over it, such as distribute
. Attackers can exploit this to increase operational costs for legitimate users.holders
array can cause functions that iterate over it to fail due to out-of-gas errors, disrupting critical contract functionalities such as token transfers, minting, burning, and reward distribution, potentially leading to a DOS condition.holders
array. Affected functionalities include, but are not limited to, distributeToAllHolders
, mintAndDistribute
, burnAndDistribute
, and burnFromAndDistribute
, which all rely on a clean and accurate holders
array to operate correctly.The problem lies in the _beforeTokenTransfer
function, which is called during the burn process and does not properly prevent the zero address from being added to the holders
array.
127: function _beforeTokenTransfer( 128: address from, 129: address to, 130: uint256 amount 131: ) internal virtual override { 132: require(!LockedForDistribution, "distribution in progress"); 133: @--> if (!(to == address(0))) { // @audit This line is only applicable for transfer/mint 134: require( 135: isApprovedHolder(to), 136: "receiver not approved to hold the token" 137: ); 138: } 139: if (from == address(0) || to == address(0)) { 140: _beforeMintOrBurn(); 141: } 142: bool exists = (this.balanceOf(to) != 0); 143: @--> if (!exists) { // @audit Here there is no check to exclude address(0) 144: holders.push(to); 145: } 146: }
In this code, the exists
variable is used to check if the to
address already has a balance. If it does not, the to
address is added to the holders
array. However, there is no check to ensure that to
is not the zero address. During a burn operation, to
will be address(0)
, and since address(0)
cannot have a balance, it will be added to the holders
array.
Now, let's consider the burn operation itself. When a user burns tokens, the _burn
function is called, which in turn calls _beforeTokenTransfer
with to
set to address(0)
.
The _burn
function will call _beforeTokenTransfer(from, address(0), amount)
, and due to the missing check against address(0)
, the zero address will be added to the holders
array.
An attacker or any user can exploit this by burning a negligible amount of tokens, such as 1 wei or 0. This would trigger the _beforeTokenTransfer
function and, due to the lack of a check against address(0)
, add the zero address to the holders
array.
The following script explains how holders
array is inflate with address(0) while burning tokens. Place this file under test
folder and run using npm run test
import chai from "chai"; import { ethers } from "hardhat"; import { deployContracts, deployLiquidERC20 } from "../test-utils"; import { LiquidInfrastructureERC20 } from "../typechain-types/contracts"; import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; const { expect } = chai; // This test makes assertions about the LiquidInfrastructureERC20 contract by running it on hardhat async function liquidErc20Fixture() { const signers = await ethers.getSigners(); const nftAccount1 = signers[0]; const nftAccount2 = signers[1]; const nftAccount3 = signers[2]; const erc20Owner = signers[3]; const holder1 = signers[4]; const holder2 = signers[5]; const holder3 = signers[6]; const holder4 = signers[7]; const badSigner = signers[8]; // Deploy several ERC20 tokens to use as revenue currencies const { testERC20A, testERC20B, testERC20C } = await deployContracts( erc20Owner ); const erc20Addresses = [ await testERC20A.getAddress(), await testERC20B.getAddress(), await testERC20C.getAddress(), ]; // Deploy the LiquidInfra ERC20 token with no initial holders nor managed NFTs const infraERC20 = await deployLiquidERC20( erc20Owner, "Infra", "INFRA", [], [], 500, erc20Addresses ); return { infraERC20, testERC20A, testERC20B, testERC20C, signers, nftAccount1, nftAccount2, nftAccount3, erc20Owner, holder1, holder2, holder3, holder4, badSigner, }; } // Attack function async function attack( infraERC20: LiquidInfrastructureERC20, holder: HardhatEthersSigner, attacker: HardhatEthersSigner ) { // 1. Approve holder await expect(infraERC20.approveHolder(holder.address)).to.not.be.reverted; // 2. Mint 500 token to holder address await expect(infraERC20.mint(holder.address, 500)).to.not.be.reverted; console.log("1. 500 tokens minted to holder adress"); console.log("Holder address added to holder array as 0'th element: ", await infraERC20.holders(0)); const infraERC20Holder = infraERC20.connect(holder); // 3. 10 token burned by holder await expect(infraERC20Holder.burn(10)).to.not.be.reverted; console.log("\n 2. 10 tokens burned by holder"); console.log("address(0) added to holder array as 1'th element: ",await infraERC20.holders(1)); // 4. 10 token burned by holder await expect(infraERC20Holder.burn(10)).to.not.be.reverted; console.log("\n 3. 10 tokens burned by holder"); console.log("address(0) added to holder array as 2'th element: ",await infraERC20.holders(2)); const infraERC20Attacker = infraERC20.connect(attacker); // 5. 0 token burned by non approved attacker await expect(infraERC20Attacker.burn(0)).to.not.be.reverted; console.log("\n 4. 0 tokens burned by attacker"); console.log("address(0) added to holder array as 3'rd element: ",await infraERC20.holders(3)); } describe("LiquidInfrastructureERC20 Holder Inflation Attack", function () { it.only("Attack holders", async function () { const { infraERC20, holder1, holder2, badSigner } = await liquidErc20Fixture(); attack(infraERC20, holder1, badSigner); }); });
Output
</details>LiquidInfrastructureERC20 Holder Inflation Attack ✔ Attack holders (2289ms) 1. 500 tokens minted to holder adress Holder address added to holder array as 0'th element: 0x2fFd013AaA7B5a7DA93336C2251075202b33FB2B 2. 10 tokens burned by holder address(0) added to holder array as 1'th element: 0x0000000000000000000000000000000000000000 3. 10 tokens burned by holder address(0) added to holder array as 2'th element: 0x0000000000000000000000000000000000000000 4. 0 tokens burned by attacker address(0) added to holder array as 3'rd element: 0x0000000000000000000000000000000000000000 1 passing (3s)
Manual review and hardhat test
Update Validation Logic: Amend the _beforeTokenTransfer
function to include a conditional check that specifically excludes address(0)
from being added to the holders array. This check should be placed before the logic that appends new addresses to the array.
bool exists = (this.balanceOf(to) != 0); - if (!exists) { + if (!exists && to != address(0)) { holders.push(to); }
DoS
#0 - c4-pre-sort
2024-02-22T06:49:02Z
0xRobocop marked the issue as duplicate of #77
#1 - c4-judge
2024-03-04T13:24:01Z
0xA5DF marked the issue as satisfactory
🌟 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 LiquidInfrastructureERC20
contract's distribution logic calculates the entitlement of rewards based on the total supply of LiquidInfrastructureERC20
tokens, without distinguishing between approved and disapproved holders. As a result, when disapproved holders are excluded from a distribution cycle, their share of the rewards is not distributed. This undistributed portion is then carried over to the next distribution cycle, where it may be distributed among a potentially different set of approved holders, including any new holders added since the last cycle. This can lead to a situation where previously approved holders receive a smaller share of the rewards than they are entitled to, as the undistributed portion from disapproved holders is shared with new approved holders.
The distribute
function is responsible for allocating rewards to token holders. It invokes the _beginDistribution
function to calculate the total amount available for distribution. During this process, the function iterates over the array of distributableERC20s
to determine each holder's entitlement based on the contract's balance of each ERC20 token and the total supply of LiquidInfrastructureERC20
tokens. This total supply calculation includes the balances of both approved and disapproved holders. However, since disapproved holders do not receive their entitlements, these amounts are not distributed and are effectively carried forward to the next distribution round.
function distribute(uint256 numDistributions) public nonReentrant { // --- Other Code --- if (!LockedForDistribution) { // --- Other Code --- @--> _beginDistribution(); // @audit this function will calculate total amount available for distribution. } // --- Other Code --- uint i; for (i = nextDistributionRecipient; i < limit; i++) { address recipient = holders[i]; @--> if (isApprovedHolder(recipient)) { // @audit : Disapproved holders' entitlements are not distributed, this will effectively carry forward to next round 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); } } // --- Other Code --- } function _beginDistribution() internal { // --- Other Code --- // 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; // @audit entitlement is calculated based on the total supply which include disapproved holders balance erc20EntitlementPerUnit.push(entitlement); } // --- Other Code ---
In the next distribution cycle, the undistributed rewards from the previous cycle are included, potentially being distributed to a different set of holders, including any new approved holders.
let's consider a scenario involving three holders: Alice, Bob, and Alex.
Alice holds 2,000 tokens Bob holds 3,000 tokens Alex holds 5,000 tokens.
However, Alex is a disapproved holder and is not eligible to receive distributions.
The total supply of LiquidInfrastructureERC20
tokens is 10,000, which is the sum of the tokens held by all three holders.
Assume the contract has 1,000 distributableERC20s
tokens available for distribution. The distribution should be allocated based on each holder's proportion of the total supply.
Here's the initial distribution calculation:
(2000 / 10000) * 1000 = 200 tokens
(3000 / 10000) * 1000 = 300 tokens
(5000 / 10000) * 1000 = 500 tokens
Since Alex is disapproved, he does not receive his 500 tokens. These tokens remain undistributed within the contract. The problem arises in the next distribution cycle, especially if new approved holders are added or existing holders change their holdings. If the undistributed 500 tokens are carried over, they may be incorrectly factored into the next distribution.
For instance, if a new holder, Charlie, enters the scene after receiving 2,000 tokens minted by the contract owner. This event increases the total supply of LiquidInfrastructureERC20
tokens to 12,000. Alex, who remains a disapproved holder, still possesses his original 5,000 tokens. Meanwhile, the 500 tokens from the previous cycle that were not distributed due to Alex's disapproved status are awaiting redistribution.
With the total supply now adjusted to account for the newly minted tokens for Charlie, the distribution calculations for the upcoming cycle, inclusive of the carried-over tokens, are as follows:
distributableERC20s
tokens (1,000 + 500)The recalculated distribution would yield:
(2000 / 12000) * 1500 = 250 tokens
(3000 / 12000) * 1500 = 375 tokens
(5000 / 12000) * 1500 = 625 tokens
(Alex's share remains undistributed due to his status)(2000 / 12000) * 1500 = 250 tokens
In this scenario, Alice, Bob, and Charlie receive their shares based on the new total supply, which includes Charlie's minted tokens. However, the undistributed 500 tokens from Alex's share in the previous cycle are now effectively spread across Alice, Bob, and Charlie's distributions. This dilutes the rewards that Alice and Bob would have received if the undistributed tokens had been reallocated to them in the first cycle, considering they were the only approved holders at that time.
Manual Review
Adjust Entitlement Calculation: Revise the entitlement calculation to exclude the tokens held by disapproved holders from the total supply considered for distribution. This change will ensure that only the tokens held by approved holders are included in the calculation, preventing the carryover of undistributed rewards to future cycles and maintaining the intended proportionality of the distribution.
Sample Code
function _beginDistribution() internal { // --- Other Code --- // Calculate the entitlement per token held - uint256 supply = this.totalSupply(); + uint256 supply = this.totalSupply() - getTotalDisapprovedHoldersBalance(); for (uint i = 0; i < distributableERC20s.length; i++) { uint256 balance = IERC20(distributableERC20s[i]).balanceOf( address(this) ); uint256 entitlement = balance / supply; erc20EntitlementPerUnit.push(entitlement); } // --- Other Code --- + /** + * @dev Function to calculate the total balance held by disapproved holders. + * @return totalDisapprovedBalance The total balance of disapproved holders. + */ + function getTotalDisapprovedHoldersBalance() public view returns (uint256 totalDisapprovedBalance) { + totalDisapprovedBalance = 0; + for (uint256 i = 0; i < holders.length; i++) { + if (!isApprovedHolder[holders[i]]) { + totalDisapprovedBalance += balanceOf(holders[i]); + } + } + return totalDisapprovedBalance; + }
Please note that iterating over a potentially large array can be gas-intensive.
Tracking the total balance of disapproved holders in another state variable is another alternative.
Math
#0 - c4-pre-sort
2024-02-22T06:20:01Z
0xRobocop marked the issue as duplicate of #703
#1 - c4-judge
2024-03-04T14:42:08Z
0xA5DF marked the issue as satisfactory
🌟 Selected for report: 0xAadi
Also found by: 0xbrett8571, DarkTower, JcFichtner, JrNet, MrPotatoMagic, PoeAudits, TheSavageTeddy, ZanyBonzy, clara
390.3215 USDC - $390.32
This report presents a comprehensive analysis of the Althea Liquid Infrastructure protocol's smart contracts. The audit scrutinizes the contracts' functionality, security measures, and overall code quality, identifying critical issues and providing recommendations for improvement.
The pivotal contracts in this audit for the Althea Liquid Infrastructure protocol are:
contracts/LiquidInfrastructureERC20.sol: This contract is an ERC20 token designed to collect revenue from Liquid Infrastructure NFT contracts and distribute it to token holders. Holders of LiquidInfrastructureERC20
tokens are entitled to a share of the profits from the associated Liquid Infrastructure NFTs, allowing for passive income generation.
contracts/LiquidInfrastructureNFT.sol: The LiquidInfrastructureNFT
contract is an ERC721 token representing ownership of a Liquid Infrastructure Account within the Althea network. It is linked to the x/microtx module, which facilitates periodic revenue in ERC20 tokens. The LiquidInfrastructureERC20
contract uses the LiquidInfrastructureNFT
to manage the withdrawal and distribution of these tokens to its holders.
contracts/OwnableApprovableERC721.sol: This abstract contract defines key modifiers used by the LiquidInfrastructureNFT.sol
contract. It ensures that certain functions are only executable by the token owner or an approved delegate, enhancing security and functionality.
During the audit of the Althea Liquid Infrastructure protocol's smart contracts, I conducted a thorough examination of the provided codebase, including the associated test files. My audit strategy encompassed an in-depth review of the Solidity code, with a particular emphasis on security aspects. To validate the functionality and robustness of the contracts, I compiled the code and executed a series of tests across various scenarios. Additionally, I verified the main invariants, explored attack ideas presented by the protocol in the scope. This rigorous analysis allowed me to assess the performance and security safeguards of the contracts under a range of conditions. Furthermore, I confirmed the integrity of the main invariants, brainstormed potential attack vectors, and took into account extra contextual details to ensure a comprehensive and meticulous audit process.
This is the Tokenized Representation of Infrastructure Accounts. It issues a unique NFT (non-fungible token) that represents ownership of a Liquid Infrastructure Account. This account is typically associated with infrastructure involved in an Althea pay-per-forward network, such as internet service provision. The contract is designed to collect periodic revenue. On Althea-L1 chains, this revenue comes from the x/bank module, which conducts microtransactions as the payment layer for Althea networks.
Owner: The contract owner is the initial holder of the NFT created upon contract deployment. This user has the highest level of privileges, like Setting Balance Thresholds and Withdrawing Balances.
Approved User:These are users who have been granted specific permissions by the contract owner.Their privileges are Withdrawing Balances and Managing Thresholds
The contract interacts with the x/microtx module to manage the balance of revenue coins(USDC,USDT) within the Liquid Infrastructure Account. It sets thresholds to determine how much of each token should be retained in the contract, with excess amounts being deposited into the LiquidInfrastructureNFT contract.
Owners or Approved users of the Liquid Infrastructure Account can withdraw the accumulated ERC20 balances. This allows them to access the funds that have been converted and transferred to the contract.
The LiquidInfrastructureERC20 contract is an ERC20 token designed to interface with Liquid Infrastructure NFT (Non-Fungible Token) contracts. The primary purpose of the contract is to collect revenue generated by these NFTs and distribute it to the holders of the LiquidInfrastructureERC20 tokens.
Owner: The owner is the account that deployed the contract and has the highest level of privileges. The Ownable contract from OpenZeppelin provides onlyOwner modifier that restricts certain functions to be callable only by the owner. The owner has the ability to perform actions such as adding or releasing managed NFTs, approving or disapproving holders, and initiating distributions.
Approved Holders: The approved holders can receive and hold the LiquidInfrastructureERC20 tokens. This role is managed through the HolderAllowlist and the approved holders are entitled to receive revenue through distribution.
Approve/Disapprove Holder: A mechanism to maintain a list of addresses that are authorized (or not authorized) to hold the contract's tokens. This feature is typically used to enforce certain restrictions on who can own or transact with the tokens.
Add/Release NFTs: The Add/Release NFTs functionality refers to the ability to manage a collection of NFTs that the contract interacts with or controls. This functionality allows the contract owner to add new NFTs to the contract's management or release them from the contract's control.
Set Distributable ERC20s: This is to define and update the list of ERC20 tokens that are eligible for distribution to the token holders. This feature is used for aggregate revenue or rewards in the form of ERC20 tokens and then distributed to the holders of the contract's native token.
Mint InfraERC20: The mint function allows the contract to generate new InfraERC20 tokens. These tokens are typically credited to a specific address. Minting is a privileged action controlled by the contract owner. This ensures that new tokens are created responsibly and in accordance with the token's monetary policy.
This is to withdraw ERC20 tokens that may have accumulated in associated NFT contracts. NFTs associated with real-world assets or digital services that generate revenue. This revenue will be collected in the form of ERC20 tokens, which are held by the NFT contract until they are withdrawn.
This allows the contract to retrieve ERC20 tokens from the NFT contracts it manages. This withdrawal process transfers the accumulated tokens from the NFT contracts to the LiquidInfrastructureERC20 contract.
The contract prevents withdrawal from being performed while a distribution is in progress. This is to ensure the integrity of the distribution process and to prevent state changes that could affect the distribution.
The LiquidInfrastructureERC20 contract aggregates the revenue from the NFT contracts and distributes it among its token holders. The distribution is proportional to the number of tokens each holder owns, meaning that the more tokens a user holds, the larger their share of the revenue.
Users who hold LiquidInfrastructureERC20 tokens can earn additional ERC20 tokens as revenue. This creates an incentive mechanism for users to invest in and hold the LiquidInfrastructureERC20 tokens.
The burn function allows token holders or approved users to destroy a specified amount of InfraERC20 tokens from their balance. The contracts enforce a distribution before burning to maintain consistency in the contract's state, especially while the distribution amount per token is calculated based on the current total supply.
The ERC20 transfer performs a few additional steps while doing the transfer. The contract checks whether the contract is currently locked for distribution and whether the recipient is an approved holder while performing a transfer. It also adds the recipient to the holders list if they are receiving tokens for the first time.And also it removes the sender from the holders list if their balance has dropped to zero as a result of the transfer.
The codebase exhibits a high level of quality by delivering clarity in functionality through self-explanatory code, employing simple functionalities and logic. And, its readability is enhanced due to the well-documented comments. Despite handling complex calculations, the code is inherently explanatory and highly understandable for both auditors and developers.
The codebase demonstrates a high level of modularity, effectively segregating complex logics and operations into separate functions, particularly with the reward distribution. The use of distinct contracts to manage the access control contributes to reducing overall complexity.
Insufficient comments throughout the contracts limit the clarity of each function, requiring auditors and developers to invest more time in understanding the functionality. Improving comments to thoroughly explain the various functionalities would greatly enhance the code's accessibility and comprehension.
The codebase effectively employs access control mechanisms to ensure smooth execution of functions, it falls short in securing some critical functions. The distribute
and withdrawFromManagedNFTs
functions are publicly accessible, allowing any user to execute them without proper access checks. This lack of control could lead to scenarios where users initiate distribution or withdrawal processes but do not complete them, consequently burdening other users or the owner with the task of finishing these operations. Such situations could render these vital contract functions idle, disrupting the contract's critical operations until the pending actions are resolved.
The contracts emit events for all functions that interact with users, which is a crucial feature for tracking and verifying contract activity. However, the events have not been optimized with appropriate indexing, which is a significant oversight. Proper indexing of event parameters is essential for enabling efficient searches and retrievals within event logs. Without indexing, users and applications may face challenges when attempting to filter and access specific event data.
The contracts exhibit a moderate level of error handling; however, there is room for improvement, particularly in the administrative functions, which currently lack essential validations, such as preventing the addition of duplicate NFTs to the managed NFT list. Additionally, the contracts rely solely on require
statements for error handling, without utilizing separate error types. To refine the codebase, it is advisable to implement a dedicated library contract that centralizes error definitions. Adopting this strategy would not only enhance the code's structure but also its maintainability, leading to a more robust and clear error handling system.
It is advisable for the contracts to utilize named imports consistently throughout the codebase. This practice enhances readability and maintainability by clearly indicating the specific functions, variables, or types that are being imported from external contracts or libraries.
The contracts currently do not incorporate any libraries. However, it is advisable to consider using a separate library to encapsulate the errors and events in the contracts. This practice can enhance modularity and maintainability within the codebase.
Entities utilizing Althea's networking devices deploy LiquidInfrastructureNFTs to tokenize their assets within the ecosystem. By doing so, they create a digital representation of their infrastructure on the blockchain. The process involves setting the Liquid Infrastructure ERC20s contract as the owner of the NFTs, which enables it to manage the assets and withdraw any revenue that exceeds predefined threshold amounts. This approach allows for the seamless collection of revenue generated from the x/bank module.
The owner of the LiquidInfrastructureERC20s contract has the capability to add or remove LiquidInfrastructureNFTs with the contract. This is achieved through two functions: addManagedNFT()
and releaseManagedNFT()
. The addManagedNFT()
function is designed to onboard new NFTs into the system; however, it lacks a crucial check to verify whether the NFT has already been added. This oversight introduces a risk where, if the owner inadvertently adds the same NFT more than once, the contract does not revert the action. Consequently, when the withdrawal functionality is invoked, it is required to withdraw ERC20 tokens from the NFT contract multiple times, leading to unnecessary gas expenditures.
On the other hand, the releaseManagedNFT()
function is removing NFTs from the contract's management. It operates under the assumption that there are no duplicate entries in the ManagedNFTs array. However, as previously mentioned, the possibility of duplication exists. The current implementation of releaseManagedNFT()
only eliminates the first occurrence of the NFT contract in a single transaction, leaving any additional duplicates untouched.
To address these issues, it would be prudent to introduce a mapping in parallel with the ManagedNFTs array. This mapping would maintain the index of each NFT address, thereby ensuring the uniqueness of entries in the array and eliminating the need for costly iterations. When the need arises to remove an NFT, the index can be retrieved from the mapping, allowing for efficient removal from the array and effectively preventing any duplication.
Furthermore, the existing require
statement within the releaseManagedNFT()
:
// By this point the NFT should have been found and removed from ManagedNFTs require(true, "unable to find released NFT in ManagedNFTs");
is flawed as it will invariably pass, given that the condition is always satisfied. This means that the associated event will be triggered regardless of whether the NFT was actually found or removed from the array. To ensure the integrity of the contract's operations, this require statement should be corrected to accurately reflect the success or failure of the NFT's removal.
Another concern is that neither addManagedNFT()
nor releaseManagedNFT()
currently checks for ongoing withdrawal processes. It is essential to ensure that no updates to the NFTs under management are made while a withdrawal is in progress. If a withdrawal is underway, these functions should block any attempts to add or release NFTs until the withdrawal is complete. This safeguard would prevent potential conflicts or inconsistencies during the management of the NFTs, especially in the context of financial operations.
The setDistributableERC20s()
function is utilized within the LiquidInfrastructureERC20
contract to establish a new array of ERC20 tokens that are eligible for distribution to token holders. However, this function does not provide the flexibility to add or remove individual ERC20 tokens from the list; it requires setting the entire array at once. While managing a small number of ERC20 tokens may not pose significant issues, the gas costs associated with updating the list can escalate as the number of tokens increases, due to the inherent nature of blockchain transactions and the need to store more data on-chain.
The current implementation lacks a safeguard to prevent updates to the distributable ERC20 tokens while a distribution is actively in progress. This oversight could lead to complications if the list of distributable tokens is altered mid-distribution, potentially affecting the accuracy and fairness of the distribution process.
The LiquidInfrastructureERC20
contract functions—approveHolder()
, disapproveHolder()
, and isApprovedHolder()
—designed to manage the list of entities authorized to hold its ERC20 tokens. A dedicated holders
array within the contract keeps track of all token holder addresses.
Token transfers within the contract are confined to approved holders. Consequently, any address that receives the token is automatically appended to the holders
array. Being an approved holder is a key criterion for eligibility to receive rewards as well as tokens, which are allocated among those on the approved list. Moreover, approved holders are at liberty to trade their LiquidInfrastructureERC20
tokens on external markets, thus providing liquidity and facilitating the discovery of the token's market price.
The withdrawFromManagedNFTs()
function serves a crucial role in managing the flow of ERC20 tokens from associated NFT contracts. Designed to be publicly callable, this function allows any user to initiate the withdrawal of accumulated ERC20 tokens, such as stablecoins, from the NFT contracts that the LiquidInfrastructureERC20
oversees. To safeguard against gas-based Denial of Service (DOS) attacks, the function is resistant to excessive gas consumption, ensuring that the withdrawal process remains efficient and secure. However, to maintain the integrity of ongoing reward distributions, the withdrawFromManagedNFTs()
function cannot be executed while a distribution is in progress, preventing any potential interference with the allocation of rewards to token holders.
The distribute
function in the contract is publicly accessible, enabling any party to trigger the distribution process after the MinDistributionPeriod
has passed. This functionality guarantees that approved holders can receive their rewards promptly. The rewards are issued in stablecoins, offering a dependable value for the beneficiaries. To counteract potential gas-based Denial of Service (DOS) attacks, the distribute
function is crafted to be resilient against DOS related to gas limits by facilitating the distribution across several incremental steps. This approach allows for a smooth and secure reward distribution that aligns with the holders' InfraERC20
token balances.
When calculating reward entitlements, it is critical to account for the decimals of the reward tokens to ensure precision. To avoid the common issue of division before multiplication, which can lead to rounding errors and loss of precision, the contract should take a snapshot of the token balances at the start of the distribution cycle. This snapshot will then be used to accurately calculate the reward entitlement for each holder during the distribution process. By multiplying the holder's balance by the reward per token first and then dividing by the total supply, the contract can maintain the integrity of the distribution calculations.
Furthermore, the contract should not include the balances of disapproved holders when calculating the total supply for reward entitlements. If disapproved holders' balances are factored into the total supply, their undistributed portion of rewards will carry over to subsequent distribution rounds. This carryover can create disparities in reward distribution, as future approved holders may receive a share of rewards that were not allocated in previous rounds. To prevent such inequities, only the balances of approved holders should be considered in the calculation of reward entitlements, ensuring a fair and proportional distribution of rewards to eligible participants.
The minting functionality is exclusively reserved for the contract owner, ensuring that only the owner can issue new tokens to users. This level of control is crucial for maintaining the integrity of the token's supply and for adhering to any predefined tokenomics. Additionally, the contract stipulates that a distribution must occur before any minting can take place, provided that the MinDistributionPeriod
has been reached. This requirement ensures that all entitled token holders receive their due rewards before any new tokens are introduced into circulation, thereby preserving the fairness and accuracy of the distribution process.
The burn functionality is accessible to token holders and users who have been granted approval, allowing them to reduce the token supply by destroying a specified amount of their tokens. The contract enforces a rule that requires a distribution to be completed before any new burning actions if the MinDistributionPeriod
has elapsed. This ensures that existing token holders receive their allocated rewards based on the current supply before any decrease in the total number of tokens occurs, maintaining the equitable distribution of rewards.
The transfers of tokens are designed to occur exclusively between approved holder addresses. This restriction ensures that only approved holders can receive the token and holding of tokens, aligning with the contract's security and compliance measures. After each transfer, if the recipient's (to
address) balance increases from zero as a result of the transaction, their address is automatically added to the holders
array. Conversely, if a sender's (from
address) balance is depleted to zero during the transfer, their address is promptly removed from the holders
array, reflecting the change in their token holding status. This dynamic updating of the holders
array after each transfer maintains an accurate record of current token holders within the contract.
function _afterTokenTransfer( address from, address to, uint256 amount ) internal virtual override { bool stillHolding = (this.balanceOf(from) != 0); if (!stillHolding) { @--> for (uint i = 0; i < holders.length; i++) { if (holders[i] == from) { // Remove the element at i by copying the last one into its place and removing the last element holders[i] = holders[holders.length - 1]; holders.pop(); } } } }
The _afterTokenTransfer
function is designed to update the holders
array, which tracks the addresses currently holding tokens. As shown in the provided Solidity code snippet, the function includes a loop that checks if the from
address, after transferring tokens, still holds any tokens.
However, this logic presents a potential inefficiency during the minting process. When new tokens are minted, the from
address is the zero address (address(0)
), and since the zero address cannot hold tokens, its balance is always zero. The loop in the _afterTokenTransfer
function would then be executed unnecessarily, iterating over the entire holders
array without the possibility of finding an entry for address(0)
to remove. This unnecessary iteration can result in the contract owner incurring significant gas costs over time, especially as the holders
array grows. To optimize gas usage and avoid this inefficiency, the loop should be bypassed or excluded from execution during minting operations, ensuring that minting remains cost-effective and does not impact the contract's performance.
During the token burning process within the LiquidInfrastructureERC20
contract, a significant issue arises where the zero address (address(0)
) is mistakenly added to the token holders array. See _beforeTokenTransfer
function. Consequently, each burn transaction triggers the addition of address(0)
to this array. This erroneous behavior leads to an inflated holders array, which not only consumes unnecessary storage space but also increases the risk of gas griefing. As the array grows with each burn transaction, operations that need to iterate over the array become more gas-intensive, potentially resulting in a systemwide Denial of Service (DOS) if the gas costs exceed block limits, thereby disrupting the contract's and possibly the broader ecosystem's operations.
function _beforeTokenTransfer( address from, address to, uint256 amount ) internal virtual override { require(!LockedForDistribution, "distribution in progress"); if (!(to == address(0))) { require( isApprovedHolder(to), "receiver not approved to hold the token" ); } if (from == address(0) || to == address(0)) { _beforeMintOrBurn(); } bool exists = (this.balanceOf(to) != 0); if (!exists) { @--> holders.push(to); } }
The same flawed logic within the contract does not prevent zero-amount transfers, which can further exacerbate the issue. If a transfer of zero tokens is made to an address with a zero balance, that address is also incorrectly added to the holders array. This can lead to repeated and unnecessary entries in the holders list, compounding the bloating problem. As with the burning issue, this can cause similar complications, including increased gas costs for iterating over the holders array and the potential for a systemwide DOS, affecting the contract's functionality and efficiency.
The current implementation of the LiquidInfrastructureERC20
contract's distribution process encounters a significant challenge due to the handling of decimal places and the order of operations in calculating entitlements. The logic does not properly account for the decimal differences between distributableERC20s
tokens such as USDC, USDT, or DAI, which typically have 10^6
decimals, compared to the InfraERC20
token's total supply with 10^18
decimals. This leads to a situation where division is performed before multiplication, necessitating an impractically high minimum balance of 1 trillion USDC
to initiate the distribution process. To rectify this, the contract should be updated to perform multiplication before division and to scale the distributableERC20s
tokens to the same decimal precision as InfraERC20
tokens before calculating entitlements.
The LiquidInfrastructureERC20
contract's distribution logic calculates the entitlement of rewards based on the total supply of tokens, without distinguishing between approved and disapproved holders. As a result, when disapproved holders are excluded from a distribution cycle, their share of the rewards remains undistributed. This undistributed portion is then carried over to the next distribution cycle, where it may be unfairly allocated among a new set of approved holders, potentially including those who have been added since the last cycle. This can lead to a dilution of rewards for existing approved holders who were entitled to a larger share. To ensure equitable distribution, the contract should adjust the entitlement calculation to exclude the balances of disapproved holders from the total supply.
The LiquidInfrastructureERC20
contract's lack of an emergency withdrawal mechanism for undistributed distributableERC20s
tokens presents a critical issue. This becomes particularly problematic when the contract owner updates the list of distributable tokens using the setDistributableERC20s
function. If certain tokens are omitted from the updated list, their remaining balance could become locked within the contract with no way to retrieve them. This not only prevents the reallocation of these funds but also poses a risk of permanent loss of assets. Implementing an emergency withdrawal function would enable the contract owner to recover undistributed tokens, ensuring that the contract's assets remain accessible and manageable.
The vulnerability has been identified in the LiquidInfrastructureERC20
contract, where an attacker can exploit zero-token transfers to manipulate/inflate the holder array. This attack involves sending zero-token transactions to an approved holder whose balance is zero. Despite not transferring any actual tokens, this action results in the recipient's address being added to the holder array multiple times.
The LiquidInfrastructureERC20
contract exhibits a critical flaw where the zero address (address(0)
) is automatically added to the holders
array during every token burn transaction. This occurs due to a lack of proper validation in the _beforeTokenTransfer
function, which should prevent the zero address from being included in the holder tracking mechanism. The presence of address(0)
in the holders
array can lead to unnecessary complications and inefficiencies within the contract's operations, particularly in functions that rely on iterating over the array of token holders.
The LiquidInfrastructureERC20
contract faces an issue in its reward distribution logic due to the order of operations where division is performed before multiplication. This sequence, particularly when using integer arithmetic in Solidity, results in premature truncation of values. Consequently, this leads to incorrect calculations of reward entitlements. The problem is further compounded by the contract's InfraERC20
token having a different decimal precision (18 decimals) than the distributable ERC20 token, such as USDC, which has 6 decimals. The flaw is located within the erc20EntitlementPerUnit
calculation in the _beginDistribution
function. The current implementation, due to the handling of decimal places and the order of operations for calculating entitlements, inadvertently necessitates a prohibitively high minimum balance of 1 trillion USDC
to initiate the distribution process.
This high threshold is impractical and hinders the contract's ability to conduct distributions.
The Althea Liquid Infrastructure protocol utilizes the LiquidInfrastructureERC20
contract, which assigns critical responsibilities to the Owner role. This role is pivotal for executing various administrative tasks, including approving or disapproving token holders (approveHolder
, disapproveHolder
), minting new tokens, adding or releasing managed NFTs (addManagedNFT
, releaseManagedNFT
), and setting the list of distributable ERC20 tokens (setDistributableERC20s
). All these functions are safeguarded by OpenZeppelin's Ownable
contract, which provides a standardized approach to access control. However, this reliance on a single Owner role introduces a potential vulnerability related to centralization, where a single point of failure could pose risks to the protocol's security and integrity.
To mitigate this risk, it is strongly recommended to implement OpenZeppelin's Ownable2Step contract. This step enhances security by addressing centralization concerns in the protocol.
_isApprovedOrOwner
in OpenZeppelin ContractsOpenZeppelin (OZ) has recently made a significant update to their ERC721 contract by removing the _isApprovedOrOwner()
internal function in the latest version. This change introduces a risk for contracts that rely on this function, such as the OwnableApprovableERC721
contract and, by extension, the LiquidInfrastructureNFT
contract. Since these contracts are built upon the assumption that _isApprovedOrOwner()
is available for use, updating to the latest version of OpenZeppelin's contracts without appropriate modifications could lead to compatibility issues or even contract failure. It is crucial for developers and auditors to be aware of this change and to ensure that any contracts depending on _isApprovedOrOwner()
are either updated to work with the new OpenZeppelin implementation or are locked to a compatible version of the OpenZeppelin contracts to mitigate potential risks associated with this breaking change.
distribute
and withdrawFromManagedNFTs
The absence of stringent access control on pivotal functions like distribute
and withdrawFromManagedNFTs
presents a systemic risk that extends beyond individual contract vulnerabilities to potentially affect the entire ecosystem dependent on it. This risk materializes when unauthorized users are able to invoke these functions. This lack of control could lead to scenarios where users initiate distribution or withdrawal processes but do not complete them, consequently burdening other users or the owner with the task of finishing these operations. Such situations could render these vital contract functions idle, disrupting the contract's critical operations until the pending actions are resolved.
The contract lacks a structured approach to upgradability. Without provisions for upgradability, deploying updates to address vulnerabilities or introduce improvements could pose challenges. Considering the integration of upgradability features is advisable to facilitate future enhancements and fixes.
The contract currently lacks a pausability mechanism, a critical feature for emergency halting of contract functions in response to vulnerabilities or attacks. Introducing a pausability feature would enable a swift response to protect user funds and maintain system integrity during unforeseen events.
48 hours
#0 - c4-pre-sort
2024-02-22T19:11:01Z
0xRobocop marked the issue as sufficient quality report
#1 - c4-judge
2024-03-08T14:32:30Z
0xA5DF marked the issue as grade-b
#2 - c4-judge
2024-03-10T08:41:35Z
0xA5DF marked the issue as selected for report
#3 - c4-judge
2024-03-10T08:41:39Z
0xA5DF marked the issue as grade-a
#4 - 0xA5DF
2024-03-10T08:44:47Z
I went again over the analysis reports, this one seems to also get the centralization risk part right and also the rest seems fair