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: 34/84
Findings: 1
Award: $104.73
🌟 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
104.7257 USDC - $104.73
https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L275 https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L222
When LiquidInfrastructureERC20
owner disapproves a holder, it prevents the holder from receiving any revenue from the contract. However, the disapproved holder will still keep part of the supply, diluting the revenue of the approved holders.
The dilution is a result of the calculation of the entitlements per token held, which is based on the division of the ERC20 balances held by the LiquidInfrastructureERC20
contract and the total supply of the LiquidInfrastructureERC20
token.
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; } // 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); } nextDistributionRecipient = 0; emit DistributionStarted(); }
Test Case:
function test_dilutedDistribution() public { address nftOwner1 = makeAddr("nftOwner1"); uint256 rewardAmount1 = 1000000; nftOwners = [nftOwner1]; vm.prank(nftOwner1); LiquidInfrastructureNFT nft1 = new LiquidInfrastructureNFT("nftAccount1"); nfts = [nft1]; // Register one NFT as a source of reward erc20s uint256 accountId = nft1.AccountId(); thresholdAmounts = [0]; // Transfer the NFT to ERC20 and manage vm.startPrank(nftOwner1); nft1.setThresholds(erc20Addresses, thresholdAmounts); nft1.transferFrom(nftOwner1, address(infraERC20), accountId); vm.stopPrank(); assertEq(nft1.ownerOf(accountId), address(infraERC20)); vm.expectEmit(address(infraERC20)); emit AddManagedNFT(address(nft1)); vm.startPrank(erc20Owner); infraERC20.addManagedNFT(address(nft1)); vm.roll(1); // Allocate rewards to the NFT erc20A.transfer(address(nft1), rewardAmount1); assertEq(erc20A.balanceOf(address(nft1)), rewardAmount1); // And then send the rewards to the ERC20 infraERC20.withdrawFromAllManagedNFTs(); // Approve holders infraERC20.approveHolder(address(holder1)); infraERC20.approveHolder(address(holder2)); // Mint LiquidInfrastructureERC20 tokens to holders // 200 total supply of LiquidInfrastructureERC20 tokens infraERC20.mint(address(holder1), 100); infraERC20.mint(address(holder2), 100); // Wait for the minimum distribution period to pass vm.roll(vm.getBlockNumber() + 500); // Distribute revenue to holders infraERC20.distributeToAllHolders(); console.log("First distribution (2 approved holders) \n balance of holder 1: %s", erc20A.balanceOf(address(holder1))); console.log("balance of holder 2: %s", erc20A.balanceOf(address(holder2))); console.log("balance remaining in infraERC20: %s", erc20A.balanceOf(address(infraERC20))); // Wait for the minimum distribution period to pass vm.roll(vm.getBlockNumber() + 500); // Allocate more rewards to the NFT erc20A.transfer(address(nft1), rewardAmount1); infraERC20.withdrawFromAllManagedNFTs(); // Holder 2 is no longer approved infraERC20.disapproveHolder(address(holder2)); // Now there is 1 holder remaining, but the rewards are diluted infraERC20.distributeToAllHolders(); console.log("\n Second distribution (1 approved holder) \n balance of holder 1: %s", erc20A.balanceOf(address(holder1))); console.log("balance of holder 2: %s", erc20A.balanceOf(address(holder2))); // There is remaining unallocated rewards in the contract console.log("balance remaining in infraERC20: %s", erc20A.balanceOf(address(infraERC20))); // holder 2 has 100 LiquidInfrastructureERC20 tokens, this dilutes the rewards assertEq(infraERC20.balanceOf(address(holder2)), 100); // Wait for the minimum distribution period to pass vm.roll(vm.getBlockNumber() + 500); // Distribute revenue to holders infraERC20.distributeToAllHolders(); console.log("\n Third distribution (1 approved holder) \n balance of holder 1: %s", erc20A.balanceOf(address(holder1))); console.log("balance of holder 2: %s", erc20A.balanceOf(address(holder2))); console.log("balance remaining in infraERC20: %s", erc20A.balanceOf(address(infraERC20))); }
Logs:
First distribution (2 approved holders) balance of holder 1: 500000 balance of holder 2: 500000 balance remaining in infraERC20: 0 Second distribution (1 approved holder) balance of holder 1: 1000000 balance of holder 2: 500000 balance remaining in infraERC20: 500000 Third distribution (1 approved holder) balance of holder 1: 1250000 balance of holder 2: 500000 balance remaining in infraERC20: 250000
Steps to reproduce the test:
Inside 2024-02-althea-liquid-infrastructure/liquid-infrastructure
folder:
npm i --save-dev @nomicfoundation/hardhat-foundry
- Install the hardhat-foundry plugin.require("@nomicfoundation/hardhat-foundry");
to the top of the hardhat.config.js
file.npx hardhat init-foundry
in the terminal. This will generate a foundry.toml
file based on the Hardhat project's existing configuration, and will install the forge-std library.2024-02-althea-liquid-infrastructure/liquid-infrastructure/test/liquidInfrastructureERC20Test.t.sol
forge test --mt test_dilutedDistribution -vv
in the terminal.Full Test Suite:
// SPDX-License-Identifier: MIT pragma solidity 0.8.12; import {Test, console} from "forge-std/Test.sol"; import {TestERC20A} from "../contracts/TestERC20A.sol"; import {LiquidInfrastructureERC20} from "../contracts/LiquidInfrastructureERC20.sol"; import {LiquidInfrastructureNFT} from "../contracts/LiquidInfrastructureNFT.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract LiquidInfrastructureERC20Test is Test { event AddManagedNFT(address nft); address nftAccount1 = makeAddr("nftAccount1"); address erc20Owner = makeAddr("erc20Owner"); address holder1 = makeAddr("holder1"); address holder2 = makeAddr("holder2"); LiquidInfrastructureERC20 infraERC20; address[] managedNFTs; address[] approvedHolders; address[] holders; address[] nftOwners; uint256[] thresholdAmounts; LiquidInfrastructureNFT[] nfts; ERC20[] rewardErc20s; TestERC20A erc20A; address[] erc20Addresses; function setUp() public { vm.startPrank(erc20Owner); erc20A = new TestERC20A(); erc20Addresses.push(address(erc20A)); infraERC20 = new LiquidInfrastructureERC20({ _name: "Infra", _symbol: "INFRA", _managedNFTs: managedNFTs, _approvedHolders: approvedHolders, _minDistributionPeriod: 500, _distributableErc20s: erc20Addresses }); vm.stopPrank(); } function test_dilutedDistribution() public { address nftOwner1 = makeAddr("nftOwner1"); uint256 rewardAmount1 = 1000000; nftOwners = [nftOwner1]; vm.prank(nftOwner1); LiquidInfrastructureNFT nft1 = new LiquidInfrastructureNFT("nftAccount1"); nfts = [nft1]; // Register one NFT as a source of reward erc20s uint256 accountId = nft1.AccountId(); thresholdAmounts = [0]; // Transfer the NFT to ERC20 and manage vm.startPrank(nftOwner1); nft1.setThresholds(erc20Addresses, thresholdAmounts); nft1.transferFrom(nftOwner1, address(infraERC20), accountId); vm.stopPrank(); assertEq(nft1.ownerOf(accountId), address(infraERC20)); vm.expectEmit(address(infraERC20)); emit AddManagedNFT(address(nft1)); vm.startPrank(erc20Owner); infraERC20.addManagedNFT(address(nft1)); vm.roll(1); // Allocate rewards to the NFT erc20A.transfer(address(nft1), rewardAmount1); assertEq(erc20A.balanceOf(address(nft1)), rewardAmount1); // And then send the rewards to the ERC20 infraERC20.withdrawFromAllManagedNFTs(); // Approve holders infraERC20.approveHolder(address(holder1)); infraERC20.approveHolder(address(holder2)); // Mint LiquidInfrastructureERC20 tokens to holders // 200 total supply of LiquidInfrastructureERC20 tokens infraERC20.mint(address(holder1), 100); infraERC20.mint(address(holder2), 100); // Wait for the minimum distribution period to pass vm.roll(vm.getBlockNumber() + 500); // Distribute revenue to holders infraERC20.distributeToAllHolders(); console.log("First distribution (2 approved holders) \n balance of holder 1: %s", erc20A.balanceOf(address(holder1))); console.log("balance of holder 2: %s", erc20A.balanceOf(address(holder2))); console.log("balance remaining in infraERC20: %s", erc20A.balanceOf(address(infraERC20))); // Wait for the minimum distribution period to pass vm.roll(vm.getBlockNumber() + 500); // Allocate more rewards to the NFT erc20A.transfer(address(nft1), rewardAmount1); infraERC20.withdrawFromAllManagedNFTs(); // Holder 2 is no longer approved infraERC20.disapproveHolder(address(holder2)); // Now there is 1 holder remaining, but the rewards are diluted infraERC20.distributeToAllHolders(); console.log("\n Second distribution (1 approved holder) \n balance of holder 1: %s", erc20A.balanceOf(address(holder1))); console.log("balance of holder 2: %s", erc20A.balanceOf(address(holder2))); // There is remaining unallocated rewards in the contract console.log("balance remaining in infraERC20: %s", erc20A.balanceOf(address(infraERC20))); // holder 2 has 100 LiquidInfrastructureERC20 tokens, this dilutes the rewards assertEq(infraERC20.balanceOf(address(holder2)), 100); // Wait for the minimum distribution period to pass vm.roll(vm.getBlockNumber() + 500); // Distribute revenue to holders infraERC20.distributeToAllHolders(); console.log("\n Third distribution (1 approved holder) \n balance of holder 1: %s", erc20A.balanceOf(address(holder1))); console.log("balance of holder 2: %s", erc20A.balanceOf(address(holder2))); console.log("balance remaining in infraERC20: %s", erc20A.balanceOf(address(infraERC20))); } }
Manual review
To prevent dilution of revenue for approved holders, consider implementing a mechanism to burn the tokens of disapproved holders upon disapproval.
In the event that disapproved holders are intended to retain their tokens for potential reapproval in the future, track the balance of the LiquidInfrastructureERC20
tokens held by the holder at the time of their disapproval. Subsequently, burn the tokens. Upon reapproval, mint the same amount of tokens to the holder, ensuring they regain their previous token balance.
Other
#0 - c4-pre-sort
2024-02-20T04:03:08Z
0xRobocop marked the issue as duplicate of #66
#1 - c4-pre-sort
2024-02-20T04:05:55Z
0xRobocop marked the issue as not a duplicate
#2 - c4-pre-sort
2024-02-20T04:05:59Z
0xRobocop marked the issue as high quality report
#3 - c4-pre-sort
2024-02-20T04:06:04Z
0xRobocop marked the issue as primary issue
#4 - c4-sponsor
2024-03-02T04:05:41Z
ChristianBorst (sponsor) confirmed
#5 - ChristianBorst
2024-03-02T04:06:27Z
This is a good suggestion.
#6 - c4-judge
2024-03-04T14:35:35Z
0xA5DF marked the issue as satisfactory
#7 - 0xA5DF
2024-03-04T14:48:56Z
Maintaining med severity (despite some dupes suggesting high) since this would only affect part of the revenue (% of disapproved supply) and the funds aren't lost forever - they're kept in the contract and will be distributed in the next round.
#8 - c4-judge
2024-03-04T14:49:06Z
0xA5DF marked the issue as selected for report
#9 - SovaSlava
2024-03-08T20:23:41Z
I think its not a valid issue, because function disapproveHolder() is intended to limit the user's receipt of rewards and this is intended.
To completely remove a user from the project and prevent him from receiving rewards, the owner must burn the user's tokens. The disapproveHolder function is not enough for this and the owner must know this.
Owner could just call function burnFromAndDistribute() and disapproveHolder() in one tx.
#10 - kazantseff
2024-03-09T07:39:22Z
Owner could just call function burnFromAndDistribute() and disapproveHolder() in one tx.
Just as a note, burnFromAndDistribute()
requires allowance, so it will be impossible for owner to just burn user's tokens.
#11 - 0xA5DF
2024-03-09T09:48:56Z
Maintaining med