Prepared by:
HALBORN
Last Updated 04/26/2024
Date of Engagement by: April 25th, 2022 - March 7th, 2023
96% of all REPORTED Findings have been addressed
All findings
24
Critical
3
High
5
Medium
4
Low
3
Informational
9
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
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
.
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
)
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:
f5c3190140139941351a68da617a91315487e917 Contracts added to the scope of this audit:
(Code changes from previously audited contracts)
LootSystem.sol
HoldingSystem.sol
QuestSystem.sol
GameGlobals.sol
Third audit Commit ID:
c49710da0cfa94e2b3bee730bf980a89a059a700 Contracts added to the scope of this audit:
(Code changes from previously audited contracts)
CraftingSystem.sol
Fourth audit Commit ID:
05007dcea3bf1f7f05b53f3d6a0cba04bc7032a0 Contracts added to the scope of this audit:
(Code changes from previously audited contracts)
EnergySystem.sol
Fifth audit Commit ID:
888ea7ac11564e981171d1b6bf671c289bee746b Added code fixes to some of the previously found issues to this Commit ID.
Sixth audit Commit ID:
856d14f0eb2b1ebbeca937ba202f9b0d74a361c6 Addition of the Delegated Transactions feature. No issues were found on this feature.
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:
e144751a79d371a8444c41b355201c753df99695 No new issues were found on this Commit ID.
Ninth audit Commit ID:
99250171609c4311a3392bfb6d44b1de8451a835 Added code fixes to some of the previously found issues to this commit ID.
Tenth audit Commit ID:
436dcee1b541a8ed9a29f3b5b6fe099de8f81ce6 No new issues were found on this Commit ID.
Eleventh audit Commit ID:
aedd5dbab65f96b452b5df0627bb562d8db8e41a No new issues were found on this Commit ID.
Twelfth audit Commit IDs:
bcca0b23cb776f7b4bedf1965b481ff732db733f No new issues were found on these 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
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 analysis | Risk level | Remediation Date |
---|---|---|
USERS CAN START A QUEST USING AS INPUT AND BURNING AN NFT THEY DO NOT OWN | Critical | Solved - 09/10/2022 |
FLAWED LOGIC CAUSES THAT NAVIES WILL NEVER STEAL PIRATE'S GOLD | Critical | Solved - 06/02/2022 |
LACK OF ACCESS CONTROL IN LOOTSYSTEM.BATCHGRANTLOOTWITHOUTRANDOMNESS FUNCTION | Critical | Solved - 03/06/2023 |
UNSAFE CAST CAN ALLOW USERS TO PERMANENTLY MINT GOLD TOKENS | High | Solved - 06/02/2022 |
REENTRANCY IN RAFFLEMINTV1.WITHDRAWNONRAFFLEPROCEEDS | High | Solved - 06/02/2022 |
USERS CAN START THE SAME QUEST MULTIPLE TIMES DRAINING THE CHAINLINK VRF SUBSCRIPTION | High | Risk Accepted |
USERS CAN CRAFT USING AS INPUT AN NFT THEY DO NOT OWN | High | Solved - 09/10/2022 |
CRAFTAMOUNT CAN BE SET TO ZERO DRAINING THE CHAINLINK VRF SUBSCRIPTION | High | Solved - 09/10/2022 |
CRAFTS COOLDOWN TIME ARE ALWAYS ZERO | Medium | Partially Solved |
QUESTDEFINITION.MAXCOMPLETIONS CAN BE BYPASSED BY STARTING THE SAME QUEST MULTIPLE TIMES BEFORE COMPLETING THEM | Medium | Risk Accepted |
LACK OF PAUSABLE FUNCTIONALITY IN THE LOOTSYSTEM CONTRACT | Medium | Risk Accepted |
MINTBATCH FUNCTION IS NOT IMPLEMENTED | Medium | Risk Accepted |
WRONG REQUIRE STATEMENTS IN GAMEGLOBALS CONTRACT | Low | Solved - 09/10/2022 |
QUESTINPUT.REQUIRED VALUE IS NEVER CHECKED | Low | Risk Accepted |
LACK OF DISABLEINITIALIZERS CALL TO PREVENT UNINITIALIZED CONTRACTS | Low | Risk Accepted |
USERS CAN NOT UNSTAKE NFTS AFTER A CALL TO RESCUEUNLOCKNFT OR RESCUEUNLOCKITEM FUNCTIONS | Informational | Acknowledged |
DANGEROUS USAGE OF TX.ORIGIN | Informational | Acknowledged |
STATE VARIABLES MISSING CONSTANT MODIFIER | Informational | Solved - 09/10/2022 |
STATE VARIABLE MISSING IMMUTABLE MODIFIER | Informational | - |
UNNEEDED INITIALIZATION OF UINT256 VARIABLES TO 0 | Informational | Solved - 09/10/2022 |
USING ++I CONSUMES LESS GAS THAN I++ IN LOOPS | Informational | Solved - 09/10/2022 |
UNNEEDED ARRAYS DECLARATION IN FINISHMINTSHIPS FUNCTION | Informational | Solved - 09/10/2022 |
MISSING VIEW FUNCTION THAT DISPLAYS ALL THE TICKETS OWNED BY A USER | Informational | Solved - 09/10/2022 |
INCORRECT COMMENT | Informational | Acknowledged |
// Critical
In the QuestSystem
contract, the startQuest()
function does not check that the NFT inputs are actually owned by the caller:
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:
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.
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.
// Critical
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:
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:
uint256 coinFlips = randomness % (2**request.balance);
(2**request.balance) = (2**0) = 1
, (considering that request.balance will always be 0 here)uint256 coinFlips = randomness % 1
, (any number divided by 1 will always have as remainder 0)coinFlips
will always be 0coinFlips
is 0, numStolen
will never be increased and will always be 0, hence: Navies will never steal pirate's gold when they are unstaked.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):
SOLVED: The Proof of Play team
fixed the issue and now correctly updates the balance
local variable before making the _requestRandomWords(1)
call:
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}
// Critical
In the LootSystem
contract, the function batchGrantLootWithoutRandomness()
was implemented:
/**
* @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.
SOLVED: The Proof of Play team
fixed the issue and added access control to the LootSystem.batchGrantLootWithoutRandomness()
function:
/**
* @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);
}
}
}
// High
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
:
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
:
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()
:
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.
// High
In the contract RaffleMintV1
, the function withdrawNonRaffleProceeds()
is vulnerable to reentrancy as it updates the nonRaffleWithdrawableProceeds
after the payable(_msgSender()).call{value: proceeds}("");
:
/* @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.
SOLVED: The Proof of Play team
fixed the issue by adding the nonReentrant
modifier to the withdrawNonRaffleProceeds()
function.
// High
In the QuestSystem
contract every time a quest is completed Chainlink VRF is used:
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.
RISK ACCEPTED: No mitigation was added to prevent this issue.
// High
In the CraftingSystem
contract, the craft()
function is called every time a new craft is started:
} 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.
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.
// High
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.
SOLVED: The Proof of Play team
added a require statement in the craft()
function that enforces that craftAmount
is higher than zero.
// Medium
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.
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.
// Medium
In the QuestSystem
contract, the _isQuestAvailable()
function is called every time a new quest is started:
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.
RISK ACCEPTED: The Proof of Play team
accepted the risk of this finding.
// Medium
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:
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.
RISK ACCEPTED: The Proof of Play team
accepted the risk of this finding.
// Medium
The LootSystem
contract makes use of the IGameNFTLoot.mintBatch()
function to assign LootType.ERC721
:
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:
RISK ACCEPTED: The Proof of Play team
accepted the risk of this finding. Currently, the mintBatch()
function is not implemented in the GameNFT
contract.
// Low
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:
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
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
SOLVED: The Proof of Play team
corrected the require statements mentioned in the GameGlobals
contract.
// Low
In the QuestSystem
contract, the following structure is defined:
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.
RISK ACCEPTED: The Proof of Play team
accepted the risk of this finding.
// Low
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:
/**
* @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}
RISK ACCEPTED: The Proof of Play team
accepted the risk of this finding.
// Informational
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:
/**
* @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:
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:
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}
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.
// Informational
tx.origin-based protection can be abused by a malicious contract if a legitimate user interacts with the malicious contract. For example:
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.
ERC721Lockable.sol
ownerOf(tokenId) == tx.origin,
tx.origin == ownerOf(tokenId),
tx.origin == ownerOf(tokenId),
GameItems.sol
tx.origin == account,
tx.origin == account,
GameNFT.sol
tx.origin == ownerOf(tokenId),
tx.origin == ownerOf(tokenId),
StakingSystem.sol
tx.origin == _msgSender() ||
account == tx.origin,
tx.origin == _msgSender() ||
account == tx.origin,
tx.origin == _msgSender(),
tx.origin == account,
address relevantAccount = tx.origin;
PirateGameV1.sol
tx.origin == _msgSender(),
NFT storage captainNFT = captainNFTs[tx.origin];
IGameNFT(tokenContract).ownerOf(tokenId) == tx.origin,
IGameNFT(nftContract).ownerOf(nftTokenId) == Line
goldToken.burn(tx.origin, goldRequired);
ACKNOWLEDGED: The Proof of Play team
acknowledged this finding.
// Informational
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
uint32 callbackGasLimit = 1000000;
uint16 requestConfirmations = 3;
SOLVED: The Proof of Play team
declared the state variables mentioned as constant
.
// Informational
// Informational
As i
is an uint256
, it is already initialized to 0. uint256 i = 0
reassigns the 0 to i
which wastes gas.
TraitsConsumer.sol
for (uint32 i = 0; i < traitIds.length; i++) {
GameItems.sol
for (uint8 idx = 0; idx < ids.length; idx++) {
GameNFT.sol
for (uint32 i = 0; i < traitIds.length; i++) {
StakingSystem.sol
for (uint256 i = 0; i < tokenIds.length; i++) {
for (uint256 i = 0; i < tokenIds.length; i++) {
for (uint256 idx = 0; idx < nftContracts.length; idx++) {
for (uint256 idx = 0; idx < stakeIndexes.length; idx++) {
for (uint256 idx = 0; idx < nftStake.gameItemStakes.length; idx++) {
for (uint256 i = 0; i < nftStake.gameItemStakes.length; i++) {
PirateGameV1.sol
for (uint32 i = 0; i < tokenIds.length; i++) {
for (uint8 i = 0; i < shipBondingSupplyPercent.length; i++) {
for (uint256 i = 0; i < amount; i++) {
RaffleMintV1.sol
for (uint256 i = 0; i < tickets.length; i++) {
for (uint256 i = 0; i < addresses.length; i++) {
for (uint256 i = 0; i < amount; i++) {
for (uint256 i = 0; i < amount; i++) {
ERC1155Lockable.sol
for (uint8 idx = 0; idx < ids.length; idx++) {
LockingSystem.sol
for (uint256 idx = 0; idx < tokenIds.length; idx++) {
for (uint256 idx = 0; idx < tokenIds.length; idx++) {
for (uint256 idx = 0; idx < lockStatus.reservationIds.length; idx++)
TraitsProvider.sol
for (uint256 idx = 0; idx < traitIds.length; idx++) {
for (uint256 idx = 0; idx < traitIds.length; idx++) {
SOLVED: The Proof of Play team
followed Halborn's suggestion and does not initialize uint variables to 0 reducing the gas costs.
// Informational
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++
.
TraitsConsumer.sol
for (uint32 i = 0; i < traitIds.length; i++) {
GameItems.sol
for (uint8 idx = 0; idx < ids.length; idx++) {
GameNFT.sol
for (uint32 i = 0; i < traitIds.length; i++) {
StakingSystem.sol
for (uint256 i = 0; i < tokenIds.length; i++) {
for (uint256 i = 0; i < tokenIds.length; i++) {
for (uint256 idx = 0; idx < nftContracts.length; idx++) {
for (uint256 idx = 0; idx < stakeIndexes.length; idx++) {
for (uint256 idx = 0; idx < nftStake.gameItemStakes.length; idx++) {
for (uint256 i = 0; i < nftStake.gameItemStakes.length; i++) {
ERC721BridgableChild.sol
for (uint256 i; i < length; i++) {
for (uint256 i; i < length; i++) {
PirateGameV1.sol
for (uint32 i = 0; i < tokenIds.length; i++) {
for (uint8 i = 0; i < shipBondingSupplyPercent.length; i++) {
for (uint256 i = 0; i < amount; i++) {
RaffleMintV1.sol
for (uint256 i = 0; i < tickets.length; i++) {
for (uint256 i = 0; i < addresses.length; i++) {
for (uint256 i = 0; i < amount; i++) {
for (uint256 i = 0; i < amount; i++) {
ERC1155Lockable.sol
for (uint8 idx = 0; idx < ids.length; idx++) {
LockingSystem.sol
for (uint256 idx = 0; idx < tokenIds.length; idx++) {
for (uint256 idx = 0; idx < tokenIds.length; idx++) {
for (uint256 idx = 0; idx < lockStatus.reservationIds.length; idx++)
TraitsProvider.sol
for (uint256 idx = 0; idx < traitIds.length; idx++) {
for (uint256 idx = 0; idx < traitIds.length; idx++) {
For example, based in the following test contract:
//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:
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.
// Informational
In the PirateGameV1
contract, the function _finishMintShips()
contains the following code:
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.
SOLVED: The Proof of Play team
solved this issue.
// Informational
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.
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.
// Informational
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:
// 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
];
ACKNOWLEDGED: The Proof of Play team
acknowledged this finding.
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.
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.
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.
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