Halborn Logo

Proof of Play / Pirate Nation - Proof of Play


Prepared by:

Halborn Logo

HALBORN

Last Updated 04/26/2024

Date of Engagement by: April 25th, 2022 - March 7th, 2023

Summary

96% of all REPORTED Findings have been addressed

All findings

24

Critical

3

High

5

Medium

4

Low

3

Informational

9


Table of Contents

1. INTRODUCTION

Pirate Nation is a fully on-chain role-playing game playable on the Polygon blockchain.

Proof of Play engaged Halborn to conduct a security audit on their smart contracts beginning on April 25th, 2022 and ending on May 16th, 2022. This initial audit included the v0.0.4 of the Proof of Play contracts. After Proof of Play addressed all the issues found during the audit, Halborn reaudited all the new code changes introduced. These code changes were covered in the v0.0.9 version.

After this initial audit, Proof of Play performed multiple updates in the code and requested a new audit on the 16th of July 2022. A new audit was performed by Halborn which included all the contracts in the Commit ID: f5c3190140139941351a68da617a91315487e917.

Some small code changes were introduced in the contracts previously audited, plus these 4 new contracts were added to the code base:

    • LootSystem.sol

    • HoldingSystem.sol

    • QuestSystem.sol

    • GameGlobals.sol

Moreover, Halborn kept auditing Proof of Play contracts as their development phase was progressing. The following contracts were also added to the scope of the audit: c49710da0cfa94e2b3bee730bf980a89a059a700

    • CraftingSystem.sol

05007dcea3bf1f7f05b53f3d6a0cba04bc7032a0

    • EnergySystem.sol

aedd5dbab65f96b452b5df0627bb562d8db8e41a

    • CaptainSystem.sol

47d175aba692a349ca47c063a39f650a34ba56cd

    • ShipSystem.sol

    • ShipNFT.sol

    • ShipNFTTokenURIHandler.sol

    • ShipNFTBeforeTokenTransferHandler.sol

    • TokenTemplateSystem.sol

2. AUDIT SUMMARY

The team at Halborn was provided three weeks for the initial audit and assigned a full-time security engineer to audit the security of the smart contracts. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols. Multiple audits were done by the same engineer in a time frame of several months. The smart contracts were audited in the development phase of the project, every time a new change was introduced into the code base.

The purpose of the audits is to:

    • Ensure that smart contract functions operate as intended.

    • Identify potential security issues with the smart contracts.

In summary, during the audits, Halborn identified some security risks that were mostly addressed and acknowledged by Proof of Play team.

3. TEST APPROACH & METHODOLOGY

Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this audit. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the audit:

    • Research into architecture and purpose.

    • Smart contract manual code review and walkthrough.

    • Graphing out functionality and contract logic/connectivity/functions. (solgraph)

    • Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.

    • Manual testing by custom scripts.

    • Scanning of solidity files for vulnerabilities, security hot-spots or bugs. (MythX)

    • Static Analysis of security for scoped contract, and imported functions. (Slither)

    • Testnet deployment. (Brownie, Remix IDE)

4. SCOPE

IN-SCOPE: The security assessment was scoped to the following smart contracts:

    • ERC721BridgableChild.sol

    • ERC721BridgableParent.sol

    • ERC721Lockable.sol

    • ERC1155Lockable.sol

    • GameItems.sol

    • GameNFT.sol

    • GameRegistry.sol

    • GameRegistryConsumer.sol

    • GoldToken.sol

    • LockingSystem.sol

    • PirateGameV1.sol

    • PirateNFT.sol

    • PirateNFTParent.sol

    • RaffleMintV1.sol

    • Randomizer.sol

    • StakingSystem.sol

    • TraitsConsumer.sol

    • TraitsProvider.sol

    • StagedMintV1.sol

    • LootSystem.sol

    • HoldingSystem.sol

    • QuestSystem.sol

    • GameGlobals.sol

    • CraftingSystem.sol

    • EnergySystem.sol

    • CaptainSystem.sol

    • ShipSystem.sol

    • ShipNFT.sol

    • ShipNFTTokenURIHandler.sol

    • ShipNFTBeforeTokenTransferHandler.sol

    • TokenTemplateSystem.sol

Initial audit version:

Fixed version:

Second audit Commit ID:

Third audit Commit ID:

Fourth audit Commit ID:

Fifth audit Commit ID:

Sixth audit Commit ID:

Seventh audit Commit ID:

    • eda67de3e770079146b4f26230385fbb03cb6a35 Addition of the giveEnergy() and TokenActions function. Added some minting restrictions to the StagedMintV1 contract. No new issues were found on this Commit ID.

Eighth audit Commit ID:

Ninth audit Commit ID:

Tenth audit Commit ID:

Eleventh audit Commit ID:

Twelfth audit Commit IDs:

Thirteenth audit Commit ID:

    • 47d175aba692a349ca47c063a39f650a34ba56cd This Commit ID includes the following contracts:

    • ShipSystem.sol

    • ShipNFT.sol

    • ShipNFTTokenURIHandler.sol

    • ShipNFTBeforeTokenTransferHandler.sol

    • TokenTemplateSystem.sol And also the changes included in the contracts:

    • LootSystem.sol

    • TraitsProvider.sol

5. RISK METHODOLOGY

Vulnerabilities or issues observed by Halborn are ranked based on the risk assessment methodology by measuring the LIKELIHOOD of a security incident and the IMPACT should an incident occur. This framework works for communicating the characteristics and impacts of technology vulnerabilities. The quantitative model ensures repeatable and accurate measurement while enabling users to see the underlying vulnerability characteristics that were used to generate the Risk scores. For every vulnerability, a risk level will be calculated on a scale of 5 to 1 with 5 being the highest likelihood or impact.
RISK SCALE - LIKELIHOOD
  • 5 - Almost certain an incident will occur.
  • 4 - High probability of an incident occurring.
  • 3 - Potential of a security incident in the long term.
  • 2 - Low probability of an incident occurring.
  • 1 - Very unlikely issue will cause an incident.
RISK SCALE - IMPACT
  • 5 - May cause devastating and unrecoverable impact or loss.
  • 4 - May cause a significant level of impact or loss.
  • 3 - May cause a partial impact or loss to many.
  • 2 - May cause temporary impact or loss.
  • 1 - May cause minimal or un-noticeable impact.
The risk level is then calculated using a sum of these two values, creating a value of 10 to 1 with 10 being the highest level of security risk.
Critical
High
Medium
Low
Informational
  • 10 - CRITICAL
  • 9 - 8 - HIGH
  • 7 - 6 - MEDIUM
  • 5 - 4 - LOW
  • 3 - 1 - VERY LOW AND INFORMATIONAL

6. SCOPE

Out-of-Scope: New features/implementations after the remediation commit IDs.

7. Assessment Summary & Findings Overview

Critical

3

High

5

Medium

4

Low

3

Informational

9

Impact x Likelihood

HAL-12

HAL-05

HAL-06

HAL-08

HAL-04

HAL-01

HAL-02

HAL-03

HAL-07

HAL-13

HAL-15

HAL-14

HAL-09

HAL-10

HAL-11

HAL-16

HAL-17

HAL-18

HAL-19

HAL-20

HAL-21

HAL-22

HAL-23

HAL-24

Security analysisRisk levelRemediation Date
USERS CAN START A QUEST USING AS INPUT AND BURNING AN NFT THEY DO NOT OWNCriticalSolved - 09/10/2022
FLAWED LOGIC CAUSES THAT NAVIES WILL NEVER STEAL PIRATE'S GOLDCriticalSolved - 06/02/2022
LACK OF ACCESS CONTROL IN LOOTSYSTEM.BATCHGRANTLOOTWITHOUTRANDOMNESS FUNCTIONCriticalSolved - 03/06/2023
UNSAFE CAST CAN ALLOW USERS TO PERMANENTLY MINT GOLD TOKENSHighSolved - 06/02/2022
REENTRANCY IN RAFFLEMINTV1.WITHDRAWNONRAFFLEPROCEEDSHighSolved - 06/02/2022
USERS CAN START THE SAME QUEST MULTIPLE TIMES DRAINING THE CHAINLINK VRF SUBSCRIPTIONHighRisk Accepted
USERS CAN CRAFT USING AS INPUT AN NFT THEY DO NOT OWNHighSolved - 09/10/2022
CRAFTAMOUNT CAN BE SET TO ZERO DRAINING THE CHAINLINK VRF SUBSCRIPTIONHighSolved - 09/10/2022
CRAFTS COOLDOWN TIME ARE ALWAYS ZEROMediumPartially Solved
QUESTDEFINITION.MAXCOMPLETIONS CAN BE BYPASSED BY STARTING THE SAME QUEST MULTIPLE TIMES BEFORE COMPLETING THEMMediumRisk Accepted
LACK OF PAUSABLE FUNCTIONALITY IN THE LOOTSYSTEM CONTRACTMediumRisk Accepted
MINTBATCH FUNCTION IS NOT IMPLEMENTEDMediumRisk Accepted
WRONG REQUIRE STATEMENTS IN GAMEGLOBALS CONTRACTLowSolved - 09/10/2022
QUESTINPUT.REQUIRED VALUE IS NEVER CHECKEDLowRisk Accepted
LACK OF DISABLEINITIALIZERS CALL TO PREVENT UNINITIALIZED CONTRACTSLowRisk Accepted
USERS CAN NOT UNSTAKE NFTS AFTER A CALL TO RESCUEUNLOCKNFT OR RESCUEUNLOCKITEM FUNCTIONSInformationalAcknowledged
DANGEROUS USAGE OF TX.ORIGINInformationalAcknowledged
STATE VARIABLES MISSING CONSTANT MODIFIERInformationalSolved - 09/10/2022
STATE VARIABLE MISSING IMMUTABLE MODIFIERInformational-
UNNEEDED INITIALIZATION OF UINT256 VARIABLES TO 0InformationalSolved - 09/10/2022
USING ++I CONSUMES LESS GAS THAN I++ IN LOOPSInformationalSolved - 09/10/2022
UNNEEDED ARRAYS DECLARATION IN FINISHMINTSHIPS FUNCTIONInformationalSolved - 09/10/2022
MISSING VIEW FUNCTION THAT DISPLAYS ALL THE TICKETS OWNED BY A USERInformationalSolved - 09/10/2022
INCORRECT COMMENTInformationalAcknowledged

8. Findings & Tech Details

8.1 USERS CAN START A QUEST USING AS INPUT AND BURNING AN NFT THEY DO NOT OWN

// Critical

Description

In the QuestSystem contract, the startQuest() function does not check that the NFT inputs are actually owned by the caller:

QuestSystem.sol

function startQuest(QuestParams calldata params)
    external
    nonReentrant
    whenNotPaused
    returns (uint256)
{
    QuestDefinition storage questDef = _questDefinitions[params.questId];
    address account = _msgSender();

    // Verify user can start this quest and meets requirements
    require(
        _isQuestAvailable(account, params.questId, questDef) == true,
        "QUEST_NOT_AVAILABLE: Sender cannot start this quest"
    );
    require(
        params.inputs.length == questDef.inputs.length,
        "INPUT_LENGTH_MISMATCH: Inputs to quest do not match to quest definition"
    );

    // Create active quest object
    _activeQuestCounter.increment();
    uint256 activeQuestId = _activeQuestCounter.current();

    ActiveQuest storage activeQuest = _activeQuests[activeQuestId];
    activeQuest.account = account;
    activeQuest.questId = params.questId;
    activeQuest.startTime = SafeCast.toUint32(block.timestamp);
    activeQuest.status = ActiveQuestStatus.IN_PROGRESS;

    // Track activeQuestId for this account
    _accountData[account].activeQuestIds.add(activeQuestId);

    // Verify that the params have inputs that meet the quest requirements
    for (uint8 idx = 0; idx < questDef.inputs.length; idx++) {
        QuestInput storage inputDef = questDef.inputs[idx];
        GameRegistryLibrary.TokenPointer memory input = params.inputs[idx];

        // Make sure token types match
        require(
            input.tokenType == inputDef.tokenPointer.tokenType,
            "TOKEN_TYPES_NOT_MATCHING: Token type is not matching that expected by the quest"
        );

        // Make sure proper token address was provided
        require(
            inputDef.tokenPointer.tokenContract == address(0) ||
                inputDef.tokenPointer.tokenContract == input.tokenContract,
            "EXPECTED_SPECIFIC_TOKEN: Expected a specific token address"
        );

        GameRegistryLibrary.TokenType tokenType = inputDef
            .tokenPointer
            .tokenType;
        uint32 reservationId = 0;

        // Check token type to ensure that the input matches what the quest expects
        if (tokenType == GameRegistryLibrary.TokenType.ERC20) {
            require(
                _hasAccessRole(
                    GameRegistryLibrary.GAME_CURRENCY_CONTRACT_ROLE,
                    input.tokenContract
                ) == true,
                "NOT_GAME_CURRENCY: Expected GameCurrency contract"
            );

            // TODO: Find a way to either lock or burn ERC20 stuff
        } else if (tokenType == GameRegistryLibrary.TokenType.ERC721) {
            // Auto-Lock NFT if necessary
            ILockingSystem lockingSystem = _lockingSystem();

            if (
                lockingSystem.isNFTLocked(
                    input.tokenContract,
                    input.tokenId
                ) == false
            ) {
                lockingSystem.lockNFT(input.tokenContract, input.tokenId);
            }

            reservationId = lockingSystem.addNFTReservation(
                input.tokenContract,
                input.tokenId,
                true,
                GameRegistryLibrary.RESERVATION_QUEST_SYSTEM
            );
        } else if (tokenType == GameRegistryLibrary.TokenType.ERC1155) {
            reservationId = _lockingSystem().addItemReservation(
                account,
                input.tokenContract,
                input.tokenId,
                input.amount,
                true,
                GameRegistryLibrary.RESERVATION_QUEST_SYSTEM
            );
        }

        // Perform all trait checks
        for (
            uint8 traitIdx = 0;
            traitIdx < inputDef.traitChecks.length;
            traitIdx++
        ) {
            TraitsLibrary.requireTraitCheck(
                inputDef.traitChecks[traitIdx],
                ITraitsConsumer(input.tokenContract),
                input.tokenId
            );
        }

        activeQuest.inputs.push(
            GameRegistryLibrary.ReservedToken({
                tokenType: input.tokenType,
                tokenId: input.tokenId,
                tokenContract: input.tokenContract,
                amount: input.amount,
                reservationId: reservationId
            })
        );
    }

    emit QuestStarted(account, params.questId, activeQuestId);

    return activeQuestId;
}

\color{black} \color{white}

Moreover, when the quest inputs are unlocked upon quest completion in the _unlockQuestInputs() the NFT reservation is removed, but the NFT remains locked:

QuestSystem.sol

function _unlockQuestInputs(
    address account,
    QuestDefinition storage questDef,
    ActiveQuest storage activeQuest,
    bool isSuccess,
    uint256 randomWord
) internal {
    uint32 successXp = isSuccess ? questDef.successXp : 0;

    // Unlock inputs, grant XP, and potentially burn inputs
    for (uint8 idx = 0; idx < questDef.inputs.length; idx++) {
        QuestInput storage input = questDef.inputs[idx];
        GameRegistryLibrary.ReservedToken
            storage activeQuestInput = activeQuest.inputs[idx];

        // Grant XP on success
        if (successXp > 0 && input.xpEarnedPercent > 0) {
            uint32 xpAmount = (successXp * input.xpEarnedPercent) /
                GameRegistryLibrary.PERCENTAGE_RANGE;

            if (xpAmount > 0) {
                ITraitsConsumer(activeQuestInput.tokenContract)
                    .incrementTrait(
                        activeQuestInput.tokenId,
                        TraitsLibrary.XP_TRAIT_ID,
                        xpAmount
                    );
            }
        }

        // Determine if the input should be burned
        bool shouldBurn;

        if (input.consumable) {
            uint256 burnRate = isSuccess
                ? input.successBurnRate
                : input.failureBurnRate;

            if (burnRate == 0) {
                shouldBurn = false;
            } else if (burnRate == GameRegistryLibrary.PERCENTAGE_RANGE) {
                shouldBurn = true;
            } else {
                randomWord = _nextRandomWord(randomWord);
                (shouldBurn, randomWord) = _weightedCoinFlip(
                    randomWord,
                    burnRate
                );
            }
        }

        // Unlock/burn based on token type
        if (
            activeQuestInput.tokenType ==
            GameRegistryLibrary.TokenType.ERC20
        ) {
            if (shouldBurn) {
                IGameCurrency(activeQuestInput.tokenContract).burn(
                    account,
                    activeQuestInput.amount
                );
            }
        } else if (
            activeQuestInput.tokenType ==
            GameRegistryLibrary.TokenType.ERC721
        ) {
            _lockingSystem().removeNFTReservation(
                activeQuestInput.tokenContract,
                activeQuestInput.tokenId,
                activeQuestInput.reservationId
            );
        } else if (
            activeQuestInput.tokenType ==
            GameRegistryLibrary.TokenType.ERC1155
        ) {
            _lockingSystem().removeItemReservation(
                account,
                activeQuestInput.tokenContract,
                activeQuestInput.tokenId,
                activeQuestInput.reservationId
            );

            if (shouldBurn) {
                IGameItems(activeQuestInput.tokenContract).burn(
                    account,
                    SafeCast.toUint32(activeQuestInput.tokenId),
                    activeQuestInput.amount
                );
            }
        }
    }
}

\color{black} \color{white}

Based on this, initially a user would not be able to use the NFT of another user as input as during the lockNFT() call the transaction would revert with the ORIGIN_NOT_NFT_OWNER error.

Although, once the original owner has started and completed that quest with that NFT as input, the NFT would remain locked and as this NFT is already locked any user would be able now to start a quest using that NFT as the NFT ownership is not checked to create a reservation nor in the startQuest() function.

This would create an exclusive reservation and while this quest is ongoing, the original owner would not be able to make use of that NFT. Moreover, if the issue described in ERC721 INPUTS ARE NEVER BURNT WHEN THE QUEST INPUTS ARE UNLOCKED was fixed, this NFT could be burnt during this process, leaving the original owner without his NFT. Basically, any user would be able to burn someone else's NFT using it as a quest input.

6.png
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: The Proof of Play team added the GameHelperLibrary._verifyInputOwnership(input, account); call in the startQuest() function that validates that the inputs are owned by the caller.

8.2 FLAWED LOGIC CAUSES THAT NAVIES WILL NEVER STEAL PIRATE'S GOLD

// Critical

Description

As per the documentation, in the contract StakingSystem, when pirates unstake their items, there should be a 50% chance for all of their gold being stolen by the navies.

This logic is implemented in the functions _claimGameNFTStakeRewards() and fulfillRandomWordsCallback().

In the _claimGameNFTStakeRewards() function, the variable balance is set initially to zero, but then it is never updated with the number of Pirate Ships staked which means that every VRFRequest.balance will be zero:

StakingSystem.sol

function _claimGameNFTStakeRewards(
    address nftContract,
    uint256 nftTokenId,
    bool unstake
) internal {
    address relevantAccount = tx.origin;

    require(
        IGameNFT(nftContract).ownerOf(nftTokenId) == relevantAccount,
        "ORIGIN_NOT_OWNER_OF_NFT: Origin is not the owner of the specified NFT"
    );

    GameNFTStake storage nftStake = stakedNFTs[nftContract][nftTokenId];
    require(
        nftStake.reservationId != 0,
        "NFT_NOT_STAKED: NFT has not been staked"
    );

    uint256 goldOwed = 0;
    uint256 xpOwed = 0;
    uint256 balance = 0;

    for (uint256 i = 0; i < nftStake.gameItemStakes.length; i++) {
        (
            uint256 goldFromShip,
            uint256 xpFromShip
        ) = _claimGameItemStakeRewards(nftStake.gameItemStakes[i], unstake);
        goldOwed += goldFromShip;
        xpOwed += xpFromShip;
    }

    // Grant XP
    if (xpOwed > 0) {
        ITraitsConsumer(nftContract).incrementTrait(
            nftTokenId,
            TraitsLibrary.XP_TRAIT_ID,
            xpOwed
        );
    }

    if (unstake) {
        // Figure out final amount of gold the player earns with some randomness
        uint256 requestId = _requestRandomWords(1);
        vrfRequests[requestId] = VRFRequest({
            account: relevantAccount,
            goldOwed: goldOwed,
            balance: balance,
            nftContract: nftContract,
            nftTokenId: nftTokenId
        });

        // Release hold on NFT
        _lockingSystem().removeNFTReservation(
            nftContract,
            nftTokenId,
            nftStake.reservationId
        );

        // Delete the stake
        delete stakedNFTs[nftContract][nftTokenId];

        // Emit unstaked event
        emit NFTUnstaked(relevantAccount, nftContract, nftTokenId, requestId);
    } else {
        // Mint gold rewards for user
        if (goldOwed > 0) {
            goldToken.mint(relevantAccount, goldOwed);
        }

        emit NFTRewardsClaimed(
            relevantAccount,
            nftContract,
            nftTokenId,
            unstake,
            goldOwed
        );
    }
}

\color{black} \color{white}

This means that when the fulFillRandomWordsCallback() function is called by Chainlink VRF:

  1. uint256 coinFlips = randomness % (2**request.balance);
  2. (2**request.balance) = (2**0) = 1, (considering that request.balance will always be 0 here)
  3. uint256 coinFlips = randomness % 1, (any number divided by 1 will always have as remainder 0)
  4. coinFlips will always be 0
  5. if coinFlips is 0, numStolen will never be increased and will always be 0, hence: Navies will never steal pirate's gold when they are unstaked.

StakingSystem.sol

function fulfillRandomWordsCallback(
    uint256 requestId,
    uint256[] memory randomWords
) external override onlyRole(GameRegistryLibrary.RANDOMIZER_ROLE) {
    VRFRequest storage request = vrfRequests[requestId];
    address account = request.account;

    if (account != address(0)) {
        uint256 randomness = randomWords[0];

        // This should not overflow since the balance is determined by gameplay logic
        // and the user wont have more than 256 ships per stake
        uint256 coinFlips = randomness % (2**request.balance); // 50% chance of stealing gold per ship staked
        uint256 numStolen = 0;
        uint256 goldOwed = request.goldOwed;

        while (coinFlips > 0) {
            if (coinFlips & 1 == 1) {
                numStolen++;
            }
            coinFlips = coinFlips >> 1;
        }

        if (numStolen > 0) {
            uint256 owedToNavy = numStolen * (goldOwed / request.balance);
            _payNavyTax(owedToNavy);
            goldOwed = goldOwed - owedToNavy;
        }

        // Mint gold rewards for user
        if (goldOwed > 0) {
            goldToken.mint(account, goldOwed);
        }

        // Emit event
        emit NFTRewardsClaimed(
            account,
            request.nftContract,
            request.nftTokenId,
            true,
            goldOwed
        );

        delete vrfRequests[requestId];
    }
}

\color{black} \color{white}

In the image below, 20 Pirate Ships are unstaked which are staked to Pirate ID 60 (command_rank of PirateId 60 = 4):

2.png
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: The Proof of Play team fixed the issue and now correctly updates the balance local variable before making the _requestRandomWords(1) call:

StakingSystem.sol

uint256 balance = 0;

for (uint256 i = 0; i < nftStake.gameItemStakes.length; i++) {
    GameItemStake storage gameItemStake = nftStake.gameItemStakes[i];
    (
        uint256 goldFromShip,
        uint256 xpFromShip
    ) = _claimGameItemStakeRewards(gameItemStake, unstake);
    goldOwed += goldFromShip;
    xpOwed += xpFromShip;
    balance += gameItemStake.balance;
}

\color{black} \color{white}

8.3 LACK OF ACCESS CONTROL IN LOOTSYSTEM.BATCHGRANTLOOTWITHOUTRANDOMNESS FUNCTION

// Critical

Description

In the LootSystem contract, the function batchGrantLootWithoutRandomness() was implemented:

LootSystem.sol

/**
 * @inheritdoc ILootSystem
 */
function batchGrantLootWithoutRandomness(
    address to,
    Loot[] calldata loots,
    uint8 amount
) external {
    for (uint8 idx; idx < loots.length; ++idx) {
        Loot memory loot = loots[idx];
        if (loot.lootType == LootType.LOOT_TABLE) {
            revert InvalidLootType(loot.lootType);
        } else {
            _mintLoot(to, loot, loot.amount * amount);
        }
    }
}

This is an external function that allows to grant the given user loot(s) in batches without any randomness. But this function lacks an access control mechanism and could be called by any user.

A malicious user could call this function to continuously mint himself any type of loot.

Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: The Proof of Play team fixed the issue and added access control to the LootSystem.batchGrantLootWithoutRandomness() function:

LootSystem.sol

/**
 * @inheritdoc ILootSystem
 */
function batchGrantLootWithoutRandomness(
    address to,
    Loot[] calldata loots,
    uint8 amount
) external nonReentrant onlyRole(GAME_LOGIC_CONTRACT_ROLE) {
    for (uint8 idx; idx < loots.length; ++idx) {
        Loot memory loot = loots[idx];
        if (loot.lootType == LootType.LOOT_TABLE) {
            revert InvalidLootType(loot.lootType);
        } else {
            _mintLoot(to, loot, loot.amount * amount);
        }
    }
}

Fixed Commit ID: aa8cddf72d4e6ec949f0a5514ec423eed4b2f60f

8.4 UNSAFE CAST CAN ALLOW USERS TO PERMANENTLY MINT GOLD TOKENS

// High

Description

In the contract StakingSystem, the function claimNavyStakeRewards() is used to claim the Gold Tokens rewards for the Navy ships staked. This function calls the _claimGameItemStakeRewards() to calculate the goldOwed:

StakingSystem.sol

function claimNavyStakeRewards(
    address account,
    uint256[] calldata stakeIndexes,
    bool unstake
) external whenNotPaused nonReentrant {
    require(
        tx.origin == account,
        "USER_CALLER_ONLY: Only EOA can claim for their own account"
    );

    GameItemStake[] storage stakedItems = navyItemStakes[account];

    uint256 goldOwed = 0;
    uint256 lastStakeIndex = stakedItems.length;
    for (uint256 idx = 0; idx < stakeIndexes.length; idx++) {
        uint256 stakeIndex = stakeIndexes[idx];
        require(
            stakeIndex < lastStakeIndex,
            "STAKE_INDEX_MUST_DECREASE: StakeIndexes must be sorted in descending order and be within bounds"
        );

        // Claim rewards
        (uint256 goldFromShip, ) = _claimGameItemStakeRewards(
            stakedItems[stakeIndex],
            unstake
        );
        goldOwed += goldFromShip;

        // Emit event
        emit NavyRewardsClaimed(
            account,
            stakedItems[stakeIndex].tokenId,
            stakedItems[stakeIndex].balance,
            unstake,
            goldOwed
        );

        // If unstaking, remove from array by swapping to end and popping
        if (unstake) {
            if (stakedItems.length > 1) {
                stakedItems[stakeIndex] = stakedItems[
                    stakedItems.length - 1
                ];
            }
            stakedItems.pop();
        }

        lastStakeIndex = stakeIndex;
    }

    // Mint gold rewards for user
    if (goldOwed > 0) {
        goldToken.mint(account, goldOwed);
    }
}

\color{black} \color{white}

The _claimGameItemStakeRewards() itself calls the _calculateGameItemStakeRewards() function and then, in the line 794, the stake.value is updated to totalTaxInGoldPerRank:

StakingSystem.sol

function _claimGameItemStakeRewards(
    GameItemStake storage stake,
    bool unstake
) internal returns (uint256 goldOwed, uint256 xpOwed) {
    address account = _msgSender();

    (goldOwed, xpOwed) = _calculateGameItemStakeRewards(stake);
    bool pirateShip = GameHelperLibrary._isPirateShip(
        gameItems,
        stake.tokenId
    );

    if (pirateShip) {
        require(
            !unstake ||
                ((block.timestamp - stake.value) >= MINIMUM_TO_EXIT),
            "STAKE_NOT_COMPLETE: Must be staked for minimum time before unstaking"
        );

        // If pirate is just collecting, there is a flat tax on their earnings
        if (!unstake) {
            uint256 owedToNavy = (goldOwed * GOLD_CLAIM_TAX_PERCENTAGE) /
                100;
            _payNavyTax(owedToNavy); // Pay tax to navy
            goldOwed = goldOwed - owedToNavy; // Rest goes to owner
        }
    } else {
        if (unstake) {
            uint8 rank = GameHelperLibrary._rankForNavy(
                gameItems,
                stake.tokenId
            );
            totalNavyRankStaked -= rank; // Remove rank from total staked
        }
    }

    if (unstake) {
        // Release reservation on items
        _lockingSystem().removeItemReservation(
            account,
            address(gameItems),
            stake.tokenId,
            stake.reservationId
        );

        // Emit event
        emit GameItemUnstaked(account, stake.tokenId, stake.balance);
    } else {
        // Reset collection timer
        stake.value = uint80(
            pirateShip ? block.timestamp : totalTaxInGoldPerRank
        );
    }
}

\color{black} \color{white}

Solidity 0.8 is introducing type checking for arithmetic operations, but not for type casting. Because of this, an overflow may occur in the Line 794 if totalTaxInGoldPerRank is higher than 2**80-1 = 1208925819614629174706175. If that overflow occurs any user with a Navy staked would be able to call claimNavyStakeRewards() as many times as he wanted with no time restriction minting with every call the same amount of Gold Tokens.

In the image below, we can see how the user2 keeps increasing his GoldToken balance after every claimNavyStakeRewards():

3.png 4.png
Score
Impact: 5
Likelihood: 4
Recommendation

SOLVED: The Proof of Play team fixed the issue by updating the GameItemStake.value to uint128. On the other hand, OpenZeppelin's SafeCast library is now used for all the castings.

8.5 REENTRANCY IN RAFFLEMINTV1.WITHDRAWNONRAFFLEPROCEEDS

// High

Description

In the contract RaffleMintV1, the function withdrawNonRaffleProceeds() is vulnerable to reentrancy as it updates the nonRaffleWithdrawableProceeds after the payable(_msgSender()).call{value: proceeds}("");:

RaffleMintV1.sol

/* @notice Allows contract owner to withdraw proceeds of mints initiated after raffle */
function withdrawNonRaffleProceeds() external onlyOwner {
    // Ensure there are proceeds to claim
    require(
        nonRaffleWithdrawableProceeds > 0,
        "NONRAFFLE_PAYOUT_EMPTY: No proceeds available to claim"
    );

    uint256 proceeds = nonRaffleWithdrawableProceeds;

    // Pay owner proceeds
    (bool sent, ) = payable(_msgSender()).call{value: proceeds}("");
    require(sent == true, "NONRAFFLE_PAYOUT_UNSUCCESFUL");

    // Proceeds are now claimed so clear amount
    nonRaffleWithdrawableProceeds = 0;

    // Emit successful proceeds claim
    emit NonRaffleProceedsClaimed(_msgSender(), proceeds);
}

\color{black} \color{white}

By exploiting this reentrancy, the contract owner could drain all the Ether of the smart contract and users would not be able to get a refund of their losing raffle tickets.

Score
Impact: 5
Likelihood: 3
Recommendation

SOLVED: The Proof of Play team fixed the issue by adding the nonReentrant modifier to the withdrawNonRaffleProceeds() function.

8.6 USERS CAN START THE SAME QUEST MULTIPLE TIMES DRAINING THE CHAINLINK VRF SUBSCRIPTION

// High

Description

In the QuestSystem contract every time a quest is completed Chainlink VRF is used:

QuestSystem.sol

function completeQuest(uint64 activeQuestId)
    external
    nonReentrant
    whenNotPaused
{
    address account = _msgSender();
    ActiveQuest storage activeQuest = _activeQuests[activeQuestId];

    // Make sure active quest exists
    require(
        activeQuest.status == ActiveQuestStatus.IN_PROGRESS,
        "INVALID_ACTIVE_QUEST_ID: ActiveQuest is not IN_PROGRESS"
    );

    // Check to make sure sender is the quest owner
    require(
        activeQuest.account == account,
        "INVALID_ACCOUNT: Sender did not undertake this quest"
    );

    // Make sure quest is still active
    QuestDefinition storage questDef = _questDefinitions[
        activeQuest.questId
    ];
    require(
        questDef.active == true,
        "QUEST_NOT_ACTIVE: Cannot complete inactive quest"
    );

    uint256 endTime = activeQuest.startTime + questDef.completionSeconds;
    require(
        endTime <= block.timestamp,
        "NOT_READY_TO_COMPLETE: Quest is not ready to be completed"
    );

    // TODO: Maybe we fail here automatically if expire time has passed instead of error?
    require(
        questDef.expireSeconds == 0 ||
            (endTime + questDef.expireSeconds > block.timestamp),
        "QUEST_HAS_EXPIRED: Quest has expired and is no longer completeable"
    );

    // Figure out final amount of gold the player earns with some randomness
    uint256 requestId = _requestRandomWords(1);
    _vrfRequests[requestId] = VRFRequest({
        account: account,
        activeQuestId: activeQuestId
    });

    // Change status
    activeQuest.status = ActiveQuestStatus.GENERATING_RESULTS;
}

\color{black} \color{white}

Considering that the accountData.completions[questId] mapping is only updated after a quest is completed successfully, the QuestDefinition.MaxCompletions value can be easily bypassed.

As explained in the QUESTDEFINITION.MAXCOMPLETIONS CAN BE BYPASSED BY STARTING THE SAME QUEST MULTIPLE TIMES BEFORE COMPLETING THEM issue, a user can start a quest as many times as he wants as long as he has enough inputs.

For ERC721 and ERC1155 inputs, as these assets are locked once a quest is started, it is not possible to perform the same quest twice. But for ERC20 inputs as there is an open TODO and this is not implemented yet, the ERC20 tokens are not locked when the quest is started, any user can start the same quest as many times as he wants.

Then, after waiting a certain period of time, the same user could complete all the quests that he started. Each completion would make use of Chainlink VRF subscription. It would be possible to totally drain all the LINK balance of the subscription, as there is no limit on how many times the user could start the same quest.

Score
Impact: 5
Likelihood: 3
Recommendation

RISK ACCEPTED: No mitigation was added to prevent this issue.

8.7 USERS CAN CRAFT USING AS INPUT AN NFT THEY DO NOT OWN

// High

Description

In the CraftingSystem contract, the craft() function is called every time a new craft is started:

CraftingSystem.sol

} else if (tokenType == GameRegistryLibrary.TokenType.ERC721) {
            // Auto-Lock NFT if necessary
            ILockingSystem lockingSystem = _lockingSystem();
            if (
                lockingSystem.isNFTLocked(
                    input.tokenContract,
                    input.tokenId
                ) == false
            ) {
                lockingSystem.lockNFT(input.tokenContract, input.tokenId);
            }

\color{black} \color{white}

As we can see, when ERC721 tokens are used as inputs, the function does not check that the token is owned by the caller. Moreover, when an NFT is used as an input for a craft, they are locked and an exclusive reservation is created. Once the craft is completed, the exclusive reservation is removed, but not the lock.

Similar to what was described in HAL-01 issue, initially a user would not be able to use the NFT of another user as input as during the lockNFT() call the transaction would revert with the ORIGIN_NOT_NFT_OWNER error.

Although, once the original owner has started and completed that craft with that NFT as input, the NFT would remain locked and as this NFT is already locked any user would be able now to start a craft using that NFT as the NFT ownership is not checked to create a reservation.

7.png
Score
Impact: 4
Likelihood: 4
Recommendation

SOLVED: The Proof of Play team added the GameHelperLibrary._verifyInputOwnership(input, account); call in the craft() function that validates that the inputs are owned by the caller.

8.8 CRAFTAMOUNT CAN BE SET TO ZERO DRAINING THE CHAINLINK VRF SUBSCRIPTION

// High

Description

In the CraftingSystem contract, the craft() function can be called passing a 0 value as a craftAmount. It is not possible to exploit this in any way when ERC1155 tokens are used as inputs, as these errors would stop the exploit: RESERVE_AMOUNT_MUST_BE_NON_ZERO, UNLOCK_AMOUNT_MUST_BE_NON_ZERO.

When using ERC721 tokens as inputs, as they get an exclusive reservation, this value is not even used, and it is not possible to abuse this.

But when using ERC20 tokens as inputs, it is possible to call this craft() function infinite times with no cost as no ERC20 tokens would be burnt because inputDef.tokenPointer.amount * params.craftAmount would always be zero.

The attacker will never receive any reward as _completeRecipe will always be called with params.craftAmount = 0 but with every craft() call, if the RecipeDefinition.needsVRF == True, a Chainlink VRF request will be done. This means that any malicious user could abuse this in order to drain the Chainlink VRF subscription.

8.png
Score
Impact: 5
Likelihood: 3
Recommendation

SOLVED: The Proof of Play team added a require statement in the craft() function that enforces that craftAmount is higher than zero.

8.9 CRAFTS COOLDOWN TIME ARE ALWAYS ZERO

// Medium

Description

In the CraftingSystem contract, when a craft is completed, the lastCompletionTime in the _accountData mapping is not updated. This means that the cooldown value is never considered, so all crafts have no cooldown.

Score
Impact: 2
Likelihood: 5
Recommendation

PARTIALLY SOLVED: The Proof of Play team now updates the lastCompletionTime value every time _completeRecipe() is called. Although, this value will be updated even if no crafts are completed successfully. This value should only be updated if at least one craft was completed successfully.

8.10 QUESTDEFINITION.MAXCOMPLETIONS CAN BE BYPASSED BY STARTING THE SAME QUEST MULTIPLE TIMES BEFORE COMPLETING THEM

// Medium

Description

In the QuestSystem contract, the _isQuestAvailable() function is called every time a new quest is started:

QuestSystem.sol

function _isQuestAvailable(
    address account,
    uint32 questId,
    QuestDefinition memory questDef
) internal view returns (bool) {
    if (!questDef.active) {
        return false;
    }

    // Perform all requirement checks
    IRequirementSystem requirementSystem = IRequirementSystem(
        _getSystem(GameRegistryLibrary.REQUIREMENT_SYSTEM)
    );
    if (
        requirementSystem.performAccountCheckBatch(
            account,
            questDef.requirements
        ) == false
    ) {
        return false;
    }

    // Make sure user hasn't completed already
    AccountData storage accountData = _accountData[account];
    if (
        questDef.maxCompletions > 0 &&
        accountData.completions[questId] >= questDef.maxCompletions
    ) {
        return false;
    }

    // Make sure enough time has passed before completions
    if (questDef.cooldownSeconds > 0) {
        if (
            accountData.lastCompletionTime[questId] +
                questDef.cooldownSeconds >
            block.timestamp
        ) {
            return false;
        }
    }

    return true;
}

\color{black} \color{white}

As we can see, one of the requirements checked is that the quest has not already been completed more than the QuestDefinition.MaxCompletions set. Although, the accountData.completions[questId] is only updated when a quest is completed successfully.

This means that a user who has enough inputs can bypass this QuestDefinition.MaxCompletions limit and complete the quests as many times as the inputs he owns allows him. All he has to do, is starting the same questId consecutively, as many times as his inputs allows him, before completing them.

Score
Impact: 2
Likelihood: 5
Recommendation

RISK ACCEPTED: The Proof of Play team accepted the risk of this finding.

8.11 LACK OF PAUSABLE FUNCTIONALITY IN THE LOOTSYSTEM CONTRACT

// Medium

Description

The LootSystem contract is importing the PausableUpgradeable library, although it is not making use of the whenNotPaused modifier anywhere in the code. Moreover, the contract is initialized in a paused state:

LootSystem.sol

function initialize(address gameRegistryAddress) public initializer {
    __Pausable_init();
    __ReentrancyGuard_init();
    __GameRegistryConsumer_init(gameRegistryAddress);
    _pause();
    _nullLoot = Loot({
        lootType: LootType.UNDEFINED,
        amount: 0,
        tokenContract: address(0),
        lootId: 0
    });
}

\color{black} \color{white}

All the core public/external functions in the LootSystem contract are missing the whenNotPaused modifier. With this modifier, functionality could be temporarily paused by the owner of the contract in case of having any issue in the smart contracts.

Score
Impact: 2
Likelihood: 5
Recommendation

RISK ACCEPTED: The Proof of Play team accepted the risk of this finding.

8.12 MINTBATCH FUNCTION IS NOT IMPLEMENTED

// Medium

Description

The LootSystem contract makes use of the IGameNFTLoot.mintBatch() function to assign LootType.ERC721:

LootSystem.sol

function _mintLoot(address to, Loot memory loot) private {
    if (loot.lootType == LootType.ERC20) {
        IGameCurrency(loot.tokenContract).mint(to, loot.amount);
    } else if (loot.lootType == LootType.ERC1155) {
        IGameItems(loot.tokenContract).mint(
            to,
            SafeCast.toUint32(loot.lootId),
            loot.amount,
            true
        );
    } else if (loot.lootType == LootType.ERC721) {
        IGameNFTLoot(loot.tokenContract).mintBatch(to, 1);
    } else if (loot.lootType == LootType.UNDEFINED) {
        // Do nothing, NOOP
        return;
    } else {
        require(false, "INVALID_LOOT_TYPE: Invalid loot type for mintLoot");
    }
}

\color{black} \color{white}

Although this mintBatch() function is not implemented anywhere in the code, not even in the GameNFT contract: 5.png

Score
Impact: 5
Likelihood: 2
Recommendation

RISK ACCEPTED: The Proof of Play team accepted the risk of this finding. Currently, the mintBatch() function is not implemented in the GameNFT contract.

8.13 WRONG REQUIRE STATEMENTS IN GAMEGLOBALS CONTRACT

// Low

Description

The GameGlobals contract allows setting global variables that can be used by any other system/contract. This contract takes an ID and sets a value, that can be a boolean, string, uint, int or arrays of those types.

Although as the following functions contains a wrong require statement, the view function will never return any result as they will revert every time:

GameGlobals.sol

function getUint256(uint32 globalId)
    external
    view
    override
    returns (uint256)
{
    GlobalDataType dataType = _globalMetadata[globalId].dataType;
    require(
        dataType != GlobalDataType.UINT &&
            dataType != GlobalDataType.NOT_INITIALIZED,
        "GLOBAL_NOT_UINT256: Global is not initialized to a integer type"
    );

    return _globalValueUint256[globalId];
}

\color{black} \color{white}

dataType != GlobalDataType.UINT should be dataType == GlobalDataType.UINT

GameGlobals.sol

function getInt256(uint32 globalId)
    external
    view
    override
    returns (int256)
{
    GlobalDataType dataType = _globalMetadata[globalId].dataType;
    require(
        dataType != GlobalDataType.INT &&
            dataType != GlobalDataType.NOT_INITIALIZED,
        "GLOBAL_NOT_UINT256: Global is not initialized to a integer type"
    );

    return _globalValueInt256[globalId];
}

\color{black} \color{white}

dataType != GlobalDataType.INT should be dataType == GlobalDataType.INT

Score
Impact: 3
Likelihood: 1
Recommendation

SOLVED: The Proof of Play team corrected the require statements mentioned in the GameGlobals contract.

8.14 QUESTINPUT.REQUIRED VALUE IS NEVER CHECKED

// Low

Description

In the QuestSystem contract, the following structure is defined:

QuestSystem.sol

struct QuestInput {
    // Pointer to a token (if ERC20, ERC721, or ERC1155 input type)
    GameRegistryLibrary.TokenPointer tokenPointer;
    // Traits to check against
    TraitsLibrary.TraitCheck[] traitChecks;
    // Whether or not this input is required
    bool required;
    // Whether or not the input is burned
    bool consumable;
    // Chance of losing the consumable item on a failure, 0 - 10000 (0 = 0%, 10000 = 100%)
    uint32 failureBurnRate;
    // Chance of burning the consumable item on success, 0 - 10000 (0 = 0%, 10000 = 100%)
    uint32 successBurnRate;
    // Amount of XP gained by this input (ERC721-types only, 0 - 10000 (0 = 0%, 10000 = 100%))
    uint32 xpEarnedPercent;
}

\color{black} \color{white}

This structure is used to define the items that a user should have to perform a specific quest. One of the fields of that struct is the required field, which apparently should be used by the smart contract to determine whether the input is required to start a quest or not.

In the case that the required field is false the quest should not force the user to have that input to start the quest although the required field is not checked anywhere in the QuestSystem smart contract and the contract always enforces the user to have that input.

Score
Impact: 3
Likelihood: 2
Recommendation

RISK ACCEPTED: The Proof of Play team accepted the risk of this finding.

8.15 LACK OF DISABLEINITIALIZERS CALL TO PREVENT UNINITIALIZED CONTRACTS

// Low

Description

Multiple contracts are using the Initializable module from OpenZeppelin. To prevent leaving an implementation contract uninitialized OpenZeppelin's documentation recommends adding the _disableInitializers function in the constructor to automatically lock the contracts when they are deployed:

DisableInitializers function

/**
 * @dev Locks the contract, preventing any future reinitialization. This cannot be part of an initializer call.
 * Calling this in the constructor of a contract will prevent that contract from being initialized or reinitialized
 * to any version. It is recommended to use this to lock implementation contracts that are designed to be called
 * through proxies.
 */
function _disableInitializers() internal virtual {
    require(!_initializing, "Initializable: contract is initializing");
    if (_initialized < type(uint8).max) {
        _initialized = type(uint8).max;
        emit Initialized(type(uint8).max);
    }
}

\color{black} \color{white}

Score
Impact: 3
Likelihood: 1
Recommendation

RISK ACCEPTED: The Proof of Play team accepted the risk of this finding.

8.16 USERS CAN NOT UNSTAKE NFTS AFTER A CALL TO RESCUEUNLOCKNFT OR RESCUEUNLOCKITEM FUNCTIONS

// Informational

Description

In the LockingSystem contract, the rescueUnlockNFT() and rescueUnlockItems() are used in case of an emergency, so users can bypass all reservations and unlock their NFTs and items:

LockingSystem.sol

/**
 * @notice Bypasses all reservations and lets the user forcibly unlock their NFT
 *
 * @param tokenContract  Token Contract to unlock
 * @param tokenId        Token Id to unlock
 */
function rescueUnlockNFT(address tokenContract, uint256 tokenId)
    external
    nonReentrant
{
    require(
        rescueUnlockEnabled == true,
        "RESCUE_NOT_ENABLED: Rescue mode not enabled"
    );
    require(
        IGameNFT(tokenContract).ownerOf(tokenId) == _msgSender(),
        "SENDER_NOT_NFT_OWNER: sender must be token owner"
    );

    // Delete lock
    delete _lockedNFTs[tokenContract][tokenId];

    // Unlock token, if locked
    if (IGameNFT(tokenContract).isLocked(tokenId)) {
        IGameNFT(tokenContract).unlockToken(tokenId);
    }
}

/**
 * @notice Bypasses all reservations and lets the user forcibly unlock their items.
 *
 * @param account        Account to unlock items for
 * @param tokenContract  Token Contract to unlock
 * @param tokenId        Token Id to unlock
 */
function rescueUnlockItems(
    address account,
    address tokenContract,
    uint256 tokenId
) external nonReentrant {
    require(
        rescueUnlockEnabled == true,
        "RESCUE_NOT_ENABLED: Rescue mode not enabled"
    );
    require(
        account == _msgSender(),
        "SENDER_NOT_ITEM_OWNER: sender must be token owner"
    );

    // Delete any lock data
    delete _lockedItems[account][tokenContract][tokenId];

    // Unlock token, if locked
    uint256 balanceLocked = IGameItems(tokenContract).amountLocked(
        account,
        tokenId
    );
    if (balanceLocked > 0) {
        IGameItems(tokenContract).unlockToken(
            account,
            tokenId,
            balanceLocked
        );
    }
}

\color{black} \color{white}

In case that one of those NFTs rescued are staked in the StakingSystem contract, any call to claimGameNFTStakeRewards() or claimNavyStakeRewards() trying to unstake would revert. The claimGameNFTStakeRewards() function would call removeNFTReservation() causing an underflow in the following line:

LockingSystem.sol

function removeNFTReservation(
    address tokenContract,
    uint256 tokenId,
    uint32 reservationId
) external override onlyRole(GameRegistryLibrary.GAME_LOGIC_CONTRACT_ROLE) {
    NFTLockStatus storage lockStatus = _lockedNFTs[tokenContract][tokenId];
    NFTReservation storage reservation = lockStatus.reservations[
        reservationId
    ];

    // Make sure reservation exists
    require(
        reservation.timestamp > 0,
        "RESERVATION_NOT_FOUND: No reservation was found with that id"
    );

    // Remove from Ids array
    uint32 index = lockStatus.reservationIndexes[reservationId];
    if (index != lockStatus.reservationIds.length - 1) {
        uint32 lastId = lockStatus.reservationIds[
            lockStatus.reservationIds.length - 1
        ];
        lockStatus.reservationIds[index] = lastId;
        lockStatus.reservationIndexes[lastId] = index;
    }

    // Remove from all array
    lockStatus.reservationIds.pop();

    // Update the reserved amount if it was an exclusive reservation
    if (reservation.exclusive) {
        lockStatus.hasExclusiveReservation = false;
    }

    // Delete the reservation mapping
    delete lockStatus.reservations[reservationId];

    // Delete index mapping
    delete lockStatus.reservationIndexes[reservationId];
}

\color{black} \color{white}

In the case of claimNavyStakeRewards(), removeItemReservation() would be called, and again, an underflow would occur in the following line:

LockingSystem.sol

function removeItemReservation(
    address account,
    address tokenContract,
    uint256 tokenId,
    uint32 reservationId
) external override onlyRole(GameRegistryLibrary.GAME_LOGIC_CONTRACT_ROLE) {
    ItemLockStatus storage lockStatus = _lockedItems[account][
        tokenContract
    ][tokenId];
    ItemReservation storage reservation = lockStatus.reservations[
        reservationId
    ];

    // Make sure reservation exists
    require(
        reservation.timestamp > 0,
        "RESERVATION_NOT_FOUND: No reservation was found with that id"
    );

    // Remove from Ids array
    uint32 index = lockStatus.reservationIndexes[reservationId];
    if (index != lockStatus.reservationIds.length - 1) {
        uint32 lastId = lockStatus.reservationIds[
            lockStatus.reservationIds.length - 1
        ];
        lockStatus.reservationIds[index] = lastId;
        lockStatus.reservationIndexes[lastId] = index;
    }

    // Remove from all array
    lockStatus.reservationIds.pop();

    // Update the reserved amount if it was an exclusive reservation
    if (reservation.exclusive) {
        lockStatus.amountExclusivelyReserved -= reservation.amount;
    } else {
        // Calculate number of items needed for non-exclusive reservation
        uint256 max = 0;
        for (
            uint256 idx = 0;
            idx < lockStatus.reservationIds.length;
            idx++
        ) {
            ItemReservation storage otherReservation = lockStatus
                .reservations[lockStatus.reservationIds[idx]];
            if (
                otherReservation.exclusive == false &&
                otherReservation.amount > max
            ) {
                max = otherReservation.amount;
            }
        }

        lockStatus.maxNonExclusiveReserved = max;
    }

    // Delete the reservation mapping
    delete lockStatus.reservations[reservationId];

    // Delete index mapping
    delete lockStatus.reservationIndexes[reservationId];
}

\color{black} \color{white}

1.png
Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Proof of Play team acknowledged this finding. The team claims that if LockingSystem.setRescueUnlockEnabled(True) is called, they are abandoning the staking contract. It is the "nuclear" option to make sure player assets are always recoverable.

8.17 DANGEROUS USAGE OF TX.ORIGIN

// Informational

Description

tx.origin-based protection can be abused by a malicious contract if a legitimate user interacts with the malicious contract. For example:

Example.sol

contract TxOrigin {
    address owner = msg.sender;

    function bug() {
        require(tx.origin == owner);
    }

Bob owns TxOrigin. Bob calls Eve's contract. Eve's contract calls TxOrigin and bypasses the tx.origin protection.

Code Location

ERC721Lockable.sol

  • Line 36: ownerOf(tokenId) == tx.origin,
  • Line 71: tx.origin == ownerOf(tokenId),
  • Line 91: tx.origin == ownerOf(tokenId),

GameItems.sol

  • Line 185: tx.origin == account,
  • Line 210: tx.origin == account,

GameNFT.sol

  • Line 66: tx.origin == ownerOf(tokenId),
  • Line 85: tx.origin == ownerOf(tokenId),

StakingSystem.sol

  • Line 215: tx.origin == _msgSender() ||
  • Line 224: account == tx.origin,
  • Line 293: tx.origin == _msgSender() ||
  • Line 302: account == tx.origin,
  • Line 396: tx.origin == _msgSender(),
  • Line 431: tx.origin == account,
  • Line 661: address relevantAccount = tx.origin;

PirateGameV1.sol

  • Line 188: tx.origin == _msgSender(),
  • Line 272: NFT storage captainNFT = captainNFTs[tx.origin];
  • Line 293: IGameNFT(tokenContract).ownerOf(tokenId) == tx.origin,
  • Line 352: IGameNFT(nftContract).ownerOf(nftTokenId) == Line
  • Line 380: goldToken.burn(tx.origin, goldRequired);
Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Proof of Play team acknowledged this finding.

8.18 STATE VARIABLES MISSING CONSTANT MODIFIER

// Informational

Description

State variables can be declared as constant or immutable. In both cases, the variables cannot be modified after the contract has been constructed. For constant variables, the value has to be fixed at compile-time, while for immutable, it can still be assigned at construction time. The following state variables are missing the constant modifier:

Randomizer.sol

  • Line 35: uint32 callbackGasLimit = 1000000;
  • Line 38: uint16 requestConfirmations = 3;
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Proof of Play team declared the state variables mentioned as constant.

8.19 STATE VARIABLE MISSING IMMUTABLE MODIFIER

// Informational

Description
Finding description placeholder
Score
Impact:
Likelihood:

8.20 UNNEEDED INITIALIZATION OF UINT256 VARIABLES TO 0

// Informational

Description

As i is an uint256, it is already initialized to 0. uint256 i = 0 reassigns the 0 to i which wastes gas.

Code Location

TraitsConsumer.sol

  • Line 437: for (uint32 i = 0; i < traitIds.length; i++) {

GameItems.sol

  • Line 339: for (uint8 idx = 0; idx < ids.length; idx++) {

GameNFT.sol

  • Line 178: for (uint32 i = 0; i < traitIds.length; i++) {

StakingSystem.sol

  • Line 235: for (uint256 i = 0; i < tokenIds.length; i++) {
  • Line 325: for (uint256 i = 0; i < tokenIds.length; i++) {
  • Line 410: for (uint256 idx = 0; idx < nftContracts.length; idx++) {
  • Line 439: for (uint256 idx = 0; idx < stakeIndexes.length; idx++) {
  • Line 546: for (uint256 idx = 0; idx < nftStake.gameItemStakes.length; idx++) {
  • Line 678: for (uint256 i = 0; i < nftStake.gameItemStakes.length; i++) {

PirateGameV1.sol

  • Line 228: for (uint32 i = 0; i < tokenIds.length; i++) {
  • Line 423: for (uint8 i = 0; i < shipBondingSupplyPercent.length; i++) {
  • Line 486: for (uint256 i = 0; i < amount; i++) {

RaffleMintV1.sol

  • Line 313: for (uint256 i = 0; i < tickets.length; i++) {
  • Line 553: for (uint256 i = 0; i < addresses.length; i++) {
  • Line 672: for (uint256 i = 0; i < amount; i++) {
  • Line 702: for (uint256 i = 0; i < amount; i++) {

ERC1155Lockable.sol

  • Line 185: for (uint8 idx = 0; idx < ids.length; idx++) {

LockingSystem.sol

  • Line 198: for (uint256 idx = 0; idx < tokenIds.length; idx++) {
  • Line 228: for (uint256 idx = 0; idx < tokenIds.length; idx++) {
  • Line 463: for (uint256 idx = 0; idx < lockStatus.reservationIds.length; idx++)

TraitsProvider.sol

  • Line 128: for (uint256 idx = 0; idx < traitIds.length; idx++) {
  • Line 174: for (uint256 idx = 0; idx < traitIds.length; idx++) {
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Proof of Play team followed Halborn's suggestion and does not initialize uint variables to 0 reducing the gas costs.

8.21 USING ++I CONSUMES LESS GAS THAN I++ IN LOOPS

// Informational

Description

In the loops below, the variable i is incremented using i++. It is known that, in loops, using ++i costs less gas per iteration than i++.

Code Location

TraitsConsumer.sol

  • Line 437: for (uint32 i = 0; i < traitIds.length; i++) {

GameItems.sol

  • Line 339: for (uint8 idx = 0; idx < ids.length; idx++) {

GameNFT.sol

  • Line 178: for (uint32 i = 0; i < traitIds.length; i++) {

StakingSystem.sol

  • Line 235: for (uint256 i = 0; i < tokenIds.length; i++) {
  • Line 325: for (uint256 i = 0; i < tokenIds.length; i++) {
  • Line 410: for (uint256 idx = 0; idx < nftContracts.length; idx++) {
  • Line 439: for (uint256 idx = 0; idx < stakeIndexes.length; idx++) {
  • Line 546: for (uint256 idx = 0; idx < nftStake.gameItemStakes.length; idx++) {
  • Line 678: for (uint256 i = 0; i < nftStake.gameItemStakes.length; i++) {

ERC721BridgableChild.sol

  • Line 51: for (uint256 i; i < length; i++) {
  • Line 150: for (uint256 i; i < length; i++) {

PirateGameV1.sol

  • Line 228: for (uint32 i = 0; i < tokenIds.length; i++) {
  • Line 423: for (uint8 i = 0; i < shipBondingSupplyPercent.length; i++) {
  • Line 486: for (uint256 i = 0; i < amount; i++) {

RaffleMintV1.sol

  • Line 313: for (uint256 i = 0; i < tickets.length; i++) {
  • Line 553: for (uint256 i = 0; i < addresses.length; i++) {
  • Line 672: for (uint256 i = 0; i < amount; i++) {
  • Line 702: for (uint256 i = 0; i < amount; i++) {

ERC1155Lockable.sol

  • Line 185: for (uint8 idx = 0; idx < ids.length; idx++) {

LockingSystem.sol

  • Line 198: for (uint256 idx = 0; idx < tokenIds.length; idx++) {
  • Line 228: for (uint256 idx = 0; idx < tokenIds.length; idx++) {
  • Line 463: for (uint256 idx = 0; idx < lockStatus.reservationIds.length; idx++)

TraitsProvider.sol

  • Line 128: for (uint256 idx = 0; idx < traitIds.length; idx++) {
  • Line 174: for (uint256 idx = 0; idx < traitIds.length; idx++) {

For example, based in the following test contract:

Test.sol

//SPDX-License-Identifier: MIT
pragma solidity 0.8.9;

contract test {
    function postiincrement(uint256 iterations) public {
        for (uint256 i = 0; i < iterations; i++) {
        }
    }
    function preiincrement(uint256 iterations) public {
        for (uint256 i = 0; i < iterations; ++i) {
        }
    }
}

We can see the difference in the gas costs: posti_prei.png

Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Proof of Play team followed Halborn's suggestion and uses now ++i instead of i++ to increment the value of uint variables inside loops.

8.22 UNNEEDED ARRAYS DECLARATION IN FINISHMINTSHIPS FUNCTION

// Informational

Description

In the PirateGameV1 contract, the function _finishMintShips() contains the following code:

PirateGameV1.sol

function _finishMintShips(
    address to,
    uint32 amount,
    bool lock,
    uint256 randomness
) private {
    uint256 numPirateShips = 0;
    uint256 numNavyShips = 0;

    uint256 seed = 0;
    for (uint256 i = 0; i < amount; i++) {
        // Pick pirate or navy
        seed = uint256(keccak256(abi.encode(randomness, i)));

        // 20% chance of navy
        if ((seed & 0xFFFF) % 5 == 0) {
            numNavyShips++;
        } else {
            numPirateShips++;
        }
    }

    // Subtract pending mints
    mintsPending -= amount;

    uint256[] memory typeIds = new uint256[](2);
    uint256[] memory balances = new uint256[](2);

    if (numPirateShips > 0) {
        gameItems.mint(to, pirateShipTypeId, numPirateShips, lock);
        typeIds[0] = pirateShipTypeId;
        balances[0] = numPirateShips;
    }

    if (numNavyShips > 0) {
        gameItems.mint(to, navyShipTypeId, numNavyShips, lock);
        typeIds[1] = navyShipTypeId;
        balances[1] = numNavyShips;
    }
}

\color{black} \color{white} As we can see above, the arrays typeIds and balances are created in memory but they are not used anywhere in the code; hence they can be removed to reduce the gas costs.

Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Proof of Play team solved this issue.

8.23 MISSING VIEW FUNCTION THAT DISPLAYS ALL THE TICKETS OWNED BY A USER

// Informational

Description

In the RaffleMintV1 contract, there is no view function that displays what tickets are currently owned by a user.

The tickets would be mixed after clearRaffle() is called, which makes it very difficult for users to know which tickets they own. To get that information, they would have to manually query every single position of the raffleEntries array.

A view function, that displays the tickets owned by the user, would be especially useful to properly do the claimRaffle() call.

Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Proof of Play team corrected this issue. The function getEntriesForAddress() can be used to display what tickets are currently owned by the caller.

8.24 INCORRECT COMMENT

// Informational

Description

In the StakingSystem contract, in the fulfillRandomWordsCallback() function above the coinFlips state variable declaration, there is a comment that states:

"This should not overflow since the balance is determined by gameplay logic and the user will not have more than 256 ships per stake."

This comment is incorrect, as a 2^256 would definitely overflow. In this case, the maximum amount of ships per stake is 50 which corresponds to a Pirate with the maximum Command Rank:

StakingSystem.sol

// Number of ships a pirate can command for each command rank
uint8[] public SHIPS_PER_COMMAND_RANK = [
    0,
    5,
    10,
    15,
    20,
    25,
    30,
    35,
    40,
    45,
    50
];
Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Proof of Play team acknowledged this finding.

9. Automated Testing

STATIC ANALYSIS REPORT

Description

Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified the smart contracts in the repository and was able to compile them correctly into their abis and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.

Slither results

ERC721BridgableChild.sol

ERC721BridgableParent.sol

ERC721Lockable.sol

ERC1155Lockable.sol

GameItems.sol

GameNFT.sol

GameRegistry.sol

GameRegistryConsumer.sol

GoldToken.sol

LockingSystem.sol

PirateGameV1.sol

PirateNFT.sol

PirateNFTParent.sol

RaffleMintV1.sol

Randomizer.sol

StakingSystem.sol

TraitsConsumer.sol

TraitsProvider.sol

StagedMintV1.sol

LootSystem.sol

HoldingSystem.sol

QuestSystem.sol

GameGlobals.sol

CraftingSystem.sol

EnergySystem.sol

CaptainSystem.sol

ShipSystem.sol

ShipNFT.sol

ShipNFTTokenURIHandler.sol

ShipNFTBeforeTokenTransferHandler.sol

TokenTemplateSystem.sol

  • tx.origin authorization is used in multiple contracts which can be abused if a legitimate user interacts with a malicious contract.

  • All the reentrancies flagged by Slither were checked individually. All of them except one (HAL04 - REENTRANCY IN RAFFLEMINTV1.WITHDRAWNONRAFFLEPROCEEDS) are false positives.

  • The deletion on a mapping containing a structure was checked, although we did not find any exploitation path for it.

  • The shift parameter mixup flagged by Slither is a false positive.

  • The weak PRNG is a false positive, as the contracts make use of Chainlink VRF to generate random numbers.

AUTOMATED SECURITY SCAN

Description

Halborn used automated security scanners to assist with detection of well-known security issues and to identify low-hanging fruits on the targets for this engagement. Among the tools used was MythX, a security analysis service for Ethereum smart contracts. MythX performed a scan on the smart contracts and sent the compiled results to the analyzers in order to locate any vulnerabilities.

MythX results

ERC721BridgableChild.sol

ERC721BridgableParent.sol

ERC721Lockable.sol

ERC1155Lockable.sol

GameItems.sol

GameNFT.sol

GameRegistry.sol

GameRegistryConsumer.sol

GoldToken.sol

LockingSystem.sol

PirateGameV1.sol

PirateNFT.sol

PirateNFTParent.sol

RaffleMintV1.sol

Randomizer.sol

StakingSystem.sol

TraitsConsumer.sol

TraitsProvider.sol

StagedMintV1.sol

LootSystem.sol

HoldingSystem.sol

QuestSystem.sol

GameGlobals.sol

CraftingSystem.sol

EnergySystem.sol

CaptainSystem.sol

ShipSystem.sol

ShipNFT.sol

ShipNFTTokenURIHandler.sol

ShipNFTBeforeTokenTransferHandler.sol

TokenTemplateSystem.sol

  • The requirement violations and assert violations are all false positives.

  • Some state variables are missing the public/private keyword.

  • A floating pragma was correctly flagged by MythX.

  • Authorization through tx.origin was correctly flagged by MythX.

  • Integer Overflows and Underflows flagged by MythX are false positives, as all the contracts are using Solidity ^0.8.0 version. After the Solidity version 0.8.0 Arithmetic operations revert to underflow and overflow by default.

  • block.number is not being used as a source of randomness.

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.

// Download the full report

* Use Google Chrome for best results

** Check "Background Graphics" in the print settings if needed