Pieces Protocol

First Flight #32
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Inconsistent ERC20 Balance Tracking in TokenDivider Contract via ERC20ToGenerateNftFraccion

Summary

The contract maintains an internal balances mapping to track how many ERC20 tokens (fractions) each user owns. However, these ERC20 tokens are standard tokens that users can transfer outside the contract (e.g., via MetaMask, Uniswap, or other wallets). This creates a critical inconsistency: the contract’s internal balances mapping does not reflect real on-chain ERC20 balances, leading to mismatched state and potential exploits.

function divideNft(address nftAddress, uint256 tokenId, uint256 amount)
external
onlyNftOwner(nftAddress, tokenId)
onlyNftOwner(nftAddress, tokenId)
{
....
....
balances[msg.sender][erc20] = amount;
....
....
}

Impact

  1. Inability to Claim NFTs:

    • Users who acquire fractions outside the contract (e.g., via decentralized exchanges) cannot claim the NFT, as their internal balances are not updated.

  2. Fake Balances for Internal Logic:

    • Functions like sellErc20 and claimNft rely on the stale balances mapping. For example:

      solidity

      Copy

      // In sellErc20():
      if (balances[msg.sender][tokenInfo.erc20Address] < amount) revert();
      • A user with a high internal balance but low real ERC20 balance can list tokens they don’t actually own.

  3. Double-Spending Risk:

    • A malicious user could exploit the inconsistency:

      • Transfer ERC20 tokens out externally (real balance decreases).

      • Use the stale internal balances to call sellErc20() or claimNft().

POC

This test assumes a user Aliceand Bob .

  1. Alice mints the NFT and divide it into 1000 fractions.

  2. Alice transfers 500 fractions to Bob DIRECTLY via ERC20 (bypassing TokenDivider)

  3. Bob tries to claim the NFT for the fraction he received from Alice(50% in this case).

  4. Because it did not update in the internal mapping, Bob fails the claim.

    <details>
    <summary>POC</summary>
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.18;
    import {Test, console2} from "forge-std/Test.sol";
    import {TokenDivider} from "src/TokenDivider.sol";
    import {ERC721Mock} from "test/mocks/ERC721Mock.sol";
    import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
    contract TokenDividerTest is Test {
    TokenDivider public tokenDivider;
    ERC721Mock public nft;
    address public alice = makeAddr("alice");
    address public bob = makeAddr("bob");
    function setUp() public {
    // Deploy contracts
    tokenDivider = new TokenDivider();
    nft = new ERC721Mock();
    // Alice gets an NFT
    nft.mint(alice);
    }
    function test_InconsistentBalances() public {
    // 1. Alice divides her NFT into 1000 ERC20 fractions
    vm.startPrank(alice);
    nft.setApprovalForAll(address(tokenDivider), true);
    tokenDivider.divideNft(address(nft), 0, 1000);
    vm.stopPrank();
    // Get ERC20 address created for the NFT
    TokenDivider.ERC20Info memory info = tokenDivider.getErc20InfoFromNft(address(nft));
    address erc20 = info.erc20Address;
    // Check initial state
    assertEq(tokenDivider.getBalanceOf(alice, erc20), 1000); // Internal balance
    assertEq(IERC20(erc20).balanceOf(alice), 1000); // Actual ERC20 balance
    console2.log("Initially Alice internal balance", tokenDivider.getBalanceOf(alice, erc20));
    console2.log("Initially Alice real balance", IERC20(erc20).balanceOf(alice));
    // 2. Alice transfers 500 fractions to Bob DIRECTLY via ERC20 (bypassing TokenDivider)
    vm.prank(alice);
    IERC20(erc20).transfer(bob, 500);
    // 3. Check balances AFTER direct transfer
    assertEq(IERC20(erc20).balanceOf(alice), 500); // Real balance decreased
    assertEq(tokenDivider.getBalanceOf(alice, erc20), 1000); // Internal balance STILL WRONG!
    console2.log("After direct transfer Alice internal balance", tokenDivider.getBalanceOf(alice, erc20));
    console2.log("After direct transfer Alice real balance", IERC20(erc20).balanceOf(alice));
    // 4. Bob tries to claim NFT (should fail)
    vm.prank(bob);
    vm.expectRevert(); // Fails because internal balance thinks Bob has 0
    tokenDivider.claimNft(address(nft));
    // 5. Alice tries to sell 1000 fractions (more than she actually owns)
    vm.prank(alice);
    vm.expectRevert(); // TransferFrom fails (real balance=500 < 1000)
    tokenDivider.sellErc20(address(nft), 1 ether, 1000);
    }
    }
    </details>

Recommendations

  1. Remove the balances Mapping:

    • Use the ERC20 token’s native balanceOf(address) function to check real balances.

    • Example fix in claimNft():

      solidity

      Copy

      // Before:
      if (balances[msg.sender][tokenInfo.rc20Address] < totalSupply) revert();
      // After:
      uint256 userBalance = IERC20(tokenInfo.erc20Address).balanceOf(msg.sender);
      if (userBalance < totalSupply) revert();
  2. Update All Functions:

    • Replace all balances[user][erc20] references with direct ERC20 balance checks.

    • Remove the transferErcTokens() function, as users can transfer ERC20 tokens directly.

Updates

Lead Judging Commences

fishy Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Transfer ERC20ToGenerateNftFraccion separately to the contract

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.