code-423n4 / 2024-07-traitforge-findings

1 stars 0 forks source link

TraitForgeNft: Generations without a golden god are possible #656

Open howlbot-integration[bot] opened 2 months ago

howlbot-integration[bot] commented 2 months ago

Lines of code

https://github.com/code-423n4/2024-07-traitforge/blob/279b2887e3d38bc219a05d332cbcb0655b2dc644/contracts/TraitForgeNft/TraitForgeNft.sol#L288

Vulnerability details

Impact

The golden god is the special entropy of 999,999. This entropy is set at slotIndexSelectionPoint and numberIndexSelectionPoint 1. These are in the third part of the entropy list. When forging two NFTs it increases the current generations generationMintCounts. This means that less NFTs of that generation can be minted, as there are only 10,000 NFTs per generation. Therefore the golden god might not be reached and no golden god for that generation exists.

Proof of Concept

First mint all NFTs from generation one. The POC also lists any forger NFT and sends merger NFTs the merger address. To be able to read the new id, _tokenIds in TraitForgeNft must be changed to public.

function _mintGeneration(uint256 gen, bytes32[] memory proof) internal {
  while(nft.generationMintCounts(gen) < 10_000) {
    uint256 price = nft.calculateMintPrice();
    nft.mintToken{ value: price }(proof);

    uint256 tokenId = nft._tokenIds();

    uint256 entropy = nft.getTokenEntropy(tokenId);
    uint8 potential = uint8((entropy / 10) % 10);
    if (potential == 0) continue;

    if (nft.isForger(tokenId)) {
      nft.approve(address(forging), tokenId);
      forging.listForForging(tokenId, 0.01 ether);
      listedTokenIds.push(tokenId);
    } else if (listedTokenIds.length > mergeTokenIds.length) {
       nft.transferFrom(address(this), merger, tokenId);
       mergeTokenIds.push(tokenId);
    }
  }
}

Next increase the current generation by minting the first generation two NFT:

assertEq(nft.getGeneration(), 1);
uint256 price = nft.calculateMintPrice();
nft.mintToken{ value: price }(proof);
assertEq(nft.getGeneration(), 2);

After that, forge all available generation one NFTs, that were tracked in the previous step:

for (uint256 i = 0; i < mergeTokenIds.length; i++) {
  nft.approve(address(forging), mergeTokenIds[i]);
  forging.forgeWithListed{value: 0.01 ether}(
    listedTokenIds[i],
    mergeTokenIds[i]
  );
}

After that there is the following state:

Now mint all the remaining generation two NFTs using the same helper function (_mintGeneration) as before.

After that, there is the following state:

Reading the current currentSlotIndex + currentNumberIndex see - they were updated to be public so there are readable - and slotIndexSelectionPoint + numberIndexSelectionPoint see:

currentSlotIndex: 544, currentNumberIndex: 10
slotIndexSelectionPoint: 717, numberIndexSelectionPoint: 2

It is show that the generation 2 is fully minted without reaching the golden god of that generation.

Notice: While this is an extreme example (all gen 1 NFTs were forged). This can still happen if only a few are forged and the golden god is far back in the entropy list. Also consider that NFTs can be forged multiple times.

Full POC

The POC is written in foundry:

pragma solidity ^0.8.20;

import {Test, console} from "forge-std/Test.sol";
import {DevFund} from "../contracts/DevFund/DevFund.sol";
import {TraitForgeNft} from "../contracts/TraitForgeNft/TraitForgeNft.sol";
import {EntityForging} from "../contracts/EntityForging/EntityForging.sol";
import {EntityTrading} from "../contracts/EntityTrading/EntityTrading.sol";
import {EntropyGenerator} from "../contracts/EntropyGenerator/EntropyGenerator.sol";
import {Airdrop} from "../contracts/Airdrop/Airdrop.sol";
import {NukeFund} from "../contracts/NukeFund/NukeFund.sol";

contract Test_POC is Test {
    TraitForgeNft public nft;
    EntropyGenerator public entropy;
    EntityForging public forging;
    EntityTrading public trading;
    DevFund public devFund;
    NukeFund public nukeFund;
    Airdrop public airdrop;
    // DAOFund public daoFund;
    address daoFund = address(420);

    address owner = address(1);
    address merger = address(16);

    function setUp() public {
        vm.startPrank(owner);

        nft = new TraitForgeNft();

        airdrop = new Airdrop();
        nft.setAirdropContract(address(airdrop));

        airdrop.transferOwnership(address(nft));

        entropy = new EntropyGenerator(address(nft));
        nft.setEntropyGenerator(address(entropy));
        entropy.writeEntropyBatch1();
        entropy.writeEntropyBatch2();
        entropy.writeEntropyBatch3();

        forging = new EntityForging(address(nft));
        nft.setEntityForgingContract(address(forging));

        trading = new EntityTrading(address(nft));

        devFund = new DevFund();

        nukeFund = new NukeFund(
            address(nft),
            address(airdrop),
            payable(address(devFund)),
            payable(daoFund)
        );

        nft.setNukeFundContract(payable(address(nukeFund)));
        trading.setNukeFundAddress(payable(address(nukeFund)));

        vm.stopPrank();

        vm.deal(merger, 50 ether);

        vm.label(address(merger), "merger");
        vm.label(address(nft), "nft");
        vm.label(address(trading), "trading");
        vm.label(address(entropy), "entropy");
        vm.label(address(devFund), "devFund");
        vm.label(address(forging), "forging");
    }  

    receive() external payable {}

    uint256[] public listedTokenIds;
    uint256[] public mergeTokenIds;

    function _mintGeneration(uint256 gen, bytes32[] memory proof) internal {
        while(nft.generationMintCounts(gen) < 10_000) {
            uint256 price = nft.calculateMintPrice();
            nft.mintToken{ value: price }(proof);

            uint256 tokenId = nft._tokenIds();

            uint256 entropy = nft.getTokenEntropy(tokenId);
            uint8 potential = uint8((entropy / 10) % 10);
            if (potential == 0) continue;

            if (nft.isForger(tokenId)) {
                nft.approve(address(forging), tokenId);
                forging.listForForging(tokenId, 0.01 ether);
                listedTokenIds.push(tokenId);
            } else if (listedTokenIds.length > mergeTokenIds.length) {
                nft.transferFrom(address(this), merger, tokenId);
                mergeTokenIds.push(tokenId);
            }
        }
    }

    function test_POC() public {
        vm.pauseGasMetering();
        // skip whitelist phase
        vm.warp(block.timestamp + 25 hours);
        bytes32[] memory proof;

        console.log("________ start ____________");
        for (uint256 j = 1; j <= 2; j++) {
            console.log("gen: ", j, nft.generationMintCounts(j));
        }
        console.log("----");
        console.log("current: ", entropy.currentSlotIndex(), entropy.currentNumberIndex());
        console.log("golden god: ", entropy.slotIndexSelectionPoint(), entropy.numberIndexSelectionPoint());
        console.log("____________________");
        _mintGeneration(1, proof);

        console.log("________ after minting 10,000 NTFs ____________");
        for (uint256 j = 1; j <= 2; j++) {
            console.log("gen: ", j, nft.generationMintCounts(j));
        }
        console.log("----");
        console.log("current: ", entropy.currentSlotIndex(), entropy.currentNumberIndex());
        console.log("golden god: ", entropy.slotIndexSelectionPoint(), entropy.numberIndexSelectionPoint());
        console.log("____________________");

        assertEq(nft.getGeneration(), 1);
        uint256 price = nft.calculateMintPrice();
        nft.mintToken{ value: price }(proof);
        assertEq(nft.getGeneration(), 2);

        console.log("________ after incrementing generation ____________");
        for (uint256 j = 1; j <= 2; j++) {
            console.log("gen: ", j, nft.generationMintCounts(j));
        }
        console.log("----");
        console.log("current: ", entropy.currentSlotIndex(), entropy.currentNumberIndex());
        console.log("golden god: ", entropy.slotIndexSelectionPoint(), entropy.numberIndexSelectionPoint());
        console.log("____________________");

        vm.startPrank(merger);
        for (uint256 i = 0; i < mergeTokenIds.length; i++) {
            nft.approve(address(forging), mergeTokenIds[i]);
            forging.forgeWithListed{value: 0.01 ether}(
                listedTokenIds[i],
                mergeTokenIds[i]
            );
        }
        vm.stopPrank();
        delete(listedTokenIds);
        delete(mergeTokenIds);

        console.log("________ after forging all possible gen 2 ____________");
        for (uint256 j = 1; j <= 2; j++) {
            console.log("gen: ", j, nft.generationMintCounts(j));
        }
        console.log("----");
        console.log("current: ", entropy.currentSlotIndex(), entropy.currentNumberIndex());
        console.log("golden god: ", entropy.slotIndexSelectionPoint(), entropy.numberIndexSelectionPoint());
        console.log("____________________");

        _mintGeneration(2, proof);

        console.log("________ after minting 10,000 NTFs ____________");
        for (uint256 j = 1; j <= 2; j++) {
            console.log("gen: ", j, nft.generationMintCounts(j));
        }
        console.log("----");
        console.log("current: ", entropy.currentSlotIndex(), entropy.currentNumberIndex());
        console.log("golden god: ", entropy.slotIndexSelectionPoint(), entropy.numberIndexSelectionPoint());
        console.log("____________________");

        vm.resumeGasMetering();
    }
}

Full logs:

Logs:
  ________ start ____________
  gen:  1 0
  gen:  2 0
  ----
  current:  0 0
  golden god:  562 10
  ____________________
  ________ after minting 10,000 NTFs ____________
  gen:  1 10000
  gen:  2 0
  ----
  current:  769 3
  golden god:  562 10
  ____________________
  ________ after incrementing generation ____________
  gen:  1 10000
  gen:  2 1
  ----
  current:  769 4
  golden god:  717 2
  ____________________
  ________ after forging all possible gen 2 ____________
  gen:  1 10000
  gen:  2 2909
  ----
  current:  769 4
  golden god:  717 2
  ____________________
  ________ after minting 10,000 NTFs ____________
  gen:  1 10000
  gen:  2 10000
  ----
  current:  544 10
  golden god:  717 2
  ____________________

Tools Used

manual review

Recommended Mitigation Steps

There is always a chance that no golden god is minted, as the NFT contract in the current form can't predict when users use mint or forge. In theory the users could call forge for the last 100s or 1000s NFTs.

Consider reverting on the last forge if the golden god is not yet minted and add a additional condition when minting, that the last NFT of a generation is guaranteed to be the golden god if not yet minted.

Assessed type

Other

c4-judge commented 2 months ago

koolexcrypto marked the issue as satisfactory

c4-judge commented 2 months ago

koolexcrypto changed the severity to 3 (High Risk)

c4-judge commented 2 months ago

koolexcrypto marked the issue as not a duplicate

c4-judge commented 2 months ago

koolexcrypto marked the issue as primary issue

c4-judge commented 2 months ago

koolexcrypto marked the issue as selected for report

c4-judge commented 1 month ago

koolexcrypto changed the severity to 2 (Med Risk)