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: 120/283
Findings: 6
Award: $23.51
π Selected for report: 0
π Solo Findings: 0
π Selected for report: CodeWasp
Also found by: 0x13, 0xAlix2, 0xAsen, 0xCiphky, 0xE1, 0xLogos, 0xaghas, 0xlemon, 0xlyov, 0xvj, ADM, Aamir, BARW, Bauchibred, DMoore, DanielArmstrong, Draiakoo, Fulum, GhK3Ndf, Josh4324, Kalogerone, KmanOfficial, Krace, KupiaSec, Limbooo, MidgarAudits, MrPotatoMagic, PedroZurdo, Pelz, Tendency, _eperezok, adam-idarrha, al88nsk, alexxander, alexzoid, aslanbek, blutorque, btk, c0pp3rscr3w3r, cartlex_, cats, d3e4, deadrxsezzz, denzi_, devblixt, dimulski, erosjohn, evmboi32, fnanni, grearlake, hulkvision, immeas, israeladelaja, jaydhales, jesjupyter, jnforja, juancito, klau5, korok, ktg, ladboy233, matejdb, merlinboii, novamanbg, nuthan2x, oualidpro, peanuts, petro_1912, pkqs90, pynschon, radev_sw, rouhsamad, sashik_eth, shaka, sobieski, soliditywala, stackachu, tallo, thank_you, ubl4nk, vnavascues, web3pwn, xchen1130, zhaojohnson
0.1044 USDC - $0.10
https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L539-L545 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L337-L366
Users can own more than MAX_FIGHTERS_ALLOWED
.
In FighterFarm.sol, the transferFrom() and safeTransferFrom() functions are overridden to call _ableToTransfer()
function transferFrom( address from, address to, uint256 tokenId ) public override(ERC721, IERC721) { > require(_ableToTransfer(tokenId, to)); _transfer(from, to, tokenId); } /// @notice Safely transfers an NFT from one address to another. /// @dev Add a custom check for an ability to transfer the fighter. /// @param from Address of the current owner. /// @param to Address of the new owner. /// @param tokenId ID of the fighter being transferred. function safeTransferFrom( address from, address to, uint256 tokenId ) public override(ERC721, IERC721) { > require(_ableToTransfer(tokenId, to)); _safeTransfer(from, to, tokenId, ""); }
_ableToTransfer()
checks that the balanceOf()
the user is not greater than MAX_FIGHTERS_ALLOWED
.
function _ableToTransfer(uint256 tokenId, address to) private view returns(bool) { return ( _isApprovedOrOwner(msg.sender, tokenId) && > balanceOf(to) < MAX_FIGHTERS_ALLOWED && !fighterStaked[tokenId] ); }
The contract inherits ERC721.
In ERC721, there are actually 2 safeTransferFrom()
functions that has a public visibility.
> function safeTransferFrom(address from, address to, uint256 tokenId) public virtual override { safeTransferFrom(from, to, tokenId, ""); } /** * @dev See {IERC721-safeTransferFrom}. */ > function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual override { require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721: caller is not token owner or approved"); _safeTransfer(from, to, tokenId, data); }
Note that one has 3 parameters and one has 4 parameters. The user can call the safeTransferFrom()
function with 4 parameters directly and transfer the tokens to bypass MAX_FIGHTERS_ALLOWED
.
Manual Review
Recommend overriding the other safeTransferFrom()
function as well. Best is to put the _ableToTransfer()
function in _beforeTokenTransfer()
and _afterTokenTransfer()
.
ERC721
#0 - c4-pre-sort
2024-02-23T05:12:46Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-23T05:13:12Z
raymondfam marked the issue as duplicate of #739
#2 - c4-judge
2024-03-11T02:09:26Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-11T02:45:01Z
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
Users can participate in the points system and potentially get NRN distribution without any consequences.
To get some points, users have to stake some tokens. These tokens are succeptible to loss if they lose the battle. However, users can bypass the stakeAtRisk
functionality by depositing 1 wei of token. This way, users can still earn points from their fighter but will not lose anything tokens.
amountStaked()
is more than 0, _addResultPoints()
will be called.curStakeAtRisk
will be truncated to zero.curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4; curStakeAtRisk = (10 * 1) / 10**4 = 0;
If they lose the battle, they will not lose any tokens. If they win the battle, they will be able to get points since their stakingFactor is 1.
/// If the user has no NRNs at risk, then they can earn points if (stakeAtRisk == 0) { points = stakingFactor[tokenId] * eloFactor; }
Users can earn some points from their fighters without any negative consequence.
Manual Review
Recommend making sure that curStakeAtRisk
is always above zero.
Context
#0 - c4-pre-sort
2024-02-22T16:56:47Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-22T16:56:55Z
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:20:52Z
HickupHH3 marked the issue as partial-50
#4 - c4-judge
2024-03-07T03:21:06Z
HickupHH3 marked the issue as partial-75
#5 - c4-judge
2024-03-07T03:33:51Z
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
Judge has assessed an item in Issue #1898 as 3 risk. The relevant finding follows:
[L-07] User can still earn points without staking NRNs. Document notes:
If a player does not stake NRNs, their NFT will not accrue Points or NRN rewards (i.e. Staking Factor would be 0 and the product of the equation is 0).
It is possible that a player accrue Points without staking any NRNs. This issue assumes that stake NRNs means the process of staking NRNs only, and not having NRNs that are at risk.
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_; }
Since _getStakingFactor() takes stakeAtRisk as well, and rounds up the stakingFactor to 1 should it be zero, users can simply get a fighter with some stakeAtRisk tokens, and withdraw all their current amountStaked tokens.
This way, the user will not need to stake anything but will be able to earn points from their fighter.
Make sure _addResultPoints() is called only if amountStaked[tokenId] > 0, and not amountStaked[tokenId] + stakeAtRisk > 0
#0 - c4-judge
2024-03-19T04:34:05Z
HickupHH3 marked the issue as duplicate of #116
#1 - c4-judge
2024-03-19T04:34:11Z
HickupHH3 marked the issue as partial-25
π 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
Winner cannot claim their NFTs.
Every round, a winner is selected in the MergingPool through pickWinner()
. The admin will run the pickWinner()
function which will set the winnerAddress[roundId]
to the currentWinnerAddress
. Note that currentWinnerAddress
is a newly created array of address which logs all the winners.
function pickWinner(uint256[] calldata winners) external { require(isAdmin[msg.sender]); require(winners.length == winnersPerPeriod, "Incorrect number of winners"); require(!isSelectionComplete[roundId], "Winners are already selected"); uint256 winnersLength = winners.length; > address[] memory currentWinnerAddresses = new address[](winnersLength); for (uint256 i = 0; i < winnersLength; i++) { currentWinnerAddresses[i] = _fighterFarmInstance.ownerOf(winners[i]); totalPoints -= fighterPoints[winners[i]]; fighterPoints[winners[i]] = 0; } > winnerAddresses[roundId] = currentWinnerAddresses; isSelectionComplete[roundId] = true; roundId += 1; }
The winner will then call claimRewards()
. The function will loop through all the winnerAddress
of each round and mints the winner an NFT.
If a winner forgets to claim his NFT, and if a long time had passed and the winner claims again, he will be unable to claim his two NFTs because of out of gas errors.
function claimRewards( string[] calldata modelURIs, string[] calldata modelTypes, uint256[2][] calldata customAttributes ) external { uint256 winnersLength; uint32 claimIndex = 0; uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { numRoundsClaimed[msg.sender] += 1; winnersLength = winnerAddresses[currentRound].length; for (uint32 j = 0; j < winnersLength; j++) { if (msg.sender == winnerAddresses[currentRound][j]) { _fighterFarmInstance.mintFromMergingPool( msg.sender, modelURIs[claimIndex], modelTypes[claimIndex], customAttributes[claimIndex] ); claimIndex += 1; }
Let's say Alice is the winner in round 1. She forgets to claim her NFT and 100 round passes. Alice finally remembers that she plays this game and tries to get another NFT. She is also a winner of round 101.
When she tries to claim her reward, the lowerBound is 1 and the roundId is at 102.
The function will then loop through every single winner from every round until it reaches round 101.
By that time, the function will reach out of gas error and Alice will be unable to claim her two NFTs.
Instead of doing a nested loop, consider creating a mapping with the roundId and the winners so that there is no need to update the numRoundsClaimed
.
Loop
#0 - c4-pre-sort
2024-02-23T23:54:44Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-23T23:54:54Z
raymondfam marked the issue as duplicate of #1541
#2 - c4-judge
2024-03-11T13:01:04Z
HickupHH3 marked the issue as duplicate of #216
#3 - c4-judge
2024-03-12T02:35:44Z
HickupHH3 marked the issue as partial-50
#4 - c4-judge
2024-03-21T02:12:10Z
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
Later users may not be able to claim their NRN due to out of gas errors
If a user wins a battle, he is entitled to claim some NRN via the claimNRN()
function. Here is how the function looks like. Note the loop, lowerBound
and roundId
function claimNRN() external { require(numRoundsClaimed[msg.sender] < roundId, "Already claimed NRNs for this period"); uint256 claimableNRN = 0; uint256 nrnDistribution; > uint32 lowerBound = numRoundsClaimed[msg.sender]; for (uint32 currentRound = lowerBound; currentRound < roundId; currentRound++) { nrnDistribution = getNrnDistribution(currentRound); claimableNRN += ( accumulatedPointsPerAddress[msg.sender][currentRound] * nrnDistribution ) / totalAccumulatedPoints[currentRound]; numRoundsClaimed[msg.sender] += 1; } if (claimableNRN > 0) { amountClaimed[msg.sender] += claimableNRN; _neuronInstance.mint(msg.sender, claimableNRN); emit Claimed(msg.sender, claimableNRN); } }
The function loops from the currentBound
to the current roundId. The current bound is selected from the
lowerBoundwhich is from
numRoundsClaimed[msg.sender]`.
uint32 lowerBound = numRoundsClaimed[msg.sender];
Let's say the protocol has been around for many years, and the roundId is at ~1000. If a user participates in the protocol at that stage, he has to go through 1000 rounds of loops in order to claim his NRN. This may result in out of gas errors.
Manual Review
Have a nested mapping to allow the msg.sender to claim tokens from the rounds that he has won instead of looping each round.
Loop
#0 - c4-pre-sort
2024-02-25T02:26:32Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-25T02:26:41Z
raymondfam marked the issue as duplicate of #1541
#2 - c4-judge
2024-03-11T13:01:09Z
HickupHH3 marked the issue as duplicate of #216
#3 - c4-judge
2024-03-12T02:44:54Z
HickupHH3 marked the issue as partial-50
#4 - c4-judge
2024-03-21T02:11:58Z
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.0352 USDC - $0.04
Users can choose not to reRoll since they already can predict the stats of the new fighter, which games the system.
A user can call reRoll()
to rolls a new fighter with random traits. Note how dna
is calculated.
function reRoll(uint8 tokenId, uint8 fighterType) public { require(msg.sender == ownerOf(tokenId)); require(numRerolls[tokenId] < maxRerollsAllowed[fighterType]); require(_neuronInstance.balanceOf(msg.sender) >= rerollCost, "Not enough NRN for reroll"); _neuronInstance.approveSpender(msg.sender, rerollCost); bool success = _neuronInstance.transferFrom(msg.sender, treasuryAddress, rerollCost); if (success) { > numRerolls[tokenId] += 1; > uint256 dna = uint256(keccak256(abi.encode(msg.sender, tokenId, numRerolls[tokenId]))); (uint256 element, uint256 weight, uint256 newDna) = _createFighterBase(dna, fighterType); fighters[tokenId].element = element;
_createFighterBase()
will get the element, weight and newDna.
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); }
When rerolling, the dna
variable will always change because of numRerolls[tokenId] += 1
. However, the user can predict his traits by calculating the dna
off chain since only numRerolls
is different.
Thus, the reRolls isn't really random. A user can choose not to reroll. Also, a user can calculate how many tries it will take to get a decent element and weight.
Manual Review
Induce some true randomness into the dna
calculation by using Chainlink VRF.
Context
#0 - c4-pre-sort
2024-02-24T01:41:07Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-24T01:41:32Z
raymondfam marked the issue as duplicate of #53
#2 - c4-judge
2024-03-06T03:49:26Z
HickupHH3 changed the severity to 3 (High Risk)
#3 - c4-judge
2024-03-06T03:50:50Z
HickupHH3 marked the issue as satisfactory
#4 - c4-judge
2024-03-15T02:10:55Z
HickupHH3 changed the severity to 2 (Med Risk)
#5 - c4-judge
2024-03-22T04:21:09Z
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
Users can game the system by getting back their stake at risk tokens without any repurcussions.
A user first stake some tokens into the RankedBattle contract through stakeNRN()
. If the user loses a battle and he does not have any points, some of his staked tokens will be transferred to the stakeAtRisk
contract.
} 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; }
The moment the user has some tokens in the stakeAtRisk contract, he can withdraw all his tokens in the RankedBattle
contract, leaving amountStaked[tokenId]
as zero. He then continues participating in battles.
If the user loses the battle, the curStakeAtRisk
will be zero since amountStaked[tokenId]
will be zero. He will not lose any tokens.
/// Do not allow users to lose more NRNs than they have in their staking pool if (curStakeAtRisk > amountStaked[tokenId]) { curStakeAtRisk = amountStaked[tokenId]; }
If the user wins the battle, the curStakeAtRisk
will be calculated as such. For example, assume bpsLostPerLoss
is 10% (1000) and stakeAtRisk is 1000e18 tokens.
curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4; curStakeAtRisk = 1000 * (0 +1000e18) / 10**4 = 100e18
If the user wins the battle, he gets to claim the stakeAtRisk tokens
if (curStakeAtRisk > 0) { _stakeAtRiskInstance.reclaimNRN(curStakeAtRisk, tokenId, fighterOwner); amountStaked[tokenId] += curStakeAtRisk; }
This way, a user that has stakeAtRisk
tokens can just withdraw all his staked tokens and simply continue battling. He will not face any negative consequences.
Manual Review
Consider punishing the user if he has no staked tokens and has stakeAtRisk
tokens. One punishment can be burning a percentage of the stakeAtRisk
so that the user can never get it back anymore.
Math
#0 - c4-pre-sort
2024-02-22T16:55:47Z
raymondfam marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-02-22T16:56:05Z
raymondfam marked the issue as duplicate of #833
#2 - c4-pre-sort
2024-02-24T05:26:21Z
raymondfam marked the issue as sufficient quality report
#3 - c4-judge
2024-03-13T11:32:39Z
HickupHH3 marked the issue as duplicate of #1641
#4 - c4-judge
2024-03-13T14:33:47Z
HickupHH3 marked the issue as satisfactory
π Selected for report: klau5
Also found by: 0xAlix2, 0xCiphky, 0xDetermination, 0xG0P1, 0xMosh, 0xabhay, 14si2o_Flint, AlexCzm, Aymen0909, CodeWasp, DanielArmstrong, FloatingPragma, Giorgio, JCN, Jorgect, Kalogerone, KmanOfficial, Kow, KupiaSec, McToady, SpicyMeatball, VAD37, WoolCentaur, ZanyBonzy, alexxander, alexzoid, almurhasan, blutorque, csanuragjain, denzi_, dipp, djxploit, evmboi32, handsomegiraffe, haxatron, immeas, jesjupyter, ke1caM, klau5, lanrebayode77, lil_eth, merlin, merlinboii, nuthan2x, peanuts, shaflow2, shaka, sl1, solmaxis69, stakog, swizz, t0x1c, tallo, ubermensch, vnavascues, yotov721
1.0089 USDC - $1.01
Judge has assessed an item in Issue #1898 as 2 risk. The relevant finding follows:
[L-08] A fighter with stakeAtRisk tokens cannot redeem their tokens if the owner is changed
#0 - c4-judge
2024-03-13T10:50:18Z
HickupHH3 marked the issue as duplicate of #1641
#1 - c4-judge
2024-03-13T10:50:24Z
HickupHH3 marked the issue as partial-50
π 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
Users can get their fighters, which are ERC721 tokens, through a mint pass or by winning ranked battles
These fighters have unique traits and abilities which can be rerolled
These fighters will be used in ranked battles. In every battle, points can be earned or deducted
These fighters can be trained to be stronger. If they are stronger, they can battle in a higher elo pool, which earns more points
The ranked battle is a one on one battle. The fighters will be matched against one of similar elo.
A user has to stake some tokens in order to earn points. If the user wins a battle, he gets some points. If the user loses the battle, he loses some points
If the user has no points to lose, then his staked tokens will be at risk. The user can claim back the tokens if he wins a battle.
There can be many battles in a round. Once the round is over, at risk tokens will be sent to the treasury
Users can earn NRN tokens from the points that they accumulated. Every battle has a set number of NRN tokens to be distributed proporitionally to the number of points earned
Every battle uses 10 voltage, which is recharged to 100 everyday. Only the challenger (the person who clicks battle) will use 10 voltage. If the user decides to play more than 10 battles in a day (using more than 100 voltage), he has the option to buy and use a battery, which will recharge the voltage back to 100
The protocol also has an ERC1155 token shop where the user can buy stuff to help with their battles (one of the stuff is the battery)
Contract | Function | Access | Comments |
---|---|---|---|
MergingPool | transferOwnership | onlyOwner | No two step, no zero address check |
MergingPool | adjustAdminAccess | onlyOwner | owner can control all isAdmin access |
MergingPool | adjustTransferability | onlyOwner | items can be transferred at the discretion of the owner |
MergingPool | instantiateNeuronContract | onlyOwner | no zero address check |
MergingPool | mint | public | all user has a daily allowance on all items |
MergingPool | setAllowedBurningAddresses | onlyAdmin | must be careful with this, burner address can burn anyone's ERC1155 |
MergingPool | setTokenURI | public | does changing URI affect any EIP specs? |
MergingPool | createGameItem | onlyAdmin | tokenID is set sequentially |
MergingPool | burn | public | |
MergingPool | getAllowanceRemaining | public |
Checks
Contract | Function | Access | Comments |
---|---|---|---|
MergingPool | transferOwnership | onlyOwner | No two step, no zero address check |
MergingPool | adjustAdminAccess | onlyOwner | owner can control all isAdmin access |
MergingPool | updateWinnersPerPeriod | isAdmin | no upper bound of winnersPerPeriod, also no lower bound |
MergingPool | pickWinner | isAdmin | Unsure how the winners are chosen. Points will be reset |
MergingPool | claimRewards | winner | Nested loop can be dangerous, may be OOG |
MergingPool | getUnclaimedRewards | view | Check whether the address has claimable rewards |
MergingPool | addPoints | rankedbattle | add points for fighter and total points |
MergingPool | getFighterPoints | view | get accumulated points for fighter |
This contract focuses on getting the winners for each round and giving them an NFT
All function has proper access controls
ClaimRewards cannot be gamed as it is a protected function
Potential Issues
Contract | Function | Access | Comments |
---|---|---|---|
Neuron | transferOwnership | onlyOwner | No two step, no zero address check |
Neuron | addMinter | onlyOwner | Sets minter |
Neuron | addStaker | onlyOwner | Staker is the MergingPoolContract |
Neuron | addSpender | onlyOwner | Spender is the GameItems contract |
Neuron | adjustAdminAccess | onlyOwner | owner can control all isAdmin access |
Neuron | setupAirdrop | isAdmin | Admin sets the amount of NRN to be given away from the treasury address |
Neuron | claim | only airdrop recipient | Claims airdrop |
Neuron | mint | onlyMinter | Cannot mint more than 1B supply |
Neuron | burn | public | Anyone can burn |
Neuron | approveSpender | onlyOwner | Give spender approval to use tokens |
Neuron | approveStaker | onlyOwner | Give staker approval to use tokens |
Neuron | burnFrom | onlyOwner | burn from an approved address |
Checks
Potential Issues
transferOwnership()
, adjustAdminAccess()
, setGameServerAddress()
, setStakeAtRiskAddress()
, instantiateNeuronContract()
, instantiateMergingPoolContract()
functions all rely on the ownerAddress and changes the address accordinglyContract | Function | Access | Comments |
---|---|---|---|
RankedBattle | setRankedNrnDistribution | Admin | No upper bound of new distributions |
RankedBattle | setBpsLostPerLoss | Admin | No upper bound of bps loss |
RankedBattle | setNewRound | Admin | setNewRound() can be called at any time if the points is >0 |
RankedBattle | stakeNRN | User | if user stake nrn, the fighter cannot be transferred. staking factor is calculated here |
RankedBattle | unstakeNRN | User | staking factor is updated here. User can unstake at any time |
RankedBattle | claimNRN | User | claimNRN can lead to OOG if the roundId is too high |
RankedBattle | updateBattleRecord | GameServer | burns the voltage of challenger, and update records. Only run if there is amountStaked and stakeAtRisk |
RankedBattle | _addResultPoints | internal | burns the voltage of challenger, and update records |
UpdateBattleRecord
is called by GameServer, which is an unknown entity.Checks
1) Win + no stake-at-risk = Increase points balance 2) Win + stake-at-risk = Reclaim some of the stake that is at risk 3) Lose + positive point balance = Deduct from the point balance 4) Lose + no points = Move some of their NRN staked to the Stake At Risk contract 5) Tie = no consequence
Contract | Function | Access | Comments |
---|---|---|---|
StakeAtRisk | setNewRound | RankedBattleAddress | Collects all stakeAtRisk to the treasury address and increases the roundId |
StakeAtRisk | reclaimNRN | RankedBattleAddress | From ranked battle, transfer nrn back to the ranked battle contract |
StakeAtRisk | updateAtRiskRecords | RankedBattleAddress | updates the stakeAtRisk variable of the fighterOwner |
stakeAtRisk
. If the round is changed, the stakeAtRisk
tokens will be swept to the treasury address.Checks
Potential Issues
Contract | Function | Access | Comments |
---|---|---|---|
VoltageManager | transferOwnership | onlyOwner | No two step, no zero address check |
VoltageManager | adjustAdminAccess | onlyOwner | Can set own access to false, controls all isAdmin access |
VoltageManager | adjustAllowedVoltageSpenders | isAdmin | sets any address to spend voltage |
VoltageManager | useVoltageBattery | msg.sender | msg.sender must have an ERC1155 battery |
VoltageManager | spendVoltage | self / allowed voltage spenders | Can call here and spend all voltage, replenish voltage if time passed |
VoltageManager | _replenishVoltage | private | Replenish time is 1 day, use of uint32 instead of uint40 |
Checks
Potential Issues
_ownerAddress()
.setNewRound()
any time if the totalAccumulatedPoints > 0.createGameItem()
pickWinner()
RankedBattle.setNewRound()
. There is no fixed scheduleDesign Patterns and Best Practices:
Code Readability and Maintainability:
Error Handling and Input Validation:
Interoperability and Standards Compliance:
safeTransferFrom()
in the original ERC721 contract (with 4 parameters)Testing and Documentation:
Upgradeability:
Dependency Management:
Protocol relies on external libraries like OpenZeppelin. Protocol should keep an eye on vulnerabilities that affects those external integrations, and make changes where necessary.
Overall, great architecture from the protocol, slight changes would be to the written code itself (using modifiers for repeated code, checking zero values, checking overflows/underflows etc).
20 hours
#0 - c4-pre-sort
2024-02-25T20:24:28Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2024-03-19T08:21:28Z
HickupHH3 marked the issue as grade-b