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: 17/283
Findings: 10
Award: $294.06
π Selected for report: 0
π 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
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L346 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L363
When the players stake NRN
for their fighters, those fighters are flagged as being stake and unable to transfer.
However, the players can transfer fighters while they are staked by using overload ERC721::safeTransferFrom(), bypassing this verification shown in the FighterFarm::transferFrom() and FighterFarm::safeTransferFrom().
File: FighterFarm.sol function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) { return ( _isApprovedOrOwner(msg.sender, tokenId) && balanceOf(to) < MAX_FIGHTERS_ALLOWED && !fighterStaked[tokenId] ); }
ERC721::safeTransferFrom()@v4.7.0
function safeTransferFrom( address from, address to, uint256 tokenId, bytes memory data ) public virtual override { require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721: caller is not token owner nor approved"); _safeTransfer(from, to, tokenId, data); }
Bypassing the fighter transfer verification that is designed to prevent the transfer of fighters when they are being staked, leading to conflicts in game mechanics and enabling the staked fighter to be traded in the secondary market.
Setup
test/FighterFarm.t.sol
forge test --mt testTransferringFighterWhileStaked_SUCCESS -vvv
The full coded PoC shown below:
<details> <summary>Details PoC</summary></details>function testTransferringFighterWhileStaked_SUCCESS() public { address alice = makeAddr("Alice"); address bob = makeAddr("Bob"); console.log("=== Addresses ==="); console.log("Alice: %s", alice); console.log("Bob: %s\n", bob); _mintFromMergingPool(alice); _fighterFarmContract.addStaker(_ownerAddress); // flag fighter as being staked vm.prank(_ownerAddress); _fighterFarmContract.updateFighterStaking(0, true); assertEq(_fighterFarmContract.fighterStaked(0), true); console.log("Staking status of Fighter ID: 0 : %s\n", _fighterFarmContract.fighterStaked(0)); vm.prank(alice); // using `ER721::safeTransferFrom(address,address,uint256,bytes)` _fighterFarmContract.safeTransferFrom(alice, bob, 0, ""); assertEq(_fighterFarmContract.ownerOf(0) != alice, true); assertEq(_fighterFarmContract.ownerOf(0), bob); console.log("=== Alice transfer the staking Fighter:0 to Bob ==="); console.log("Owner of Fighter:0 : %s (%s)", _fighterFarmContract.ownerOf(0), _fighterFarmContract.ownerOf(0) == bob? "Bob" : (_fighterFarmContract.ownerOf(0) == alice? "Alice" : "None")); }
Results of running the test:
Running 1 test for test/FighterFarm.t.sol:FighterFarmTest [PASS] testTransferringFighterWhileStaked_SUCCESS() (gas: 511035) Logs: === Addresses === Alice: 0xBf0b5A4099F0bf6c8bC4252eBeC548Bae95602Ea Bob: 0x4dBa461cA9342F4A6Cf942aBd7eacf8AE259108C Staking status of Fighter ID: 0 : true === Alice transfer the staking Fighter:0 to Bob === Owner of Fighter:0 : 0x4dBa461cA9342F4A6Cf942aBd7eacf8AE259108C (Bob) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.99ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommend applying the verification into the override FighterFarm::_beforeTokenTransfer()
to verify transferable fighters in every fighter transfer.
Applying the recommended code below will execute the hook and verify transferable fighters in transferFrom()
, safeTransferFrom()
, and overloaded safeTransferFrom()
executions.
Therefore, applying the recommended code can remove the need for overriding FighterFarm.transferFrom()
and FighterFarm.safeTransferFrom()
.
File: FighterFarm.sol function _beforeTokenTransfer(address from, address to, uint256 tokenId) internal override(ERC721, ERC721Enumerable) { + if(from != address(0) && to != address(0)){ + require(_ableToTransfer(tokenId, to), "unable to transfer"); + } super._beforeTokenTransfer(from, to, tokenId); }
Token-Transfer
#0 - c4-pre-sort
2024-02-23T05:20:44Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-23T05:20:56Z
raymondfam marked the issue as duplicate of #739
#2 - c4-judge
2024-03-11T02:47:10Z
HickupHH3 marked the issue as satisfactory
π 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.0037 USDC - $0.00
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/GameItems.sol#L301
the players can transfer non-transferable game items using ERC1155::safeBatchTransferFrom(), bypassing this verification shown in the GameItems::safeTransferFrom().
require(allGameItemAttributes[tokenId].transferable);
The ERC1155::safeBatchTransferFrom()@v4.7.0
function safeBatchTransferFrom( address from, address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data ) public virtual override { require( from == _msgSender() || isApprovedForAll(from, _msgSender()), "ERC1155: caller is not token owner nor approved" ); _safeBatchTransferFrom(from, to, ids, amounts, data); }
The ability to bypass the verification of transferable game items, allows players to possess a specific quantity of items to progress through the game.
For example, the protocol team might choose to temporarily mark battery items as non-transferable to prevent the dumping of batteries from alternate accounts for replenishing and then transferring the items to the main player account for initiating battles.
Setup
test/GameItems.t.sol
forge test --mt testBypassTransferGameItems -vvv
The full coded PoC shown below:
<details> <summary>Details PoC</summary></details>function testBypassTransferGameItems() public { (, ,bool transferable, , ,) = _gameItemsContract.allGameItemAttributes(0); console.log("Initial Battery item transferable flag: %s\n", transferable); //update battery items to be non-transferable _gameItemsContract.adjustTransferability(0, false); console.log("=== Admin adjust battery to non-transferable ==="); (, ,transferable, , ,) = _gameItemsContract.allGameItemAttributes(0); console.log("Battery item transferable flag: %s\n", transferable); uint256 dailyAllowance = 10; address alice = makeAddr("Alice"); address bob = makeAddr("Bob"); _fundUserWith4kNeuronByTreasury(alice); // mint the battery items vm.startPrank(alice); _gameItemsContract.mint(0, dailyAllowance); assertEq(_gameItemsContract.balanceOf(alice, 0), dailyAllowance); console.log("=== Alice mint Battery ==="); console.log("Alice Battery items balance: %s", _gameItemsContract.balanceOf(alice, 0)); console.log("Bob Battery items balance: %s\n", _gameItemsContract.balanceOf(bob, 0)); // alice attemp to transfer to Bob uint256 transferAmount = _gameItemsContract.balanceOf(alice, 0); // Fail on `safeTransferFrom` vm.expectRevert(); _gameItemsContract.safeTransferFrom(alice, bob, 0, transferAmount, ""); // Success on `safeBatchTransferFrom` uint256[] memory craftIds = new uint256[](1); craftIds[0] = 0; uint256[] memory craftAmounts = new uint256[](1); craftAmounts[0] = transferAmount; _gameItemsContract.safeBatchTransferFrom(alice, bob, craftIds, craftAmounts, ""); vm.stopPrank(); console.log("=== Alice transfer Battery to Bob via safeBatchTransferFrom() ==="); console.log("Alice Battery items balance: %s", _gameItemsContract.balanceOf(alice, 0)); console.log("Bob Battery items balance: %s\n", _gameItemsContract.balanceOf(bob, 0)); console.log("=== Transfer of non-tranferable items SUCCESS ==="); assertEq(_gameItemsContract.balanceOf(bob, 0), transferAmount); }
Results of running the test:
Running 1 test for test/GameItems.t.sol:GameItemsTest [PASS] testBypassTransferGameItems() (gas: 197141) Logs: Initial Battery item transferable flag: true === Admin adjust battery to non-transferable === Battery item transferable flag: false === Alice mint Battery === Alice Battery items balance: 10 Bob Battery items balance: 0 === Alice transfer Battery to Bob via safeBatchTransferFrom() === Alice Battery items balance: 0 Bob Battery items balance: 10 === Transfer of non-tranferable items SUCCESS === Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.58ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommend overriding the ERC1155::_beforeTokenTransfer()
to verify transferable items in every item transfer.
Overriding ERC1155::_beforeTokenTransfer()
will execute the hook and verify transferable items in both safeTransferFrom()
and safeBatchTransferFrom()
executions.
Therefore, applying the recommended code can remove the need for overriding GameItems.safeTransferFrom()
.
File: GameItems.sol + function _beforeTokenTransfer( + address operator, + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) internal override(ERC1155) { + if (from != address(0) && to != address(0)){ + for (uint256 i = 0; i < ids.length; ++i) { + require(allGameItemAttributes[ids[i]].transferable); + } + } + }
Token-Transfer
#0 - c4-pre-sort
2024-02-22T04:13:31Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T04:13:38Z
raymondfam marked the issue as duplicate of #18
#2 - c4-pre-sort
2024-02-26T00:29:00Z
raymondfam marked the issue as duplicate of #575
#3 - c4-judge
2024-03-05T04:55:46Z
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
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L370-L391
The FighterFarm::reRoll() allows the player to specify the fighterType
to the FighterFarm::reRoll(), which can lead to predictable fighter rerolled traits and
ability to reroll fighters more than the maximum allowance for each fighterType
.
File: FighterFarm.sol 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"); _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] = ""; } }
File: 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); }
File: AiArenaHelper.sol function createPhysicalAttributes( uint256 dna, uint8 generation, uint8 iconsType, bool dendroidBool ) external view returns (FighterOps.FighterPhysicalAttributes memory) { if (dendroidBool) { return FighterOps.FighterPhysicalAttributes(99, 99, 99, 99, 99, 99); } else { uint256[] memory finalAttributeProbabilityIndexes = new uint[](attributes.length); uint256 attributesLength = attributes.length; for (uint8 i = 0; i < attributesLength; i++) { if ( i == 0 && iconsType == 2 || // Custom icons head (beta helmet) i == 1 && iconsType > 0 || // Custom icons eyes (red diamond) i == 4 && iconsType == 3 // Custom icons hands (bowling ball) ) { finalAttributeProbabilityIndexes[i] = 50; } else { --> uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100; uint256 attributeIndex = dnaToIndex(generation, rarityRank, attributes[i]); finalAttributeProbabilityIndexes[i] = attributeIndex; } } return FighterOps.FighterPhysicalAttributes( finalAttributeProbabilityIndexes[0], finalAttributeProbabilityIndexes[1], finalAttributeProbabilityIndexes[2], finalAttributeProbabilityIndexes[3], finalAttributeProbabilityIndexes[4], finalAttributeProbabilityIndexes[5] ); } }
Impact of the Issue:
Predictable rerolled fighter attributes.
Ability to reroll fighters more than the maximum allowance for each fighterType
.
Let's assume that
id: 0
fighterType:0
dendroidBool: false
.Alice rerolls her fighter by passing the fighterType as 1
(dendroid type), which is inconsistent with her current fighter type.
% FighterFarm.reRoll(tokenId: 0, fighterType: 1)
The sequence and result of execution will be as follows:
maxRerollsAllowed[fighterType: 1]
will be checked instead of maxRerollsAllowed[fighterType: 0]
which is the actual type of the given fighter.File: FighterFarm.sol function reRoll(uint8 tokenId, uint8 fighterType) public { require(msg.sender == ownerOf(tokenId)); --> require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]); // SNIPPED }
newDna
will be set to 1uint256 newDna = fighterType(1) == 0 ? dna : uint256(fighterType(1)); uint256 newDna = uint256(fighterType(1))
File: FighterFarm.sol function reRoll(uint8 tokenId, uint8 fighterType) public { // SNIPPED 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); // SNIPPED } }
File: 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); }
FighterOps.FighterPhysicalAttributes(1, 1, 1, 1, 1, 1)
as calling the AiArenaHelper.createPhysicalAttributes() as follows:
fighters[tokenId].physicalAttributes = _aiArenaHelperInstance.createPhysicalAttributes( newDna: 1, generation[fighterType], fighters[tokenId].iconsType, fighters[tokenId].dendroidBool );
File: FighterFarm.sol function reRoll(uint8 tokenId, uint8 fighterType) public { // SNIPPED 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, // 1 generation[fighterType], fighters[tokenId].iconsType, fighters[tokenId].dendroidBool ); _tokenURIs[tokenId] = ""; } }
File: AiArenaHelper.sol function createPhysicalAttributes( uint256 dna, uint8 generation, uint8 iconsType, bool dendroidBool ) external view returns (FighterOps.FighterPhysicalAttributes memory) { if (dendroidBool) { return FighterOps.FighterPhysicalAttributes(99, 99, 99, 99, 99, 99); } else { uint256[] memory finalAttributeProbabilityIndexes = new uint[](attributes.length); uint256 attributesLength = attributes.length; for (uint8 i = 0; i < attributesLength; i++) { if ( i == 0 && iconsType == 2 || // Custom icons head (beta helmet) i == 1 && iconsType > 0 || // Custom icons eyes (red diamond) i == 4 && iconsType == 3 // Custom icons hands (bowling ball) ) { finalAttributeProbabilityIndexes[i] = 50; } else { --> uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100; // uint256 rarityRank = (1 / attributeToDnaDivisor[attributes[i]]) % 100; // uint256 rarityRank = 0 uint256 attributeIndex = dnaToIndex(generation, rarityRank, attributes[i]); finalAttributeProbabilityIndexes[i] = attributeIndex; } } return FighterOps.FighterPhysicalAttributes( finalAttributeProbabilityIndexes[0], finalAttributeProbabilityIndexes[1], finalAttributeProbabilityIndexes[2], finalAttributeProbabilityIndexes[3], finalAttributeProbabilityIndexes[4], finalAttributeProbabilityIndexes[5] ); } } // SNIPPED function dnaToIndex(uint256 generation, uint256 rarityRank, string memory attribute) public view returns (uint256 attributeProbabilityIndex) { uint8[] memory attrProbabilities = getAttributeProbabilities(generation, attribute); uint256 cumProb = 0; uint256 attrProbabilitiesLength = attrProbabilities.length; for (uint8 i = 0; i < attrProbabilitiesLength; i++) { cumProb += attrProbabilities[i]; if (cumProb >= rarityRank) { // 0 >= 0 attributeProbabilityIndex = i + 1; // 0+1 break; } } return attributeProbabilityIndex; }
Recommend updating the FighterFarm::reRoll()
to use the current fighterType
of the given fighter in the reroll mechanism.
File: FighterFarm.sol - function reRoll(uint8 tokenId, uint8 fighterType) public { + function reRoll(uint8 tokenId) public { + uint8 fighterType = fighters[tokenId].dendroidBool ? 1 : 0; require(msg.sender == ownerOf(tokenId)); require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]); require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll"); _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] = ""; } }
Other
#0 - c4-pre-sort
2024-02-22T02:04:13Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T02:04:22Z
raymondfam marked the issue as duplicate of #306
#2 - c4-judge
2024-03-05T04:34:21Z
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: 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/main/src/FighterFarm.sol#L462-L474 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L129-L134 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L110
There is no update to the FighterFarm::numElements[]
when a new generation of fighters occurs via FighterFarm::incrementGeneration() as the FighterFarm::numElements[]
is hardcoded to support only generation:0
at during deployment.
File: FighterFarm.sol constructor(address ownerAddress, address delegatedAddress, address treasuryAddress_) ERC721("AI Arena Fighter", "FTR") { _ownerAddress = ownerAddress; _delegatedAddress = delegatedAddress; treasuryAddress = treasuryAddress_; --> numElements[0] = 3; }
File: FighterFarm.sol function incrementGeneration(uint8 fighterType) external returns (uint8) { require(msg.sender == _ownerAddress); generation[fighterType] += 1; maxRerollsAllowed[fighterType] += 1; return generation[fighterType]; }
Let's imagine that when the generation of the fighterType:0 (champions)
is increased to generation:1
=> generation[fighterType] == 1
.
There is no numElements[1]
support, making
numElements[generation[fighterType]]
=> numElements[1] == 0
which consequently creates a modulo by zero
revert in
uint256 element = dna % 0; //REVERT
The transaction reverts on the fighter's element
calculation in FighterFarm::_createFighterBase()
Consequently, it's unable to create the new generation fighter in case the input customAttributes == 100
, triggering the execution of FighterFarm::_createFighterBase().
The following functions are impacted by this issue:
File: 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); }
Setup
test/FighterFarm.t.sol
forge test --mt testCreateFighterRevertWhenNoSupportNumElements -vvv
The full coded PoC shown below:
<details> <summary>Details PoC</summary></details>function testCreateFighterRevertWhenNoSupportNumElements() public { // increase the generation of both fighter type: 0 _fighterFarmContract.incrementGeneration(0); assertEq(_fighterFarmContract.generation(0), 1); assertEq(_fighterFarmContract.generation(1), 0); uint8[2] memory numToMint = [1, 0]; bytes memory claimSignature = abi.encodePacked( hex"407c44926b6805cf9755a88022102a9cb21cde80a210bc3ad1db2880f6ea16fa4e1363e7817d5d87e4e64ba29d59aedfb64524620e2180f41ff82ca9edf942d01c" ); string[] memory claimModelHashes = new string[](1); claimModelHashes[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory claimModelTypes = new string[](1); claimModelTypes[0] = "original"; // Right sender of signature should be able to claim fighter => revert vm.expectRevert(stdError.divisionError); _fighterFarmContract.claimFighters(numToMint, claimSignature, claimModelHashes, claimModelTypes); }
Results of running the test:
Running 1 test for test/FighterFarm.t.sol:FighterFarmTest [PASS] testCreateFighterRevertWhenNoSupportNumElements() (gas: 91208) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.60ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommend updating the FighterFarm::numElements[]
to support all generation increases.
Error
#0 - c4-pre-sort
2024-02-22T18:46:19Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T18:46:28Z
raymondfam marked the issue as duplicate of #45
#2 - c4-judge
2024-03-07T07:04:26Z
HickupHH3 marked the issue as satisfactory
π Selected for report: nuthan2x
Also found by: 0xE1, 0xblackskull, 0xgrbr, 0xvj, Greed, McToady, MidgarAudits, PetarTolev, Sabit, SovaSlava, SpicyMeatball, Timenov, Tychai0s, _eperezok, alexxander, btk, c0pp3rscr3w3r, favelanky, jesjupyter, josephdara, juancito, klau5, kutugu, lil_eth, merlinboii, pynschon, sandy, shaflow2, zaevlad
29.1474 USDC - $29.15
Judge has assessed an item in Issue #1100 as 2 risk. The relevant finding follows:
L-04: Lack of role revocation functionality in Neuron Contract
#0 - c4-judge
2024-03-19T23:56:56Z
HickupHH3 marked the issue as duplicate of #1507
#1 - c4-judge
2024-03-19T23:57:08Z
HickupHH3 marked the issue as satisfactory
π Selected for report: ahmedaghadi
Also found by: 0x13, 0xAleko, 0xDetermination, 0xKowalski, 0xPluto, 0xRiO, 0xvj, AlexCzm, Avci, BARW, BigVeezus, Cryptor, DeFiHackLabs, Draiakoo, Fitro, Giorgio, GoSlang, Greed, Honour, Kalogerone, KmanOfficial, Krace, McToady, MidgarAudits, MrPotatoMagic, Nyxaris, ReadyPlayer2, Ryonen, SovaSlava, SpicyMeatball, VAD37, _eperezok, alexzoid, almurhasan, btk, cu5t0mpeo, deadrxsezzz, djxploit, dvrkzy, emrekocak, erosjohn, evmboi32, fnanni, grearlake, inzinko, jesjupyter, jesusrod15, josephdara, ke1caM, klau5, ktg, ladboy233, merlinboii, nuthan2x, peanuts, pipidu83, pontifex, radev_sw, sl1, sobieski, soliditywala, t0x1c, taner2344, vnavascues, y4y, yovchev_yoan, zaevlad
0.2347 USDC - $0.23
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L139-L167
The winners selected by the MergingPool::pickWinner() potentially be unable to claim at least some of their rewards (whenever the winner's owner balance is less than the FighterFarm::MAX_FIGHTERS_ALLOWED) if their balance is insufficient for their maximum rewards from their all rewards.
Since the MergingPool::pickWinner() selects the winner based on the fighter ID, it allows the same winner's owner to be included multiple times in the MergingPool::currentWinnerAddresses[]
.
File: 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; }
File: MergingPool.sol function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; --> for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { --> _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
Winners selected by the MergingPool::pickWinner() be unable to claim some or all of their rewards if their balance is insufficient to cover their maximum rewards.
This issue prevents players from receiving rewards due to their balance being unsatisfactory within the claim rewards iteration
Let's consider the following scenario:
MAX_FIGHTERS_ALLOWED
is set to 10, roundId
is 0, and winnersPerPeriod
is 2.winnerAddresses[roundId: 0] = [Bob'address, Bob'address];
Bob tries to claim his rewards through the MergingPool::claimRewards()
Unfortunately, Bob is unable to claim any rewards due to a revert caused by the require statement in the FighterFarm::_createNewFighter().
Imagine within Bob's MergingPool::claimRewards()
transaction:
require(balanceOf(Bob): 10 < MAX_FIGHTERS_ALLOWED: 10);
.As a result, Bob is unable to claim at least one reward from two eligible rewards because the claim rewards transaction reverts, even though he is eligible to claim at least one reward since Bob's balance has not reached the maximum to claim that one.
File: FighterFarm.sol function _createNewFighter( address to, uint256 dna, string memory modelHash, string memory modelType, uint8 fighterType, uint8 iconsType, uint256[2] memory customAttributes ) private { --> require(balanceOf(to) < MAX_FIGHTERS_ALLOWED); // SNIPPED }
Implement partially claim functionality within the MergingPool
contract, or update the MergingPool::claimRewards() function to support the mentioned case in this issue.
Other
#0 - c4-pre-sort
2024-02-22T09:03:01Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T09:03:08Z
raymondfam marked the issue as duplicate of #216
#2 - c4-judge
2024-03-11T12:50:32Z
HickupHH3 marked the issue as satisfactory
π Selected for report: klau5
Also found by: 0xAlix2, 0xCiphky, 0xDetermination, 0xG0P1, 0xMosh, 0xabhay, 14si2o_Flint, AlexCzm, Aymen0909, CodeWasp, DanielArmstrong, FloatingPragma, Giorgio, JCN, Jorgect, Kalogerone, KmanOfficial, Kow, KupiaSec, McToady, SpicyMeatball, VAD37, WoolCentaur, ZanyBonzy, alexxander, alexzoid, almurhasan, blutorque, csanuragjain, denzi_, dipp, djxploit, evmboi32, handsomegiraffe, haxatron, immeas, jesjupyter, ke1caM, klau5, lanrebayode77, lil_eth, merlin, merlinboii, nuthan2x, peanuts, shaflow2, shaka, sl1, solmaxis69, stakog, swizz, t0x1c, tallo, ubermensch, vnavascues, yotov721
1.0089 USDC - $1.01
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L270-L290 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/RankedBattle.sol#L244-L265 https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L539-L545
The RankedBattle::unstakeNRN() allows the fighters to unstake all their amountStaked
and be flagged as UNSTAKED
, while those fighters still hold the remaining stakeAtRisk
that has the potential to increase their amountStaked
in their next battles, resulting in UNSTAKED
fighters potentially having amountStaked > 0
.
This allows them to
STAKED
status.STAKED
status.Impact of the Issue:
Bypassing the fighter transfer verification that is designed to prevent the transfer of fighters when they are being staked, leading to conflicts in game mechanics and enabling the staked fighter to be traded in the secondary market.
Conflicting logic regarding unstaked fighters as the term UNSTAKED
fighter should refer to the fighters having no remaining stake.
Consider the following scenario
0. Let's assume that the fighter01
is being staked and then it play through the battles create some stakeAtRisk
.
fighter01
unstake all amountStaked[fighter01]
by calling RankedBattle::unstakeNRN() and leaving some stakeAtRisk
remaining, leading to that fighter will be flagged as UNSTAKED
and gain the ability to transfer.File: RankedBattle.sol function unstakeNRN(uint256 amount, uint256 tokenId) external { require(_fighterFarmInstance.ownerOf(tokenId) == msg.sender, "Caller does not own fighter"); if (amount > amountStaked[tokenId]) { amount = amountStaked[tokenId]; } amountStaked[tokenId] -= amount; globalStakedAmount -= amount; stakingFactor[tokenId] = _getStakingFactor( //@note if user not stake it will be 1 tokenId, _stakeAtRiskInstance.getStakeAtRisk(tokenId) ); _calculatedStakingFactor[tokenId][roundId] = true; hasUnstaked[tokenId][roundId] = true; bool success = _neuronInstance.transfer(msg.sender, amount); if (success) { --> if (amountStaked[tokenId] == 0) { --> _fighterFarmInstance.updateFighterStaking(tokenId, false); } emit Unstaked(msg.sender, amount); } }
fighter01
win in the next battle at this round, amountStaked[fighter01]
will be increased to reclaim its left stakeAtRisk
through the RankedBattle::updateBattleRecord() --> RankedBattle::_addResultPoints().However, the current staking status of the fighter01
still be UNSTAKED
, while the amountStaked[fighter01] > 0
File: RankedBattle.sol if (curStakeAtRisk > 0) { --> _stakeAtRiskInstance.reclaimNRN(curStakeAtRisk, tokenId, fighterOwner); --> amountStaked[tokenId] += curStakeAtRisk; // increase amountStaked }
fighter01
is already flagged as UNSTAKED
, and amountStaked[fighter01] > 0
, when they call RankedBattle::stakeNRN() in the next round (the check of unstake status of this round is also passed), the execution will bypass this check and not flag the fighter01
back to STAKED
.File: RankedBattle.sol function stakeNRN(uint256 amount, uint256 tokenId) external { require(amount > 0, "Amount cannot be 0"); require(_fighterFarmInstance.ownerOf(tokenId) == msg.sender, "Caller does not own fighter"); require(_neuronInstance.balanceOf(msg.sender) >= amount, "Stake amount exceeds balance"); require(hasUnstaked[tokenId][roundId] == false, "Cannot add stake after unstaking this round"); _neuronInstance.approveStaker(msg.sender, address(this), amount); bool success = _neuronInstance.transferFrom(msg.sender, address(this), amount); if (success) { --> if (amountStaked[tokenId] == 0) { //Bypass this check _fighterFarmInstance.updateFighterStaking(tokenId, true); } amountStaked[tokenId] += amount; globalStakedAmount += amount; stakingFactor[tokenId] = _getStakingFactor( tokenId, _stakeAtRiskInstance.getStakeAtRisk(tokenId) ); _calculatedStakingFactor[tokenId][roundId] = true; emit Staked(msg.sender, amount); } }
And able to bypass this fighter transfer verification:
File: FighterFarm.sol function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) { return ( _isApprovedOrOwner(msg.sender, tokenId) && //> approval pass balanceOf(to) < MAX_FIGHTERS_ALLOWED && //> destination not reach the MAX !fighterStaked[tokenId] //> not stake/ not lock ); }
Setup
test/RankedBattle.t.sol
forge test --mt testIncorrectlyFlagOfStakingStatus -vvv
The full coded PoC shown below:
<details> <summary>Details PoC</summary></details>function testIncorrectlyFlagOfStakingStatus() public { //STAKE address player = vm.addr(3); _mintFromMergingPool(player); _fundUserWith4kNeuronByTreasury(player); console.log("== STAKED at round 0 =="); vm.prank(player); _rankedBattleContract.stakeNRN(3_000 * 10 ** 18, 0); //amount id console.log("STAKED Flag #1: %s\n", _fighterFarmContract.fighterStaked(0)); address player2 = vm.addr(4); _mintFromMergingPool(player2); _fundUserWith4kNeuronByTreasury(player2); vm.prank(player2); _rankedBattleContract.stakeNRN(3_000 * 10 ** 18, 1); //amount id //ANOTHER player Win => to increase the point in order to set new round vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, false); //BATTLE-1-Lost vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, false); //Unstake All console.log("== UNSTAKED at round 0 =="); vm.prank(player); _rankedBattleContract.unstakeNRN(3_000 * 10 ** 18, 0); console.log("STAKED Flag #2: %s\n", _fighterFarmContract.fighterStaked(0)); console.log("== _GAME_SERVER_ADDRESS set new round ==\n"); //BATTLE-2-Win vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, false); _rankedBattleContract.setNewRound(); console.log("== STAKED at round 1 =="); vm.prank(player); _rankedBattleContract.stakeNRN(3_000 * 10 ** 18, 0); console.log("STAKED Flag #3: %s\n", _fighterFarmContract.fighterStaked(0)); }
Results of running the test:
Running 1 test for test/RankedBattle.t.sol:RankedBattleTest [PASS] testPoC17() (gas: 1646900) Logs: == STAKED at round 0 == STAKED Flag #1: true == UNSTAKED at round 0 == STAKED Flag #2: false == _GAME_SERVER_ADDRESS set new round == == STAKED at round 1 == STAKED Flag #3: false Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.68ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
I would recommend two approaches:
As the current behavior of unstaking allows fighters to unstake all eligible amountStaked
and can be transferred even if the fighter has stakeAtRisk
in the current round, which has the potential to increase their amountStaked
in the next battles.
I would recommend changing the unstake condition to flag back as UNSTAKED
by including all potential stake factors: amountStaked
and stakeAtRisk
to ensure all staking and potential staking amounts are included to clarify marking as UNSTAKED
.
File: RankedBattle.sol function unstakeNRN(uint256 amount, uint256 tokenId) external { require(_fighterFarmInstance.ownerOf(tokenId) == msg.sender, "Caller does not own fighter"); if (amount > amountStaked[tokenId]) { amount = amountStaked[tokenId]; } amountStaked[tokenId] -= amount; globalStakedAmount -= amount; stakingFactor[tokenId] = _getStakingFactor( tokenId, _stakeAtRiskInstance.getStakeAtRisk(tokenId) ); _calculatedStakingFactor[tokenId][roundId] = true; hasUnstaked[tokenId][roundId] = true; bool success = _neuronInstance.transfer(msg.sender, amount); if (success) { + uint256 stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId); + if (amountStaked[tokenId] + stakeAtRisk == 0) { _fighterFarmInstance.updateFighterStaking(tokenId, false); } emit Unstaked(msg.sender, amount); } }
This still allows fighters to unstake all eligible amountStaked
, and it will prevent the fighter from being flagged as UNSTAKED
and transferred, in case they have only stakeAtRisk
remaining.
In case there is a need to maintain the same behavior.
I would recommend applying the condition to flag fighters back to STAKED
whenever the amountStaked[tokenId]
increases while they hold the UNSTAKED
flag (this may consume more gas than the first approach).
File: RankedBattle.sol function _addResultPoints( uint8 battleResult, uint256 tokenId, uint256 eloFactor, uint256 mergingPortion, address fighterOwner ) private { uint256 stakeAtRisk; uint256 curStakeAtRisk; uint256 points = 0; /// Check how many NRNs the fighter has at risk stakeAtRisk = _stakeAtRiskInstance.getStakeAtRisk(tokenId); /// Calculate the staking factor if it has not already been calculated for this round if (_calculatedStakingFactor[tokenId][roundId] == false) { stakingFactor[tokenId] = _getStakingFactor(tokenId, stakeAtRisk); _calculatedStakingFactor[tokenId][roundId] = true; } /// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4; if (battleResult == 0) { /// If the user won the match /// If the user has no NRNs at risk, then they can earn points if (stakeAtRisk == 0) { points = stakingFactor[tokenId] * eloFactor; } /// Divert a portion of the points to the merging pool uint256 mergingPoints = (points * mergingPortion) / 100; points -= mergingPoints; _mergingPoolInstance.addPoints(tokenId, mergingPoints); /// Do not allow users to reclaim more NRNs than they have at risk if (curStakeAtRisk > stakeAtRisk) { curStakeAtRisk = stakeAtRisk; } /// If the user has stake-at-risk for their fighter, reclaim a portion /// Reclaiming stake-at-risk puts the NRN back into their staking pool if (curStakeAtRisk > 0) { _stakeAtRiskInstance.reclaimNRN(curStakeAtRisk, tokenId, fighterOwner); amountStaked[tokenId] += curStakeAtRisk; + if (!_fighterFarmInstance.fighterStaked(tokenId)) { + _fighterFarmInstance.updateFighterStaking(tokenId, true); + } } /// Add points to the fighter for this round accumulatedPointsPerFighter[tokenId][roundId] += points; accumulatedPointsPerAddress[fighterOwner][roundId] += points; totalAccumulatedPoints[roundId] += points; if (points > 0) { emit PointsChanged(tokenId, points, true); } } else if (battleResult == 2) { // SNIPPED } }
Other
#0 - c4-pre-sort
2024-02-24T04:48:20Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T04:48:28Z
raymondfam marked the issue as duplicate of #1641
#2 - c4-pre-sort
2024-02-24T06:10:51Z
raymondfam marked the issue as not a duplicate
#3 - c4-pre-sort
2024-02-24T06:11:06Z
raymondfam marked the issue as duplicate of #833
#4 - c4-judge
2024-03-13T10:05:06Z
HickupHH3 marked the issue as satisfactory
#5 - c4-judge
2024-03-13T10:12:54Z
HickupHH3 changed the severity to 2 (Med Risk)
#6 - c4-judge
2024-03-13T11:32:35Z
HickupHH3 marked the issue as duplicate of #1641
π Selected for report: Timenov
Also found by: 0x11singh99, 0xblackskull, CodeWasp, MidgarAudits, MrPotatoMagic, Rolezn, Sabit, SovaSlava, andywer, btk, josephdara, lil_eth, merlinboii, sobieski, vnavascues
238.8948 USDC - $238.89
Judge has assessed an item in Issue #1100 as 2 risk. The relevant finding follows:
[L-05] Lack of resetting accessible of crucial roles
#0 - c4-judge
2024-03-19T23:58:11Z
HickupHH3 marked the issue as duplicate of #47
#1 - c4-judge
2024-03-19T23:59:18Z
HickupHH3 marked the issue as satisfactory
π Selected for report: Timenov
Also found by: 0x11singh99, 0xblackskull, CodeWasp, MidgarAudits, MrPotatoMagic, Rolezn, Sabit, SovaSlava, andywer, btk, josephdara, lil_eth, merlinboii, sobieski, vnavascues
238.8948 USDC - $238.89
Judge has assessed an item in Issue #1100 as 2 risk. The relevant finding follows:
[L-05] Lack of resetting accessible of crucial roles
#0 - c4-judge
2024-03-20T02:37:48Z
HickupHH3 marked the issue as duplicate of #47
#1 - c4-judge
2024-03-20T02:37:52Z
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
8.8123 USDC - $8.81
ID | Description | Severity |
---|---|---|
L-01 | Previous owner retains administrative abilities after ownership transfer | Low |
L-02 | The Neuron::burnFrom() does not support infinite approval | Low |
L-03 | Lack of setter functions for crucial external Addresses | Low |
L-04 | Lack of role revocation functionality in Neuron Contract | Low |
L-05 | Lack of resetting accessible of crucial roles | Low |
L-06 | Lack of customized replenishment times for game items | Low |
N-01 | Recommend explicitly setting the GameItems::itemsRemaining when the supply is not finite | Non-Critical |
N-02 | Enhance consistency in the GameItems::remainingSupply() when querying items of non-finite supply | Non-Critical |
As the first owner retrieves the admin accessibility at the deployment of the contract, however, the transferOwnership()
does not reset the admin accessibility of the previous owner when transferring ownership.
This leads to the previous owner still retaining administrative abilities until the new owner resets their accessibility via adjustAdminAccess()
.
There is 5 instances of this issue:
File: GameItems.sol function transferOwnership(address newOwnerAddress) external { require(msg.sender == _ownerAddress); _ownerAddress = newOwnerAddress; }
File: MergingPool.sol function transferOwnership(address newOwnerAddress) external { require(msg.sender == _ownerAddress); _ownerAddress = newOwnerAddress; }
File: Neuron.sol function transferOwnership(address newOwnerAddress) external { require(msg.sender == _ownerAddress); _ownerAddress = newOwnerAddress; }
File: RankedBattle.sol function transferOwnership(address newOwnerAddress) external { require(msg.sender == _ownerAddress); _ownerAddress = newOwnerAddress; }
File: VoltageManager.sol function transferOwnership(address newOwnerAddress) external { require(msg.sender == _ownerAddress); _ownerAddress = newOwnerAddress; }
Update the transferOwnership()
to reset the admin accessibility of the previous owner by adding a line of code to revoke their admin access.
The functions that should be updated for this issue are:
The example recommendation code:
</br>File: GameItems.sol function transferOwnership(address newOwnerAddress) external { require(msg.sender == _ownerAddress); + isAdmin[_ownerAddress] = false; _ownerAddress = newOwnerAddress; }
Neuron::burnFrom()
does not support infinite approval<a name="l-02"></a>The Neuron::burnFrom() does not support infinite approval, which is inconsistent with the behavior of ERC20::transferFrom() when the spender performs on behalf of the token owner.
There is one instance of this issue:
File: Neuron.sol function burnFrom(address account, uint256 amount) public virtual { require( allowance(account, msg.sender) >= amount, "ERC20: burn amount exceeds allowance" ); uint256 decreasedAllowance = allowance(account, msg.sender) - amount; _burn(account, amount); _approve(account, msg.sender, decreasedAllowance); }
Implement the functionality to support infinite approval within the Neuron::burnFrom()
function, and also refactoring the function to consume gas more efficiently.
</br>File: Neuron.sol function burnFrom(address account, uint256 amount) public virtual { + uint256 currentAllowance = allowance(account, msg.sender); + if (currentAllowance != type(uint256).max) { require( - allowance(account, msg.sender) >= amount + currentAllowance >= amount, "ERC20: burn amount exceeds allowance" ); + unchecked { + _approve(account, msg.sender, currentAllowance - amount); + } + } - uint256 decreasedAllowance = allowance(account, msg.sender) - amount; _burn(account, amount); - _approve(account, msg.sender, decreasedAllowance); }
The treasuryAddress_
and delegatedAddress
addresses are external contracts used to retrieve the NRN and act as signers of the claim fighter messages, respectively.
These two states lack setter functions, which means their addresses cannot be updated. Considering these addresses could potentially be compromised, adding the ability to update them would mitigate this risk.
There is 4 contracts of this issue:
Implementing setter functions for the treasuryAddress_
and delegatedAddress
states to allow for their update is recommended.
These setter functions should be restricted to high-level roles to ensure proper control over the modification of these critical addresses.
The contracts that should be updated for this issue are:
Neuron
Contract<a name="l-04"></a>The Neuron contract does not designate an account holding theAccessControl::DEFAULT_ADMIN_ROLE.
Therefore, if the contract owner mistakenly assigns roles such as MINTER_ROLE
, STAKER_ROLE
, or SPENDER_ROLE
to unintended addresses, there is no mechanism to revoke these roles from those addresses.
--> function revokeRole(bytes32 role, address account) public virtual override onlyRole(getRoleAdmin(role)) { _revokeRole(role, account); }
There is one instance of this issue:
File: Neuron.sol contract Neuron is ERC20, AccessControl { // SNIPPED }
Assign an account holding the AccessControl::DEFAULT_ADMIN_ROLE
in the Neuron
contract to enable role management.
Additionally, implement logic to revoke the AccessControl::DEFAULT_ADMIN_ROLE
when transferring ownership
</br>File: Neuron.sol constructor(address ownerAddress, address treasuryAddress_, address contributorAddress) ERC20("Neuron", "NRN") { _ownerAddress = ownerAddress; treasuryAddress = treasuryAddress_; isAdmin[_ownerAddress] = true; _mint(treasuryAddress, INITIAL_TREASURY_MINT); _mint(contributorAddress, INITIAL_CONTRIBUTOR_MINT); + _grantRole(DEFAULT_ADMIN_ROLE, _ownerAddress); } //SNIPPED function transferOwnership(address newOwnerAddress) external { require(msg.sender == _ownerAddress); + _revokeRole(DEFAULT_ADMIN_ROLE, _ownerAddress); _ownerAddress = newOwnerAddress; + _grantRole(DEFAULT_ADMIN_ROLE, newOwnerAddress); }
The FighterFarm and GameItems contracts do not designate an address holding the staker role hasStakerRole
and burning role allowedBurningAddresses
, respectively.
There is 2 instances of this issue:
File: FighterFarm.sol function addStaker(address newStaker) external { require(msg.sender == _ownerAddress); --> hasStakerRole[newStaker] = true; }
File: GameItems.sol function setAllowedBurningAddresses(address newBurningAddress) public { require(isAdmin[msg.sender]); --> allowedBurningAddresses[newBurningAddress] = true; }
Update the logic to flexibly set the accessible status as follows:
File: FighterFarm.sol - function addStaker(address newStaker) external { + function setStaker(address newStaker, bool status) external { require(msg.sender == _ownerAddress); - hasStakerRole[newStaker] = true; + hasStakerRole[newStaker] = status; }
</br>File: GameItems.sol - function setAllowedBurningAddresses(address newBurningAddress) public { + function setAllowedBurningAddresses(address newBurningAddress, bool status) public { require(isAdmin[msg.sender]); - allowedBurningAddresses[newBurningAddress] = true; + allowedBurningAddresses[newBurningAddress] = status; }
Each game item should have its replenishment time specified, taking into account factors such as the item's rarity or special abilities. This ensures that the time required to replenish each item is customized to its unique characteristics within the game.
There is one instance of this issue:
File: GameItems.sol function _replenishDailyAllowance(uint256 tokenId) private { allowanceRemaining[msg.sender][tokenId] = allGameItemAttributes[tokenId].dailyAllowance; --> dailyAllowanceReplenishTime[msg.sender][tokenId] = uint32(block.timestamp + 1 days); }
Update the logic to dynamically set the dailyAllowanceReplenishTime
for each game items:
File: GameItems.sol function _replenishDailyAllowance(uint256 tokenId) private { allowanceRemaining[msg.sender][tokenId] = allGameItemAttributes[tokenId].dailyAllowance; - dailyAllowanceReplenishTime[msg.sender][tokenId] = uint32(block.timestamp + 1 days); + dailyAllowanceReplenishTime[msg.sender][tokenId] = uint32(block.timestamp + allGameItemAttributes[tokenId].dailyAllowanceReplenishPeriod); }
File: GameItems.sol struct GameItemAttributes { string name; bool finiteSupply; bool transferable; uint256 itemsRemaining; uint256 itemPrice; uint256 dailyAllowance; + uint256 dailyAllowanceReplenishPeriod; }
</br>File: GameItems.sol function createGameItem( string memory name_, string memory tokenURI, bool finiteSupply, bool transferable, uint256 itemsRemaining, uint256 itemPrice, uint16 dailyAllowance, + uint256 dailyAllowanceReplenishPeriod, ) public { require(isAdmin[msg.sender]); allGameItemAttributes.push( GameItemAttributes( name_, finiteSupply, transferable, itemsRemaining, itemPrice, dailyAllowance, + dailyAllowanceReplenishPeriod ) ); if (!transferable) { emit Locked(_itemCount); } setTokenURI(_itemCount, tokenURI); _itemCount += 1; }
GameItems::itemsRemaining
when the supply is not finite <a name="n-01"></a>The GameItems::createGameItem() could enhancing the explicit setup of itemsRemaining
when creating non-finite supply items.
There is one instance of this issue:
File: GameItems.sol function createGameItem( string memory name_, string memory tokenURI, bool finiteSupply, bool transferable, uint256 itemsRemaining, uint256 itemPrice, uint16 dailyAllowance ) public { require(isAdmin[msg.sender]); allGameItemAttributes.push( GameItemAttributes( name_, finiteSupply, transferable, itemsRemaining, itemPrice, dailyAllowance ) ); if (!transferable) { emit Locked(_itemCount); } setTokenURI(_itemCount, tokenURI); _itemCount += 1; }
Explicitly set the GameItems::itemsRemaining
when the supply is not finite to infinite itemsRemaining
supply.
</br>File: GameItems.sol function createGameItem( string memory name_, string memory tokenURI, bool finiteSupply, bool transferable, uint256 itemsRemaining, uint256 itemPrice, uint16 dailyAllowance ) public { require(isAdmin[msg.sender]); allGameItemAttributes.push( GameItemAttributes( name_, finiteSupply, transferable, + finiteSupply ? itemsRemaining: type(uint256).max, itemPrice, dailyAllowance ) ); if (!transferable) { emit Locked(_itemCount); } setTokenURI(_itemCount, tokenURI); _itemCount += 1; }
GameItems::remainingSupply()
when querying items of non-finite supply<a name="n-02"></a>The GameItems::remainingSupply() could be improved to provide clearer support for querying the supply of each item by returning the finiteSupply
status along with the supply value.
This can explicitly assist in off-chain retrieval of remainingSupply
of each item and facilitate better decision-making regarding the management of items as a non-finite supply items.
There is one instance of this issue:
File: GameItems.sol function remainingSupply(uint256 tokenId) public view returns (uint256) { return allGameItemAttributes[tokenId].itemsRemaining; }
Update the setting the GameItems::remainingSupply()
to return itemsRemaining
amount along with the item's finiteSupply
status.
</br>File: GameItems.sol function remainingSupply(uint256 tokenId) public view returns (uint256, bool) { + return (allGameItemAttributes[tokenId].itemsRemaining, allGameItemAttributes[tokenId].finiteSupply); }
#0 - raymondfam
2024-02-26T05:52:33Z
Adequate amount of L and NC entailed.
#1 - c4-pre-sort
2024-02-26T05:52:39Z
raymondfam marked the issue as sufficient quality report
#2 - HickupHH3
2024-03-05T02:31:31Z
#1089: R #1090: L #1092: L #1086: L
#3 - c4-judge
2024-03-12T04:23:31Z
HickupHH3 marked the issue as grade-b
#4 - merlinboii
2024-03-19T13:29:59Z
Dear @HickupHH3,
Thank you for your time and dedication to addressing the massive of issues.
I believe the following issues could be considered duplicates and/or partially overlapping with the previously judged valid Medium
issue:
Thank you for your attention to this matter.
Best Regards
π Selected for report: 0xDetermination
Also found by: 0x11singh99, 0xAnah, 0xRiO, JcFichtner, K42, MatricksDeCoder, McToady, PetarTolev, Raihan, SAQ, SM3_SS, SY_S, Timenov, ahmedaghadi, al88nsk, dharma09, donkicha, emrekocak, favelanky, hunter_w3b, judeabara, kiqo, lrivo, lsaudit, merlinboii, mikesans, offside0011, oualidpro, peter, rekxor, shamsulhaq123, unique, yashgoel72, yovchev_yoan, ziyou-
13.6293 USDC - $13.63
ID | Description |
---|---|
G-01 | Recommend removing the redundant logic |
G-02 | Recommend refactoring to execute point addition logic for nonzero values |
G-03 | Recommend reusing the functionality of AccessControl for role management |
G-04 | Recommend enhancing GameItems contract with burnBatch functionality |
G-05 | Recommend implementing separate reward claiming functionality within the MergingPool contract |
G-06 | Recommend refactoring FighterFarm::_createNewFighter() to implement Inline Emission |
The AiArenaHelper::constructor() contains redundant logic, specifically at line AiArenaHelper::L49, where attribute probabilities are set.
File: AiArenaHelper.sol 49: attributeProbabilities[0][attributes[i]] = probabilities[i];
This logic redundant the functionality found in the AiArenaHelper::addAttributeProbabilities().
File: AiArenaHelper.sol function addAttributeProbabilities(uint256 generation, uint8[][] memory probabilities) public { require(msg.sender == _ownerAddress); require(probabilities.length == 6, "Invalid number of attribute arrays"); uint256 attributesLength = attributes.length; for (uint8 i = 0; i < attributesLength; i++) { --> attributeProbabilities[generation][attributes[i]] = probabilities[i]; } }
There is one instance of this issue:
</br>File: AiArenaHelper.sol constructor(uint8[][] memory probabilities) { _ownerAddress = msg.sender; // Initialize the probabilities for each attribute addAttributeProbabilities(0, probabilities); uint256 attributesLength = attributes.length; for (uint8 i = 0; i < attributesLength; i++) { attributeProbabilities[0][attributes[i]] = probabilities[i]; attributeToDnaDivisor[attributes[i]] = defaultAttributeDivisor[i]; } }
The point addition logic at RankedBattle::L466-468 and RankedBattle::L485-487 can be optimized by executing it conditionally, where points > 0
, to reduce gas consumption from unnecessary addition or subtraction operations when the amount is zero.
There is two instances of this issue:
File: RankedBattle.sol function _addResultPoints( uint8 battleResult, uint256 tokenId, uint256 eloFactor, uint256 mergingPortion, address fighterOwner ) private { // SNIPPED if (battleResult == 0) { /// If the user won the match /// If the user has no NRNs at risk, then they can earn points if (stakeAtRisk == 0) { points = stakingFactor[tokenId] * eloFactor; } /// Divert a portion of the points to the merging pool uint256 mergingPoints = (points * mergingPortion) / 100; points -= mergingPoints; _mergingPoolInstance.addPoints(tokenId, mergingPoints); /// Do not allow users to reclaim more NRNs than they have at risk if (curStakeAtRisk > stakeAtRisk) { curStakeAtRisk = stakeAtRisk; } /// If the user has stake-at-risk for their fighter, reclaim a portion /// Reclaiming stake-at-risk puts the NRN back into their staking pool if (curStakeAtRisk > 0) { _stakeAtRiskInstance.reclaimNRN(curStakeAtRisk, tokenId, fighterOwner); amountStaked[tokenId] += curStakeAtRisk; } /// Add points to the fighter for this round --> accumulatedPointsPerFighter[tokenId][roundId] += points; --> accumulatedPointsPerAddress[fighterOwner][roundId] += points; --> totalAccumulatedPoints[roundId] += points; if (points > 0) { emit PointsChanged(tokenId, points, true); } } else if (battleResult == 2) { // SNIPPED } }
</br>File: RankedBattle.sol function _addResultPoints( uint8 battleResult, uint256 tokenId, uint256 eloFactor, uint256 mergingPortion, address fighterOwner ) private { // SNIPPED if (battleResult == 0) { // SNIPPED } else if (battleResult == 2) { /// If the user lost the match /// Do not allow users to lose more NRNs than they have in their staking pool if (curStakeAtRisk > amountStaked[tokenId]) { curStakeAtRisk = amountStaked[tokenId]; } if (accumulatedPointsPerFighter[tokenId][roundId] > 0) { /// If the fighter has a positive point balance for this round, deduct points points = stakingFactor[tokenId] * eloFactor; if (points > accumulatedPointsPerFighter[tokenId][roundId]) { points = accumulatedPointsPerFighter[tokenId][roundId]; } --> accumulatedPointsPerFighter[tokenId][roundId] -= points; --> accumulatedPointsPerAddress[fighterOwner][roundId] -= points; --> totalAccumulatedPoints[roundId] -= points; if (points > 0) { emit PointsChanged(tokenId, points, false); } } 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; } } } }
AccessControl
for role management<a name="g-03"></a>Since the Neuron contract inherits the AccessControl, it can reuse the provided AccessControl::grantRole() to assign specific roles to certain addresses instead of implementing a separate role assignment function.
This approach can reduce the contract size and deployment gas costs.
There is one instance of this issue:
</br>File: Neuron.sol contract Neuron is ERC20, AccessControl { // SNIPPED }
GameItems
contract with burnBatch
functionality<a name="g-04"></a>As the GameItems contract introduce the ability to burn items, it can be improved by implementing the external GameItems::burnBatch()
to support safer gas consumption during batch burning operations for multiple items.
The contract that can be used as a reference:
There is one instance of this issue:
</br>File: GameItems.sol contract GameItems is ERC1155 { // SNIPPED }
MergingPool
contract<a name="g-05"></a>As the MergingPool contract introduces the ability to batch claim rewards for multiple rounds, it can be improved by implementing the external MergingPool::claimReward()
to support claiming rewards for specific rounds.
Consider the scenario where a player has never won in many previous rounds. To claim their rewards, they would need to iterate through all rounds and winners arrays, resulting in significant gas consumption.
This improvement would provide safer gas consumption and prevent denial of service scenarios, particularly in cases where a winner has not won in many previous rounds.
There is one instance of this issue:
</br>File: MergingPool.sol function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; --> for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; } } } if (claimIndex > 0) { emit Claimed(msg.sender, claimIndex); } }
As the FighterFarm::fighterCreatedEmitter() is solely used to emit an event when a fighter is created within FighterFarm::_createNewFighter()#L530, the FighterFarm::_createNewFighter() function can be optimized by refactoring to inline the emission.
There is one instance of this issue:
</br>File: FighterFarm.sol function _createNewFighter( address to, uint256 dna, string memory modelHash, string memory modelType, uint8 fighterType, uint8 iconsType, uint256[2] memory customAttributes ) private { require(balanceOf(to) < MAX_FIGHTERS_ALLOWED); uint256 element; uint256 weight; uint256 newDna; if (customAttributes[0] == 100) { (element, weight, newDna) = _createFighterBase(dna, fighterType); } else { element = customAttributes[0]; weight = customAttributes[1]; newDna = dna; } uint256 newId = fighters.length; bool dendroidBool = fighterType == 1; FighterOps.FighterPhysicalAttributes memory attrs = _aiArenaHelperInstance.createPhysicalAttributes( newDna, generation[fighterType], iconsType, dendroidBool ); fighters.push( FighterOps.Fighter( weight, element, attrs, newId, modelHash, modelType, generation[fighterType], iconsType, dendroidBool ) ); _safeMint(to, newId); --> FighterOps.fighterCreatedEmitter(newId, weight, element, generation[fighterType]); }
#0 - raymondfam
2024-02-25T21:53:57Z
6 generic G not found in the bot.
#1 - c4-pre-sort
2024-02-25T21:54:41Z
raymondfam marked the issue as sufficient quality report
#2 - c4-judge
2024-03-19T07:48:52Z
HickupHH3 marked the issue as grade-b