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: 182/283
Findings: 6
Award: $5.38
π Selected for report: 0
π Solo Findings: 0
π 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/5b2ab9f9fadd0b91268ff6f22b4ae0fd5b79ec09/src/FighterFarm.sol#L370 https://github.com/code-423n4/2024-02-ai-arena/blob/5b2ab9f9fadd0b91268ff6f22b4ae0fd5b79ec09/src/FighterFarm.sol#L481
The owner of a fighter NFT is given the opportunity the reRoll the attributes the fighter has. However if the user inputs function reRoll(uint8 tokenId, uint8 fighterType) public {
fighter type corresponding to dendroid instead of AX-bot, the owner will be able to predict the attributes rerolled, or even reroll more times than actually allowed.
This glitch holds significant consequences for the anticipated rarity of an NFT, as it allows easy acquisition of rare attributes. Consequently, the perceived rarity of an NFT initially designated as 'rarer' may be significantly diminished.
Moreover, in cases where NFT fighter types do not share equal reroll opportunities, the 'reroller' could surpass the maximum reroll limit imposed.
The number of reRolls a type of fighter has is directly correlated to his generation, for every generation increment a fighter type is granted one more max reroll. So fighter type 0 generation 1 will be allowed 4 max reRolls.
function incrementGeneration(uint8 fighterType) external returns (uint8) { require(msg.sender == _ownerAddress); @> generation[fighterType] += 1; maxRerollsAllowed[fighterType] += 1; return generation[fighterType]; }
In the reRoll
function, the user is allowed to enter the fighter type,
function reRoll(uint8 tokenId, uint8 fighterType) public {
.
This will allow him to reRoll more times than allowed if his generation number is below the other type of fighter.
This is because the function doesn't check which fighter the user actually owns but which fighter he inputs.
require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]);
This vulnerability also introduces another problem, reRolling a type AX but with the argument dendroid instead of AX will make every physical attribute be equal to 0.
This happens because the _createFighterBase()
will be called with 1 instead of 0. The dna of the rerolled fighter will thus be 1, new dna. Further down the rolling process, createPhysicalAttributes
is called with the new dna(1).
uint256 rarityRank = (dna / attributeToDnaDivisor[attributes[i]]) % 100;
is derived from the dna, in our case, if the attributeToDnaDivisor[attributes[i]]
is bigger than 1, the result will be 0, if attributeToDnaDivisor[attributes[i]] == 1, the rarity rank will be 1.
Therefore dnaToIndex()
will return 1 for every attribute.
In the coded POC below we can see that the user can reRoll 4 times although the max reRolls for his fighter should be 3.
And if we do look at the console output using forge test --match-path ... -vvvv
.
ββ β FighterPhysicalAttributes({ head: 1, eyes: 1, mouth: 1, body: 1, hands: 1, feet: 1 })
We can see the following emitted event which confirms that the attributes will all be 1.
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Test, console} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {FighterFarm} from "../src/FighterFarm.sol"; import {Neuron} from "../src/Neuron.sol"; import {AAMintPass} from "../src/AAMintPass.sol"; import {MergingPool} from "../src/MergingPool.sol"; import {RankedBattle} from "../src/RankedBattle.sol"; import {VoltageManager} from "../src/VoltageManager.sol"; import {GameItems} from "../src/GameItems.sol"; import {AiArenaHelper} from "../src/AiArenaHelper.sol"; // import {Utilities} from "./utils/Utilities.sol"; import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; /// @notice Unit test for FighterFarm Contract. contract FighterFarmTest is Test { // Utilities internal _utils; // address payable[] internal _users; /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ uint8[][] internal _probabilities; address internal constant _DELEGATED_ADDRESS = 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0; address internal _ownerAddress; address internal _treasuryAddress; address internal _neuronContributorAddress; /*////////////////////////////////////////////////////////////// CONTRACT INSTANCES //////////////////////////////////////////////////////////////*/ FighterFarm internal _fighterFarmContract; AAMintPass internal _mintPassContract; MergingPool internal _mergingPoolContract; RankedBattle internal _rankedBattleContract; VoltageManager internal _voltageManagerContract; GameItems internal _gameItemsContract; AiArenaHelper internal _helperContract; Neuron internal _neuronContract; function getProb() public { _probabilities.push([25, 25, 13, 13, 9, 9]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 10]); _probabilities.push([25, 25, 13, 13, 9, 23]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 3]); } function setUp() public { // _utils = new Utilities(); // _users = _utils.createUsers(5); _ownerAddress = address(this); _treasuryAddress = vm.addr(1); _neuronContributorAddress = vm.addr(2); getProb(); _fighterFarmContract = new FighterFarm(_ownerAddress, _DELEGATED_ADDRESS, _treasuryAddress); _helperContract = new AiArenaHelper(_probabilities); _mintPassContract = new AAMintPass(_ownerAddress, _DELEGATED_ADDRESS); _mintPassContract.setFighterFarmAddress(address(_fighterFarmContract)); _mintPassContract.setPaused(false); _gameItemsContract = new GameItems(_ownerAddress, _treasuryAddress); _voltageManagerContract = new VoltageManager(_ownerAddress, address(_gameItemsContract)); _neuronContract = new Neuron(_ownerAddress, _treasuryAddress, _neuronContributorAddress); _rankedBattleContract = new RankedBattle( _ownerAddress, address(_fighterFarmContract), _DELEGATED_ADDRESS, address(_voltageManagerContract) ); _rankedBattleContract.instantiateNeuronContract(address(_neuronContract)); _mergingPoolContract = new MergingPool(_ownerAddress, address(_rankedBattleContract), address(_fighterFarmContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); _fighterFarmContract.instantiateAIArenaHelperContract(address(_helperContract)); _fighterFarmContract.instantiateMintpassContract(address(_mintPassContract)); _fighterFarmContract.instantiateNeuronContract(address(_neuronContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); } function _fundUserWith4kNeuronByTreasury(address user) internal { vm.prank(_treasuryAddress); _neuronContract.transfer(user, 4_000 * 10 ** 18); assertEq(4_000 * 10 ** 18 == _neuronContract.balanceOf(user), true); } function _mintFromMergingPool(address to) internal { vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(to, "_neuralNetHash", "original", [uint256(1), uint256(80)]); } function onERC721Received(address, address, uint256, bytes memory) public pure returns (bytes4) { // Handle the token transfer here return this.onERC721Received.selector; } function test_reRollMoreThanAllowed() public { _fighterFarmContract.incrementGeneration(1); //so now type 1 has 4 rerolls but type 0 has 3, so if I get a gen 1 and reroll 4 times shouldn't be possible but it will be ;) //now I need a gen 0 fighter... _fighterFarmContract.addElToGen(1, 3); //Missing functionality to add a elemets to a generation, here we add 3 elements to gen 1 _mintFromMergingPool(_ownerAddress); _fundUserWith4kNeuronByTreasury(_ownerAddress); _helperContract.addAttributeProbabilities(1 ,_probabilities); _neuronContract.addSpender(address(_fighterFarmContract)); vm.startPrank(address(_ownerAddress)); _fighterFarmContract.reRoll(0, 1); _fighterFarmContract.reRoll(0, 1); _fighterFarmContract.reRoll(0, 1); _fighterFarmContract.reRoll(0, 1); console.log(_fighterFarmContract.numRerolls(0)); //although we should only be able to reRoll 3 times we reroll 4 times } }
Manual review
Do not allow users to input the fighter type when rerolling. Fetch the data from the fighters mapping instead.
-- function reRoll(uint8 tokenId, uint8 fighterType) public { ++ function reRoll(uint8 tokenId) public { ++ fighterType = uint8(fighters[tokenId].dendroidBool); . . .
Invalid Validation
#0 - c4-pre-sort
2024-02-22T00:10:58Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T00:11:06Z
raymondfam marked the issue as duplicate of #305
#2 - c4-pre-sort
2024-02-22T01:04:54Z
raymondfam marked the issue as duplicate of #306
#3 - c4-judge
2024-03-05T04:31:19Z
HickupHH3 marked the issue as satisfactory
#4 - 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
The FighterFarm
contract is missing the functionality to add number of elements to a new generation. The number of elements for the first generation is initiated through the constructor. The mapping(uint8 => uint8) public numElements;
is updated through the constructor as such: numElements[0] = 3;
. However when a generation will be incremented through incrementGeneration
, there will be no way of updating the number of elements for the new generation.
After firing incrementGeneration()
, calls to mint the new generation will fail because it will lead to a division by 0 in the _createFighterBase function.
After incrementing the generation, it will be impossible to mint new fighter (only possible through the MergingPool
, which doesn't allow dendroid minting)
This bit will revert if there the number of elements of a generation is 0. https://github.com/code-423n4/2024-02-ai-arena/blob/5b2ab9f9fadd0b91268ff6f22b4ae0fd5b79ec09/src/FighterFarm.sol#L479
uint256 element = dna % numElements[generation[fighterType]];
Which will revert the whole minting process.
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Test, console} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {FighterFarm} from "../src/FighterFarm.sol"; import {Neuron} from "../src/Neuron.sol"; import {AAMintPass} from "../src/AAMintPass.sol"; import {MergingPool} from "../src/MergingPool.sol"; import {RankedBattle} from "../src/RankedBattle.sol"; import {VoltageManager} from "../src/VoltageManager.sol"; import {GameItems} from "../src/GameItems.sol"; import {AiArenaHelper} from "../src/AiArenaHelper.sol"; import {FighterOps} from "../src/FighterOps.sol"; // import {Utilities} from "./utils/Utilities.sol"; import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; /// @notice Unit test for FighterFarm Contract. contract FighterFarmTest is Test { // Utilities internal _utils; // address payable[] internal _users; /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ uint8[][] internal _probabilities; address internal constant _DELEGATED_ADDRESS = 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0; address internal _ownerAddress; address internal _treasuryAddress; address internal _neuronContributorAddress; /*////////////////////////////////////////////////////////////// CONTRACT INSTANCES //////////////////////////////////////////////////////////////*/ FighterFarm internal _fighterFarmContract; AAMintPass internal _mintPassContract; MergingPool internal _mergingPoolContract; RankedBattle internal _rankedBattleContract; VoltageManager internal _voltageManagerContract; GameItems internal _gameItemsContract; AiArenaHelper internal _helperContract; Neuron internal _neuronContract; function getProb() public { _probabilities.push([25, 25, 13, 13, 9, 9]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 10]); _probabilities.push([25, 25, 13, 13, 9, 23]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 3]); } function setUp() public { // _utils = new Utilities(); // _users = _utils.createUsers(5); _ownerAddress = address(this); _treasuryAddress = vm.addr(1); _neuronContributorAddress = vm.addr(2); getProb(); _fighterFarmContract = new FighterFarm(_ownerAddress, _DELEGATED_ADDRESS, _treasuryAddress); _helperContract = new AiArenaHelper(_probabilities); _mintPassContract = new AAMintPass(_ownerAddress, _DELEGATED_ADDRESS); _mintPassContract.setFighterFarmAddress(address(_fighterFarmContract)); _mintPassContract.setPaused(false); _gameItemsContract = new GameItems(_ownerAddress, _treasuryAddress); _voltageManagerContract = new VoltageManager(_ownerAddress, address(_gameItemsContract)); _neuronContract = new Neuron(_ownerAddress, _treasuryAddress, _neuronContributorAddress); _rankedBattleContract = new RankedBattle( _ownerAddress, address(_fighterFarmContract), _DELEGATED_ADDRESS, address(_voltageManagerContract) ); _rankedBattleContract.instantiateNeuronContract(address(_neuronContract)); _mergingPoolContract = new MergingPool(_ownerAddress, address(_rankedBattleContract), address(_fighterFarmContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); _fighterFarmContract.instantiateAIArenaHelperContract(address(_helperContract)); _fighterFarmContract.instantiateMintpassContract(address(_mintPassContract)); _fighterFarmContract.instantiateNeuronContract(address(_neuronContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); } function _fundUserWith4kNeuronByTreasury(address user) internal { vm.prank(_treasuryAddress); _neuronContract.transfer(user, 4_000 * 10 ** 18); assertEq(4_000 * 10 ** 18 == _neuronContract.balanceOf(user), true); } function _mintFromMergingPool(address to) internal { vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(to, "_neuralNetHash", "original", [uint256(1), uint256(80)]); } function onERC721Received(address, address, uint256, bytes memory) public pure returns (bytes4) { // Handle the token transfer here return this.onERC721Received.selector; } function test_mintAfterGenIncrement() public { _fighterFarmContract.incrementGeneration(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 vm.expectRevert(); _fighterFarmContract.claimFighters(numToMint, claimSignature, claimModelHashes, claimModelTypes); }
The test above will give us the confidence that the minting will indeed revert due to a division or modulo by 0. β panic: division or modulo by zero (0x12)
Manual review
To mitigate the issue add a function that will allow to add elements to a generation. Here below is an example of such function.
function addElToGen(uint8 gen, uint8 el) public { require(msg.sender == _ownerAddress); numElements[gen] = el; }
Other
#0 - c4-pre-sort
2024-02-22T18:25:35Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T18:25:49Z
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:55:46Z
HickupHH3 marked the issue as satisfactory
π Selected for report: ktg
Also found by: 0xCiphky, 0xDetermination, 0xRiO, 0xWallSecurity, 0xlemon, 0xvj, AlexCzm, BARW, Blank_Space, Draiakoo, FloatingPragma, Giorgio, Matue, McToady, MrPotatoMagic, Silvermist, SpicyMeatball, Tendency, Topmark, Tumelo_Crypto, _eperezok, agadzhalov, ahmedaghadi, alexxander, aslanbek, cats, d3e4, denzi_, dutra, evmboi32, fnanni, givn, handsomegiraffe, haxatron, immeas, juancito, ke1caM, kiqo, klau5, krikolkk, niser93, peter, petro_1912, pkqs90, rspadi, sl1, stakog, visualbits, vnavascues, yotov721
1.876 USDC - $1.88
https://github.com/code-423n4/2024-02-ai-arena/blob/5b2ab9f9fadd0b91268ff6f22b4ae0fd5b79ec09/src/MergingPool.sol#L140-L165 https://github.com/code-423n4/2024-02-ai-arena/blob/5b2ab9f9fadd0b91268ff6f22b4ae0fd5b79ec09/src/FighterFarm.sol#L321-L339
Winners from MergingPool
are granted an NFT as a prize, winners are given the liberty to pick the weight and elements, however these attributes can be well out of bounds and break the game.
Given that when creating under normal condition the weight is calculated through this
uint256 weight = dna % 31 + 65;
bit, we can assume that the intended weight range is [65, 95]. But as mentioned before, if an NFT is minted through the mergePool, winners can claim nft's with any weight and elements even outside of normal bounds.
These NFTs affected by glitches have the potential to disrupt the game, presenting stats that the game's mechanics were not designed to accommodate.
Claiming rewards through the claimRewards()
function allows for uint256[2][] calldata customAttributes
to be input without restriction.
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import {Test, console} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {FighterFarm} from "../src/FighterFarm.sol"; import {Neuron} from "../src/Neuron.sol"; import {AAMintPass} from "../src/AAMintPass.sol"; import {MergingPool} from "../src/MergingPool.sol"; import {RankedBattle} from "../src/RankedBattle.sol"; import {VoltageManager} from "../src/VoltageManager.sol"; import {GameItems} from "../src/GameItems.sol"; import {AiArenaHelper} from "../src/AiArenaHelper.sol"; import {FighterOps} from "../src/FighterOps.sol"; // import {Utilities} from "./utils/Utilities.sol"; import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; /// @notice Unit test for FighterFarm Contract. contract FighterFarmTest is Test { // Utilities internal _utils; // address payable[] internal _users; /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ uint8[][] internal _probabilities; address internal constant _DELEGATED_ADDRESS = 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0; address internal _ownerAddress; address internal _treasuryAddress; address internal _neuronContributorAddress; /*////////////////////////////////////////////////////////////// CONTRACT INSTANCES //////////////////////////////////////////////////////////////*/ FighterFarm internal _fighterFarmContract; AAMintPass internal _mintPassContract; MergingPool internal _mergingPoolContract; RankedBattle internal _rankedBattleContract; VoltageManager internal _voltageManagerContract; GameItems internal _gameItemsContract; AiArenaHelper internal _helperContract; Neuron internal _neuronContract; function getProb() public { _probabilities.push([25, 25, 13, 13, 9, 9]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 10]); _probabilities.push([25, 25, 13, 13, 9, 23]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 3]); } function setUp() public { // _utils = new Utilities(); // _users = _utils.createUsers(5); _ownerAddress = address(this); _treasuryAddress = vm.addr(1); _neuronContributorAddress = vm.addr(2); getProb(); _fighterFarmContract = new FighterFarm(_ownerAddress, _DELEGATED_ADDRESS, _treasuryAddress); _helperContract = new AiArenaHelper(_probabilities); _mintPassContract = new AAMintPass(_ownerAddress, _DELEGATED_ADDRESS); _mintPassContract.setFighterFarmAddress(address(_fighterFarmContract)); _mintPassContract.setPaused(false); _gameItemsContract = new GameItems(_ownerAddress, _treasuryAddress); _voltageManagerContract = new VoltageManager(_ownerAddress, address(_gameItemsContract)); _neuronContract = new Neuron(_ownerAddress, _treasuryAddress, _neuronContributorAddress); _rankedBattleContract = new RankedBattle( _ownerAddress, address(_fighterFarmContract), _DELEGATED_ADDRESS, address(_voltageManagerContract) ); _rankedBattleContract.instantiateNeuronContract(address(_neuronContract)); _mergingPoolContract = new MergingPool(_ownerAddress, address(_rankedBattleContract), address(_fighterFarmContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); _fighterFarmContract.instantiateAIArenaHelperContract(address(_helperContract)); _fighterFarmContract.instantiateMintpassContract(address(_mintPassContract)); _fighterFarmContract.instantiateNeuronContract(address(_neuronContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); } function _fundUserWith4kNeuronByTreasury(address user) internal { vm.prank(_treasuryAddress); _neuronContract.transfer(user, 4_000 * 10 ** 18); assertEq(4_000 * 10 ** 18 == _neuronContract.balanceOf(user), true); } function _mintFromMergingPool(address to) internal { vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(to, "_neuralNetHash", "original", [uint256(1), uint256(80)]); } function onERC721Received(address, address, uint256, bytes memory) public pure returns (bytes4) { // Handle the token transfer here return this.onERC721Received.selector; } function test_weightAndElement() public { vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(_ownerAddress, "_neuralNetHash", "original", [uint256(99), uint256(10000)]); (uint256 weightOfFigher, , , , , , , ,) = _fighterFarmContract.fighters(0); console.log(weightOfFigher); }
This test above is all we need to confirm the fact that the weight and element restrictions are indeed missing.
Manuel reviews
If the weight or element aren't in the mandatory range, revert the transaction.
function mintFromMergingPool( address to, string calldata modelHash, string calldata modelType, uint256[2] calldata customAttributes ) public { require(msg.sender == _mergingPoolAddress); ++ uint256 weight = customAttributesp[1] ++ uint256 el = customAttributesp[0] ++ require(weight <=95 && weight >= 65, "not in range"); ++ require(el <= numElements[gen], "not in range"); _createNewFighter( to, uint256(keccak256(abi.encode(msg.sender, fighters.length))), modelHash, modelType, 0, //@note only normal 0, //@note what icon type ? customAttributes ); } ## Assessed type Invalid Validation
#0 - c4-pre-sort
2024-02-24T08:53:21Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-24T08:53:35Z
raymondfam marked the issue as duplicate of #226
#2 - c4-judge
2024-03-11T10:22:51Z
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/5b2ab9f9fadd0b91268ff6f22b4ae0fd5b79ec09/src/FighterFarm.sol#L35 https://github.com/code-423n4/2024-02-ai-arena/blob/5b2ab9f9fadd0b91268ff6f22b4ae0fd5b79ec09/src/MergingPool.sol#L140-L146
At the end of each round, participants with the highest accumulated points will be designated as winners, earning them the opportunity to receive an NFT. However, there is a limit of 10 NFT rewards per address. Consequently, if a participant accumulates more than 10 unclaimed rewards, the transaction to claim the NFTs will revert.
User will not be able to claim NFT rewards .
Here, uint8 public constant MAX_FIGHTERS_ALLOWED = 10;
, we can see that limit of NFTs per address is 10.
The claimRewards function will loop through every round and mint claim the pending rewards of the caller.
The issue is that if the caller happens to have more than 10 rewards pending the
transaction will revert, and there is no way to input a claim range, so the NFTs are unclaimable.
Here is a coded POC that proves the illustrates the scenario.
pragma solidity ^0.8.13; import {Test, console} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {FighterFarm} from "../src/FighterFarm.sol"; import {Neuron} from "../src/Neuron.sol"; import {AAMintPass} from "../src/AAMintPass.sol"; import {MergingPool} from "../src/MergingPool.sol"; import {RankedBattle} from "../src/RankedBattle.sol"; import {VoltageManager} from "../src/VoltageManager.sol"; import {GameItems} from "../src/GameItems.sol"; import {AiArenaHelper} from "../src/AiArenaHelper.sol"; import {StakeAtRisk} from "../src/StakeAtRisk.sol"; import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import {GasHelpers} from "./GasLeft.sol"; contract RankedBattleTest is Test { /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ uint8[][] internal _probabilities; address internal constant _DELEGATED_ADDRESS = 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0; address internal constant _GAME_SERVER_ADDRESS = 0x7C0a2BAd62C664076eFE14b7f2d90BF6Fd3a6F6C; address internal _ownerAddress; address internal _treasuryAddress; address internal _neuronContributorAddress; /*////////////////////////////////////////////////////////////// CONTRACT INSTANCES //////////////////////////////////////////////////////////////*/ FighterFarm internal _fighterFarmContract; AAMintPass internal _mintPassContract; MergingPool internal _mergingPoolContract; RankedBattle internal _rankedBattleContract; VoltageManager internal _voltageManagerContract; GameItems internal _gameItemsContract; AiArenaHelper internal _helperContract; Neuron internal _neuronContract; StakeAtRisk internal _stakeAtRiskContract; GasHelpers gasHelp; function getProb() public { _probabilities.push([25, 25, 13, 13, 9, 9]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 10]); _probabilities.push([25, 25, 13, 13, 9, 23]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 3]); } function setUp() public { _ownerAddress = address(this); _treasuryAddress = vm.addr(1); _neuronContributorAddress = vm.addr(2); getProb(); gasHelp = new GasHelpers(); _fighterFarmContract = new FighterFarm(_ownerAddress, _DELEGATED_ADDRESS, _treasuryAddress); _helperContract = new AiArenaHelper(_probabilities); _mintPassContract = new AAMintPass(_ownerAddress, _DELEGATED_ADDRESS); _mintPassContract.setFighterFarmAddress(address(_fighterFarmContract)); _mintPassContract.setPaused(false); _gameItemsContract = new GameItems(_ownerAddress, _treasuryAddress); _voltageManagerContract = new VoltageManager(_ownerAddress, address(_gameItemsContract)); _neuronContract = new Neuron(_ownerAddress, _treasuryAddress, _neuronContributorAddress); _rankedBattleContract = new RankedBattle( _ownerAddress, _GAME_SERVER_ADDRESS, address(_fighterFarmContract), address(_voltageManagerContract) ); _mergingPoolContract = new MergingPool(_ownerAddress, address(_rankedBattleContract), address(_fighterFarmContract)); _stakeAtRiskContract = new StakeAtRisk(_treasuryAddress, address(_neuronContract), address(_rankedBattleContract)); _voltageManagerContract.adjustAllowedVoltageSpenders(address(_rankedBattleContract), true); _neuronContract.addStaker(address(_rankedBattleContract)); _neuronContract.addMinter(address(_rankedBattleContract)); _rankedBattleContract.instantiateNeuronContract(address(_neuronContract)); _rankedBattleContract.instantiateMergingPoolContract(address(_mergingPoolContract)); _rankedBattleContract.setStakeAtRiskAddress(address(_stakeAtRiskContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); _fighterFarmContract.addStaker(address(_rankedBattleContract)); _fighterFarmContract.instantiateAIArenaHelperContract(address(_helperContract)); _fighterFarmContract.instantiateMintpassContract(address(_mintPassContract)); _fighterFarmContract.instantiateNeuronContract(address(_neuronContract)); } function testClaimNRN() public { address claimee = vm.addr(3); address doomer = vm.addr(4); _mintFromMergingPool(claimee); _mergingPoolContract.updateWinnersPerPeriod(1); _fundUserWith4kNeuronByTreasury(claimee); vm.prank(claimee); _rankedBattleContract.stakeNRN(3_000 * 10 ** 18, 0); // boostWins(); //claimee winzz a lot getPickedForManyRounds(); //claime rewards (uint256[2][] memory data1, string[] memory data2, string[] memory data3) = loopArray(); vm.startPrank(claimee); _rankedBattleContract.unstakeNRN(3_000 * 10 ** 18, 0); _fighterFarmContract.transferFrom(claimee, doomer, 0); vm.expectRevert(); _mergingPoolContract.claimRewards(data3, data2, data1); } /*///////////////////////claimee/////////////////////////////////////// HELPERS //////////////////////////////////////////////////////////////*/ /// @notice Helper function to mint an fighter nft to an address. function _mintFromMergingPool(address to) internal { vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(to, "_neuralNetHash", "original", [uint256(1), uint256(80)]); } /// @notice Helper function to fund an account with 4k $NRN tokens. function _fundUserWith4kNeuronByTreasury(address user) internal { vm.prank(_treasuryAddress); _neuronContract.transfer(user, 4_000 * 10 ** 18); assertEq(4_000 * 10 ** 18 == _neuronContract.balanceOf(user), true); } function onERC721Received(address, address, uint256, bytes memory) public pure returns (bytes4) { // Handle the token transfer here return this.onERC721Received.selector; } function getPickedForManyRounds() public { uint256[] memory _winners = new uint256[](1); _winners[0] = 0; for(uint256 i = 0; i < 11; i++) { _mergingPoolContract.pickWinner(_winners); vm.warp(block.timestamp + 1 days); vm.startPrank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); vm.startPrank(_ownerAddress); _rankedBattleContract.setNewRound(); vm.stopPrank(); } } function loopArray() public view returns (uint256[2][] memory data1, string[] memory _modelTypes, string[] memory _modelURIs) { data1 = new uint256[2][](11); _modelTypes = new string[](11); _modelURIs = new string[](11); for(uint96 i = 0; i < 11; i++) { data1[i][0] = uint256(1); data1[i][1] = uint256(80); _modelURIs[i] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelTypes[i] = "original"; } } }
In order to mitigate this issue, allow user to input a range for claims.
mapping(address user => (uint256 round => bool claimed)) roundsClaimed; ... function claimRewards( ++ uint256[] calldata roundIds, string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { ++ uint256 loops ... for (uint32 currentRound = roundIds[loops]; currentRound < roundIds.length; currentRound++) { ... ++ if(roundsClaimed[msg.sender][roundIds[loops]] == true) continue; ++ roundsClaimed[msg.sender][roundIds[loops]] = true; ++ loops ++; } ... }
Other
#0 - c4-pre-sort
2024-02-22T08:58:12Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T08:58:20Z
raymondfam marked the issue as duplicate of #216
#2 - c4-judge
2024-03-11T12:50:22Z
HickupHH3 marked the issue as satisfactory
π Selected for report: klau5
Also found by: 0rpse, 0xBinChook, 0xDetermination, 0xGreyWolf, 0xLogos, 0xWallSecurity, 0xaghas, 0xgrbr, 0xkaju, 0xlyov, AlexCzm, BARW, Blank_Space, BoRonGod, Daniel526, DanielArmstrong, Draiakoo, FloatingPragma, Giorgio, Greed, Jorgect, Matue, McToady, MidgarAudits, Nyxaris, PUSH0, PedroZurdo, Pelz, PoeAudits, Silvermist, SpicyMeatball, Tekken, Tricko, Tumelo_Crypto, VAD37, WoolCentaur, Zac, alexzoid, andywer, aslanbek, bgsmallerbear, cats, d3e4, desaperh, dimulski, dutra, erosjohn, evmboi32, favelanky, fnanni, forkforkdog, gesha17, givn, grearlake, haxatron, honey-k12, iamandreiski, immeas, juancito, kaveyjoe, ke1caM, kiqo, klau5, korok, lil_eth, lsaudit, n0kto, ni8mare, niser93, pa6kuda, peanuts, peter, shaka, sl1, soliditywala, solmaxis69, t0x1c, tallo, thank_you, tpiliposian, visualbits, vnavascues, web3pwn, yotov721
0.0176 USDC - $0.02
Judge has assessed an item in Issue #979 as 3 risk. The relevant finding follows:
Deterministic minting allows users to revert the transaction if the NFT attributes don't match their expectations
#0 - c4-judge
2024-03-07T02:36:47Z
HickupHH3 marked the issue as duplicate of #53
#1 - c4-judge
2024-03-07T02:36:54Z
HickupHH3 marked the issue as partial-50
#2 - c4-judge
2024-03-15T02:10:54Z
HickupHH3 changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-03-22T04:23:15Z
HickupHH3 marked the issue as duplicate of #376
π 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
There is a voltage restriction in order to limit the amount of battles a user can initiate per address. Every user has 100 voltage granted each day and a game initiation costs 10 voltage, so a user is limited to 10 games each day. The issue is that a greedy player could unstake his NRN after having played 10 games and transfer it to another address to play 10 other games. This way the player will be able to maximize his stake in proportion to the amount of fighters he owns
An individual can effortlessly circumvent the game's restrictions, amassing rewards across multiple accounts with identical amounts of $NRN. This strategy leads to a dilution of points in favor of the abuser.
In order to achieve this the greedy player will have to have to play 10 games on an account, unstake, send $NRN to another account that holds another NFT to play with, play 10 more games. This game be repeated until the player is out of NFT's to play with.
Here is a coded scenario where the player is able to accumulate wins on two different account with the same stake.
pragma solidity ^0.8.13; import {Test, console} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {FighterFarm} from "../src/FighterFarm.sol"; import {Neuron} from "../src/Neuron.sol"; import {AAMintPass} from "../src/AAMintPass.sol"; import {MergingPool} from "../src/MergingPool.sol"; import {RankedBattle} from "../src/RankedBattle.sol"; import {VoltageManager} from "../src/VoltageManager.sol"; import {GameItems} from "../src/GameItems.sol"; import {AiArenaHelper} from "../src/AiArenaHelper.sol"; import {StakeAtRisk} from "../src/StakeAtRisk.sol"; import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import {GasHelpers} from "./GasLeft.sol"; contract RankedBattleTest is Test { /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ uint8[][] internal _probabilities; address internal constant _DELEGATED_ADDRESS = 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0; address internal constant _GAME_SERVER_ADDRESS = 0x7C0a2BAd62C664076eFE14b7f2d90BF6Fd3a6F6C; address internal _ownerAddress; address internal _treasuryAddress; address internal _neuronContributorAddress; /*////////////////////////////////////////////////////////////// CONTRACT INSTANCES //////////////////////////////////////////////////////////////*/ FighterFarm internal _fighterFarmContract; AAMintPass internal _mintPassContract; MergingPool internal _mergingPoolContract; RankedBattle internal _rankedBattleContract; VoltageManager internal _voltageManagerContract; GameItems internal _gameItemsContract; AiArenaHelper internal _helperContract; Neuron internal _neuronContract; StakeAtRisk internal _stakeAtRiskContract; GasHelpers gasHelp; function getProb() public { _probabilities.push([25, 25, 13, 13, 9, 9]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 10]); _probabilities.push([25, 25, 13, 13, 9, 23]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 3]); } function setUp() public { _ownerAddress = address(this); _treasuryAddress = vm.addr(1); _neuronContributorAddress = vm.addr(2); getProb(); gasHelp = new GasHelpers(); _fighterFarmContract = new FighterFarm(_ownerAddress, _DELEGATED_ADDRESS, _treasuryAddress); _helperContract = new AiArenaHelper(_probabilities); _mintPassContract = new AAMintPass(_ownerAddress, _DELEGATED_ADDRESS); _mintPassContract.setFighterFarmAddress(address(_fighterFarmContract)); _mintPassContract.setPaused(false); _gameItemsContract = new GameItems(_ownerAddress, _treasuryAddress); _voltageManagerContract = new VoltageManager(_ownerAddress, address(_gameItemsContract)); _neuronContract = new Neuron(_ownerAddress, _treasuryAddress, _neuronContributorAddress); _rankedBattleContract = new RankedBattle( _ownerAddress, _GAME_SERVER_ADDRESS, address(_fighterFarmContract), address(_voltageManagerContract) ); _mergingPoolContract = new MergingPool(_ownerAddress, address(_rankedBattleContract), address(_fighterFarmContract)); _stakeAtRiskContract = new StakeAtRisk(_treasuryAddress, address(_neuronContract), address(_rankedBattleContract)); _voltageManagerContract.adjustAllowedVoltageSpenders(address(_rankedBattleContract), true); _neuronContract.addStaker(address(_rankedBattleContract)); _neuronContract.addMinter(address(_rankedBattleContract)); _rankedBattleContract.instantiateNeuronContract(address(_neuronContract)); _rankedBattleContract.instantiateMergingPoolContract(address(_mergingPoolContract)); _rankedBattleContract.setStakeAtRiskAddress(address(_stakeAtRiskContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); _fighterFarmContract.addStaker(address(_rankedBattleContract)); _fighterFarmContract.instantiateAIArenaHelperContract(address(_helperContract)); _fighterFarmContract.instantiateMintpassContract(address(_mintPassContract)); _fighterFarmContract.instantiateNeuronContract(address(_neuronContract)); } /*////////////////////////////////////////////////////////////// HELPERS //////////////////////////////////////////////////////////////*/ /// @notice Helper function to mint an fighter nft to an address. function _mintFromMergingPool(address to) internal { vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(to, "_neuralNetHash", "original", [uint256(1), uint256(80)]); } /// @notice Helper function to fund an account with 4k $NRN tokens. function _fundUserWith4kNeuronByTreasury(address user) internal { vm.prank(_treasuryAddress); _neuronContract.transfer(user, 4_000 * 10 ** 18); assertEq(4_000 * 10 ** 18 == _neuronContract.balanceOf(user), true); } function onERC721Received(address, address, uint256, bytes memory) public pure returns (bytes4) { // Handle the token transfer here return this.onERC721Received.selector; } function testClaimNRN() public { address staker = vm.addr(3); address claimee = vm.addr(4); _mintFromMergingPool(claimee); _mintFromMergingPool(staker); _fundUserWith4kNeuronByTreasury(claimee); vm.prank(claimee); _rankedBattleContract.stakeNRN(4_000 * 10 ** 18, 0); //Let's add some games vm.startPrank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); //we set initiator to true so that it spends voltage _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); //2 _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); //3 _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); //4 _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); //5 _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); //6 _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); //7 _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); //8 _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); //9 _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); //10 => no more voltage vm.expectRevert(); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); vm.startPrank(address(claimee)); _rankedBattleContract.unstakeNRN(type(uint256).max, 0); //unstake uint256 balanceClaime = _neuronContract.balanceOf(claimee); _neuronContract.transfer(staker, balanceClaime); // transfer the stake vm.startPrank(address(staker)); uint256 balanceStaker = _neuronContract.balanceOf(staker); _rankedBattleContract.stakeNRN(balanceStaker, 1); // stake again and play vm.startPrank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); // play 10 games again _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); } }
Manual review
In order to mitigate this, rewards should not be staked and unstaked within the same round. There should be one round delay between staking and unstaking.
++ mapping(uint256 => mapping(uint256 => bool)) public hasStaked; function unstakeNRN(uint256 amount, uint256 tokenId) external { ++ require(hasStaked[tokenId][roundId] == false, "Please wait next round to unstake");
Other
#0 - c4-pre-sort
2024-02-25T03:51:41Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-25T03:52:02Z
raymondfam marked the issue as duplicate of #395
#2 - c4-judge
2024-03-14T10:40:58Z
HickupHH3 marked the issue as duplicate of #137
#3 - c4-judge
2024-03-14T10:42:24Z
HickupHH3 marked the issue as partial-25
π 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
When a user stakes $NRN on their NFT, the contract secures it, rendering it untransferrable until the user decides to unstake. This precautionary measure is in place to prevent inadvertent $NRN transfers along with NFTs. As part of the game dynamics, players may incur point losses when they experience defeats, and in cases where a player lacks sufficient points, a portion of their stake becomes vulnerable. Upon unstaking all $NRN, the Fighter NFT becomes transferrable. However, this unstaking action doesn't consider the stake at risk that a player might have won back or currently has in jeopardy. The issue arises when a user successfully recovers $NRN from a stake at risk, yet this recovery does not trigger a re-locking of the NFT. Consequently, there is a risk of mistakenly transferring an NFT that still holds some stake, which can occur if the player decides to unstake while there is an outstanding stake at risk on their NFT.
Players will transfer NFT's with staked $NRN or $NRN at risk.
When unstaking the available $NRN
if (amountStaked[tokenId] == 0) { _fighterFarmInstance.updateFighterStaking(tokenId, false); }
The contract will allow us to send the NFT to another address through the updateFighterStaking()
function. But the current stake at risk isn't accounted for which means that if the players earns the stake at risk back the player might end up sending the NFT to another address with $NRN or $NRN at risk included.
The coded poc below illustrates a case where the user transfers an NFT with stake on it.
//UnstakeButStaked pragma solidity ^0.8.13; import {Test, console} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {FighterFarm} from "../src/FighterFarm.sol"; import {Neuron} from "../src/Neuron.sol"; import {AAMintPass} from "../src/AAMintPass.sol"; import {MergingPool} from "../src/MergingPool.sol"; import {RankedBattle} from "../src/RankedBattle.sol"; import {VoltageManager} from "../src/VoltageManager.sol"; import {GameItems} from "../src/GameItems.sol"; import {AiArenaHelper} from "../src/AiArenaHelper.sol"; import {StakeAtRisk} from "../src/StakeAtRisk.sol"; import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import {GasHelpers} from "./GasLeft.sol"; contract UnstakeButStaked is Test { /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ uint8[][] internal _probabilities; address internal constant _DELEGATED_ADDRESS = 0x22F4441ad6DbD602dFdE5Cd8A38F6CAdE68860b0; address internal constant _GAME_SERVER_ADDRESS = 0x7C0a2BAd62C664076eFE14b7f2d90BF6Fd3a6F6C; address internal _ownerAddress; address internal _treasuryAddress; address internal _neuronContributorAddress; /*////////////////////////////////////////////////////////////// CONTRACT INSTANCES //////////////////////////////////////////////////////////////*/ FighterFarm internal _fighterFarmContract; AAMintPass internal _mintPassContract; MergingPool internal _mergingPoolContract; RankedBattle internal _rankedBattleContract; VoltageManager internal _voltageManagerContract; GameItems internal _gameItemsContract; AiArenaHelper internal _helperContract; Neuron internal _neuronContract; StakeAtRisk internal _stakeAtRiskContract; GasHelpers gasHelp; function getProb() public { _probabilities.push([25, 25, 13, 13, 9, 9]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 10]); _probabilities.push([25, 25, 13, 13, 9, 23]); _probabilities.push([25, 25, 13, 13, 9, 1]); _probabilities.push([25, 25, 13, 13, 9, 3]); } function setUp() public { _ownerAddress = address(this); _treasuryAddress = vm.addr(1); _neuronContributorAddress = vm.addr(2); getProb(); gasHelp = new GasHelpers(); _fighterFarmContract = new FighterFarm(_ownerAddress, _DELEGATED_ADDRESS, _treasuryAddress); _helperContract = new AiArenaHelper(_probabilities); _mintPassContract = new AAMintPass(_ownerAddress, _DELEGATED_ADDRESS); _mintPassContract.setFighterFarmAddress(address(_fighterFarmContract)); _mintPassContract.setPaused(false); _gameItemsContract = new GameItems(_ownerAddress, _treasuryAddress); _voltageManagerContract = new VoltageManager(_ownerAddress, address(_gameItemsContract)); _neuronContract = new Neuron(_ownerAddress, _treasuryAddress, _neuronContributorAddress); _rankedBattleContract = new RankedBattle( _ownerAddress, _GAME_SERVER_ADDRESS, address(_fighterFarmContract), address(_voltageManagerContract) ); _mergingPoolContract = new MergingPool(_ownerAddress, address(_rankedBattleContract), address(_fighterFarmContract)); _stakeAtRiskContract = new StakeAtRisk(_treasuryAddress, address(_neuronContract), address(_rankedBattleContract)); _voltageManagerContract.adjustAllowedVoltageSpenders(address(_rankedBattleContract), true); _neuronContract.addStaker(address(_rankedBattleContract)); _neuronContract.addMinter(address(_rankedBattleContract)); _rankedBattleContract.instantiateNeuronContract(address(_neuronContract)); _rankedBattleContract.instantiateMergingPoolContract(address(_mergingPoolContract)); _rankedBattleContract.setStakeAtRiskAddress(address(_stakeAtRiskContract)); _fighterFarmContract.setMergingPoolAddress(address(_mergingPoolContract)); _fighterFarmContract.addStaker(address(_rankedBattleContract)); _fighterFarmContract.instantiateAIArenaHelperContract(address(_helperContract)); _fighterFarmContract.instantiateMintpassContract(address(_mintPassContract)); _fighterFarmContract.instantiateNeuronContract(address(_neuronContract)); } function _mintFromMergingPool(address to) internal { vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(to, "_neuralNetHash", "original", [uint256(1), uint256(80)]); } /// @notice Helper function to fund an account with 4k $NRN tokens. function _fundUserWith4kNeuronByTreasury(address user) internal { vm.prank(_treasuryAddress); _neuronContract.transfer(user, 4_000 * 10 ** 18); assertEq(4_000 * 10 ** 18 == _neuronContract.balanceOf(user), true); } function onERC721Received(address, address, uint256, bytes memory) public pure returns (bytes4) { // Handle the token transfer here return this.onERC721Received.selector; } function test_sendWithStaked() public { address staker = vm.addr(3); address claimee = vm.addr(4); _mintFromMergingPool(claimee); _fundUserWith4kNeuronByTreasury(claimee); vm.prank(claimee); _rankedBattleContract.stakeNRN(4_000 * 10 ** 18, 0); // what is next ? unstake and win a few times get some staked at risk back loseStreak(); vm.startPrank(claimee); _rankedBattleContract.unstakeNRN(type(uint256).max, 0); winStreak(); uint256 amountAtRisk = _stakeAtRiskContract.stakeAtRisk(0, 0); vm.startPrank(claimee); _fighterFarmContract.transferFrom(claimee ,staker, 0); vm.startPrank(staker); _rankedBattleContract.unstakeNRN(type(uint256).max, 0); uint256 balanceOfReceiver = _neuronContract.balanceOf(staker); assertGt(balanceOfReceiver, 0); // > 0 } function loseStreak() public { vm.startPrank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); vm.warp(block.timestamp + 1 days); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); vm.warp(block.timestamp + 1 days); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 2, 1500, true); } function winStreak() public { vm.startPrank(address(_GAME_SERVER_ADDRESS)); vm.warp(block.timestamp + 1 days); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); } }
Manual review
Make sure that the NFT stake at risk balance is also 0 before unstaking it.
function unstakeNRN(uint256 amount, uint256 tokenId) external { . . . if (success) { -- if (amountStaked[tokenId] == 0) { ++ if (amountStaked[tokenId] == 0 && ++ _stakeAtRiskInstance.getStakeAtRisk(tokenId) == 0) { _fighterFarmInstance.updateFighterStaking(tokenId, false); } emit Unstaked(msg.sender, amount); } ## Assessed type Other
#0 - c4-pre-sort
2024-02-24T04:21:07Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T04:22:17Z
raymondfam marked the issue as duplicate of #1641
#2 - c4-judge
2024-03-12T04:01:25Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-12T04:03:31Z
HickupHH3 changed the severity to 2 (Med Risk)
#4 - c4-judge
2024-03-12T06:54:19Z
HickupHH3 marked the issue as satisfactory