As of 3 Nov 2026, The Sandbox has 8 audit reports by 2 security firms on Trustblock; the latest is by OpenZeppelin, dated 9 Aug 2023. Those reports list no open critical findings.
Record last updated . It holds audits and findings, contracts and incidents up to Nov 2026. Trustblock does not review code.
Newest first
| Audit | Security firm | Date | Scope | Critical findings | Report |
|---|---|---|---|---|---|
| Land and Tunnel Audit | Security firm: OpenZeppelin | Date: | Scope: Files named in the report | No open critical findings | Open · blog.openzeppelin.com |
| Sandbox L2 | Security firm: CertiK | Date: | Scope: Files named in the report | No open critical findings | Open · skynet.certik.com |
| Sandbox L1 | Security firm: CertiK | Date: | Scope: Files named in the report | No open critical findings | Open · skynet.certik.com |
| Sandbox Sand Reward Pool | Security firm: CertiK | Date: | Scope: Files named in the report | No open critical findings | Open · skynet.certik.com |
| Sandbox PR | Security firm: CertiK | Date: | Scope: Files named in the report | No open critical findings | Open · skynet.certik.com |
| Sandbox V1 | Security firm: CertiK | Date: | Scope: Files named in the report | No open critical findings | Open · skynet.certik.com |
| SandBox V4 | Security firm: CertiK | Date: | Scope: Files named in the report | No open critical findings | Open · skynet.certik.com |
| Sandbox V3 | Security firm: CertiK | Date: | Scope: Files named in the report | 1 critical finding, fixed | Open · skynet.certik.com |
Statuses are the security firm’s own, from each report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 1 |
| High | 1 | 2 | 4 |
| Medium | 1 | 0 | 4 |
| Low | 36 | 1 | 50 |
The function “_burn” ’s visibility is public. For a certain owner anyone can call this function by setting the same input for “from” and “owner”, hence burn the estate.
Open the reportThe report gives no description here.
Open the reportBoth functions `batchTransferQuad()` and `transferQuad()` call the internal function `_transferQuad()` to transfer quads. If the quad being transferred is 1x1, then the token is checked to ensure that it is not burned. However, when the quad is of a larger size, no such check is made, meaning that tokens with the burnt flag can have the flag reset. Each token is associated to a 256 bit `uint` in the mapping `_owners`. The least significant 160 bits are used for the token owner's address while the least significant 161st bit is used to decide if the token is burned or not. When using `batchTransferQuad()` or `transferQuad()` to transfer a quad of size larger than 1x1, the internal function `_regroup()` is called to perform checks and execute the transfer. The function `_regroup()` first checks if each 1x1 in the quad is owned by the correct owner for the transfer via `_checkAndClear()`, but this check only considers if the owner is non-zero and if so, the least significant 160 bits. ```solidity=534 function _checkAndClear(address from, uint256 id) internal returns (bool) { uint256 owner = _owners[id]; if (owner != 0) { require(address(uint160(owner)) == from, "not owner"); _owners[id] = 0; return true; } return false; } ``` Note that `_checkAndClear()` sets the owner of the 1x1 to 0 if the checks are passed. As burned tokens may retain information about the previous owner, such as if a user transferred a 1x1 LAND to themselves and then burned the 1x1, they would be able to clear the burn flag by calling `transferQuad()` to transfer the 1x1 to themselves. However, this process decreases the user's balance due to the burn, so resetting the burn flag can only be done if the user has an excess balance amount. Another issue of this is that it is possible for users to be unable to transfer quads. The reason is that the burning process decreases their balance. For example, if a user minted themselves a 6x6 quad, their current balance is 36. Suppose the user transfers a 1x1 LAND to themselves and then burns it, bringing their balance to 35. The user may forget this occurred and if they sell a 3x3 quad containing the burnt land, their new balance would be 26, even though they are the owner of 27 pieces of LAND. Hence they will be unable to sell all of their tokens due to an underflow revert. **Note: the detailed proof of concept can be found in *Appendix - Supplementary Tests: Test 1 & Test 2*.**
Open the reportThe function `burnFrom(from, id)` will enable anyone to burn the item, when the item id's operator is set to its owner.
Open the reportThe function “_ownerAndOperatorEnabledOf” returns the owner of an estate and whether an operator for the estate has been set. When an estate is burnt by “_burn” the value “_owners[id]” is not set to “0” but still tracks the last owner by setting the 161-th digit to “1”. The function “_ownerAndOperatorEnabledOf” does not consider this and would return the last owner even though the estate has already been burnt.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe Sandbox has an `anti-compound` mechanism to "implement a time buffer for reward retrieval". The associated modifier is defined as follow: ```solidity modifier antiCompoundCheck(address account) { require( block.timestamp > antiCompound.lastWithdraw[account] + antiCompound.lockPeriodInSecs, "SandRewardPool: must wait" ); antiCompound.lastWithdraw[account] = block.timestamp; _; } ``` The issue is that the variable `antiCompound.lastWithdraw[account]` is not set when a user enters `stake()`, meaning a user does not have to respect the delay when he first enters the staking contract before 7 days. Below is an example of a scenario that would bypass the mechanism, with `antiCompound.lockPeriodInSecs : 10 080 (7 days)`. Exploit Scenario: 1. User enters the contract with `stake()`. \ Since `antiCompound.lastWithdraw[account]` is not defined, it is equal to 0. 2. After 2 days, user calls `exit()`, since `antiCompound.lastWithdraw[account]` is 0, the condition below is met, and the user can exit the staking contract. ```solidity require(block.timestamp > antiCompound.lockPeriodInSecs,"SandRewardPool: must wait"); ``` 3. The user sends his staking token to a new address, and go back to Step 1. \ The user needs to use a new address since his current address now has a correct value for `antiCompound.lastWithdraw[account]`.
Open the reportThe _burn() function has a 'public' access modifier, which might be invoked by any address.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe report gives no description here.
Open the reportThe NatSpec of the `_mintQuad()` function states that mints a quad of size 3, 6, 12, or 24 only. ```solidity=111 * @notice Mint a new quad (aligned to a quad tree with size 3, 6, 12 or 24 only) ``` However, the function implementation allows mints of size 1. ```solidity=117 function _mintQuad( address to, uint256 size, uint256 x, uint256 y, bytes memory data ) internal { require(to != address(0), "to is zero address"); require(!exists(size, x, y), "Already minted"); uint256 quadId; uint256 id = x + y * GRID_SIZE; if (size == 1) { quadId = id; ``` **Proof of Concept** ```typescript describe('Can mint 1x1 Land with mintQuad function', function () { it('Invoke mintQuad() to mint 1x1 land', async function() { const {landOwners} = await setupTest(); const bytes = '0x3333'; await waitFor( landOwners[0].MockLandWithMint.mintQuad( landOwners[1].address, 1, 0, 0, bytes ) ); const owner = await landOwners[0].MockLandWithMint.ownerOf(0) await expect(owner).to.equal(landOwners[1].address); console.log('Successfully mint a 1x1 land at (0,0)') }); }); ``` result: ```typescript Successfully mint a 1x1 land at (0,0) ✓ Invoke mintQuad() to mint 1x1 land (6578ms) ```
Open the reportThe `approveFor()` and `approve()` functions are used to approve an operator to spend tokens on a user's behalf, but they are inconsistent with each other as it is possible to approve a burnt token using `approveFor()` but not `approve()`. When `approve()` is used, the owner of the token is acquired by calling `_ownerOf()`, which will return the zero address for a burnt token, and a check is done to ensure that the owner is not the zero address. ```solidity=35 function approve(address operator, uint256 id) external override { uint256 ownerData = _owners[_storageId(id)]; address owner = _ownerOf(id); address msgSender = _msgSender(); require(owner != address(0), "NONEXISTENT_TOKEN"); ``` Hence it is not possible to approve a burnt token. However, for the function `approveFor()`, the owner is acquired from the `_owners` mapping and the check on this owner is that the least significant 20 bytes is the same as the `sender`. ```solidity=51 function approveFor( address sender, address operator, uint256 id ) external { uint256 ownerData = _owners[_storageId(id)]; address msgSender = _msgSender(); require(sender != address(0), "ZERO_ADDRESS_SENDER"); require( msgSender == sender || _superOperators[msgSender] || _operatorsForAll[sender][msgSender], "UNAUTHORIZED_APPROVAL" ); require(address(uint160(ownerData)) == sender, "OWNER_NOT_SENDER"); ``` As a burnt token retains information about the previous owner, it is possible to pass all of these checks. A consequence of this is that after approving the burnt token, its associated value in the `_owners` mapping will be `BURNED_FLAG = 2**160`, losing the information of the previous owner. **Note: the detailed proof of concept can be found in *Appendix - Supplementary Tests: Test 3*.**
Open the reportThe `exists()` function is used to see if an input quad of size `size` starting at coordinates `(x,y)` is contained in or contains an already minted quad. It returns a bool value: - `true`: the input quad contains an already minted quad (meaning you cannot mint the quad) - `false`: the input quad does not contain an already minted quad (meaning you may mint the quad) However, this function does not ensure that `size` is a parameter supported by the project. Although this function is only called in `_mintQuad()` where `size` is ensured to be an input supported by the project, `exists()` is a `public` function, meaning a user may use this on unsupported parameters for `size`, which may lead to incorrect return values. For example, suppose a user wants enough LAND to cover a 13x13 square starting at (13,0). Assume (12,0) is owned as a 12x12 quad and no one owns a 24x24 quad at (0,0). Now suppose the user calls `exists(13,13,0)` to see if all LANDs and quads are free to mint. The 24x24 check passes since no one owns the 24x24. As `size` is larger than 12, the function checks if anyone owns a 12x12 starting at (13,0), which is not possible. Similarly, the 6x6, 3x3, and 1x1 checks can be passed so it returns `false` even though pieces of the 13x13 grid are owned. **Proof of Concept**\ The following test is derivated from the example above: ```typescript it('Exists function can be misleading', async function () { const {landOwners} = await setupTest(); const bytes = '0x3333'; await waitFor( landOwners[0].MockLandWithMint.mintQuad( landOwners[0].address, 12, 12, 0, bytes )); const owner = await landOwners[0].MockLandWithMint.ownerOf(12); await expect(owner).to.be.equal(landOwners[0].address); const existResult = await landOwners[0].MockLandWithMint.exists(13,13,0); await expect(existResult).to.be.equal(false); }); }); ``` The result shows it returns `false`, which could be misleading to front end/users. ```typescript Possibly Incorrect Return for Function exists() Nothing to compile ✓ Exists function can be misleading (6426ms) ```
Open the reportThe internal function `_ownerOfQuad()` is used to find the owner or parent owner of a quad and can only be called by itself or one of the regroup functions. As none of these will call `_ownerOfQuad()` with a value of 1 for the input variable `size`, the `if` code branch `size == 1` will never be reached.
Open the reportThe NatSpec of the `mintQuad()` function states that mints a quad of size 3, 6, 12, or 24 only. However, the function allows mints of size 1. ```solidity=75 function mintQuad(address to, uint256 size, uint256 x, uint256 y, bytes calldata data) external { require(to != address(0), "to is zero address"); require( isMinter(msg.sender), "Only a minter can mint" ); require(x % size == 0 && y % size == 0, "Invalid coordinates"); require(x <= GRID_SIZE - size && y <= GRID_SIZE - size, "Out of bounds"); uint256 quadId; uint256 id = x + y * GRID_SIZE; if (size == 1) { quadId = id; ```
Open the reportWhen the contract tries to mint a quad via `_mintQuad()`, there is a check to ensure that no quads containing the quad to mint and no quads (or LANDS) within the quad to mint have already been minted by calling the function `exists()`. This leads to a possible denial-of-service attack where the attacker mints 1x1 LANDS at specific locations to prevent the minting of larger quads. For example, out of the possible 166,464 LAND placements, only 289 LANDS need to be minted to prevent 24x24 quads from occurring.
Open the reportThe internal function `_ownerOfQuad()` is used to find the owner or parent owner of a quad and can only be called by itself or one of the regroup functions. As none of these will call `_ownerOfQuad()` with a value of 1 for the input variable `size`, the `if` code branch `size == 1` will never be reached.
Open the reportMinting of tokens on L1 is done by the function `mintQuad()`, which checks whether the tokens already exist or not. In detail, the `mintQuad()` function checks the owner of each token ID to decide whether or not the token has already been minted. For example, for 1x1 LAND tokens, it checks to see if the owner is non-zero. ```solidity=141 for (uint256 i = 0; i < size*size; i++) { uint256 id = _idInPath(i, size, x, y); require(_owners[id] == 0, "Already minted"); emit Transfer(address(0), to, id); } ``` However, for burnt tokens, their value in the `_owners` mapping is non-zero due to the burning flag. This means that burnt tokens cannot be minted again. **Proof of Concept** ```typescript it('Burnt land cannot be minted again', async function () { const { landContract, getNamedAccounts, ethers, mintQuad, } = await setupLand(); const {deployer, landAdmin} = await getNamedAccounts(); const contract = landContract.connect(ethers.provider.getSigner(deployer)); const x = 0; const y = 0; await mintQuad(deployer, 3, x, y); const tokenId = x + y * GRID_SIZE; await contract.burn(tokenId); await expect(mintQuad(deployer,1,x,y)).to.be.revertedWith('Already minted as 3x3'); }); ``` The test passed: ```typescript ✓ Burnt land cannot be minted again (64ms) ``` From the result, the burnt token is unable to be minted again.
Open the reportThe Sandbox wants to ensure that people stake their tokens for a certain amount of time before being able to withdraw their rewards. Users can claim their rewards by : - Calling the `getReward()` function; - Exiting the staking feature with the `exit()` function. In both cases, the function to withdraw the rewards is `_withdrawRewards()` : ```solidity function _withdrawRewards(address account) internal antiCompoundCheck(account) { ``` This `_withdrawRewards()` function uses the `antiCompoundCheck` modifier: ```solidity modifier antiCompoundCheck(address account) { require( block.timestamp > antiCompound.lastWithdraw[account] + antiCompound.lockPeriodInSecs, "SandRewardPool: must wait" ); antiCompound.lastWithdraw[account] = block.timestamp; _; } ``` The modifier essentially ensures that a period of time is respected between 2 rewards withdrawals. The issue is that in case of major security issue regarding the project, users might need to exit the staking immediately with their rewards. \ However, if a user does not meet the previous condition from the modifier, the whole `exit()` transaction would revert and the user would be stuck. The auditors would like to know how The Sandbox team would deal this issue.
Open the reportThe staking contract operates as the main entry for interaction with staking users. The staking users deposit tokens into the `SandRewardPool` contract and in return get a proportionate amount of shares. Later on, the staking users can withdraw their own assets from the pool. In this procedure, `_stake()` and `_unstake()` from `StakeTokenWrapper` are involved in transferring users’ assets into (or out of) the protocol. When transferring standard ERC20 deflationary tokens, the input amount may not be equal to the received amount due to the charged (and burned) transaction fee. As a result, this may not meet the assumption behind these low-level asset-transferring routines and will bring unexpected balance inconsistencies.
Open the reportThe given input are missing the check for the non-zero address: - `SandRewardPool`.`setTrustedForwarder()`: Modify the `Trusted Forwarder`; - `SandRewardPool`.`recoverFunds()`: Send rewards tokens from the contract.
Open the reportThe variable `rewardPool` assigned in the constructor can declare with `Immutable`. Immutable state variables can be assigned during contract creation but will remain constant throughout the lifetime of a deployed contract. A big advantage of immutable variables is that reading them is significantly cheaper than reading from regular state variables since will not be stored in storage. Still, values will be directly inserted the values into the runtime code.
Open the reportThe check for the validity of the transfer is missing in the function before making modifications to states. In the current code, _checkTransfer() is called before each call of _transferFrom() so the code is safe. However, this pattern is not guaranteed in future implementations
Open the reportThe given input `to` is missing the check for the non-zero address.
Open the reportIn the function balanceOf the return value is missing in the function declaration.
Open the reportIn the function `approveFor`, it can extracted as a common internal function_approveFor() for better code reusability.
Open the reportAccording to the package.json, it seems the codebase referenced the library `@openzeppelin/contracts-upgradeable" with a version higher than "^4.0.0". It called us the attention here because openzeppelin released a hotfix for UUPS contract vulnerability for contract version v4.1.0 to v4.3.1. back in September this year. Reference: https://forum.openzeppelin.com/t/security-advisory-initialize-uups-implementation-contracts/15301
Open the reportthe statemnt `408 (size of the land) is hard coded` could be declared as `constant` since these state variables are never to be changed.
Open the reportThe variable x,y,size are using `uint16`, which might cause additional type casting cost.
Open the reportThe function “_firstBytes32” between L123 and L127 is a leftover function of the previous iteration whereby the “_name” and “_symbol” variables were of type “bytes32”.
Open the reportThroughout the codebase, there are numerous instances where variables are checked using the GT operator against zero. This check costs more gas than simply conducting a not-equal operator with zero which achieves the same purpose.
Open the reportThe “storage” assignments of the aforementioned lines update all the fields of the “CatalystStored” struct at the specific index. Individually, each assignment needs to conduct specialized padding operations that increase their gas cost and conducts a full 256-bit update on storage.
Open the reportThe “CatalystMinter.sol” contract is utilizing the “SafeMathWithRequire” library whereas the “ERC20SubToken.sol” contract is utilizing the “SafeMath” library directly.
Open the reportThis library is meant to replicate the exact statements of the “SafeMath” library albeit with “require” statements rather than “assert” statements. The re-implementation is correct barring for the “div” implementation which does not “require” that “b” is different from zero.
Open the reportThe function of “CatalystDataBase” is called “_setMindData” whereby it interacts with minting data.
Open the reportThe declaration on L315 assigns a struct of the in-memory array of structs to an in-memory variable that is subsequently accessed by the line below it.
Open the reportThe aforementioned “require” calls rely on data that is accessible earlier than their invocations.
Open the reportThe conditional check of the “getValues” function checks whether the value of a gem ID is zero and if so, computes it and assigns it to the “values” array. If it is non-zero, however, the value literal “25” is assigned instead of the actual “valuesPerGemIds” value.
Open the reportThe “require” call of the specified line checks whether the “msg.sender” is a minter, however the error message in case the check fails is “NOT_AUTHORIZED_ADMIN”.
Open the reportIt is generally a widespread coding practice to store the maximum of an “uint256” to a “constant” contract variable if it is meant to be utilized across multiple segments of the code.
Open the reportThe specified line contains the magic number “0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF” which is meant to represent the address that triggers a “burn” instead of a “transfer” transaction.
Open the reportThe mapping lookups conducted in “_batchMint”, “batchTransferFrom” and “_batchBurnFrom” can be stored to an in-memory storage variable to optimize the gas cost of the functions significantly.
Open the reportThe function “addCatalysts” internally replicates the statements of “addCatalyst”.
Open the reportThe struct “CatalystData” contains a “uint16 maxGems” variable that is never read from and only assigned as a side-effect of “_setMintData”.
Open the reportThe return statement of L131 can be safely omitted as the named return variable is utilized within.
Open the reportWhen a variable is declared in Solidity it possesses a default value, usually equal to zero. As such, assignments of this default value to declared variables are unnecessary.
Open the reportThe “Admin.sol” contract multiple contracts of the scope inherit from provides methods via which a contract's administrator is maintained. This contract exposes an “onlyAdmin” modifier that makes sure the “msg.sender” is equal to the “_admin” of the contract, however this check is manually carried out in multiple segments of the codebase.
Open the reportThe contract at hand stores the “_name” and “_symbol” of an ERC-20 contract in raw “bytes32” format rather than a “string” format directly and consequently converts these raw byte formats to a “string” format in the corresponding getter functions of the variables.
Open the reportThe documentation of L103 indicates that code should never reach this point.
Open the reportA safe mathematics library is utilized throughout the codebase called “SafeMathWithRequire” except for the specified line whereby an unsafe addition is done with the result of a safe multiplication.
Open the reportThe “decimals” function of the above lines returns the value literal of “0” casted to a “uint8”.
Open the reportThe visibility specifiers of L113 and L114 & L146 and L147 are missing on CatalystDataBase and ERC20SubToken respectively.
Open the reportVisibility specifiers are missing in the aforementioned lines that contain contract-level variable declarations.
Open the reportThe reports name a repository or files. Open a report to see its scope.
Is this your project? Show this record on your own website. The label states what the record holds, links to this page and updates by itself. It is free and needs no sign-in.
Automatic: the three strongest facts of this project’s own record (its last audit and two more), highlighted. They update with the record. Picking a fact turns Automatic off.
<head><script src="https://widget.trustblock.run/project-widget.min.js" defer></script></head><body><divdata-tb-project-widgetdata-tb-project-slug="the-sandbox"></div></body>
Paste the code where the label should appear. It uses no API credit and updates by itself.
Something wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us