TraitForge
Findings & Analysis Report

2024-09-13

Table of contents

Overview

About C4

Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.

A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.

During the audit outlined in this document, C4 conducted an analysis of the TraitForge smart contract system written in Solidity. The audit took place between July 29 — August 6, 2024.

Wardens

247 Wardens contributed reports to TraitForge:

  1. 0xlemon
  2. KupiaSec
  3. yixxas
  4. pfapostol
  5. Bauchibred
  6. ZanyBonzy
  7. Sabit
  8. anonymousjoe
  9. 0xDemon
  10. jesjupyter
  11. samuraii77
  12. OMEN
  13. p0wd3r
  14. pep7siup
  15. 0XRolko
  16. 0rpse
  17. gesha17
  18. Agontuk
  19. Kalogerone
  20. Abhan
  21. dimulski
  22. sl1
  23. Abdessamed
  24. 0xb0k0
  25. kutugu
  26. shaflow2
  27. amaron
  28. boredpukar
  29. 0xDarko
  30. smbv-1923
  31. mansa11
  32. cryptomoon
  33. Shahil_Hussain
  34. 0x3b
  35. perseus
  36. 0xDazai
  37. Fitro
  38. 0xAadi
  39. dvrkzy
  40. namx05
  41. King_
  42. Decap
  43. K42
  44. yotov721
  45. inzinko
  46. d3e4
  47. _karanel
  48. AvantGard
  49. Udsen
  50. nnez
  51. Trooper
  52. 0x0bserver
  53. 0xR360
  54. Shubham
  55. 0xJoyBoy03
  56. PetarTolev
  57. ZdravkoHr
  58. stanchev
  59. FastChecker
  60. 0xHash
  61. broccolirob
  62. Matin
  63. nikhil840096
  64. the_hunter
  65. DemoreX
  66. rbserver
  67. preslavxyz
  68. JuggerNaut63
  69. swapnaliss
  70. AuditGuy
  71. CyberscopeInterns
  72. avoloder
  73. 0xvd
  74. yaioxy
  75. Trident-Audits (ebok21 and mgnfy_view)
  76. Tamer
  77. n3smaro
  78. dontonka
  79. Nihavent
  80. ke1caM
  81. shikhar229169
  82. ABAIKUNANBAEV
  83. desaperh
  84. Zac
  85. hail_the_lord
  86. eta
  87. peanuts
  88. hl_
  89. vinica_boy
  90. MrValioBg
  91. onthehunt11
  92. dyoff
  93. hakunamatata
  94. persik228
  95. ilchovski
  96. shaka
  97. Rhaydden
  98. 0xrex
  99. gkrastenov
  100. gajiknownnothing
  101. franfran20
  102. burnerelu
  103. slvDev
  104. Joshuajee
  105. cozymfer
  106. phoenixV110
  107. Tomas0707
  108. LeFy
  109. 4rdiii
  110. waydou
  111. Autosaida
  112. y4y
  113. 3n0ch
  114. ogKapten
  115. LonelyWolfDemon
  116. D_Auditor
  117. Coinymous
  118. den-sosnowsky
  119. rndquu
  120. Xcrypt
  121. 0xPwned
  122. robertodf99
  123. MKVsentry (bozhikov_, vikthetilted and 0xFear)
  124. unnamed
  125. Yunaci (cholakov and Vancelot)
  126. artilugio0
  127. Phoenix_x
  128. Quaternion2691
  129. VulnViper
  130. Ruhum
  131. dobrevaleri
  132. LSHFGJ
  133. Daniel_eth
  134. Tigerfrake
  135. zhaojohnson
  136. EaglesSecurity (julian_avantgarde and kane-goldmisth)
  137. dhank
  138. 0xAleko
  139. TopStar
  140. PASCAL
  141. Topmark
  142. JustUzair
  143. kartik_giri_47538
  144. iam_emptyset
  145. Tonchi
  146. abdulsamijay
  147. JanuaryPersimmon2024
  148. Ryonen
  149. emerald7017
  150. denzi_
  151. ArsenLupin
  152. eierina
  153. aldarion
  154. TECHFUND-inc
  155. dimah7
  156. eLSeR17
  157. PNS
  158. Dots
  159. valy001
  160. StraawHaat
  161. valentin_s2304
  162. klau5
  163. MinhTriet
  164. almurhasan
  165. oxwhite
  166. turvy_fuzz
  167. al88nsk
  168. shui
  169. nervouspika
  170. air_0x
  171. Beosin
  172. DigiSafe (Svetoslavb and corporalking)
  173. BajagaSec
  174. bhavya0911
  175. x0t0wt1w
  176. 0xlookman
  177. erike1
  178. _thanos1
  179. Night
  180. blackVul
  181. zeroProtocol
  182. 0xcontrol
  183. binary
  184. Bac0nj
  185. federodes
  186. PENGUN
  187. SharpPeaks
  188. jeremie
  189. mashbust (gulDozer, abdulsamijay and 0xabdullah0)
  190. LogBytes (developerjordy, Mylifechangefast_eth, Avalance and Cyberwonder)
  191. lrivo
  192. Stoicov
  193. kingnull
  194. Pataroff
  195. synackrst
  196. Auditor_Nate
  197. brevis
  198. 0xHelium
  199. EdMarcavage
  200. Undefined (Silvermist and boringslav)
  201. Erko
  202. KaligoAudits (0xShiki, owliie and weedeus)
  203. McToady
  204. zxriptor
  205. pipidu83
  206. Akay
  207. zhanmingjing
  208. Kunhah
  209. Bob
  210. frodoBaggins
  211. Ward (natzuu and 0xpessimist)
  212. atoko
  213. MSaptarshi
  214. c4whitehat
  215. dic0de
  216. UAARRR
  217. kodyvim
  218. Mahi_Vasisth
  219. zarkk01
  220. 0xAkira
  221. boraichodrunkenmaster
  222. 0xjarix
  223. ak1
  224. Chad0
  225. mgf15
  226. 4B
  227. korok
  228. ast3ros
  229. rekxor
  230. arman
  231. Stefanov
  232. Saurabh_Singh

This audit was judged by Koolex.

Final report assembled by thebrittfactor.

Summary

The C4 analysis yielded an aggregated total of 25 unique vulnerabilities. Of these vulnerabilities, 6 received a risk rating in the category of HIGH severity and 19 received a risk rating in the category of MEDIUM severity.

Additionally, C4 analysis included 18 reports detailing issues with a risk rating of LOW severity or non-critical.

All of the issues presented here are linked back to their original finding.

Scope

The code under review can be found within the C4 TraitForge repository, and is composed of 6 smart contracts written in the Solidity programming language and includes 880 lines of Solidity code.

Severity Criteria

C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.

High-level considerations for vulnerabilities span the following key areas when conducting assessments:

  • Malicious Input Handling
  • Escalation of privileges
  • Arithmetic
  • Gas use

For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.

High Risk Findings (6)

[H-01] Wrong minting logic based on total token count across generations

Submitted by TopStar, also found by Rhaydden (1, 2), dimulski, 0xlemon, pep7siup, aldarion, gajiknownnothing, zhaojohnson, Fitro, eta, 0x3b, dontonka, p0wd3r, pipidu83, almurhasan, 0xDazai, ogKapten, samuraii77, 0xAleko, Ruhum, jesjupyter, stanchev, MinhTriet, avoloder, persik228, Abdessamed, Shubham, AvantGard, Akay, FastChecker, 0xHash, federodes, 0xlookman, y4y, peanuts, zhanmingjing, anonymousjoe, erike1, PENGUN, pfapostol (1, 2), onthehunt11, Autosaida, LeFy, yaioxy, hakunamatata, zeroProtocol, MrValioBg, blackVul, PASCAL, VulnViper, cryptomoon, binary, Daniel_eth, 0xcontrol, Bac0nj, sl1, vinica_boy, Kunhah, 3n0ch, SharpPeaks, inzinko, 0x0bserver, jeremie, DigiSafe, dvrkzy, Abhan, Nihavent, Udsen, 0xPwned, mashbust, shikhar229169, smbv-1923, Bob, Xcrypt, LonelyWolfDemon, frodoBaggins, gkrastenov, Shahil_Hussain, dhank, Topmark, rbserver, ArsenLupin, KupiaSec, gesha17, klau5, ilchovski, ke1caM, 0xrex, shaka, nnez, and 0xJoyBoy03

TraitForgeNft::mintWithBudget function is similar to mintToken, but allows users to mint multiple tokens in a single transaction if they have a budget exceeding the minting price for one token. However, _tokenIds tracks the total number of tokens ever minted, not just the tokens in the current generation.

Impact

In the current implementation, _tokenIds is used to control the minting process. The check while (budgetLeft >= mintPrice && _tokenIds < maxTokensPerGen) ensures that minting will stop when current generation minted tokens reaches maxTokensPerGen. Instead of checking the number of tokens minted in the current generation, the function incorrectly checks the total number of tokens minted across all generations (_tokenIds).

Proof of Concept

Here is the current implementation of the mintWithBudget function in the smart contract on line 215:

TraitForgeNft.sol
202:   function mintWithBudget(
203:     bytes32[] calldata proof
204:   )
205:     public
206:     payable
207:     whenNotPaused
208:     nonReentrant
209:     onlyWhitelisted(proof, keccak256(abi.encodePacked(msg.sender)))
210:   {
211:     uint256 mintPrice = calculateMintPrice();
212:     uint256 amountMinted = 0;
213:     uint256 budgetLeft = msg.value;
214: 
215:     while (budgetLeft >= mintPrice && _tokenIds < maxTokensPerGen) { // @audit - _tokenIds counts total tokens minted, not just current generation
216:       _mintInternal(msg.sender, mintPrice);
217:       amountMinted++;
218:       budgetLeft -= mintPrice;
219:       mintPrice = calculateMintPrice();
220:     }
221:     if (budgetLeft > 0) {
222:       (bool refundSuccess, ) = msg.sender.call{ value: budgetLeft }('');
223:       require(refundSuccess, 'Refund failed.');
224:     }
225:   }

The function will not allow minting if _tokenIds is greater than 10,000, which will happen after the 1st generation is fully minted.

Use generationMintCounts[currentGeneration] instead of _tokenIds:

- while (budgetLeft >= mintPrice && _tokenIds < maxTokensPerGen) {
+ while (budgetLeft >= mintPrice && generationMintCounts[currentGeneration] <= maxTokensPerGen) {

Assessed type

Invalid Validation

TForge1 (TraitForge) confirmed


[H-02] Griefing attack on seller’s airdrop benefits

Submitted by ZanyBonzy, also found by Trooper, 0x3b, Sabit, King_ (1, 2), _karanel, sl1, Shubham, 0xR360, inzinko, 0x0bserver, PetarTolev, AvantGard, and 0xJoyBoy03

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

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

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

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

Impact

TraitForge NFTs can be transferred and sold, but the new owner can burn or most likely nuke the token to reduce the the initial owner’s airdrop benefits.

Proof of Concept

When a user mints or forges a new token, they’re set as the token’s initialOwner and given airdrop benefits equal to the entropy of their newly minted token.

  function _mintInternal(address to, uint256 mintPrice) internal {
//...
    initialOwners[newItemId] = to;

    if (!airdropContract.airdropStarted()) {
      airdropContract.addUserAmount(to, entropyValue);
    }
  function _mintNewEntity(
    address newOwner,
    uint256 entropy,
    uint256 gen
  ) private returns (uint256) {
//...
    initialOwners[newTokenId] = newOwner;
//...
    if (!airdropContract.airdropStarted()) {
      airdropContract.addUserAmount(newOwner, entropy);
    }

    emit NewEntityMinted(newOwner, newTokenId, gen, entropy);
    return newTokenId;
  }

When burning, the entropy is then deducted from the initialOwner’s amount, but not removed when transfered or when the token is bought, which in another case could allow the owner to earn from minting airdrop and from token sale.

  function burn(uint256 tokenId) external whenNotPaused nonReentrant {
//...
    if (!airdropContract.airdropStarted()) {
      uint256 entropy = getTokenEntropy(tokenId);
      airdropContract.subUserAmount(initialOwners[tokenId], entropy);
    }
//...
  }

However, a transferred or sold token can still be nuked or burned which directly reduces the initial owner’s user amount causing loss of funds to the initial owner. Also by nuking, the nuker doesn’t lose as much in the griefing attack compared to burning the token.

  function nuke(uint256 tokenId) public whenNotPaused nonReentrant {
//...
    nftContract.burn(tokenId); // Burn the token
//...
  }

Runnable POC

The gist link here holds a test case that shows that airdrop amounts are not migrated to new owner during transfers/sales, and that by burning/nuking the token, the seller’s airdrop amount can be successfully reduced.

The expected result should look like this:

Note: to view the provided image, please see the original submission here.

Recommend introducing a check in the burn function that skips reducing user amount if the caller is the NukeFund contract or not the initial owner.

TForge1 (TraitForge) confirmed and commented:

Great find. We want to keep only minters/forgers as airdrop receivers, but yes, if they transfer or sell and that next person nukes, the initialOwner will lose entropy from their airdrop amount. This will be a real issue.

Koolex (judge) increased severity to High

KupiaSec (warden) commented:

I think this issue is not valid.

The documentation regarding the airdrop states that:

In the event that a player ‘nukes’ an entity, the entropy allocated by that entity is removed.

This implies that the incentive should be removed when an entity is nuked. Even if the new entity holder performs the nuke, the entropy allocated to that entity should be removed. Since the incentive assigned to that entity does not transfer during ownership changes, the original owner remains responsible for its burning.

Therefore, I believe this should be considered either a design choice or potentially a user error.

dimulski (warden) commented:

Hey @Koolex thanks for the swift judging! The Airdrop contract is not within the scope of this audit. The TraitForge contract doesn’t inherit it, it just interacts with it in certain conditions - if the airdrop is started. Since all the logic that may cause some potential losses for the initial owner is in the Airdrop.sol contract, this issue is clearly out of scope and thus invalid.

ZanyBonzy (warden) commented:

@KupiaSec - from the docs

Everytime someone mints directly or forges to mint, they are recorded in the AirDrop Contract, alongside the entropy of the entity they minted. If they nuke, that allocation given by the entity is removed.

Keyword here is “they” meaning the person doing the minting or the forging. In this case, the nuker is the buyer (or someone that the token is transferred to), someone who didn’t do the minting nor the forging, and as a result, their actions shouldn’t have to affect the original minter.

@dimulski - I’d suggest reading the issue carefully again. The root cause is in the TraitForge/NukeFund contract, the fix is also in the NukeFund contract. The airdrop points just happen to be rewards being lost which is the impact.

dimulski (warden) commented:

There are no points without the Airdrop contract, so what exactly should be fixed if the contract where the point distribution logic and their potential value is determined, is out of scope? All the interactions with that contract should be considered OOS. This is not a USDC or some other ERC20 contract, the value of these points comes from the Airdrop contract. Feel free to check the SC verdict on this type of situations.

Koolex (judge) commented:

Given that:

  • The bug is in scope since mapping(uint256 => address) public initialOwners; is used to track minters and forgers. Without tracking, no one will get airdrops even if the airdrop contract is ready and implemented.
  • Loss of funds to initial owners.

I believe the issue stands as is.


[H-03] Incorrect percentage calculation in NukeFund and EntityForging when taxCut is changed from default value

Submitted by 0xDarko, also found by Fitro, 0XRolko (1, 2), Abdessamed, 0xDazai, namx05, samuraii77, cryptomoon, perseus, dvrkzy, and 0xAadi

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

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

Impact

In both NukeFund::receive and EntityForging::forgeWithListed, if the owner changes taxCut from its default value of 10, the percentage calculations for fee distribution become severely inaccurate. This flaw can lead to:

  1. Incorrect distribution of funds between developers, users, and the protocol.
  2. Potential for significant financial losses or unintended gains.
  3. Undermining of the protocol’s economic model.
  4. Loss of user trust if discrepancies are noticed.

The root cause is the use of simple division instead of proper percentage calculation:

    // NukeFund
    uint256 devShare = msg.value / taxCut; // Calculate developer's share (10%)
    uint256 remainingFund = msg.value - devShare; // Calculate remaining funds to add to the fund

    // EntityForging
    uint256 devFee = forgingFee / taxCut;
    uint256 forgerShare = forgingFee - devFee;

Some mathematical examples:

  • When taxCut = 10, 1/10 = 0.1 = 10% (correct).
  • When taxCut = 5, 1/5 = 0.2 = 20% (intended 5%, actually 20%).
  • When taxCut = 20, 1/20 = 0.05 = 5% (intended 20%, actually 5%).

Proof of Concept

This is a test for the NukeFund, EntityForging is the same scenario just longer so I chose to send you the more concise test. We are going to set the taxCut to 5:

    function test_FundsRoundWrong() public {
        nukeFund.setTaxCut(5);
        uint256 devShare = 0.05 ether;
        uint256 initialDevBalance = nukeFund.devAddress().balance;
        uint256 initialNukeFund = nukeFund.getFundBalance();

        vm.prank(user1);
        
        address(nukeFund).call{value: 1 ether}("");

        uint256 finalDevBalance = nukeFund.devAddress().balance;
        uint256 devGained = finalDevBalance - initialDevBalance;

        console.log("Dev Balance Gained:", devGained);

        assertNotEq(address(nukeFund).balance, initialNukeFund + 0.95 ether);

        uint256 devBalance = nukeFund.devAddress().balance;

        assertNotEq(devBalance, initialDevBalance + devShare);
    }

You can clearly see when it’s supposed to be “5%” tax, it’s instead 20%, leading the dev to get 2e17 of the 1e18 and the NukeFund getting 8e17.

Tools Used

Foundry

Implement a basis point (BPS) system for precise percentage calculations. This means now that with a BPS value of 10_000 you would represent 10% as 1000 and 5% in our tests as 500.

Add the following changes:

  1. At the top of EntityForging and NukeFund where state variables reside, add the following BPS state variable and change default taxCut of 10% to correspond to the BPS values:
+  uint256 private constant BPS = 10_000;
-  uint256 public taxCut = 10;
+  uint256 public taxCut = 10_000;
  1. Update the calculations in EntityForging and NukeFund where taxCut is used.
    // NukeFund 
-    uint256 devShare = msg.value / taxCut; // Calculate developer's share (10%)
+    uint256 devShare = (msg.value * taxCut) / BPS;

    // EntityForging
-    uint256 devFee = forgingFee / taxCut;
+    uint256 devShare = (msg.value * taxCut) / BPS;
  1. Also consider adding a boundary setTaxCut to make sure taxCut isn’t greater than BPS:
  function setTaxCut(uint256 _taxCut) external onlyOwner {
+    require(_taxCut <= BPS, "Tax cut cannot exceed 100%");
    taxCut = _taxCut;
  }

Assessed type

Math

TForge1 (TraitForge) confirmed

ZdravkoHr (warden) commented:

@Koolex - I think this issue depends on assumptions that were not confirmed when the audit was running.

What we see in the codebase is a taxCut variable that represent a part of the whole. Nowhere it was said that this cut has to be in percents. In fact, the only place where percents are mentioned, is the second link attached to the report that says 10%. And really, when you divide by 10, you get 10%.

jesjupyter (warden) commented:

I think this issue is pretty easy to solve so it’s actually not a valid issue. Here, if we want 5% rate, we just set taxcut to 20. If we want 20%, set it to 5. Nothing will be changed, so this issue looks like an admin input error.

samuraii77 (warden) commented:

The sponsor has confirmed this one so your assumptions are incorrect. It is supposed to be a %.

Abdessamed (warden) commented:

@ZdravkoHr

Nowhere it was said that this cut has to be in percents

All examples in the docs are referred to a %, not a share.

In fact, the only place where percents are mentioned, is the second link attached to the report that says 10%. And really, when you divide by 10, you get 10%.

There is an explicit setTaxCut function on every contract that demonstrates tax fees can change.

@jesjupyter - What you are proposing is just a workaround. Semantically, the tax represents a fee percentage and that’s how it is expected by admins to work when calling setTaxCut.

Kalogerone (warden) commented:

The setTaxCut function is onlyOwner protected:

  function setTaxCut(uint256 _taxCut) external onlyOwner {
    taxCut = _taxCut;
  }

All owner actions are trusted. The owners can still set the taxCut to their desired value, so nothing is really broken. Nowhere it was mentioned that setting taxCut to 20 should mean 20%. TRUSTED function and still works anyway, probably a low/info.

Koolex (judge) commented:

It is clearly intended to be a percentage. We can see this from the comment in the code:

  // Fallback function to receive ETH and update fund balance
  receive() external payable {
    uint256 devShare = msg.value / taxCut; // Calculate developer's share (10%)
    uint256 remainingFund = msg.value - devShare; // Calculate remaining funds to add to the fund

contracts/NukeFund/NukeFund.sol:L38-L43

This is not an admin issue, but the code is not working as intended (i.e., percentage based calculations).

KupiaSec (warden) commented:

@Koolex - I don’t understand why it is clearly intended to be a percentage in the following code. I believe it is meant to be a denominator, as 1/10 equals 10%.

    uint256 public taxCut = 10;
        [...]
    uint256 devShare = msg.value / taxCut;  // Calculate developer's share (10%)

This is the code from a audit in which I participated.

        // 20% of last users receive rewards
        uint256 Amount;
        if (share == 0 && block.timestamp >= time) {
            share = totalSupply() / 5;  
        }

Perhaps it would be better to use taxcut as a percentage, but that’s a design choice rather than an issue.


[H-04] Number of entities in generation can surpass the 10k number

Submitted by hakunamatata, also found by zhaojohnson (1, 2), waydou, 0x3b, _karanel, oxwhite, emerald7017, Abdessamed, 0xDazai, dontonka, Tigerfrake, _thanos1, x0t0wt1w, jesjupyter, almurhasan, Shubham, D_Auditor, MinhTriet, Ruhum, stanchev, FastChecker, 0xHash, 0xlookman, ABAIKUNANBAEV, samuraii77, LeFy, Tomas0707, erike1, onthehunt11, Trooper, ogKapten, pep7siup, EaglesSecurity, shikhar229169, VulnViper, cryptomoon, CyberscopeInterns, kutugu, vinica_boy, inzinko, BajagaSec, nikhil840096, 0x0bserver, franfran20, yaioxy, DigiSafe, Nihavent, perseus, Udsen, smbv-1923, Coinymous, Abhan, AvantGard, Night, Shahil_Hussain, den-sosnowsky, LonelyWolfDemon, amaron, bhavya0911, dhank, denzi_, turvy_fuzz, rbserver, 0rpse, KupiaSec (1, 2), klau5, al88nsk, Zac, ilchovski, 0xJoyBoy03, dimulski, ke1caM, shaka, shaflow2, nnez, and ZdravkoHr

Increment generation function sets the count of the entites of the new generation to 0, which could lead to incorrect calculations of the entities in new generation. What can happen is that users will forge entities that will belong to the next generation, and then via the mintToken function the generation will be incremented and genMintCount will be reset for the next generation, which means that all forged entities will not be counted.

Proof of Concept

Create new test file, paste the code below and run it. The tests show incorrect calculation of the number of entities of a given generation.

import {
  Airdrop,
  DevFund,
  EntityForging,
  EntropyGenerator,
  EntityTrading,
  NukeFund,
  TraitForgeNft,
} from '../typechain-types';
import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers';
import generateMerkleTree from '../scripts/genMerkleTreeLib';
import { fastForward } from '../utils/evm';
import { N, parseEther } from 'ethers';

const { expect } = require('chai');
const { ethers } = require('hardhat');

describe('TraitForgeNFT', () => {
  let entityForging: EntityForging;
  let entropyGeneratorContract: EntropyGenerator;
  let nft: TraitForgeNft;
  let owner: HardhatEthersSigner;
  let user1: HardhatEthersSigner;
  let user2: HardhatEthersSigner;
  let entityTrading: EntityTrading;
  let nukeFund: NukeFund;
  let devFund: DevFund;
  let merkleInfo: any;

  before(async () => {
    [owner, user1, user2] = await ethers.getSigners();

    // Deploy TraitForgeNft contract
    const TraitForgeNft = await ethers.getContractFactory('TraitForgeNft');
    nft = (await TraitForgeNft.deploy()) as TraitForgeNft;

    // Deploy Airdrop contract
    const airdropFactory = await ethers.getContractFactory('Airdrop');
    const airdrop = (await airdropFactory.deploy()) as Airdrop;

    await nft.setAirdropContract(await airdrop.getAddress());

    await airdrop.transferOwnership(await nft.getAddress());

    // Deploy EntityForging contract
    const EntropyGenerator = await ethers.getContractFactory(
      'EntropyGenerator'
    );
    const entropyGenerator = (await EntropyGenerator.deploy(
      await nft.getAddress()
    )) as EntropyGenerator;

    await entropyGenerator.writeEntropyBatch1();

    entropyGeneratorContract = entropyGenerator;

    await nft.setEntropyGenerator(await entropyGenerator.getAddress());

    // Deploy EntityForging contract
    const EntityForging = await ethers.getContractFactory('EntityForging');
    entityForging = (await EntityForging.deploy(
      await nft.getAddress()
    )) as EntityForging;
    await nft.setEntityForgingContract(await entityForging.getAddress());

    devFund = await ethers.deployContract('DevFund');
    await devFund.waitForDeployment();

    const NukeFund = await ethers.getContractFactory('NukeFund');

    nukeFund = (await NukeFund.deploy(
      await nft.getAddress(),
      await airdrop.getAddress(),
      await devFund.getAddress(),
      owner.address
    )) as NukeFund;
    await nukeFund.waitForDeployment();

    await nft.setNukeFundContract(await nukeFund.getAddress());

    entityTrading = await ethers.deployContract('EntityTrading', [
      await nft.getAddress(),
    ]);

    // Set NukeFund address
    await entityTrading.setNukeFundAddress(await nukeFund.getAddress());

    merkleInfo = generateMerkleTree([
      owner.address,
      user1.address,
      user2.address,
    ]);

    await nft.setRootHash(merkleInfo.rootHash);
  });

  describe('POC: Mint with budget cannot be used after 10_000 nft has been minted', async () => {
    /// End of whitelist
    it('Should mint with budget', async () => {
      ///Due to the bug in incrementGeneration function we need to transferOwnership to the nft contract so the incorrect functionality can be shown
      await entropyGeneratorContract
        .connect(owner)
        .transferOwnership(await nft.getAddress());
      fastForward(24 * 60 * 60 + 1);

      await nft.connect(user1).mintToken([], {
        value: ethers.parseEther('1'),
      });
      await nft.connect(user1).mintToken([], {
        value: ethers.parseEther('1'),
      });

      const token_1_entropy = await nft.getTokenEntropy(1);
      const token_2_entropy = await nft.getTokenEntropy(2);
      //  uint8 forgePotential = uint8((entropy / 10) % 10);
      const token1ForgePotential = (token_1_entropy / BigInt(10)) % BigInt(10);
      const token2ForgePotential = (token_2_entropy / BigInt(10)) % BigInt(10);

      expect(token1ForgePotential).to.be.eql(BigInt(5));

      const isToken_1_Forger =
        token_1_entropy % BigInt(3) == BigInt(0) ? true : false;
      const isToken_2_Merger =
        token_2_entropy % BigInt(3) != BigInt(0) ? true : false;

      expect(isToken_1_Forger).to.be.eql(true);
      expect(isToken_2_Merger).to.be.eql(true);

      await nft
        .connect(user1)
        .transferFrom(user1.getAddress(), user2.getAddress(), 2);

      const ownerOfToken1 = await nft.ownerOf(1);
      const ownerOfToken2 = await nft.ownerOf(2);

      expect(ownerOfToken1).to.be.eql(await user1.getAddress());
      expect(ownerOfToken2).to.be.eql(await user2.getAddress());

      ///Minting 10k tokens so the next token to be minted is from the next generation
      for (let i = 0; i < 9998; i++) {
        await nft.connect(user2).mintToken([], {
          value: ethers.parseEther('1'),
        });
        console.log('FINISHED: ');
        console.log(i);
      }

      const currentGen = await nft.getGeneration();
      const lastMintedTokenGeneration = await nft.getTokenGeneration(10_000);

      expect(currentGen).to.be.eql(BigInt(1));
      expect(lastMintedTokenGeneration).to.be.eql(BigInt(1));

      await entityForging.connect(user1).listForForging(1, parseEther('0.01'));
      await entityForging
        .connect(user2)
        .forgeWithListed(1, 2, { value: parseEther('0.01') });

      const forgedTokenGeneration = await nft.getTokenGeneration(10_001);
      expect(forgedTokenGeneration).to.be.eql(BigInt(2));

      const generation2MintCount = await nft.generationMintCounts(2);

      //there is one recorded entity belonging to gen2 after forging
      expect(generation2MintCount).to.be.eql(BigInt(1));

      await nft.connect(user1).mintToken([], {
        value: ethers.parseEther('1'),
      });

      const generation2MintCountAfterIncrementingGeneration =
        await nft.generationMintCounts(2);
      expect(generation2MintCountAfterIncrementingGeneration).to.be.eql(
        BigInt(1)
      );

      const generationOfFirstGeneration2TokenByMint =
        await nft.getTokenGeneration(10_002);

      //there is one recorded entity belonging to gen2 after minting. The contract does not account the previously forged entity which proves that the 10k cap per generation can be surpassed
      expect(generationOfFirstGeneration2TokenByMint).to.be.eql(BigInt(2));

      ///transaction reverts because token does not exists
      const tx = nft.ownerOf(10_003);
      await expect(tx).to.be.revertedWith('ERC721: invalid token ID');
    });
  });
});

Impact

The main invariant of the protocol is broken, thus potentially breaking the economy of the game.

Do not reset the genMintCount of the generation.

TForge1 (TraitForge) confirmed


[H-05] The maximum number of generations is infinite

Submitted by Daniel_eth, also found by Rhaydden (1, 2), hl_, LSHFGJ, iam_emptyset, Fitro, dontonka, TopStar, Abdessamed, 0xDazai, Agontuk, 0x3b, Shubham, 0xb0k0, Ruhum, avoloder, 0xlemon, AvantGard, Tigerfrake, 0xAleko, ABAIKUNANBAEV, FastChecker, 0xHash, anonymousjoe, y4y, JustUzair, samuraii77, onthehunt11, Autosaida, Trident-Audits, yaioxy, LeFy, EaglesSecurity, MrValioBg, PASCAL, shikhar229169, pfapostol, Tonchi, AuditGuy, 3n0ch, 0x0bserver, dobrevaleri, inzinko, kartik_giri_47538, dyoff, Topmark, DemoreX, abdulsamijay, rbserver, JanuaryPersimmon2024, KupiaSec, Kalogerone, 0xJoyBoy03, Zac, ilchovski, ke1caM, nnez, 0xrex, and shaka

In ‘TraitForgeNft’ there should be a maximum number of generations (it should be capped at 10), but instead, users can mint infinite generations and there is not a limit.

Proof of Concept

Foundry:

function test_vulnerability_NumberOfGenerationsIsInfinite() public {
vm.startPrank(owner); 
//Here the owner sets all the contracts, transfer ownerhsip and writes batch
traitForgeNft.setAirdropContract(address(airdropContract));
traitForgeNft.setEntityForgingContract(address(entityForging));
traitForgeNft.setEntropyGenerator(address(entropyGenerator));
airdropContract.transferOwnership(address(traitForgeNft));
entropyGenerator.transferOwnership(address(traitForgeNft));
entropyGenerator.writeEntropyBatch1();
vm.stopPrank(); 

address user1 = address(123); 
//Assigning 100000 ether to user1
deal(user1, 100000 ether); 
vm.startPrank(user1); 
skip(2 days);
bytes32[] memory proof = new bytes32[](1); 
proof[0] = keccak256(abi.encode("Hi")); 

//User mints generation 1
uint256 price = traitForgeNft.startPrice(); 
for(uint256 i = 0; i < 10000; i++){
traitForgeNft.mintToken{value: price}(proof);
price += traitForgeNft.priceIncrement() * traitForgeNft.getGeneration(); 
}
assertEq(traitForgeNft.balanceOf(user1), 10000); 
//User mints generation 2
uint256 price2 = traitForgeNft.calculateMintPrice();
for(uint256 i = 0; i < 10000; i++){
traitForgeNft.mintToken{value: price2}(proof);
price2 = traitForgeNft.calculateMintPrice(); 
}
assertEq(traitForgeNft.balanceOf(user1), 20000);
//User mints generation 3
uint256 price3 = traitForgeNft.calculateMintPrice();
for(uint256 i = 0; i < 10000; i++){
traitForgeNft.mintToken{value: price3}(proof);
price3 = traitForgeNft.calculateMintPrice(); 
}
assertEq(traitForgeNft.balanceOf(user1), 30000);
//User mints generation 4
uint256 price4 = traitForgeNft.calculateMintPrice();
for(uint256 i = 0; i < 10000; i++){
traitForgeNft.mintToken{value: price4}(proof);
price4 = traitForgeNft.calculateMintPrice(); 
}
assertEq(traitForgeNft.balanceOf(user1), 40000);
//User mints generation 5
uint256 price5 = traitForgeNft.calculateMintPrice();
for(uint256 i = 0; i < 10000; i++){
traitForgeNft.mintToken{value: price5}(proof);
price5 = traitForgeNft.calculateMintPrice(); 
}
assertEq(traitForgeNft.balanceOf(user1), 50000);
//User mints generation 6
uint256 price6 = traitForgeNft.calculateMintPrice();
for(uint256 i = 0; i < 10000; i++){
traitForgeNft.mintToken{value: price6}(proof);
price6 = traitForgeNft.calculateMintPrice(); 
}
assertEq(traitForgeNft.balanceOf(user1), 60000);
//User mints generation 7
uint256 price7 = traitForgeNft.calculateMintPrice();
for(uint256 i = 0; i < 10000; i++){
traitForgeNft.mintToken{value: price7}(proof);
price7 = traitForgeNft.calculateMintPrice(); 
}
assertEq(traitForgeNft.balanceOf(user1), 70000);
//User mints generation 8
uint256 price8 = traitForgeNft.calculateMintPrice();
for(uint256 i = 0; i < 10000; i++){
traitForgeNft.mintToken{value: price8}(proof);
price8 = traitForgeNft.calculateMintPrice(); 
}
assertEq(traitForgeNft.balanceOf(user1), 80000);
//User mints generation 9
uint256 price9 = traitForgeNft.calculateMintPrice();
for(uint256 i = 0; i < 10000; i++){
traitForgeNft.mintToken{value: price9}(proof);
price9 = traitForgeNft.calculateMintPrice(); 
}
assertEq(traitForgeNft.balanceOf(user1), 90000);
//User mints generation 10
uint256 price10 = traitForgeNft.calculateMintPrice();
for(uint256 i = 0; i < 10000; i++){
traitForgeNft.mintToken{value: price10}(proof);
price10 = traitForgeNft.calculateMintPrice(); 
}
assertEq(traitForgeNft.balanceOf(user1), 100000);
//User mints generation 11, this get also minted!
uint256 price11 = traitForgeNft.calculateMintPrice();
for(uint256 i = 0; i < 10000; i++){
traitForgeNft.mintToken{value: price11}(proof);
price11 = traitForgeNft.calculateMintPrice(); 
}
//Finally we can see that the user owns 110.000 NFT's, when the maximum would be 100.000
assertEq(traitForgeNft.balanceOf(user1), 110000);
}

In TraiForgeNft::\_incrementGeneration, add a check in order to do not allow the the mint of the NFT’s above the maximum generation.

Instead of doing this:

function _incrementGeneration() private {
    require(
      generationMintCounts[currentGeneration] >= maxTokensPerGen,
      'Generation limit not yet reached'
    );
    currentGeneration++;
    generationMintCounts[currentGeneration] = 0;
    priceIncrement = priceIncrement + priceIncrementByGen;
    entropyGenerator.initializeAlphaIndices();
    emit GenerationIncremented(currentGeneration);
  }

Consider doing this:

function _incrementGeneration() private {
    require(
      generationMintCounts[currentGeneration] >= maxTokensPerGen,
      'Generation limit not yet reached'
    );
    currentGeneration++;
    require(currentGeneration <= maxGeneration,
     'Maximum generation reached'
    ); 
    generationMintCounts[currentGeneration] = 0;
    priceIncrement = priceIncrement + priceIncrementByGen;
    entropyGenerator.initializeAlphaIndices();
    emit GenerationIncremented(currentGeneration);
  }

TForge1 (TraitForge) confirmed


[H-06] mintToken(), mintWithBudget(), and forge() in the TraitForgeNft contract will fail due to a wrong modifier used in EntropyGenerator.initializeAlphaIndices()

Submitted by 0xAadi, also found by LogBytes, Fitro, Abdessamed, avoloder, Tomas0707, synackrst, dontonka, _karanel, lrivo, Autosaida, ZanyBonzy, samuraii77, x0t0wt1w, rndquu, Auditor_Nate, blackVul (1, 2), brevis, 0xAleko, zeroProtocol, MinhTriet, 0xb0k0, 0xHelium, AvantGard, Trooper, Stoicov, 0xcontrol, EdMarcavage, yaioxy, anonymousjoe, federodes, Decap, desaperh, ogKapten, LSHFGJ, PENGUN, 0xDarko, onthehunt11, Trident-Audits, ABAIKUNANBAEV, hakunamatata, MrValioBg, 0xlemon, LeFy, kingnull, PetarTolev, binary, Undefined, Bac0nj, Erko, Daniel_eth, pep7siup, vinica_boy, SharpPeaks, yixxas, kutugu, KaligoAudits, inzinko, BajagaSec, franfran20, jeremie, 0x0bserver, dobrevaleri, DigiSafe, Nihavent, mashbust, McToady, King_, Pataroff, eierina, Shahil_Hussain, bhavya0911, amaron, hail_the_lord, ArsenLupin, KupiaSec, gesha17, zxriptor, klau5, ilchovski, Zac, 0xrex, Kalogerone, dimulski, and ZdravkoHr

The EntropyGenerator contract has an issue where the initializeAlphaIndices() function uses the wrong modifier. This function is supposed to be called by the TraitForgeNft contract, but it currently uses the onlyOwner modifier instead of onlyAllowedCaller.

Impact

The initializeAlphaIndices() function will not be callable by the TraitForgeNft contract as intended. This could lead to failures in the expected functionality of the system, particularly in scenarios where the indices need to be initialized or updated by the TraitForgeNft contract while performing minting or forging. That means this vulnerability will cause DoS on mintToken(), mintWithBudget() and forge().

Proof of Concept

The function initializeAlphaIndices() is intended to be called by the TraitForgeNft contract. However, it is currently protected by the onlyOwner modifier. This means only the owner of the EntropyGenerator contract can call it, not the TraitForgeNft contract. The correct modifier should be onlyAllowedCaller, which restricts the function to be called by the address set as the allowedCaller.

The vulnerability lies in the following line of EntropyGenerator contract:

@>  function initializeAlphaIndices() public whenNotPaused onlyOwner {

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

The above initializeAlphaIndices() is called by TraitForgeNft._incrementGeneration():

  function _incrementGeneration() private {
    require(
      generationMintCounts[currentGeneration] >= maxTokensPerGen,
      'Generation limit not yet reached'
    );
    currentGeneration++;
    generationMintCounts[currentGeneration] = 0;
    priceIncrement = priceIncrement + priceIncrementByGen;
@>  entropyGenerator.initializeAlphaIndices();
    emit GenerationIncremented(currentGeneration);
  }

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

Some of the important functions defined in TraitForgeNft contract, such as mintToken(), mintWithBudget() and forge(), internally use _incrementGeneration(). Due to this vulnerability, the execution of these mentioned functions will fail.

Replace the onlyOwner modifier with the onlyAllowedCaller modifier in the initializeAlphaIndices() function to ensure it can be called by the TraitForgeNft contract.

- function initializeAlphaIndices() public whenNotPaused onlyOwner {
+ function initializeAlphaIndices() public whenNotPaused onlyAllowedCaller {

Assessed type

Access Control

TForge1 (TraitForge) confirmed


Medium Risk Findings (19)

[M-01] Potential uninitialized entropySlots reading in getNextEntropy, causing 0 entropy mint

Submitted by pep7siup, also found by anonymousjoe, boredpukar, amaron, shaflow2, and d3e4

The getNextEntropy function can be called at any time without waiting for the write entropy batches process to finish. This could lead to the function returning an uninitialized entropy value of 000000, resulting in users losing funds to mint useless tokens and not being eligible for future airdrops as they get 0 shares. This vulnerability can severely impact the users’ trust and the protocol’s functionality.

Proof of Concept

Found in contracts/EntropyGenerator/EntropyGenerator.sol at Line 103:

If currentSlotIndex > lastInitializedIndex (writeEntropyBatch process not complete), getEntropy would return entropy = 000000 instead of revert. Which leads to user mint entities with 0 entropy.

101:  function getNextEntropy() public onlyAllowedCaller returns (uint256) { 
102:    require(currentSlotIndex <= maxSlotIndex, 'Max slot index reached.');
103:@>     uint256 entropy = getEntropy(currentSlotIndex, currentNumberIndex); 
104:
    ...
120:  }

POC

Apply following POC via git apply POC.patch and run yarn test. The test confirms getNextEntropy did return entropy 0 instead of revert.

diff --git a/test/EntropyGenerator.test.ts b/test/EntropyGenerator.test.ts
index 69551f5..f7e8698 100644
--- a/test/EntropyGenerator.test.ts
+++ b/test/EntropyGenerator.test.ts
@@ -3,7 +3,7 @@ import { ethers } from 'hardhat';
 import { EntropyGenerator } from '../typechain-types';
 import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers';
 
-describe('EntropyGenerator', function () {
+describe.only('EntropyGenerator', function () {
   let entropyGenerator: EntropyGenerator;
   let owner: HardhatEthersSigner;
   let allowedCaller: HardhatEthersSigner;
@@ -32,6 +32,15 @@ describe('EntropyGenerator', function () {
     expect(updatedCaller).to.equal(allowedCaller);
   });
 
+  it('getNextEntropy returns 0 entropy if entropySlots not properly init', async function () {
+    // call getNextEntropy when write entropy batches not finished
+    await expect(
+      entropyGenerator.connect(allowedCaller).getNextEntropy()
+    ).to.emit(entropyGenerator, 'EntropyRetrieved').withArgs(0);
+
+    // @audit: should revert getNextEntropy if entropySlots not properly init
+  });
+
   it('should write entropy batches 1', async function () {
     // Write entropy batch 1
     await entropyGenerator.writeEntropyBatch1();

Tools Used

Hardhat

Only allow getNextEntropy call if currentSlotIndex < lastInitializedIndex to ensure that the entropy slots are properly initialized:

  function getNextEntropy() public onlyAllowedCaller returns (uint256) {
...
+    require(currentSlotIndex < lastInitializedIndex, 'Slot not initialized');
    uint256 entropy = getEntropy(currentSlotIndex, currentNumberIndex);

Abdessamed (warden) commented:

@Koolex Sorry for dropping a comment now, but this is a completely invalid issue. The entropySlots will be initialized just after the deployment. This is confirmed by the deploy script.

Koolex (judge) commented:

Please check this for more context.

Abdessamed (warden) commented:

@Koolex - The validity of this issue is based on the timeframe after deployment where the entropies are not initialized, as stated by the sponsor:

This is a valid issue as there could be a timeframe where there is no initialised entropy. Better yet, we should put a batch in constructor to be sure of mitigation.

However, this information was not accessible during the audit and the deployment script I pointed out above contradicts this fact and demonstrates that entropies will be initiated just after the deployment.


[M-02] Funds can be locked indefinitely in NukeFund.sol

Submitted by Sabit, also found by 0xDemon, KupiaSec, and dimulski

https://github.com/code-423n4/2024-07-traitforge/blob/279b2887e3d38bc219a05d332cbcb0655b2dc644/contracts/NukeFund/NukeFund.sol#L40-L61

Impact

  • Permanent loss of funds.
  • Dependency on user action.

Proof of Concept

The NukeFund contract has a unique fund distribution model where the primary - and only - method to withdraw funds is through the nuke() function. This design creates a specific set of circumstances under which funds can be extracted from the contract.

Practical example: Let’s say the contract has accumulated 100 ETH in its fund. A user holding a qualified NFT (let’s call it TokenID 42) wants to claim a portion of this fund.

function nuke(uint256 tokenId) public whenNotPaused nonReentrant {
    // ... (verification checks)
    uint256 claimAmount = /* calculated based on nuke factor */;
    fund -= claimAmount;
    nftContract.burn(tokenId);
    payable(msg.sender).call{value: claimAmount}("");
    // ... (event emissions)
}

https://github.com/code-423n4/2024-07-traitforge/blob/279b2887e3d38bc219a05d332cbcb0655b2dc644/contracts/NukeFund/NukeFund.sol#L153-L183

In this scenario:

  • The user calls nuke(42).
  • The function calculates the claim amount based on the token’s properties.
  • The NFT (TokenID 42) is burned.
  • The calculated amount is sent to the user.
  • The fund is reduced by the claimed amount.

This process works as intended, but it relies entirely on NFT holders initiating the withdrawal process. There’s no other mechanism for fund distribution or withdrawal, which leads to the second point.

  • Risk of permanently locked funds.

If all NFTs are burned or if no one calls the nuke() function, there’s a real risk that funds could remain permanently locked in the contract.

Practical example: Imagine a scenario where the contract has accumulated 500 ETH over time. There are only 10 NFTs left that are eligible for nuking.

Scenario A: All NFTs are burned:

  • Users with the remaining 10 NFTs all call nuke().
  • After these transactions, all NFTs are burned.
  • The contract still holds, say, 100 ETH (depending on the nuke factor calculations).
  • There are no more NFTs left to trigger the nuke() function.
  • The remaining 100 ETH is now locked in the contract with no way to withdraw it.

OR

  • Users decide to directly call burn() to ball their NFTs.
 function burn(uint256 tokenId) external whenNotPaused nonReentrant {
    require(
      isApprovedOrOwner(msg.sender, tokenId),
      'ERC721: caller is not token owner or approved'
    );
    if (!airdropContract.airdropStarted()) {
      uint256 entropy = getTokenEntropy(tokenId);
      airdropContract.subUserAmount(initialOwners[tokenId], entropy);
    }
    _burn(tokenId);
  }

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

Scenario B: Inactivity:

  • Out of the 10 NFT holders, only 5 decide to call nuke().
  • The other 5 forget about their NFTs or lose access to their wallets.
  • The contract is left with 200 ETH and 5 unclaimed NFTs.
  • If these 5 NFTs are never used to call nuke(), the 200 ETH remains locked indefinitely.

Add a function that allows the contract owner to withdraw funds in case of emergencies or when all NFTs are burned.

Assessed type

Context

Koolex (judge) decreased severity to Medium


[M-03] A dev will lose rewards if after claiming his rewards he mints an NFT

Submitted by 0xlemon, also found by yixxas

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/DevFund/DevFund.sol#L69

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/DevFund/DevFund.sol#L74

Summary

A developer may lose rewards if the receive function is triggered during the reward claim process from DevFund, resulting in their info.rewardDebt being higher than expected. This scenario can occur when a developer claims their rewards and then immediately mints an NFT with the funds, but the airdrop hasn’t yet started, they stand to lose those rewards. This happens because the system updates the user’s rewardDebt after the callback.

Vulnerability Details

When claiming the dev’s rewards are calculated as follows: uint256 pending = info.pendingRewards + (totalRewardDebt - info.rewardDebt) * info.weight;

Then, we do a call to that dev to transfer his funds and only after that call do we update his reward debt to the total reward debt.

This can create a flow in which the developer loses a portion of his rewards. For example, if the dev has a contract that receives his rewards and wants to use that money to then buy an NFT, as he believes in the protocol and wants to support it, he would have a receive function like this:

 receive() external payable {
      traitForgeNFtContract.mintWithBudget{ value: address(this).balance }("");
 }

However, in this scenario the DevFund::receive function will be triggered (if the airdrop hasn’t started) which increases totalRewardDebt and only after that the dev’s info is updated to the totalRewardDebt, which means that the user will later receive less rewards because info.rewardDebt will include the new rewards from the received minting fees but the dev didn’t claim them.

Proof of Concept

This is DevFund::claim():

  function claim() external whenNotPaused nonReentrant {
    DevInfo storage info = devInfo[msg.sender];

    uint256 pending = info.pendingRewards +
      (totalRewardDebt - info.rewardDebt) *
      info.weight;

    if (pending > 0) {
@->      uint256 claimedAmount = safeRewardTransfer(msg.sender, pending);
      info.pendingRewards = pending - claimedAmount;
      emit Claim(msg.sender, claimedAmount);
    }

@->    info.rewardDebt = totalRewardDebt;
  }

We can see that the info.rewardDebt happens after the call to the dev, so now if the dev immediately calls the TraitForgeNft contract:

 receive() external payable {
      traitForgeNFtContract.mintWithBudget{ value: address(this).balance }("");
 }

This will mint the developer some NFTs and for each minted NFT the DevFund::receive() will be triggered. The minting in TraitForgeNft distributes the fees to the NukeFund contract and if the airdrop hasn’t started yet, the rewards are then redirected to the DevFund contract which triggers the receive function and the totalRewardDebt is updated:

  receive() external payable {
    if (totalDevWeight > 0) {
      uint256 amountPerWeight = msg.value / totalDevWeight;
      uint256 remaining = msg.value - (amountPerWeight * totalDevWeight);
@->      totalRewardDebt += amountPerWeight;
      if (remaining > 0) {
        (bool success, ) = payable(owner()).call{ value: remaining }('');
        require(success, 'Failed to send Ether to owner');
      }
    } else {
      (bool success, ) = payable(owner()).call{ value: msg.value }('');
      require(success, 'Failed to send Ether to owner');
    }
    emit FundReceived(msg.sender, msg.value);
  }

Only after these steps we update the reward debt of the dev, which will be much higher so the developer loses the rewards from this minting fees.

Note: This scenario is more than likely to happen because the developers will want to support the protocol and participate in it.

Follow the CEI pattern and transfer the dev’s funds only after updating all the state variables in DevFund::claim().

TForge1 (TraitForge) commented:

If I understand it correctly then it’s not an issue. I don’t see why myself or anybody else will do this. Especially using a sequencer based tx system like base. This is not an issue to me, not sure about everybody else.

Koolex (judge) commented:

@TForge1 - In this scenario, the sequencer is irrelevant. This is on EVM level and applicable on all chains.

To summarize this for better assessment:

  • Scenario:

    1. A developer claims rewards from DevFund.
    2. The developer’s contract immediately uses these funds to mint NFTs.
    3. If the airdrop hasn’t started, this minting triggers the DevFund’s receive() function.
    4. The totalRewardDebt is increased before the developer’s info.rewardDebt is updated.
    5. This results in the developer’s info.rewardDebt being set higher than it should be, causing them to lose out on some rewards.
  • Impact: Developers may lose rewards.
  • Root cause: The issue occurs because the contract updates the user’s rewardDebt after transferring funds to the developer.

Note from the warden: This scenario is considered likely because developers are expected to want to support and participate in the protocol actively.

For clearer picture of the technical flow, here is a diagram:

sequenceDiagram
    participant DeveloperContract
    participant DevFund
    participant NFTContract
    
    DeveloperContract->>DevFund: claim() rewards
    activate DevFund
    DevFund->>DeveloperContract: transfer rewards
    activate DeveloperContract
    Note over DeveloperContract: receive() function triggered
    DeveloperContract->>NFTContract: mintWithBudget()
    NFTContract->>DevFund: receive() (minting fees)
    DevFund->>DevFund: Update totalRewardDebt
    deactivate DeveloperContract
    DevFund->>DevFund: Update developer's info.rewardDebt
    deactivate DevFund
    Note over DevFund: Developer's info.rewardDebt now higher than expected

@0xlemon - Please confirm or correct me if I’m wrong.

TForge1 (TraitForge) commented:

Ok, fair enough. I guess it’s an issue then. not sure if I’ll do this but if I do then it can be an issue.

samuraii77 (warden) commented:

@Koolex, so we are assuming that the protocol would have a receive() function to mint NFTs automatically? Why would that be a likely thing to happen? It was never mentioned that they would be minting NFTs and reenter into their own contract, that can’t possibly be more than a QA. It classifies as a few different things - out of scope and user error, not only is it user error but it is actually owner error and owners are supposed to be much more knowledgeable than users.

0xlemon (warden) commented:

  1. As it can be seen by the sponsor’s reply, they were not aware of this bug so it would be an owner error only if they knew the bug existed.
  2. I don’t see why the devs wouldn’t do such thing to support their protocol by using the claimed rewards to mint NFTs. You can see that the sponsor said “not sure if I’ll do this but if I do then it can be an issue”. This means the devs can use this method.
  3. How is it out of scope when the root cause is in the DevFund - a contract that is in-scope?

Koolex (judge) commented:

@TForge1 - There is a middle solution, implement a sweep function under restrictive condition; for instance, when there is no NFT to be nuked any longer. This way, rug-pull is not possible. You could also add to it a timeframe that has to pass first as a delay before sweeping even if all NFTs are nuked.


[M-04] Lack of slippage protection in dynamic pricing mint function

Submitted by OMEN, also found by p0wd3r, KupiaSec, gesha17, and kutugu

The mintWithBudget function uses a dynamic pricing mechanism without implementing slippage protection. Given the specific pricing parameters, this can lead to users paying more than anticipated for their NFTs, especially for larger mints or mints occurring later in a generation. Specific impacts include:

  • Unexpected cost increases for users, particularly for multi-NFT mints.
  • Potential for users to receive fewer NFTs than expected if prices rise during minting.
  • Possible transaction failures if the price increases beyond the user’s budget, wasting gas.

Proof of Concept

The current implementation calculates the mint price for each NFT individually within the minting loop:

function mintWithBudget(bytes32[] calldata proof) public payable whenNotPaused nonReentrant onlyWhitelisted(proof, keccak256(abi.encodePacked(msg.sender))) {
    uint256 mintPrice = calculateMintPrice();
    uint256 amountMinted = 0;
    uint256 budgetLeft = msg.value;

    while (budgetLeft >= mintPrice && _tokenIds < maxTokensPerGen) {
      _mintInternal(msg.sender, mintPrice);
      amountMinted++;
      budgetLeft -= mintPrice;
      mintPrice = calculateMintPrice();  // Price recalculated for each mint
    }
    // ...
}

function calculateMintPrice() public view returns (uint256) {
    uint256 currentGenMintCount = generationMintCounts[currentGeneration];
    uint256 priceIncrease = priceIncrement * currentGenMintCount;
    uint256 price = startPrice + priceIncrease;
    return price;
}

Consider the following scenario:

User calculates that they can mint 100 NFTs with a budget of 12.75 ETH when 5000 NFTs have been minted in the current generation.

  • Initial price: 0.005 + (0.0000245 * 5000) = 0.1275 ETH per NFT.
  • Expected total: 100 * 0.1275 = 12.75 ETH
  • Before the user’s transaction is processed, another person mints 50 NFTs.
  • When the user’s transaction is processed, the new starting price is: 0.005 + (0.0000245 * 5050) = 0.128725 ETH
  • The user attempts to mint with their 12.75 ETH budget:

They can now only mint 99 NFTs (12.75 / 0.128725 ≈ 99.04).

  • Total cost for 99 NFTs: approximately 12.74 ETH
  • Remaining 0.01 ETH is refunded.

Actual outcome:

  • User receives 99 NFTs instead of the expected 100.
  • The average price paid per NFT is higher than initially calculated.

This scenario demonstrates how the lack of slippage protection in the dynamic pricing mechanism can lead to users receiving fewer NFTs than they anticipated or paying more per NFT than expected, due to price increases caused by other users’ minting activities between the time of calculation and the transaction being processed.

Add slippage protection.

Assessed type

Context


[M-05] Incorrect check against golden entropy value in the first two batches

Submitted by Abdessamed, also found by DemoreX, anonymousjoe, 0xlemon, jesjupyter, Udsen, rbserver, 0rpse, KupiaSec, ZdravkoHr, and Kalogerone

Entropy is stored in uint256 slots, with each slot able to contain 13 concatenated entropies. Entropies are generated in three passes, and there is a golden entropy value 999999 that should not be generated in the first two passes, as specified in the documentation:

There is a certain entropy, “999999” which is referred to as “the Golden God”, since it has perfect parameters and will exceed all other entities if played correctly. The Golden God is scanned for and is kept out of the first 2 passes, but is deliberately set in the final pass (at some random point).

The issue is that writeEntropyBatch1 and writeEntropyBatch2 functions do not correctly check for the golden entropy:

function writeEntropyBatch1() public {
    require(lastInitializedIndex < batchSize1, 'Batch 1 already initialized.');

    uint256 endIndex = lastInitializedIndex + batchSize1; // calculate the end index for the batch
    unchecked {
      for (uint256 i = lastInitializedIndex; i < endIndex; i++) {
        uint256 pseudoRandomValue = uint256(
          keccak256(abi.encodePacked(block.number, i))
        ) % uint256(10) ** 78; // generate a  pseudo-random value using block number and index
        require(pseudoRandomValue != 999999, 'Invalid value, retry.'); // @audit INCORRECT check
        entropySlots[i] = pseudoRandomValue; // store the value in the slots array
      }
    }
    lastInitializedIndex = endIndex;
  }

The function (same for writeEntropyBatch2) attempts to ensure that no golden entropy is generated using the following check: require(pseudoRandomValue != 999999, 'Invalid value, retry.');. However, this check is incorrect because pseudoRandomValue does NOT represent a single entropy value but represents the entire slot value, which contains 13 entropies. For example, if the pseudo-randomly generated slot value is ........999999123345, the check will pass while it should not because the second entropy is a golden one.

The current check incorrectly validates the entire slot value. Consider updating the check to validate each of the 13 entropies within the slot value.

Assessed type

Invalid Validation


[M-06] TraitForgeNft: Generations without a golden god are possible

Submitted by Trooper, also found by 0x3b (1, 2), Decap, cryptomoon, ABAIKUNANBAEV, desaperh, eta, nikhil840096, CyberscopeInterns, AuditGuy, shikhar229169, sl1, the_hunter, Nihavent (1, 2), hail_the_lord, anonymousjoe, Zac, ke1caM, and Kalogerone

The golden god is the special entropy of 999,999. This entropy is set at slotIndexSelectionPoint and numberIndexSelectionPoint here. 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:

  • gen: 1, NFTs: 10,000
  • gen: 2, NFTs: 2,909

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

After that, there is the following state:

  • gen: 1, NFTs: 10,000
  • gen: 2, NFTs: 10,000

Reading the current currentSlotIndex and currentNumberIndex (see here), they were updated to be public so there are readable. For slotIndexSelectionPoint and numberIndexSelectionPoint (see here):

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

It shows 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
      ____________________

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.

TForge1 (TraitForge) confirmed via duplicate Issue #220


[M-07] Discrepancy between nfts minted, price of nft when a generation changes & position of _incrementGeneration() inside _mintInternal() and _mintNewEntity()

Submitted by Shubham, also found by ArsenLupin, ogKapten, zhaojohnson, Tomas0707, aldarion, dontonka, 0x3b (1, 2), eLSeR17, p0wd3r, Agontuk (1, 2), jesjupyter, samuraii77, emerald7017, Ruhum, LSHFGJ, FastChecker, 0xHash, anonymousjoe, TECHFUND-inc, Trident-Audits, hakunamatata, avoloder, Abdessamed, MrValioBg, VulnViper (1, 2, 3), PNS, perseus, shikhar229169, Dots, valy001, inzinko, 0x0bserver, dobrevaleri, Udsen, ABAIKUNANBAEV, eierina, StraawHaat, valentin_s2304, dimah7, gkrastenov, LeFy, dhank, denzi_, King_, rbserver, KupiaSec, Ryonen, 0xJoyBoy03, ZdravkoHr, 4rdiii (1, 2), dimulski, and 0xrex

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/TraitForgeNft/TraitForgeNft.sol#L280-L283

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/TraitForgeNft/TraitForgeNft.sol#L227-L232

Vulnerability details

The report covers two bugs:

  • User pays the maximum price of the last generation when minting the 1st nft of the next generation.
  • Wrong positioning of _incrementGeneration() will lead to the user paying the starting price of 1st nft of generation 1 even when the nft being minted is the 1st nft of a different generation.

Proof of Concept

1st Bug Scenario: Taking generation 1 into consideration, only direct minting can occur. No nft can be minted through forging.

File: TraitForgeNft.sol

  function calculateMintPrice() public view returns (uint256) {
    uint256 currentGenMintCount = generationMintCounts[currentGeneration];
    uint256 priceIncrease = priceIncrement * currentGenMintCount;
    uint256 price = startPrice + priceIncrease;
    return price;
  }
  • When the 1st nft is minted, the price to be paid is equal to startPrice (0.005 ether). _tokenIds & generationMintCounts is updated to 1. Meaning that one nft has been minted in the protocol.
  • To mint the 2nd nft, the price to be paid is 0.005 + (0.0000245 * 1) = 0.0050245 ether. _tokenIds & generationMintCounts is updated to 2. Meaning that as the first increment in price happens, the 2nd nft for the protocol has been minted.
  • Lets move forward to the end of generation 1 when _tokenIds and generationMintCounts has reached 9999.
  • The maximum nft that can be minted in a generation is 10000 defined by maxTokensPerGen.
  • The price that a user pays to mint the 10,000th nft is 0.005 + (0.0000245 * 9999) = 0.2499755 ether.
File: TraitForgeNft.sol

function _mintInternal(address to, uint256 mintPrice) internal {
>   if (generationMintCounts[currentGeneration] >= maxTokensPerGen) {
      _incrementGeneration();
    }

>   _tokenIds++;
    uint256 newItemId = _tokenIds;
    _mint(to, newItemId);
    uint256 entropyValue = entropyGenerator.getNextEntropy();

    tokenCreationTimestamps[newItemId] = block.timestamp;
    tokenEntropy[newItemId] = entropyValue;
>   tokenGenerations[newItemId] = currentGeneration;
    generationMintCounts[currentGeneration]++;
    initialOwners[newItemId] = to;
    ...

Since generationMintCounts has not reached maxTokensPerGen, the _incrementGeneration() check is skipped. _tokenIds and generationMintCounts is updated to 10000.

Now comes the interesting part.

  • The next nft that is to be minted should come in generation 2 because 10000 nfts have already been minted at this point.
  • Also, the price for the user to pay to mint the 1st nft of generation 2 should be the startPrice plus the priceIncrementByGen that is 0.005 + 0.000005 = 0.005005 ether.
  • However, when the user calls mintToken(), the price he has to pay is 0.005 + (0.0000245 * 10000) = 0.25 ether (as mentioned in whitepaper).

    This states that to mint the 1st nft in generation 2, user has to pay the maximum price of generation 1.

  • When the call enters _mintInternal(), due to the first check, the generation is incremented from generation 1 to generation 2. _tokenIds is updated to 10001 & generationMintCounts to 1.
  • The price the user should pay be 0.005005 ether but instead, ends up paying 0.25 ether. User’s loss = 0.244995 ether.

2nd Bug Scenario:

Suppose we are at the end of generation 2. The generationMintCounts is 10000 and no minting has taken place through forging for generation 2. Meaning that nft count for generation 3 is zero.

If minting happens directly by calling the mintToken(), the same situation is repeated as explained in the scenario above & the user ends up paying the max price of generation 2 to mint the 1st nft of generation 3.

Now, if the generationMintCounts is at 9999 & _mintNewEntity() is called internally through forging, a different scenario takes place.

File: TraitForgeNft.sol

  function _mintNewEntity(
    address newOwner,
    uint256 entropy,
    uint256 gen
  ) private returns (uint256) {
    require(
      generationMintCounts[gen] < maxTokensPerGen,
      'Exceeds maxTokensPerGen'
    );

>   _tokenIds++;
    uint256 newTokenId = _tokenIds;
    _mint(newOwner, newTokenId);

    tokenCreationTimestamps[newTokenId] = block.timestamp;
    tokenEntropy[newTokenId] = entropy;
    tokenGenerations[newTokenId] = gen;
>   generationMintCounts[gen]++;
    initialOwners[newTokenId] = newOwner;

    if (
>     generationMintCounts[gen] >= maxTokensPerGen && gen == currentGeneration
    ) {
      _incrementGeneration();
    }
    ...
  • Since minting is happening through forging, the mint price is not calculated. _tokenIds becomes 20000 and generationMintCounts becomes 10000.
  • Immediately, the call enters the if condition and the generation is incremented.
  • generationMintCounts for generation 3 is reset to 0.
  • Now, if a user calls mintToken(), the price calculated will be 0.005 + (0.0000345 * 0) = 0.005 ether which the initial starting price of the 1st nft of generation 1. (priceIncrement for gen 3 would be = 0.0000245 + 0.000005 + 0.000005 = 0.0000345).

This scenario would lead to a loss to the protocol and benefit for the user.

Fixing the bugs would require changes in the calculateMintPrice() and _mintInternal(). However, the protocol will not be able to claim the the last calculated price as intended by the whitepaper but it would then align with the number of nfts minted & the price the user pays.

File: TraitForgeNft.sol

  function calculateMintPrice() public view returns (uint256) {
+  if (generationMintCounts[currentGeneration] > 0) {
      uint256 currentGenMintCount = generationMintCounts[currentGeneration];
      uint256 priceIncrease = priceIncrement * currentGenMintCount;
      uint256 price = startPrice + priceIncrease;
      return price;
   }
+  else {
+     uint256 price = startPrice + ((currentGeneration - 1) * priceIncrementByGen );
+     return price;
   }
  }
File: TraitForgeNft.sol
 
  function _mintInternal(address to, uint256 mintPrice) internal {
-   if (generationMintCounts[currentGeneration] >= maxTokensPerGen) {
-     _incrementGeneration();
-   }

    _tokenIds++;
    uint256 newItemId = _tokenIds;
    _mint(to, newItemId);
    uint256 entropyValue = entropyGenerator.getNextEntropy();

    tokenCreationTimestamps[newItemId] = block.timestamp;
    tokenEntropy[newItemId] = entropyValue;
    tokenGenerations[newItemId] = currentGeneration;
    generationMintCounts[currentGeneration]++;
    initialOwners[newItemId] = to;
 
+   if (generationMintCounts[currentGeneration] >= maxTokensPerGen) {
+     _incrementGeneration();
+   }
    ...

Assessed type

Error

TForge1 (TraitForge) confirmed via duplicate Issue #210

Koolex (judge) decreased severity to Medium


[M-08] Lack of ability to make an some external function calls makes the DAO stage unreachable

Submitted by pfapostol, also found by 0xlemon and yixxas

Switching to DAO phase in NukeFund is not possible. This vulnerability is on the boundary of scope because it makes it impossible to use the out-of-scope contract but the problem is in the contact inside the scope that is the owner of this external contract.

The TraitForgeNft contract is written in such a way that it can add and remove users from the airdrop. For this the NFT contract must have ownership over the airdrop contract.

Because of this, an additional function is created in TraitForgeNft to start the airdrop while other functions that can only be called by the owner (setTraitToken, allowDaoFund) are absent in TraitForgeNft contract. Which makes these functions impossible to call.

The first function may not be necessary (deployer can call it at deploy), but the second function must have its own handle in the TraitForgeNft contract since this function is used by NukeFund and can only be called after the start of the airdrop; that is, after the transfer of ownership to the TraitForgeNft contract.

Proof of Concept

With normal initialization owner of Airdrop is TraitForgeNft:

contract DeployAllTest is Test {
  string constant NAME = "TRAIT";
  string constant SYMBOL = "TRAIT";
  uint8 constant DECIMALS = 18;
  uint256 constant TOTAL_SUPPLY = 1_000_000e18;

  address constant UNISWAP_ROUTER = address(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);

  address immutable DEPLOYER = makeAddr("DEPLOYER");
  address immutable USER1 = makeAddr("USER1");
  address immutable USER2 = makeAddr("USER2");
  address immutable USER3 = makeAddr("USER3");
  address immutable DEV1 = makeAddr("DEV1");
  address immutable DEV2 = makeAddr("DEV2");
  address immutable DEV3 = makeAddr("DEV3");

  Trait token;
  TraitForgeNft nft;
  EntropyGenerator entropyGenerator;
  EntityTrading entityTrading;
  EntityForging entityForging;
  DevFund devFund;
  Airdrop airdrop;
  DAOFund daoFund;
  NukeFund nukeFund;

  Attacker attacker;

  uint256 trackId = 0;

  function setUp() public {
      vm.startPrank(DEPLOYER);
      // Deploy all contracts using Foundry's deployment method
      token = new Trait(NAME, SYMBOL, DECIMALS, TOTAL_SUPPLY); // @audit-qa Would be nice to burn some tokens
      nft = new TraitForgeNft();
      entropyGenerator = new EntropyGenerator(address(nft));
      entityTrading = new EntityTrading(address(nft));
      entityForging = new EntityForging(address(nft));
      devFund = new DevFund();
      airdrop = new Airdrop();
      daoFund = new DAOFund(address(token), UNISWAP_ROUTER);
      nukeFund = new NukeFund(address(nft), address(airdrop), payable(address(devFund)), payable(address(daoFund)));

      attacker = new Attacker(nft, airdrop);
      vm.stopPrank();
  }

  function _initVars() internal {
      vm.startPrank(DEPLOYER);
      nft.setEntityForgingContract(address(entityForging));
      nft.setEntropyGenerator(address(entropyGenerator));
      nft.setAirdropContract(address(airdrop));
      airdrop.setTraitToken(address(token));
      airdrop.transferOwnership(address(nft));
      nft.setNukeFundContract(payable(address(nukeFund)));

      entropyGenerator.transferOwnership(address(nft));

      entityTrading.setNukeFundAddress(payable(address(nukeFund)));
      entityForging.setNukeFundAddress(payable(address(nukeFund)));

      entropyGenerator.writeEntropyBatch1();
      entropyGenerator.writeEntropyBatch2();
      entropyGenerator.writeEntropyBatch3();
      vm.stopPrank();
  }


  function testMedium_deadFunc() public {
      _initVars();
  
      console2.log(airdrop.owner());
      assert(airdrop.owner() == address(nft));
  }
}

There is no way to call Airdrop.allowDaoFund(). The third stage in NukeFund dev share distribution relies completely on airdropContract.daoFundAllowed().

        if (!airdropContract.airdropStarted()) {
            (bool success,) = devAddress.call{value: devShare}("");
            require(success, "ETH send failed");
            emit DevShareDistributed(devShare);
        } else if (!airdropContract.daoFundAllowed()) {
            (bool success,) = payable(owner()).call{value: devShare}("");
            require(success, "ETH send failed");
        } else {
            (bool success,) = daoAddress.call{value: devShare}("");
            require(success, "ETH send failed");
            emit DevShareDistributed(devShare);
        }

Add delegate handle to TraitForgeNft:

diff --git a/contracts/TraitForgeNft/TraitForgeNft.sol b/contracts/TraitForgeNft/TraitForgeNft.sol
index b933d74..14b2f99 100644
--- a/contracts/TraitForgeNft/TraitForgeNft.sol
+++ b/contracts/TraitForgeNft/TraitForgeNft.sol
@@ -86,6 +86,11 @@ contract TraitForgeNft is ITraitForgeNft, ERC721Enumerable, ReentrancyGuard, Own
         airdropContract.startAirdrop(amount); //@external-logic
     }

+    function allowDaoFund() external whenNotPaused onlyOwner {
+        airdropContract.allowDaoFund();
+    }
+
     function setStartPrice(uint256 _startPrice) external onlyOwner {
         startPrice = _startPrice;
     }

Assessed type

Access Control

pfapostol (warden) commented:

I think this issue was marked as invalid because validators mistakenly flagged it as a duplicate of Issue 214, but this is a completely different issue. Issue #214 addresses the normal behavior by reporting that “the TraitForgeNft cannot call subUserAmount and addUserAmount because they are ownable”, but this is incorrect since the ownership is transferred to TraitForgeNft.

Since the ownership is transferred to TraitForgeNft, allowDaoFund cannot be called (not implemented); which results in the failure to switch to the DAO stage of the project.

samuraii77 (warden) commented:

@Koolex - I just want to mention that the Airdrop contract is OOS and any issues related to improper access control set on it should not be valid at all.

Koolex (judge) commented:

I believe the issue is valid. Owner of Airdrop is TraitForgeNft and the bug prevents from entering the DAO stage. The root cause and the fix is in TraitForgeNft, therefore it is in scope.

Note: For full discussion, see here.


[M-09] Golden God tokens can be minted twice per generation

Submitted by waydou, also found by 0xb0k0, Decap, robertodf99, Autosaida, 0x3b, Tomas0707 (1, 2), _karanel, Trooper, rndquu, D_Auditor, MKVsentry, samuraii77, unnamed, yaioxy, ogKapten, anonymousjoe, peanuts, y4y, LeFy, MrValioBg, shikhar229169 (1, 2), artilugio0, Phoenix_x, 0xR360, cryptomoon, Coinymous, pfapostol, 0xPwned, Yunaci, Nihavent, 3n0ch, Xcrypt, Quaternion2691, perseus, Udsen, LonelyWolfDemon, den-sosnowsky, Shahil_Hussain, nnez, Zac, ke1caM, shaka, 4rdiii, and Kalogerone

The current implementation of the EntropyGenerator contract allows for the possibility of having two “Golden God” tokens (tokens with an entropy value of 999999) per generation. This duplicity undermines the intended uniqueness and rarity of the Golden God token, potentially disrupting the game’s economy and fairness. The predictability of one Golden God token, combined with the chance of a second one, could lead to exploitation and loss of player trust.

Proof of Concept

The vulnerability stems from two separate mechanisms that can produce a Golden God token:

  1. Predetermined Golden God:
function getEntropy(uint256 slotIndex, uint256 numberIndex) private view returns (uint256) {
  if (slotIndex == slotIndexSelectionPoint && numberIndex == numberIndexSelectionPoint) {
    return 999999;
  }
  1. Entropy Calculation from the valued given by writeBatch:
uint256 slotValue = entropySlots[slotIndex];
uint256 entropy = (slotValue / (10 ** (72 - position))) % 1000000;
uint256 paddedEntropy = entropy * (10 ** (6 - numberOfDigits(entropy)));

The predetermined Golden God is set during initialization, and the other Golden God token is set in the writeEntropyBatch3. Since the math in the getEntropy doesn’t affect 6 figure digits, this means if writeEntropyBatch3 does write a slot with golden number 999999; it’s only a matter of time before someone gets since the nfts are attributed the slot sequentially. (Token 1 will get slotIndex and numberIndex [0,0], token 2 will get [0,1], token 3 will get [0,2], and so on).

Add a boolean to check if a golden god token has already been issued.

Assessed type

Context

TForge1 (TraitForge) acknowledged and commented:

Although it is technically an issue, it is definitely not high risk. Low at most as it doesn’t affect the game that much, just doesn’t make the golden god as cool.

Koolex (judge) decreased severity to Medium and commented:

Based on the given criteria, This would be categorized as Medium.

Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements

https://docs.code4rena.com/awarding/judging-criteria/severity-categorization

Reasoning:

  • Assets are not at direct risk, which rules out a High severity categorization.
  • The function of the protocol is impacted, as the uniqueness and rarity of the “Golden God” token are compromised.
  • The issue could impact the game fairness, which are core aspects of the protocol’s intended function.

Abdessamed (warden) commented:

This is an intended behavior and a design choice. It is explicitly mentioned in the docs that Golden entropy can be selected at random slots and indexes:

The getEntropy function calculates an entropy value based on a given slot and number index. It first verifies that the provided slot index is within bounds. If the slot and number indices match the predefined selection points, a special value of 999999 is returned. Otherwise, it computes the position for slicing the entropy value from the selected slot in the entropySlots array.

Koolex (judge) commented:

@Abdessamed - I don’t see where it mentions two Gods are allowed (even implicitly).

Abdessamed (warden) commented:

@Koolex - Golden entropy is generated on the third batch, so it exists on the entropySlots for every generation. The documentation I linked explicitly says that when the function getEntropy is called, it can return 999999 directly without consulting the entropySlots state which makes the fact that golden entropies can be found twice per generation.

Note: For full discussion, see here.


[M-10] Imprecise token age calculation results in an incorrect nuke factor, causing users to claim the wrong amount

Submitted by Matin, also found by preslavxyz, JuggerNaut63, samuraii77, the_hunter, stanchev, swapnaliss, inzinko, Udsen, nikhil840096, and Abhan

The function calculateAge() computes the age of a token ID based on the current and creation time difference. This token age is then used to determine the nuke factor to calculate the possible claim amount.

The problem here is that when calculating the age of a token, it first divides the time difference to 1 day in seconds, and then multiplies the result to the other variables.

  function calculateAge(uint256 tokenId) public view returns (uint256) {
    require(nftContract.ownerOf(tokenId) != address(0), 'Token does not exist');

    uint256 daysOld = (block.timestamp -
      nftContract.getTokenCreationTimestamp(tokenId)) /
      60 /
      60 /
      24;
    uint256 perfomanceFactor = nftContract.getTokenEntropy(tokenId) % 10;

    uint256 age = (daysOld *
      perfomanceFactor *
      MAX_DENOMINATOR *
      ageMultiplier) / 365; // add 5 digits for decimals
    return age;
  }

With having this pattern, users will face significant losses. Especially, in the case of small differences (less than 1 day), the daysOld variable becomes 0, making the age zero respectively.

Proof of Concept

The function below illustrates the significant discrepancies between the results:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;

import "forge-std/Test.sol";


contract DifferenceTest is Test {

    uint256 public constant MAX_DENOMINATOR = 100000;
    error InvalidTokenAmount();

    function setUp() public {

    }

    function calculateAge_precision(
        uint256 creationTimestamp,
        uint256 startTime,
        uint256 ageMultiplier
    )  public view returns (uint256) {

        uint256 perfomanceFactor = 150000;

        uint256 age = ((startTime - creationTimestamp) *
        perfomanceFactor *
        MAX_DENOMINATOR *
        ageMultiplier) / (60 * 60 * 24 * 365);
        return age;
    }

    function calculateAge_actual(
        uint256 creationTimestamp,
        uint256 startTime,
        uint256 ageMultiplier
    )  public view returns (uint256) {

        uint256 perfomanceFactor = 150000;

        uint256 daysOld = (startTime -
        creationTimestamp) /
        60 /
        60 /
        24;

        uint256 age = (daysOld *
        perfomanceFactor *
        MAX_DENOMINATOR *
        ageMultiplier) / 365; // add 5 digits for decimals
        return age;
    }

    function test_diffAges(
        uint256 creationTimestamp,
        uint256 startTime,
        uint256 ageMultiplier
    )  public {

        vm.assume(startTime > creationTimestamp);
        vm.assume(ageMultiplier < MAX_DENOMINATOR);
        uint actualAge = calculateAge_actual(creationTimestamp, startTime, ageMultiplier);
        uint accurateAge = calculateAge_precision(creationTimestamp, startTime, ageMultiplier);
        console.log("The actual age is:   ", actualAge);
        console.log("The accurate age is: ", accurateAge);
        assertFalse(actualAge == accurateAge);
    }
}

For these results we will have:

  • creationTimestamp = 1722511200,
  • startTime = 1722799000,
  • ageMultiplier = 150000,
  • perfomanceFactor = 15620:
[PASS] test_diffAges() (gas: 8201)
Logs:
  The actual age is:    1925753424657
  The accurate age is:  2138240106544

Traces:
  [8201] DifferenceTest::test_diffAges()
    ├─ [0] console::log("The actual age is:   ", 1925753424657 [1.925e12]) [staticcall]
    │   └─ ← [Stop]
    ├─ [0] console::log("The accurate age is: ", 2138240106544 [2.138e12]) [staticcall]
    │   └─ ← [Stop]
    ├─ [0] VM::assertFalse(false) [staticcall]
    │   └─ ← [Return]
    └─ ← [Stop]

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 520.80µs (150.20µs CPU time)
  • The calculated age is: 1925753424657
  • The accurate age is: 2138240106544

This will lead to ~ 11% of errors which is a significant error here. This huge difference will also increase the nuke factor (even more) and affect the results.

Consider multiplying the numerator variables first and then divide by the denominator:

  function calculateAge(uint256 tokenId) public view returns (uint256) {
    require(nftContract.ownerOf(tokenId) != address(0), 'Token does not exist');

-    uint256 daysOld = (block.timestamp -
-      nftContract.getTokenCreationTimestamp(tokenId)) /
-      60 /
-      60 /
-      24;
    uint256 perfomanceFactor = nftContract.getTokenEntropy(tokenId) % 10;

-    uint256 age = (daysOld *
+    uint256 age = (perfomanceFactor *
      MAX_DENOMINATOR *
+      (block.timestamp - nftContract.getTokenCreationTimestamp(tokenId)) *
      ageMultiplier) / (60 * 60 * 24 * 365); // add 5 digits for decimals
    return age;
  }

Assessed type

Math

TForge1 (TraitForge) acknowledged and commented:

Age multiplier is marked as an invalid value, and should not have been used as I marked several times in the public channel (I forgot to remove from codebase which is my fault). The default for age multiplier should be 0.

The difference of nuke factor % between each day is 0.006% which is not much 2.5% / 365. Although this is still a valid finding as it could be more accurate, I personally would mark as low risk.

Koolex (judge) commented:

The issue as demonstrated is valid with a Medium risk. For fairness, since it is a part of the actual code, I will keep it as it is (Medium). If there is something that I may have missed here that may change the risk level, please let me know. Will consider inputs from all parties in post-judging QA, as usual.

dimulski (warden) commented:

@Koolex - I believe this issue is invalid, the sponsor was asked several times about the ageMultiplier value in the public chat as this value can’t be found anywhere in the tests or deployments scripts, and was adamant that this value won’t be used.

From the code implementation, it is clear that the age multiplier should increase only once whole days are passed not seconds or minutes. I believe this issue is invalid, or QA at best. Given the fact that the sponsor has mentioned that ageMultiplier won’t be used multiple times in the public channel it will be extremely unfair to other wardens to validate this issue.

Kalogerone (warden) commented:

I agree with the comment above and want to add some additional info as to why this issue seems like an invalid, at best QA. Quoting from the issue report:

With having this pattern, users will face significant losses. Especially, in the case of small differences (less than 1 day), the daysOld variable becomes 0, making the age zero respectively.

The daysOld variable is how many days old an NFT is. I don’t understand why it shouldn’t be 0 if an NFT is minted less than 24 hours ago. It’s not an hoursOld variable. What matters to the protocol is how many full days old an NFT is. There is no precision loss here and the variable works as intended.

Also, the issue above in the PoC is setting the ageMultiplier variable to something different that 1 to make the percentage of the error seem big (ageMultiplier = 150000). With the correct ageMultiplier = 1, the calculation of the daysOld variable is at most 23 hours and 59 minutes behind. Which is not really behind because, again, the variable is supposed to calculate the full days of the NFT’s existence. The sponsors have mentioned multiple times that the ageMultiplier should be set to 1 for testing.

Koolex (judge) commented:

After further review, I will keep it as it is, since it is a part of the actual code; which I believe it should take priority over the public chat. I understand that this might not be favourable to some of you, but I have to do this, to keep the game fair.


[M-11] Duplicate NFT generation via repeated forging with the same parent

Submitted by avoloder, also found by _karanel, stanchev, Trident-Audits, 0xb0k0, Tamer, yaioxy, CyberscopeInterns, AuditGuy, 0xvd, n3smaro, AvantGard, Shahil_Hussain, and ZdravkoHr

https://github.com/code-423n4/2024-07-traitforge/blob/279b2887e3d38bc219a05d332cbcb0655b2dc644/contracts/EntityForging/EntityForging.sol#L102-L144

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

Impact

It is possible to get the same NFT (same generation, same entropy) when the forging is done with the same parentId multiple times. Although the number of times this action will be feasible is limited with the forging potential of the token it can still occur at least once.

Scenario:

  1. User A lists his/her NFT for forging.
  2. User B forges his/her NFT with the NFT from User A.
  3. The new NFT is created with the combined properties of the NFT1 and NFT2.
  4. User A lists his/her NFT for forging again.
  5. User B forges his/her NFT with the NFT from User A again.
  6. The NFT will be of the same generation and will have the same entropy as the one forged before.

Proof of Concept

Add this test to the EntityForging.test.sol contract:

it('should not allow forging with the same parent multiple times', async () => {
      const forgerTokenId = FORGER_TOKEN_ID;
      const mergerTokenId = MERGER_TOKEN_ID;
      const fee = FORGING_FEE;

      await entityForging.connect(owner).listForForging(forgerTokenId, fee);

      const forgerEntropy = await nft.getTokenEntropy(forgerTokenId);
      const mergerEntrypy = await nft.getTokenEntropy(mergerTokenId);
      
      /// The new token id will be forger token id + 1, cause it's the last item
      const expectedTokenId = FORGER_TOKEN_ID + 1;
      await expect(
        entityForging
          .connect(user1)
          .forgeWithListed(forgerTokenId, mergerTokenId, {
            value: FORGING_FEE,
          })
      )
        .to.emit(entityForging, 'EntityForged')
        .withArgs(
          expectedTokenId,
          forgerTokenId,
          mergerTokenId,
          (forgerEntropy + mergerEntrypy) / 2n,
          FORGING_FEE
        )
        .to.emit(nft, 'NewEntityMinted')
        .withArgs(
          await user1.getAddress(),
          expectedTokenId,
          2,
          (forgerEntropy + mergerEntrypy) / 2n
        );

        // List it again
        await entityForging.connect(owner).listForForging(forgerTokenId, fee);
        const expectedId = FORGER_TOKEN_ID + 2;

        // Forge again with the same parent
        await expect(
          entityForging
            .connect(user1)
            .forgeWithListed(forgerTokenId, mergerTokenId, {
              value: FORGING_FEE,
            })
        )
          .to.emit(entityForging, 'EntityForged')
          .withArgs(
            expectedId,
            forgerTokenId,
            mergerTokenId,
            (forgerEntropy + mergerEntrypy) / 2n,
            FORGING_FEE
          )
          .to.emit(nft, 'NewEntityMinted')
          .withArgs(
            await user1.getAddress(),
            expectedId,
            2,
            (forgerEntropy + mergerEntrypy) / 2n
          );
    });

Tools Used

VS Code, Hardhat

You could use a mapping(bytes32 => bool) forgedPairs to store token ids that have been forged before. An additional function to generate the unique key for id pairs would be necessary, for example:

keccak256(abi.encodePacked(id1 < id2 ? id1 : id2, id1 < id2 ? id2 : id1));

This type of hashing would ensure that you treat two pairs in reverse order as the same, since (entropy1 + entropy2) / 2 is the same as (entropy2 + entropy1) / 2.

You could then add require statement to check if the pair has already been used before. Don’t forget to add the pairs to the mapping after they have been forged.

Assessed type

Error

TForge1 (TraitForge) confirmed

dimulski (warden) commented:

@Koolex - This is not a real issue, nowhere in the docs or in the code is it even slightly mentioned that minting with NFTs with the same parents is forbidden. There is no randomness at all in the project, as the entropies for all NFTs can be calculated before the user mints them, or forges. The entropy is used in different game mechanics but having the same entropy for NFTs is guaranteed for NFTs from different generations. I don’t see how this is an issue, it doesn’t lead to any lost funds, doesn’t break any protocol invariants in the slightest, and there is no broken functionality. This is at best some design recommendation.

samuraii77 (warden) commented:

I agree, I also wanted to mention that, it’s never mentioned that parents should not merge with each other more than once and it’s in no way problematic.

As a matter of fact, it is completely expected for parents to merge more than once, those who have a brother or sister will probably agree with me.

Koolex (judge) commented:

Not every implementation in the code must be mentioned in the docs and the sponsor confirmed the issue. This confirm that it wasn’t intended since we don’t have in the docs or the code what states otherwise.

dimulski (warden) commented:

@Koolex - I disagree with your decision. The only way this qualifies as a medium is if it was stated as an invariant of the protocol. The documentation was a google doc file, which the sponsor edited multiple times during the audit, and still didn’t include this. The fact that this is undesired behavior was not disclosed in any public way before or during the audit. Given that this is an NFT game and developers can come up with whatever logic they want, they have to disclose all of their decisions before or during the audit.

Lastly, I would like to ask since when whether the sponsors confirms something or not, decides whether an issue is valid or not. This issue doesn’t cover a single thing from the requirements for a medium issue.

Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.

Someone could have reported that NFT with id 55 and NFT with id 77 shouldn’t be forged if there is no full moon, and since the sponsor didn’t explicitly say these NFTs can forge even without a full moon, should this also be considered a medium issue?

Abdessamed (warden) commented:

@Koolex - This submission was more like a speculation of something that might be wrong. The documentation was very clear about how forging should happen and all its requirements (forging role, number of forging times per generation). The codebase was very clear on the forging process. If we ask the warden who submitted the report how he knows that this is an issue, he wouldn’t have a clear response since he has no reference to go to that proves this is indeed an issue. We feel like this was intended behavior and the sponsor decided to change the forging behavior after reading this submission, so it was more like a feature suggestion.

Koolex (judge) commented:

From README

Core Mechanics

Entities: NFTs with unique traits and parameters impacting the game.

Same generation and same entropy goes against this Mechanic. Therefore, I still see it an issue.


[M-12] NFTs mature too slowly under default settings.

Submitted by sl1, also found by 0x3b, peanuts, anonymousjoe, samuraii77, shikhar229169, hakunamatata, MrValioBg, cryptomoon, perseus, vinica_boy, 0x0bserver, onthehunt11, ABAIKUNANBAEV, persik228, dyoff, DemoreX, rbserver, KupiaSec, ZdravkoHr, ke1caM, hl_, Kalogerone, and dimulski

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

Description

The Nuke Fund accumulates ETH from new mints and economic activity. After a 3-day maturity period, anyone can nuke their entity to claim a share of the ETH in the Fund. Every entity has a parameter, called initialNukeFactor, set on mint which represents how much of the Fund can be claimed on nuke. The maximum total nukeFactor is 50%, expressed as 50_000.

The calculations of finalNukeFactor consist of adjustedAge, defaultNukeFactorIncrease and initialNukeFactor.

NukeFund.sol#L145-L148

uint256 initialNukeFactor = entropy / 40; // calculate initalNukeFactor based on entropy, 5 digits
uint256 finalNukeFactor = ((adjustedAge * defaultNukeFactorIncrease) /
  MAX_DENOMINATOR) + initialNukeFactor;

The age is calculated via calculateAge() function.

NukeFund.sol#L121-L131

uint256 daysOld = (block.timestamp -
  nftContract.getTokenCreationTimestamp(tokenId)) /
  60 /
  60 /
  24;
uint256 perfomanceFactor = nftContract.getTokenEntropy(tokenId) % 10;
uint256 age = (daysOld *
  perfomanceFactor *
  MAX_DENOMINATOR *
  ageMultiplier) / 365; // add 5 digits for decimals

The idea behind age calculations is quite simple: we calculate how many days have passed since token creation and convert that into years.

However, the default value of the nukeFactorIncrease set to 250 is extremely low. While whitepaper mentions that the fastest maturing NFT should mature (reach nukeFactor of 50_000) in around 30 days and the slowest in 600 days, in reality it would take 4050 days for the best possible NFT to fully mature.

To showcase this let’s do some math:

To mature NFT must reach finalNukeFactor of 50_000, the best possible NFT would have 25000 initialNukeFactor and performanceFactor of 9.

  • defaultNukeFactor is set to 250
  • finalNukeFactor = adjustedAge * defaultNukeFactorIncrease + initialNukeFactor
  • 50000 = x * 250 + 25000
  • 25000 = 250x
  • x = 100
  • adjustedAge = daysOld * performanceFactor / 365
  • 100 = x * 9 / 365
  • 9x = 100 * 365
  • x = 365000 / 9 = 4055.

For the best NFT to mature fully in 30 days, the defaultNukeFactor would need to be 135 times bigger, more precisely 33750.

Impact

NFTs mature extremely slowly with default settings, to the point where performanceFactor of an NFT does not play any role and finalNukeFactor is determined solely by the initialNukeFactor.

Proof of Concept

To set up the following POC in Foundry please follow the steps below.

Inside Hardhat project working directory:

  • yarn add --dev @nomicfoundation/hardhat-foundry - Install the hardhat-foundry plugin.
  • Add require("@nomicfoundation/hardhat-foundry"); to the top of your hardhat.config.js file.
  • Run npx hardhat init-foundry in your terminal. This will generate a foundry.toml file based on your Hardhat project’s existing configuration, and will install the forge-std library.

Run it with forge test --match-test 'test_matureToSlowly' -vv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import '../lib/forge-std/src/Test.sol';
import { console2 } from '../lib/forge-std/src/console2.sol';
import '../contracts/TraitForgeNft/TraitForgeNft.sol';
import '../contracts/EntropyGenerator/EntropyGenerator.sol';
import '../contracts/EntityForging/EntityForging.sol';
import '../contracts/Airdrop/Airdrop.sol';
import '../contracts/NukeFund/NukeFund.sol';

contract EntropyGeneratorTest is Test {
    TraitForgeNft public forgeNFT;
    EntropyGenerator public entropyGenerator;
    EntityForging public forgingContract;
    Airdrop public airdrop;
    NukeFund public nukeFund;
    address dev = makeAddr('dev');
    address dao = makeAddr('dao');

    function setUp() public {
        forgeNFT = new TraitForgeNft();
        entropyGenerator = new EntropyGenerator(address(forgeNFT));
        forgingContract = new EntityForging(address(forgeNFT));
        airdrop = new Airdrop();
        airdrop.transferOwnership(address(forgeNFT));
        nukeFund = new NukeFund(
            address(forgeNFT),
            address(airdrop),
            payable(dev),
            payable(dao)
        );
        nukeFund.setAgeMultplier(1);

        forgeNFT.setEntityForgingContract(address(forgingContract));
        forgeNFT.setEntropyGenerator(address(entropyGenerator));
        forgeNFT.setAirdropContract(address(airdrop));
    }

    function test_matureToSlowly() public {
        address user = makeAddr('user');
        vm.deal(user, 100000e18);
        bytes32[] memory proof;

        entropyGenerator.writeEntropyBatch1();
        entropyGenerator.writeEntropyBatch2();
        entropyGenerator.writeEntropyBatch3();

        uint256 initialNukeFactor;
        uint256 performanceFactor;
        uint256 tokenId = 0;

        vm.warp(1722790420);
        vm.warp(block.timestamp + 48 hours);

        while (initialNukeFactor != 24999 && performanceFactor != 9) {
            vm.prank(user);
            forgeNFT.mintToken{ value: 2e18 }(proof);
            tokenId++;
            initialNukeFactor = forgeNFT.getTokenEntropy(tokenId) / 40;
            performanceFactor = forgeNFT.getTokenEntropy(tokenId) % 10;
        }
        // Almost perfect NFT
        console2.log('initialNukeFactor:', initialNukeFactor);
        console2.log('performanceFactor:', performanceFactor);
        console2.log('--------------------------------------');

        // Judging by the docs this NFT should take around 30 days to mature fully
        vm.warp(block.timestamp + 30 days);

        // In 30 days NFT did not mature at all
        console2.log(
            'nukeFactor after 30 days: ',
            nukeFund.calculateNukeFactor(tokenId)
        );
        console2.log(
            'How much NFT matured:',
            nukeFund.calculateNukeFactor(tokenId) - initialNukeFactor
        );

        // It takes around 4000 days for an NFT to mature in perfect conditions
        vm.warp(block.timestamp + 4000 days);
        console2.log(
            'nukeFactor after 4000 days: ',
            nukeFund.calculateNukeFactor(tokenId)
        );
        console2.log(
            'How much NFT matured:',
            nukeFund.calculateNukeFactor(tokenId) - initialNukeFactor
        );
    }

The console output of the test:

Logs:
  initialNukeFactor: 24220
  performanceFactor: 9
  --------------------------------------
  nukeFactor after 30 days:  24404
  How much NFT matured: 184
  nukeFactor after 4000 days:  49062
  How much NFT matured: 24842

defaultNukeFactorIncrease variable should be set to reasonable value, that will correctly reflect the speed at which NFTs should mature. Judging by the docs, its value should be set to at least 33750.

TForge1 (TraitForge) confirmed


[M-13] Pause and unpause functions are inaccessible

Submitted by Ward, also found by klau5, atoko, MSaptarshi, _thanos1, LogBytes, 0xAleko, c4whitehat, Autosaida, lrivo, TECHFUND-inc, dic0de, Tigerfrake, zhaojohnson, gajiknownnothing, Abdessamed, UAARRR, oxwhite, p0wd3r, ZanyBonzy, PetarTolev, kodyvim, samuraii77, Mahi_Vasisth, jesjupyter, 0xcontrol, persik228, blackVul, peanuts, Matin, zarkk01, zeroProtocol, yaioxy, avoloder, 0xDarko, ABAIKUNANBAEV, 0xAkira, boraichodrunkenmaster, Bac0nj, anonymousjoe, Stoicov, JustUzair, aldarion, _karanel, Shubham, unnamed, 0xjarix, King_, 0xlemon, ak1, Trident-Audits, MKVsentry, kingnull, dontonka, shui, nervouspika, VulnViper, Chad0, 0xDemon, binary, Yunaci, mgf15, sl1, pep7siup, cryptomoon, yixxas, 4B, kutugu, dimah7, BajagaSec, korok, ast3ros, kartik_giri_47538, dobrevaleri, rekxor, dyoff, 0x0bserver, 0xAadi, Pataroff, Xcrypt, LonelyWolfDemon, Shahil_Hussain, arman, hail_the_lord, bhavya0911, ArsenLupin, KupiaSec, Ryonen, hl_, ilchovski, Stefanov, ke1caM, dimulski, ZdravkoHr, and Kalogerone

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/TraitForgeNft/TraitForgeNft.sol#L19

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/NukeFund/NukeFund.sol#L11

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/EntropyGenerator/EntropyGenerator.sol#L9

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/EntityTrading/EntityTrading.sol#L11

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/DevFund/DevFund.sol#L9

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/EntityForging/EntityForging.sol#L10

Impact

All in-scope contracts of TraitForge inherit from the Pausable contract of OpenZeppelin, a feature designed to allow the pausing and unpausing of contract functionalities in emergency situations or for maintenance. However, (parent) contracts do not expose the _pause() and _unpause() functions and the Pausable contract contains only internal pausing/unpausing functions.

As a result, despite inheriting the pausability feature, administrators are unable to utilize these critical controls to pause or resume the contract’s operations when needed, potentially leading to issues during periods requiring immediate intervention.

Proof of Concept

Pause and unpause functions of the Pausable contract:

    function _pause() internal virtual whenNotPaused {
        _paused = true;
        emit Paused(_msgSender());
    }

    function _unpause() internal virtual whenPaused {
        _paused = false;
        emit Unpaused(_msgSender());
    }

All in-scope contracts inherit Pausable but do not expose it. An example is EntityTrading.sol, which explicitly tries to use the pausing functionality, as can be seen with the whenNotPaused modifier used in several functions.

Tools Used

Foundry

To leverage the full capabilities of the Pausable inheritance and enhance the contract’s operational security and flexibility, it is recommended to expose the pause and unpause functions in the parent contracts. These should be accessible by the contract owner or other authorized roles, ensuring they can respond effectively to operational needs or emergencies:

    /**
     * @dev Pauses all functions affected by `whenNotPaused`.
     */
    function pause() public onlyOwner {
        _pause();
    }

    /**
     * @dev Unpauses all functions affected by `whenNotPaused`.
     */
    function unpause() public onlyOwner {
        _unpause();
    }

Assessed type

Library

TForge1 (TraitForge) confirmed


[M-14] Forger Entities can forge more times than intended

Submitted by AvantGard, also found by Tigerfrake, gajiknownnothing, zhaojohnson, robertodf99, Abdessamed, _karanel, 0x3b, p0wd3r, ZanyBonzy, almurhasan, Agontuk, oxwhite, samuraii77, D_Auditor, MinhTriet, peanuts, stanchev, Tamer, 0xb0k0, air_0x, anonymousjoe, Autosaida, MrValioBg, pfapostol, shikhar229169, hakunamatata, LeFy, dontonka, shui, VulnViper, jesjupyter, sl1, rndquu, Coinymous, cryptomoon, 0xvd, 0xlemon, onthehunt11, 0xPwned, Xcrypt, dyoff, nervouspika, perseus, Nihavent, LonelyWolfDemon, den-sosnowsky, Udsen, inzinko, dhank, hail_the_lord, turvy_fuzz, rbserver, yotov721, KupiaSec, al88nsk, Ryonen, klau5, nnez, ke1caM, shaflow2, shaka, ZdravkoHr, Beosin, and dimulski

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/EntityForging/EntityForging.sol#L87-L90

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/EntityForging/EntityForging.sol#L133

Vulnerability details

As per the docs:

Entities’ Forge Potential is determined by the [5] digit in entropy Each entity may have the ability to forge 0-9 times depending on its Forge Potential.

This means an entity with forgePotential of 0 is infertile, an entity with forgePotential = 1 can forge 1 time an year, an entity with forgePotential = 2 can forge 2 times an year and so on.

The number of times an entity forges is tracked in the forgingCounts mapping. This mapping is incremented for both the forgerTokenId and mergerTokenId everytime a successful forging happens thru forgeWithListed.

The forgingCounts[] of mergerTokenIds is handled correctly. The increment of forgingCounts[] precedes the condition check:

    forgingCounts[mergerTokenId]++;
        require(mergerForgePotential > 0 &&
    		        forgingCounts[mergerTokenId] <= mergerForgePotential,
    			      'forgePotential insufficient'
    );

However, Forger Entities forgerTokenId are able to exceed the forging limit due to increment of forgingCounts[] happening after the condition check. The check is made during the listForForging call, but the forgingCounts[] mapping is only incremented during forgeWithListed call. This allows the ‘Forger Entities’ to list one more time than intended.

This results in each Forger entity with non-zero forgingPotential having the ability to forge 2-10 times, instead of 1-9 times intended by the protocol.

Impact

As long as Forger Entities have a non-zero forgingPotential, the Entity is able to exceed the limit intended by forgingPotential.

Proof of Concept

Assume a Forger Entity of:

       _tokenIds = 123 
       forgingPotential = 1 (extracted from the 5th digit of entropy).
       Initial forgingCounts for tokenId 1 is 0.

First Listing:

  • During listing: forgingCounts[123] = 0
  • Check: forgingCounts[123] <= forgePotential (0 <= 1) => Pass
  • During forging : forgingCounts[123]++ => 1 (during forging)

Second Listing:

  • During listing: forgingCounts[123] = 1
  • Check: forgingCounts[123] <= forgePotential (1 <= 1) => Pass
  • During forging: forgingCounts[123]++ => 2

Third Listing: (Reverts only at the third attempt)

  • During listing: forgingCounts[123] = 2 (during listing)
  • Check: forgingCounts[123] < forgePotential (2 <= 1) => Fail
  • During forging: forging does not proceed.

This shows a Forger Entity with a forgingPotential of 1 can forge 2 times. Similarly, a forger entity with forgingPotential of 9 can forge 10 times.

Tools Used

Foundry

This can be fixed in different ways. The simplest way is to use the < instead of the <= in the listForForging condition check.

forgingCounts[tokenId] < forgePotential

    require(
          forgePotential > 0 && forgingCounts[tokenId] < forgePotential,
          'Entity has reached its forging limit'
          );

Assessed type

Invalid Validation

TForge1 (TraitForge) confirmed


[M-15] Users’ ability to nuke will be DoSed for three days after putting NFTs up for sale and canceling the sale

Submitted by 0rpse, also found by 0xb0k0, smbv-1923, Abhan, mansa11, and anonymousjoe

Users will not be able to nuke their NFTs for three days after canceling sale of their NFTs.

Proof of Concept

When a user puts up their NFT for sale and cancels their NFT sale, the NFT is transferred to the EntityTrading contract and back to the owner. This transfer triggers the _beforeTokenTransfer function, which resets the lastTokenTransferredTimestamp variable.

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

  function _beforeTokenTransfer(
    address from,
    address to,
    uint256 firstTokenId,
    uint256 batchSize
  ) internal virtual override {
    super._beforeTokenTransfer(from, to, firstTokenId, batchSize);

    uint listedId = entityForgingContract.getListedTokenIds(firstTokenId);
    /// @dev don't update the transferred timestamp if from and to address are same
    if (from != to) { 
      lastTokenTransferredTimestamp[firstTokenId] = block.timestamp;
    }

    if (listedId > 0) {
      IEntityForging.Listing memory listing = entityForgingContract.getListings(
        listedId
      );
      if (
        listing.tokenId == firstTokenId &&
        listing.account == from &&
        listing.isListed
      ) {
        entityForgingContract.cancelListingForForging(firstTokenId);
      }
    }

    require(!paused(), 'ERC721Pausable: token transfer while paused');
  }

This timestamp is later checked by the nuke function to verify if the owner has held the token for at least three days, allowing them to nuke the NFT for rewards.

The issue arises because the transfers to and from the EntityTrading contract, triggered by the sale listing and cancellation, reset the lastTokenTransferredTimestamp. As a result, even though the owner still possesses the token, the reset timestamp prevents them from nuking the NFT for another three days, denying them the opportunity to receive rewards.

Add necessary checks to _beforeTokenTransfer so that:

  1. If a transfer is happening to EntityTrading contract.
  2. If a transfer is happening from EntityTrading to sale’s original listing account lastTokenTransferredTimestamp is not reset.

Assessed type

DoS

Koolex (judge) decreased severity to Low

0rpse (warden) commented:

This issue effectively locks users out of accessing the nuke functionality for three days after canceling a sale, which will be unexpected and detrimental, especially if users intended to use the nuke feature shortly after the cancellation. Given that the rewards are tied to the NFT’s attributes and the state of the nuke fund, a delay could mean the difference between a lucrative transaction and a missed opportunity.

The nuking function is a key part of the utility and value proposition of the NFT. By introducing an unintended delay through the sale cancellation process, the contract effectively diminishes the NFT’s usability and could potentially harm the user’s financial interests. This is not just a minor inconvenience; it can materially impact the user’s ability to interact with their assets in a way that is consistent with their expectations and strategic planning.

Given these factors, I think the severity should be Medium.

Koolex (judge) commented:

This seems to be a design choice. Otherwise, the fix can be exploited to the locking duration.

0rpse (warden) commented:

@Koolex- When I talked to the sponsors about the issue, they answered that this is just something that people will have to accept because there is no way to stop this without ruining the maturity period mitigation, I don’t think it is a design choice but a flaw.

Otherwise, the fix can be exploited to the locking duration.

I don’t understand how the fix can be exploited; lastTokenTransferredTimestamp is not going to be updated only when a transfer is happening to trading contract, or from the trading contract to the original listing account. This is logically the same thing as transferring from address A to address A, which they have done here.

Koolex (judge) commented:

When I talked to the sponsors about the issue, they answered that this is just something that people will have to accept because there is no way to stop this without ruining the maturity period mitigation, I don’t think it is a design choice but a flaw.

Isn’t this a design choice since the trade-off is would be ruining the maturity period mitigation?

Does this mean if the user list and cancel twice, they have to wait 3 days again? The code seems to apply this. I can see your concern here.

0rpse (warden) commented:

Isn’t this a design choice since the trade-off is would be ruining the maturity period mitigation?

No, _beforeTokenTransfer should simply check if a user is canceling his/her sale listing and should not update the timestamp (also when a user is listing), if any other kinds of transfers occur, timestamp update will still take place, there is no ruining of maturity period mitigation.

Does this mean if the user list and cancel twice, they have to wait 3 days again? The code seems to apply this. I can see your concern here.

If I am not understanding this question please clarify. The code as it stands will block a user’s ability to nuke his NFT for three days after canceling his sale. We can easily imagine a scenario where:

  1. Alice puts her NFT for sale.
  2. After some time, Alice notices nuke-fund has gathered a lot of funds and if she nukes her NFT she will make a nice profit.
  3. Alice cancels the sale and tries to nuke, but at this point she will be locked out of the nuke function for 3 days because of the current implementation.

As Alice is logically the owner of her NFT throughout the sale process, these transfers should not block her ability to nuke her own NFT.

Koolex (judge) commented:

My question is, is this repetitive?

Example: Alice puts her NFT for sale, then cancels. Now she has to wait for 3 days, right? Let’s say she waits for 1 days, since nuke is not possible, she lists her NFT and then canceled. Will she wait again for 3 days?

0rpse (warden) commented:

Alice puts her NFT for sale, then cancels. Now she has to wait for 3 days, right?

Yes.

Let’s say she waits for 1 days, since nuke is not possible, she lists here NFT and then canceled. Will she wait again for 3 days?

Yes, timestamp will be updated again, and she has to wait for another 3 days from the moment of cancellation.

TForge1 (TraitForge) commented:

Yeah, I’d say this is an issue. Maybe low as it’s not detrimental, but can impact UX. We can definitely check if its just being canceled by the original owner and make sure it doesn’t lock it for 3 days. Valid, in my opinion.

Koolex (judge) commented:

After careful review, I believe this issue hits the mark of Medium severity.

Reasoning:

  • The problem persists where canceling an NFT sale extends the lock period, preventing immediate nuking.
  • After canceling an NFT sale, users face a dilemma: they can’t relist the NFT (as this resets the 3-day waiting period) nor can they nuke it. This restriction is harmful to the user and could negatively impact the game’s economic dynamics.
  • The frequency of listing and canceling NFTs is likely high due to competitive price adjustments. This issue affects many active participants, as they’re all temporarily barred from nuking their NFTs, amplifying the overall impact.
  • It appears to have a straightforward resolution that doesn’t conflict with any fundamental game design principles.

[M-16] There is no slippage check in the nuke() function

Submitted by KupiaSec, also found by burnerelu, gajiknownnothing, Decap, Rhaydden, p0wd3r, 0x3b, samuraii77, peanuts, DemoreX, Abdessamed, desaperh, jesjupyter, franfran20, vinica_boy, 0xvd, amaron, slvDev, Joshuajee, cozymfer, phoenixV110, gkrastenov, ilchovski (1, 2), 0xrex, Kalogerone, ke1caM, shaflow2, shaka, dimulski, and hl_

NFT owners may receive significantly less nuke funds than they expected.

Proof of Concept

In the nuke() function, there is no minimum claim amount check. Consider the following scenario:

  1. The fund value of the NukeFund contract is 10 ETH.
  2. Alice is about to nuke an NFT with a nuke factor of 0.5, allowing her to receive 5 ETH.
  3. Bob also has an NFT with a nuke factor of 0.5 and calls the nuke() function with his NFT, front-running Alice.

    • Bob receives 5 ETH (see L166).
    • The remaining fund is now 10 ETH - 5 ETH = 5 ETH (see L174).
  4. Next, Alice’s transaction is processed, and she receives only 2.5 ETH (0.5 of the remaining fund of 5 ETH).

If Alice’s transaction had been processed before Bob’s, she would have received 5 ETH. However, due to being front-run by Bob, she only receives 2.5 ETH, which is significantly less than expected. This highlights the necessity of implementing a minimum claim amount check in the nuke() function. With such a check, it is possible to prevent Alice from receiving only 2.5 ETH which is far below her expectations.

    function nuke(uint256 tokenId) public whenNotPaused nonReentrant {
      require(
        nftContract.isApprovedOrOwner(msg.sender, tokenId),
        'ERC721: caller is not token owner or approved'
      );
      require(
        nftContract.getApproved(tokenId) == address(this) ||
          nftContract.isApprovedForAll(msg.sender, address(this)),
        'Contract must be approved to transfer the NFT.'
      );
      require(canTokenBeNuked(tokenId), 'Token is not mature yet');

      uint256 finalNukeFactor = calculateNukeFactor(tokenId); // finalNukeFactor has 5 digits
166   uint256 potentialClaimAmount = (fund * finalNukeFactor) / MAX_DENOMINATOR; // Calculate the potential claim amount based on the finalNukeFactor
      uint256 maxAllowedClaimAmount = fund / maxAllowedClaimDivisor; // Define a maximum allowed claim amount as 50% of the current fund size

      // Directly assign the value to claimAmount based on the condition, removing the redeclaration
      uint256 claimAmount = finalNukeFactor > nukeFactorMaxParam
        ? maxAllowedClaimAmount
        : potentialClaimAmount;

174   fund -= claimAmount; // Deduct the claim amount from the fund

      nftContract.burn(tokenId); // Burn the token
      (bool success, ) = payable(msg.sender).call{ value: claimAmount }('');
      require(success, 'Failed to send Ether');

      emit Nuked(msg.sender, tokenId, claimAmount); // Emit the event with the actual claim amount
      emit FundBalanceUpdated(fund); // Update the fund balance
    }

It is recommended to implement a minimum claim amount check in the nuke() function.

TForge1 (TraitForge) confirmed and commented:

Although base-chain has a private mempool, meaning MEV and Front-Running is not possible. There is a possibility of the mempool privacy changing, which will make this a valid issue. But it is impossible, now, judges can decide what they want to do with this. But possibility of mempool going public means this is still a risk.

Koolex (judge) increased severity to High and commented:

Valid issue.

Reasoning:

  • Even without intentional front-running, transaction ordering can still have impacts similar to front-running.
  • While users can’t see other pending transactions, market conditions can still change between transaction submission and execution
  • As far as I know, Transactions on Base are generally ordered on a first-come, first-served basis by the sequencer. Since, there is no guarantee that the first submitted TX will be processed first (due to multiple factors including network latency), the risk still exists.

ABAIKUNANBAEV (warden) commented:

I think this should downgraded to medium or QA for the same reasoning as described in Issue #166:

  1. The mempool is private meaning the probability is extremely low.
  2. Front-running issues will always exist and it’s the same reasoning as with the auctions: “If I front-run you, you will have to pay the bigger price”. I guess it’s just the nature of the game.
  3. This certainly has a risk but the problem is that the game allows “the fastest” to win and let’s say the suggested mitigation is introduced and Alice gets minNukeAmount. Her tx will revert multiple times before that due to deviation from minNukeAmount and it’s not likely that she will get a much bigger amount as the competition will be extremely high for such amounts of ETH. There will be just the same situation if she was front-runned with the absence of slippage. The example with 2.5 ETH and 5 ETH seems highly unrealistic, as such amounts from NukeFund in real scenario will be taken almost right away.

Abdessamed (warden) commented:

@ABAIKUNANBAEV - You are mistaking two things:

  1. The slippage here is different from #166. The slippage here can cause significant loss for users (up to 50% of what they expected) as outlined by the PoC: the user received 2.5 ETH instead of 5 ETH, we are talking about a 2.5 ETH difference which is about $6k.
  2. There doesn’t have to be front-running for this issue to happen as explained in another duplicate Issue #753, the warden incorrectly mentioned this in his report.

If Alice submits her transaction before Bob, according to the fastest rule, Alice’s transaction should be executed first. But still, Bob’s transaction can be executed before Alice due to several reasons like network congestion, etc.; something that players can not control.

ABAIKUNANBAEV (warden) commented:

Yes, and as I outlined, such minAmount mitigation for this issue is senseless as, at the start of the game, everybody will try to claim and here “the fastest wins” rule comes into play. The fastest here will get the most amount of money. You make a logical mistake by saying that users here “lose the funds” as they don’t lose anything - they only own a tokenId that makes them eligible to claim at most 50% of the funds. By saying they lose funds - they didn’t lose, they just did not get amount that they could potentially get - big difference. I personally think it’s at most QA.

dimulski (warden) commented:

I disagree that users don’t lose the funds. If they don’t receive what they expect, they have burned their NFT with which later on they can claim what they desire. There is definitely a loss of funds, as you have to pay for the NFT; either mint it, forge it or buy it on an open market.

ABAIKUNANBAEV (warden) commented:

They don’t own any funds to lose them, it’s just opportunity for everybody.

Koolex (judge) decreased severity to Medium and commented:

I believe it is fair to downgrade it to Medium for similar reasons explained here.


[M-17] Incorrect isApprovedForAll check in the NukeFund.nuke() function

Submitted by KupiaSec, also found by Bauchibred and ZanyBonzy

Approved users can’t nuke the owner’s NFT.

Proof of Concept

In the NukeFund.nuke function, it checks whether nftContract.isApprovedForAll(msg.sender, address(this)) is true from L160 and tries to burn the NFT token of tokenID from L176.

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/NukeFund/NukeFund.sol#L153-L182

        function nuke(uint256 tokenId) public whenNotPaused nonReentrant {
          require(
            nftContract.isApprovedOrOwner(msg.sender, tokenId),
            'ERC721: caller is not token owner or approved'
          );
          require(
            nftContract.getApproved(tokenId) == address(this) ||
L160:       nftContract.isApprovedForAll(msg.sender, address(this)),
            'Contract must be approved to transfer the NFT.'
          );
          [...]
L176:     nftContract.burn(tokenId); // Burn the token
          [...]
        }

In the TraitForgeNft.burn function, it checks whether isApprovedOrOwner(msg.sender, tokenId) is true from L143.

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/TraitForgeNft/TraitForgeNft.sol#L141-L151

        function burn(uint256 tokenId) external whenNotPaused nonReentrant {
          require(
L143:       isApprovedOrOwner(msg.sender, tokenId),
            'ERC721: caller is not token owner or approved'
          );
          [...]
        }

msg.sender at L143 is NukeFund contract. In the ERC721._isApprovedOrOwner, it checks the following:

        function _isApprovedOrOwner(address spender, uint256 tokenId) internal view virtual returns (bool) {
            address owner = ERC721.ownerOf(tokenId);
            return (spender == owner || isApprovedForAll(owner, spender) || getApproved(tokenId) == spender);
        }

Totally nuking tokens requires nftContract.isApprovedOrOwner(msg.sender, tokenId) && (nftContract.getApproved(tokenId) == address(this) ||nftContract.isApprovedForAll(msg.sender, address(this))) where this is NukeFund contract.

Burning tokens requires isApprovedOrOwner(msg.sender, tokenId) where msg.sender is NukeFund contract.

Checking isApprovedOrOwner(msg.sender, tokenId) is same as msg.sender == owner || getApproved(tokenId) == msg.sender || isApprovedForAll(owner, msg.sender).

The nuke function should check nftContract.isApprovedForAll(nftContract.ownerOf(tokenId), address(this)), instead of nftContract.isApprovedForAll(msg.sender, address(this)).

It is recommended to change the code as follows:

    function nuke(uint256 tokenId) public whenNotPaused nonReentrant {
      require(
        nftContract.isApprovedOrOwner(msg.sender, tokenId),
        'ERC721: caller is not token owner or approved'
      );
      require(
        nftContract.getApproved(tokenId) == address(this) ||
-         nftContract.isApprovedForAll(msg.sender, address(this)),
+         nftContract.isApprovedForAll(nftContract.ownerOf(tokenId), address(this)),
        'Contract must be approved to transfer the NFT.'
      );
      [...]
    }

Assessed type

Access Control

KupiaSec (warden) commented:

This report clearly demonstrates the presence of a bug.

Scenario

Consider the following scenario:

  1. Alice approved the NukeFund contract as an operator.(Alice called setApprovalForAll(nukeFund, true)).
  2. Alice is the owner of an entity.
  3. Alice gave permission to Bob for the entity. (Alice called approve(Bob's address, the entity's tokenId)).
  4. Bob called nuke(the entity's tokenId).

In the scenario described above, it should be allowed for Bob to call nuke(the entity's tokenId). However, Bob’s call will be reverted due to the bug highlighted in the report.

nftContract.getApproved(tokenId) returns Bob, not address(this). Additionally, msg.sender at L160 is Bob who have not approved the NukeFund contract as an operator. Consequently, the nuke() function will be reverted unexpectedly.

So, msg.sender at L160 should be replaced by nftContract.ownerOf(tokenId):.

        require(
            nftContract.getApproved(tokenId) == address(this) ||
160:            nftContract.isApprovedForAll(msg.sender, address(this)),
                'Contract must be approved to transfer the NFT.'
            );

This unexpected reversal also takes place when Alice approves Bob as an operator in step 3 of the scenario. Therefore, I believe this issue is valid.

Coded PoC

Add the following code into test/NukeFund.test.ts: In the PoC, Alice is the owner and Bob is the user1.

    it('should revert to nuke a token', async function () {
        const tokenId = 1;

        // Mint a token
        await nft.connect(owner).mintToken(merkleInfo.whitelist[0].proof, {
        value: ethers.parseEther('1'),
        });

        // Send some funds to the contract
        await user1.sendTransaction({
        to: await nukeFund.getAddress(),
        value: ethers.parseEther('1'),
        });

        const prevNukeFundBal = await nukeFund.getFundBalance();
        // Ensure the token can be nuked
        expect(await nukeFund.canTokenBeNuked(tokenId)).to.be.true;

        const prevUserEthBalance = await ethers.provider.getBalance(
        await owner.getAddress()
        );
        // await nft.connect(owner).approve(await nukeFund.getAddress(), tokenId);
        await nft.connect(owner).approve(user1, tokenId);
        await nft.connect(owner).setApprovalForAll(await nukeFund.getAddress(), true);

        const finalNukeFactor = await nukeFund.calculateNukeFactor(tokenId);
        const fund = await nukeFund.getFundBalance();

        // await expect(nukeFund.connect(owner).nuke(tokenId))
        await expect(nukeFund.connect(user1).nuke(tokenId)).to.be.revertedWith("Contract must be approved to transfer the NFT.");
    });

Koolex (judge) commented:

To summarize this, Alice approves Bob, Bob calls nuke but it reverts because it expect Bob to approve the contract which shouldn’t be a requirement.

I believe this is a valid issue due to the broken functionality.


[M-18] Excess ETH from forgingFee can get stuck in EntityForging under certain situations

Submitted by nnez, also found by Bauchibred, 0x3b, p0wd3r, samuraii77, FastChecker, 0xHash, pep7siup, broccolirob, and dontonka

ETH being permanently locked in the contract under certain situations.

Description

The forgeWithListed function contains a vulnerability in the fee checking mechanism. The line that checks the forging fee allows users to send more ETH than the required amount. Normally, users would want to send the exact amount of the forging fee to avoid overpayment. However, if the forging fee is somehow reduced between the time a user initiates a transaction and when it’s executed, the excess ETH becomes permanently locked in the contract.

See: EntityForging.sol#L102-L175:

uint256 forgingFee = _forgerListingInfo.fee;
require(msg.value >= forgingFee, 'Insufficient fee for forging');

// ... (later in the function)

uint256 devFee = forgingFee / taxCut;
uint256 forgerShare = forgingFee - devFee;
address payable forgerOwner = payable(nftContract.ownerOf(forgerTokenId));

// ... (fee distribution)
(bool success, ) = nukeFundAddress.call{ value: devFee }('');
require(success, 'Failed to send to NukeFund');
(bool success_forge, ) = forgerOwner.call{ value: forgerShare }('');
require(success_forge, 'Failed to send to Forge Owner');

The >= check in the require statement allows for overpayment, but the contract only distributes the exact forgingFee amount, leaving excess ETH locked in the contract.

Here is an example of a scenario that could get the fund stuck from allowing overpayment:

  1. Bob lists his forger entity with a fee of 0.1 ETH.
  2. Alice initiates a transaction to forge with Bob’s entity, sending exactly 0.1 ETH.
  3. Alice’s transaction is delayed or stuck due to gas price fluctuations or other network conditions.
  4. Before Alice’s transaction is executed, Bob reduces his forging fee to 0.08 ETH.
  5. Alice’s transaction is finally executed after the fee reduction.
  6. The contract accepts Alice’s 0.1 ETH, sends 0.08 ETH to Bob, and 0.02 ETH remains locked in the contract.
       [Bob's Entity (Forger)]
             |  
       Listing: Fee = 0.1 ETH
             |
       Alice sends 0.1 ETH
             |
      ------------------------------
      |  Transaction Delayed       |
      ------------------------------
             |
      Bob reduces fee to 0.08 ETH
             |
       -----------------------------
       | Alice's Transaction       |
       | Executed After Delay      |
       -----------------------------
             |
       Contract Receives 0.1 ETH
             |
       -----------------------------
       |  Sends 0.08 ETH to Bob     |
       |  0.02 ETH Locked in Contract|
       -----------------------------

Rationale for Severity

The severity is set to Medium because:

  1. It can cause financial losses to users, but only in limited scenarios.
  2. The impact is restricted to the difference between the original and reduced forging fee.

There are two available options:

  1. Only allow paying an exact fee.
  2. Allow overpayment but send excess amount back to msg.sender.

Proof of Concept

The following test demonstrates that an overpayment is allowed, and excess amount get stuck in the contract. Combining with the detailed scenario above, it should prove the claimed impact.

  1. Place a new file, Overpayment.test.ts inside /test directory with the test in the code block below.
  2. Run npx hardhat test test/Overpayment.test.ts.
  3. Observe that the excess fund is stuck in EntityForging contract.
import { parseEther } from 'ethers';
import {
  Airdrop,
  DevFund,
  EntityForging,
  EntropyGenerator,
  EntityTrading,
  NukeFund,
  TraitForgeNft,
} from '../typechain-types';
import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers';
import generateMerkleTree from '../scripts/genMerkleTreeLib';

const { expect } = require('chai');
const { ethers } = require('hardhat');

import { fastForward } from '../utils/evm';

describe('EntityForging', () => {
  let entityForging: EntityForging;
  let nft: TraitForgeNft;
  let owner: HardhatEthersSigner;
  let user1: HardhatEthersSigner;
  let user2: HardhatEthersSigner;
  let user3: HardhatEthersSigner;
  let entityTrading: EntityTrading;
  let nukeFund: NukeFund;
  let devFund: DevFund;
  let FORGER_TOKEN_ID: number;
  let MERGER_TOKEN_ID: number;

  const FORGING_FEE = ethers.parseEther('1.0'); // 1 ETH

  before(async () => {
    [owner, user1, user2, user3] = await ethers.getSigners();

    // Deploy TraitForgeNft contract
    const TraitForgeNft = await ethers.getContractFactory('TraitForgeNft');
    nft = (await TraitForgeNft.deploy()) as TraitForgeNft;

    // Deploy Airdrop contract
    const airdropFactory = await ethers.getContractFactory('Airdrop');
    const airdrop = (await airdropFactory.deploy()) as Airdrop;

    await nft.setAirdropContract(await airdrop.getAddress());

    await airdrop.transferOwnership(await nft.getAddress());

    // Deploy EntityForging contract
    const EntropyGenerator = await ethers.getContractFactory(
      'EntropyGenerator'
    );
    const entropyGenerator = (await EntropyGenerator.deploy(
      await nft.getAddress()
    )) as EntropyGenerator;

    await entropyGenerator.writeEntropyBatch1();

    entropyGenerator.connect(owner).transferOwnership(await nft.getAddress());

    await nft.setEntropyGenerator(await entropyGenerator.getAddress());

    // Deploy EntityForging contract
    const EntityForging = await ethers.getContractFactory('EntityForging');
    entityForging = (await EntityForging.deploy(
      await nft.getAddress()
    )) as EntityForging;
    await nft.setEntityForgingContract(await entityForging.getAddress());

    devFund = await ethers.deployContract('DevFund');
    await devFund.waitForDeployment();

    const NukeFund = await ethers.getContractFactory('NukeFund');

    nukeFund = (await NukeFund.deploy(
      await nft.getAddress(),
      await airdrop.getAddress(),
      await devFund.getAddress(),
      owner.address
    )) as NukeFund;
    await nukeFund.waitForDeployment();

    await nft.setNukeFundContract(await nukeFund.getAddress());

    entityTrading = await ethers.deployContract('EntityTrading', [
      await nft.getAddress(),
    ]);

    // Set NukeFund address
    await entityTrading.setNukeFundAddress(await nukeFund.getAddress());

    const merkleInfo = generateMerkleTree([
      owner.address,
      user1.address,
      user2.address,
    ]);

    await nft.setRootHash(merkleInfo.rootHash);

    // Mint some tokens for testing
    await nft.connect(owner).mintToken(merkleInfo.whitelist[0].proof, {
      value: ethers.parseEther('1'),
    });
    await nft.connect(user1).mintToken(merkleInfo.whitelist[1].proof, {
      value: ethers.parseEther('1'),
    });
    await nft.connect(user1).mintToken(merkleInfo.whitelist[1].proof, {
      value: ethers.parseEther('1'),
    });

    for (let i = 0; i < 10; i++) {
      await nft.connect(owner).mintToken(merkleInfo.whitelist[0].proof, {
        value: ethers.parseEther('1'),
      });
      const isForger = await nft.isForger(i + 4);
      if (isForger) {
        FORGER_TOKEN_ID = i + 4;
        break;
      }
    }

    MERGER_TOKEN_ID = 3;

  });

    describe('Overpayment is allowed', () => {
        it.only('should demonstrate that overpayment is allowed and funds are stuck in EntityForging', async () => {
            let initialForgingFee = ethers.parseEther('1');
            let overpaymentForgingFee = ethers.parseEther('1.1');
            let intialBalanceInEntityForging = await ethers.provider.getBalance(await entityForging.getAddress());
            console.log("@> Forger lists entity with fee = 1 ETH");
            await entityForging.connect(owner).listForForging(FORGER_TOKEN_ID, initialForgingFee);
            console.log("@> Merger forges, sends fee = 1.1 ETH");
            await entityForging.connect(user1).forgeWithListed(FORGER_TOKEN_ID, MERGER_TOKEN_ID, {
                value: overpaymentForgingFee,
            });
            let finalBalanceInEntityForging = await ethers.provider.getBalance(await entityForging.getAddress());
            console.log("@> Excess is stuck in EntityForging: ", ethers.formatEther(finalBalanceInEntityForging - intialBalanceInEntityForging), "ETH");
        });
  });
});

Koolex (judge) decreased severity to Low

nnez (warden) commented:

@Koolex - I believe the severity of this finding should be reevaluated to Medium.

From C4 Severity Categorization:

“Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.”  

This finding could fall under Medium category:

  1. Value leak: funds get stuck.
  2. Hypothetical path with stated assumptions: it requires a situation as described in my finding.

In addition, the root cause of this issue is that the contract allows forging with mismatched fees from users’ intention which doesn’t require users’ error as I suspected is the reason for downgrading to QA.

Koolex (judge) increased severity to Medium


[M-19] Each generation should have 1 “Golden God” NFT, but there could be 0

Submitted by Kalogerone, also found by samuraii77, anonymousjoe, Decap, sl1, Shahil_Hussain, and yotov721

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/EntropyGenerator/EntropyGenerator.sol#L206

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/TraitForgeNft/TraitForgeNft.sol#L345

https://github.com/code-423n4/2024-07-traitforge/blob/main/contracts/EntropyGenerator/EntropyGenerator.sol#L10

Vulnerability Details

The location of the “Golden God” NFT with entropy of 999999 is calculated by the initializeAlphaIndices function in the EntropyGenerator contract.

    function initializeAlphaIndices() public whenNotPaused onlyOwner {
        uint256 hashValue = uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)));

        uint256 slotIndexSelection = (hashValue % 258) + 512;
        uint256 numberIndexSelection = hashValue % 13;

        slotIndexSelectionPoint = slotIndexSelection;
        numberIndexSelectionPoint = numberIndexSelection;
    }

It is confirmed by the devs/sponsors that TraitForgeNft contract is the only allowed caller of the initializeAlphaIndices. As we can see, the function is protected by a modifier and when the generation is getting incremented, TraitForgeNft is the caller.

TraitForgeNft.sol

    function _incrementGeneration() private {
        require(generationMintCounts[currentGeneration] >= maxTokensPerGen, "Generation limit not yet reached");
        currentGeneration++;
        generationMintCounts[currentGeneration] = 0;
        priceIncrement = priceIncrement + priceIncrementByGen;
@>      entropyGenerator.initializeAlphaIndices();
        emit GenerationIncremented(currentGeneration);
    }

Each generation is supposed to contain 1 “Golden God” NFT. According to the TraitForge docs:

Entropy is set into uint256 slots, with each slot able to contain a 78 digit number - or 13 concatenated entropies. Thus 770 slots are needed to contain 10,000 entropies.

Entropies are stored in the following array:

    uint256[770] private entropySlots; // Array to store entropy values

We have 770 slots with 13 entropies each, which means 770 * 13 = 10,010 total entropies generated.

The last 10 entropies are skipped when the generation ends, since there is a max mint value of 10,000 NFTs per generation and when it is reached, it increments to the next generation. There is a chance that the 999999 entropy is placed in the last entropy slot, in the last 10 entropies of that slot and is lost and not mintable. Since the generation has incremented by the TraitForgeNft contract, the action is not recoverable.

Impact

There is a chance that the “Golden God” NFT is completely lost and not mintable for a generation. This goes against the docs and the expectations of the game.

Proof of Concept

  1. 10,000 NFTs get minted in a generation, so it’s time to increment to the next generation.
  2. TraitForgeNft contract increments generation and calls the initializeAlphaIndices to set the location of the “Golden God”.
  3. “Golden God” entropy is set in the last slot (770th), in the last 10 entropy slots.
  4. The “Golden God” NFT is not mintable, as there needs to be more than 10,000 mints in the generation, which is not possible.

Implement a check that ensures that the 999999 entropy is not set out of bounds.

    function initializeAlphaIndices() public whenNotPaused onlyOwner {
        uint256 hashValue = uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)));

        uint256 slotIndexSelection = (hashValue % 258) + 512;
        uint256 numberIndexSelection = hashValue % 13;

        slotIndexSelectionPoint = slotIndexSelection;
        numberIndexSelectionPoint = numberIndexSelection;

+       if (slotIndexSelection == 769 && numberIndexSelection > 3) {
+           initializeAlphaIndices();
+       }
    }

Assessed type

Math

Kalogerone (warden) commented:

@Koolex - I believe this issue is placed in the wrong group/duplicate of Issue #656. Issue #656 describes how there can be 0 Golden God NFT due to the forging, while my issue doesn’t involve forging at all. My issue describes how there can be 0 Golden God NFT even in the first generation which forging doesn’t affect, simply if the Golden God NFT slot gets placed in the last 10 slots during the entropy generation.

EDIT: My issue can even happen in the 1st generation while Issue #656 can’t.

Koolex (judge) commented:

@Kalogerone - Could you please provide a PoC for this?

Kalogerone (warden) commented:

@Koolex - I have created a foundry test file for my PoC so I could fuzz block numbers, because the location of the Golden God entropy relies on the block number, as shown in the report. I have created a tests.t.sol file inside the test folder.

Coded PoC
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import {console2} from "forge-std/Test.sol";
import {EntropyGenerator} from "../contracts/EntropyGenerator/EntropyGenerator.sol";
import {DevFund} from "../contracts/DevFund/DevFund.sol";
import {Airdrop} from "../contracts/Airdrop/Airdrop.sol";
import {TraitForgeNft} from "../contracts/TraitForgeNft/TraitForgeNft.sol";
import {EntityForging} from "../contracts/EntityForging/EntityForging.sol";
import {IEntityForging} from "../contracts/EntityForging/IEntityForging.sol";
import {NukeFund} from "../contracts/NukeFund/NukeFund.sol";
import {EntityTrading} from "../contracts/EntityTrading/EntityTrading.sol";
//import {IEntityTrading} from "../contracts/EntityTrading/IEntityTrading.sol";
import {TraitForgeNftMock} from "../contracts/TraitForgeNft/TraitForgeNftMock.sol";

contract ContractBTest is Test {
    address public owner = makeAddr("owner");
    address player1 = makeAddr("player1");
    address player2 = makeAddr("player2");

    EntityForging entityForging;
    TraitForgeNft traitForgeNft;
    EntropyGenerator entropyGenerator;
    Airdrop airdrop;
    DevFund devFund;
    NukeFund nukeFund;
    EntityTrading entityTrading;

    function setUp() public {
        vm.startPrank(owner);
        // Deploy TraitForgeNft contract
        traitForgeNft = new TraitForgeNft();

        // Deploy Airdrop contract
        airdrop = new Airdrop();
        traitForgeNft.setAirdropContract(address(airdrop));
        airdrop.transferOwnership(address(traitForgeNft));

        // Deploy entropyGenerator contract
        entropyGenerator = new EntropyGenerator(address(traitForgeNft));
        entropyGenerator.transferOwnership(address(traitForgeNft));

        traitForgeNft.setEntropyGenerator(address(entropyGenerator));

        // Deploy EntityForging contract
        entityForging = new EntityForging(address(traitForgeNft));
        traitForgeNft.setEntityForgingContract(address(entityForging));

        devFund = new DevFund();
        nukeFund = new NukeFund(address(traitForgeNft), address(airdrop), payable(address(devFund)), payable(owner));
        traitForgeNft.setNukeFundContract(payable(address(nukeFund)));

        entityTrading = new EntityTrading(address(traitForgeNft));
        entityTrading.setNukeFundAddress(payable(address(nukeFund)));

        nukeFund.setAgeMultplier(1);
        vm.stopPrank();

        vm.deal(player1, 10 ether);
        vm.deal(player2, 10 ether);

        // to skip whitelist
        vm.warp(block.timestamp + 3 days);
    }

    function testFuzzBlocks(uint256 blockn) public {
        // panic reverts
        vm.assume(blockn != 0);

        // blocks already found that provide the wanted outcome
        vm.assume(blockn != 2207465665814711110);
        vm.assume(blockn != 3307);
        vm.assume(blockn != 2774072794181196);

        vm.roll(blockn);

        vm.startPrank(address(traitForgeNft));
        entropyGenerator.initializeAlphaIndices();
        vm.stopPrank();

        uint256 slotIndexSelection = entropyGenerator.slotIndexSelectionPoint();

        assert(slotIndexSelection != 769);
    }

    function testBlock() public {
        vm.roll(3307);

        vm.startPrank(address(traitForgeNft));
        entropyGenerator.initializeAlphaIndices();

        uint256 slotIndexSelection = entropyGenerator.slotIndexSelectionPoint();
        uint256 numberIndexSelection = entropyGenerator.numberIndexSelectionPoint();

        assert(slotIndexSelection == 769 && numberIndexSelection > 2);

        console2.log(slotIndexSelection);
        console2.log(numberIndexSelection);
        vm.stopPrank();
    }
}

Explanation of the PoC code:

The testFuzzBlocks fuzz tests different block numbers and calls the entropyGenerator.initializeAlphaIndices() function which sets the position of the Golden God NFT. If we find the NFT placed in the last position possible (slotIndexSelection == 769 since 770 total slots, 0-769), we take the block.number and use it in the testBlock function, placing it inside the vm.roll() function.

Then, we confirm if the numberIndexSelection value is in the last 10 possible places (13 total places, 0-2 are the first 3 places, 3-12 are the last 10). If numberIndexSelection > 2, then the Golden God NFT is placed in the last 10 places possible and is not mintable. See report’s Vulnerability Details section for more detailed information on the reason why.


Low Risk and Non-Critical Issues

For this audit, 18 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by jesjupyter received the top score from the judge.

The following wardens also submitted reports: Agontuk, K42, Bauchibred, Udsen, lrivo, TECHFUND-inc, _karanel, ZanyBonzy, atoko, Trooper, Saurabh_Singh, 0xlemon, kutugu, KupiaSec, hl_, 4rdiii, and dimulski.

[01] Changing centralized parameters half-way could cause issues

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

https://github.com/code-423n4/2024-07-traitforge/blob/279b2887e3d38bc219a05d332cbcb0655b2dc644/contracts/EntityForging/EntityForging.sol#L37-L48

Description

In the project, several parameters such as maxTokensPerGen, startPrice, priceIncrement, and priceIncrementByGen are designed to be constants:

  // Constants for token generation and pricing
  uint256 public maxTokensPerGen = 10000; // constants should be constants
  uint256 public startPrice = 0.005 ether;
  uint256 public priceIncrement = 0.0000245 ether;
  uint256 public priceIncrementByGen = 0.000005 ether;

However, these parameters, along with others like maxGeneration and rootHash, can be modified by the contract owner:

  function setStartPrice(uint256 _startPrice) external onlyOwner {
    startPrice = _startPrice;
  }

  function setPriceIncrement(uint256 _priceIncrement) external onlyOwner {
    priceIncrement = _priceIncrement;
  }

  function setPriceIncrementByGen(
    uint256 _priceIncrementByGen
  ) external onlyOwner {
    priceIncrementByGen = _priceIncrementByGen;
  }

  function setMaxGeneration(uint maxGeneration_) external onlyOwner {
    require(
      maxGeneration_ >= currentGeneration,
      "can't below than current generation"
    );
    maxGeneration = maxGeneration_;
  }

  function setRootHash(bytes32 rootHash_) external onlyOwner {
    // @note: Centralized Risk. Should not change.
    rootHash = rootHash_;
  }

  function setWhitelistEndTime(uint256 endTime_) external onlyOwner {
    whitelistEndTime = endTime_;
  }

Allowing these parameters to be changed midway through the contracts owner’s operation can lead to several unexpected consequences:

  • Price Changes: If startPrice, priceIncrement, or priceIncrementByGen are suddenly altered, users could unintentionally spend more funds than anticipated.
  • Generation Limits: Modifying maxGeneration can unexpectedly change the global total supply of tokens.
  • Whitelist Integrity: Changing the rootHash halfway can allow more whitelisted accounts to join and mint, leading to unexpected outcomes.
  • Whitelist Period Extension: Changing the whitelistEndTime halfway can extend the whitelisted period.

To note, this is also the same in other contracts like EntityForging:

  • Fee and Tax: If taxCut, minimumListFeeare suddenly altered, users could unintentionally pay more taxes/fees than anticipated.

To note, this is also the same in other contracts like NukeFund:

  • Quick Exit: If ageMultiplier, minimumDaysHeldare suddenly altered, users could nuke their NFT in a shorter period and get more shares.

Recommendation

To mitigate, re-assess whether these parameters need to be modifiable. If they are intended to be constants, enforce immutability to avoid potential issues caused by centralized operations.

[02] Whitelist users could participate more than once

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

Description

The onlyWhitelisted modifier ensures that only whitelisted users can perform certain operations within the whitelist period:

  modifier onlyWhitelisted(bytes32[] calldata proof, bytes32 leaf) {
    if (block.timestamp <= whitelistEndTime) { // @note could disable the whitelist only at anytime by the owner
      require(
@=>      MerkleProof.verify(proof, rootHash, leaf), // @note: whitelist can continuously participate
        'Not whitelisted user'
      );
    }
    _;
  }

However, the modifier does not record the leaf input to prevent future replays. Consequently, whitelisted users can participate in minting more than once by providing the same proof multiple times.

Recommendation

To mitigate this issue, revise the design to check if each whitelisted user can participate more than once once during the whitelist period.

If it is to be restricted, this can be achieved by recording the participation of each user and preventing multiple entries.

[03] The usage of amountMinted is redundant

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

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

Description

In the mintWithBudget function, the local variable amountMinted is used to increment with each mint but serves no further purpose within the function. This makes the use of amountMinted redundant.

  function mintWithBudget(
    bytes32[] calldata proof
  )
    public
    payable
    whenNotPaused
    nonReentrant
    onlyWhitelisted(proof, keccak256(abi.encodePacked(msg.sender)))
  {
	    uint256 mintPrice = calculateMintPrice();
@=> 	uint256 amountMinted = 0;
	    uint256 budgetLeft = msg.value;
	
	    while (budgetLeft >= mintPrice && _tokenIds < maxTokensPerGen) { // @audit _tokenIds can only work with 1 generation
	      _mintInternal(msg.sender, mintPrice);
@=> 	  amountMinted++;
	      budgetLeft -= mintPrice;
	      mintPrice = calculateMintPrice();
	    }
	    if (budgetLeft > 0) {
	      (bool refundSuccess, ) = msg.sender.call{ value: budgetLeft }('');
	      require(refundSuccess, 'Refund failed.');
	    }
  }

The variable amountMinted is incremented within the while loop but is not used after the loop. Hence, it serves no purpose in the function and can be removed.

Recommendation

Remove the amountMinted variable and its associated increment statement to simplify the code.

[04] The >= check for generationMintCounts could be simplified

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

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

Description

In the TraitForgeNft contract, the check generationMintCounts[currentGeneration] >= maxTokensPerGen can be simplified to generationMintCounts[currentGeneration] == maxTokensPerGen.

Since generationMintCounts[currentGeneration] will not exceed maxTokensPerGen under normal circumstances (if maxTokensPerGen is not altered during the contract’s lifecycle), the check can be simplified to:

    if (generationMintCounts[currentGeneration] >= maxTokensPerGen) {
      _incrementGeneration();
    }

Recommendation

Simplify the check to == if it is guaranteed that maxTokensPerGen will not be changed during the contract’s lifecycle.

[05] Self-transfer of TraitForgeNft could clear listing info

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

Description

In the TraitForgeNft contract, the _beforeTokenTransfer function is overridden to include additional constraints, such as canceling the listing information of a token upon transfer. However, the current implementation does not account for self-transfers (where the from and to addresses are the same). This could lead to accidental clearing of listing information.

    /// @dev don't update the transferred timestamp if from and to address are same
    if (from != to) {
      lastTokenTransferredTimestamp[firstTokenId] = block.timestamp;
    }

    // @note: self-transfer could clear listing info.
    if (listedId > 0) {
      IEntityForging.Listing memory listing = entityForgingContract.getListings(
        listedId
      );
      if (
        listing.tokenId == firstTokenId &&
        listing.account == from &&
        listing.isListed
      ) {
        entityForgingContract.cancelListingForForging(firstTokenId);
      }
    }

The condition from != to is considered for updating the transfer timestamp lastTokenTransferredTimestamp, but not for canceling the listing. This oversight allows a self-transfer (where from == to) to inadvertently clear the listing information.

Recommendation

To prevent accidental clearing of listing information during self-transfers, modify the _beforeTokenTransfer function to only cancel the listing if from!=to.

[06] Inconsistent usage of pause() and whenNotPaused in TraitForgeNft

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

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

Description

The TraitForgeNft contract inherits from Pausable and supports pausing functionalities. However, the contract uses the pausing mechanism inconsistently across different functions.

In the TraitForgeNft contract, the mintToken function uses the whenNotPaused modifier to ensure it can only be executed when the contract is not paused. On the other hand, the _beforeTokenTransfer function directly checks the paused() state without using the whenNotPaused modifier.

  function mintToken(
    bytes32[] calldata proof
  )
    public
    payable
@=> whenNotPaused
    nonReentrant
    onlyWhitelisted(proof, keccak256(abi.encodePacked(msg.sender)))
  {...}

  function _beforeTokenTransfer(
    address from,
    address to,
    uint256 firstTokenId,
    uint256 batchSize
  ) internal virtual override {
	    super._beforeTokenTransfer(from, to, firstTokenId, batchSize);
		...
@=>     require(!paused(), 'ERC721Pausable: token transfer while paused'); // @note: why not use whenNotPaused
  }

The inconsistent usage is not a good practice and could cause confusion.

Recommendation

To ensure consistency and improve readability, use the whenNotPaused modifier in the _beforeTokenTransfer function instead of directly checking the paused() state. This will align the pausing logic with other functions in the contract.

[07] Uninitialized/unbounded issue in fetchListings

https://github.com/code-423n4/2024-07-traitforge/blob/279b2887e3d38bc219a05d332cbcb0655b2dc644/contracts/EntityForging/EntityForging.sol#L49-L54

Description

The function fetchListings initializes an array of listingCount + 1 but only fills elements from index 1 to listingCount, leaving the first element uninitialized. This off-by-one issue can cause confusion and may lead to unexpected behavior when the function is used.

  function fetchListings() external view returns (Listing[] memory _listings) {
    _listings = new Listing[](listingCount + 1);
    for (uint256 i = 1; i <= listingCount; ++i) {
      _listings[i] = listings[i]; //@audit incorrect setup
    }
  }

Additionally, if listings are deleted via the cancelListingForForging function, the corresponding elements in the listings array become uninitialized or hold no value, further complicating the data returned by fetchListings.

Also, since the listing is unbounded (grows over time) and is looped. When there are so many listings, this could cause performance issues (DOS) or OOG(out-of-gas) issues when all previous listings are retrieved.

Recommendation

  • Revise the design to ensure that the returned array only contains initialized elements. One approach is to skip index 0 or filter out uninitialized elements before returning the array.
  • Revise the design to keep track of the active listing to avoid OOG/DOS issues, or use pagination to improve performance.

[08] Seller could deliberately refuse a purchase/forge

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

https://github.com/code-423n4/2024-07-traitforge/blob/279b2887e3d38bc219a05d332cbcb0655b2dc644/contracts/EntityForging/EntityForging.sol#L159-L160

Description

The seller/forgeOwner could deliberately refuse a purchase/forge in the callback/receive function by simply reverting. This can result in a denial of service (DoS) attack, where the seller/forgeOwner prevents successful transactions, causing frustration and poor user experience.

...
    (bool success_forge, ) = forgerOwner.call{ value: forgerShare }('');
...
    (bool success, ) = payable(listing.seller).call{ value: sellerProceeds }(
      ''
    );

If the seller or forgeOwner reverts the transaction, it will cause a failure in the purchase/forge process, resulting in a denial of service for other users. Since the listing can only be canceled by the seller/forgeOwner, this can lead to a bad user experience.

Recommendation

To mitigate this issue, implement a method to allow the contract owner to cancel the listing. This will provide a mechanism to handle cases where the seller/forgeOwner deliberately refuses transactions.

[09] Relationship between maxAllowedClaimDivisor and nukeFactorMaxParam is not strictly enforced

https://github.com/code-423n4/2024-07-traitforge/blob/279b2887e3d38bc219a05d332cbcb0655b2dc644/contracts/NukeFund/NukeFund.sol#L168-L173

Description

The nuke function in the NukeFund contract does not enforce the relationship between maxAllowedClaimDivisor and nukeFactorMaxParam. This relationship is crucial for the proper calculation of the claimAmount. If these variables are not set correctly, it can lead to incorrect or unexpected behavior in the nuke process, potentially resulting in incorrect distribution of funds.

In the nuke function, the claimAmount is calculated based on whether finalNukeFactor exceeds nukeFactorMaxParam:

    uint256 potentialClaimAmount = (fund * finalNukeFactor) / MAX_DENOMINATOR; // Calculate the potential claim amount based on the finalNukeFactor
    uint256 maxAllowedClaimAmount = fund / maxAllowedClaimDivisor; // Define a maximum allowed claim amount as 50% of the current fund size

    // Directly assign the value to claimAmount based on the condition, removing the redeclaration
    uint256 claimAmount = finalNukeFactor > nukeFactorMaxParam
      ? maxAllowedClaimAmount
      : potentialClaimAmount;

    fund -= claimAmount; // Deduct the claim amount from the fund

The implicit relationship is:

fund / maxAllowedClaimDivisor = fund * nukeFactorMaxParam / MAX_DENOMINATOR
=>
maxAllowedClaimDivisor * nukeFactorMaxParam = MAX_DENOMINATOR

However, both maxAllowedClaimDivisor and nukeFactorMaxParam are set independently without enforcing this constraint:

  function setMaxAllowedClaimDivisor(uint256 value) external onlyOwner {
    maxAllowedClaimDivisor = value;
  }

  function setNukeFactorMaxParam(uint256 value) external onlyOwner {
    nukeFactorMaxParam = value;
  }

Recommendation

Ensure that the relationship between maxAllowedClaimDivisor and nukeFactorMaxParam is maintained when setting these values. This can be done by modifying the setter functions to enforce the constraint.

[10] Secondary market buyer may suffer a loss if the forging happens before the transfer

https://github.com/code-423n4/2024-07-traitforge/blob/279b2887e3d38bc219a05d332cbcb0655b2dc644/contracts/EntityForging/EntityForging.sol#L112-L119

Description

The parameter forgePotential is crucial as it determines the number of times an NFT can forge within a year.

    require(
      forgePotential > 0 && forgingCounts[tokenId] <= forgePotential,
      'Entity has reached its forging limit'
    );

In the secondary market, NFTs with a higher forgePotential or lower forgeCounts are valued more. However, there is a potential issue that can cause losses for buyers in the secondary market. Consider the following scenario:

  1. User Alice lists an NFT for forging, which can still forge one more time.
  2. User Alice lists the NFT in the secondary market.
  3. User Bob buys the NFT, willing to pay a higher price since the NFT can still forge.
  4. Just before his purchase, the NFT is forged. Consequently, Bob loses money as the NFT’s value decreases due to it reaching its forging limit.

Recommendation

Cooldown Period for Transfer - Implement a cooldown period after a successful forge, during which the NFT cannot be transferred. This ensures that the buyer can verify the forging status and potential before completing the purchase.

[11] Ambiguous mod due to 2 ** 256 < 10 ** 78

https://github.com/code-423n4/2024-07-traitforge/blob/279b2887e3d38bc219a05d332cbcb0655b2dc644/contracts/EntropyGenerator/EntropyGenerator.sol#L73-L75

Description

The mod operation in the code below is unnecessary and adds unnecessary complexity. This is because the modulus value 10 ** 78 is larger than the maximum value of a uint256 (2 ** 256). As a result, the modulus operation has no effect, leading to ambiguity and potential misunderstanding of the code’s intent.

        uint256 pseudoRandomValue = uint256(
          keccak256(abi.encodePacked(block.number, i))
        ) % uint256(10) ** 78;

Recommendation

Remove the unnecessary modulus operation to simplify the code and avoid confusion.

[12] deriveTokenParameters does not align with the doc

https://github.com/code-423n4/2024-07-traitforge/blob/279b2887e3d38bc219a05d332cbcb0655b2dc644/contracts/EntropyGenerator/EntropyGenerator.sol#L152-L153

Description

The function deriveTokenParameters contains calculations that do not align with the project’s documentation. This discrepancy can lead to misunderstandings and potential bugs in the system, as the calculated parameters will not match the expected values described in the documentation.

The documentation specifies the following calculations:

  1. Entropy / 40 = initialNukeFactor
  2. Entropy[4] = colour1 && forgePotential

However, the deriveTokenParameters function calculates:

  1. nukeFactor = entropy / 4000000
  2. forgePotential = getFirstDigit(entropy)
  // function to derive various parameters based on entropy values, demonstrating potential cases
  function deriveTokenParameters(
    uint256 slotIndex,
    uint256 numberIndex
  )
    public
    view
    returns (
      uint256 nukeFactor,
      uint256 forgePotential,
      uint256 performanceFactor,
      bool isForger
    )
  {
    uint256 entropy = getEntropy(slotIndex, numberIndex);

    // example calculations using entropyto derive game-related parameters
@=> nukeFactor = entropy / 4000000; // inconsistent with design
@=> forgePotential = getFirstDigit(entropy);
    performanceFactor = entropy % 10;

    // example logic to determine a boolean property based on entropy
    uint256 role = entropy % 3;
    isForger = role == 0;

    return (nukeFactor, forgePotential, performanceFactor, isForger); // return derived parameters
  }

Recommendation

Since the deriveTokenParameters function is not being used anymore, it is recommended to remove it from the codebase to avoid confusion and ensure the code remains clean and maintainable.


Disclosures

C4 is an open organization governed by participants in the community.

C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.

C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.