Althea Liquid Infrastructure - 0xloscar01's results

Liquid Infrastructure.

General Information

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

Althea

Findings Distribution

Researcher Performance

Rank: 34/84

Findings: 1

Award: $104.73

🌟 Selected for report: 1

🚀 Solo Findings: 0

Awards

104.7257 USDC - $104.73

Labels

bug
2 (Med Risk)
high quality report
primary issue
satisfactory
selected for report
sponsor confirmed
M-01

External Links

Lines of code

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

Vulnerability details

Impact

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.

https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L257-L281

    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();
    }

Proof of Concept

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:

  1. npm i --save-dev @nomicfoundation/hardhat-foundry - Install the hardhat-foundry plugin.
  2. Add require("@nomicfoundation/hardhat-foundry"); to the top of the hardhat.config.js file.
  3. Run 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.
  4. Copy the full test suite case from below and paste it in a new file: 2024-02-althea-liquid-infrastructure/liquid-infrastructure/test/liquidInfrastructureERC20Test.t.sol
  5. Run 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)));
    }
}

Tools Used

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.

Assessed type

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

  1. The sponsor confirmed this is not the intended design
  2. The current design doesn't actually reserve the reward for the disapproved holder, the remaining balance would be distributed in the next cycle to all holders equally

Maintaining med

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter