Prepared by:
HALBORN
Last Updated 04/26/2024
Date of Engagement by: February 4th, 2022 - March 25th, 2022
100% of all REPORTED Findings have been addressed
All findings
7
Critical
0
High
0
Medium
2
Low
3
Informational
2
Seascape engaged Halborn to conduct a security audit on their smart contracts beginning on 2022-02-04 and ending on 2022-03-25. The security assessment was scoped to the smart contracts provided to the Halborn team.
The team at Halborn was provided seven weeks for the engagement and assigned a full-time security engineer to audit the security of the smart contract. 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.
The purpose of this audit is to:
Ensure that smart contract functions operate as intended
Identify potential security issues with the smart contracts
In summary, Halborn identified some security risks that were mostly addressed by the Seascape 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 bridge code and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of 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
Static Analysis of security for scoped contract, and imported functions. (Slither
)
Testnet deployment (Brownie
, Remix IDE
)
The security assessment was scoped to the following Seastarinteractive/moonscape-smartcontracts
/contracts/game/MoonscapeGame.sol
/contracts/nfts/CityNft.sol
/contracts/nfts/RoverNft.sol
Commit ID: 7f01159e97c132b6bbad5d5511768461d2d480c9
/contracts/defi/MoonscapeDefi.sol
/contracts/defi/Stake.sol
/contracts/beta/MoonscapeBeta.sol
Commit ID: ba85acb2bf7b893d338b9150c2273305e5303eb7
Critical
0
High
0
Medium
2
Low
3
Informational
2
Impact x Likelihood
HAL-02
HAL-01
HAL-03
HAL-04
HAL-06
HAL-07
HAL-05
Security analysis | Risk level | Remediation Date |
---|---|---|
UNTRUSTED TOKENS NOT CHECKING BEFORE AND AFTER BALANCE | Medium | Solved - 04/04/2022 |
POSSIBLE SIGNATURE RE-USAGE | Medium | Solved - 03/31/2022 |
MULTIPLE ACTIVE SESSIONS | Low | Risk Accepted |
MISSING PARAMETERS VALIDATION | Low | Solved - 04/04/2022 |
UNUSED MINTED VARIABLE | Low | Solved - 04/06/2022 |
STORAGE USAGE ON READ ONLY FLOW | Informational | Solved - 03/31/2022 |
UNUSED PARAMETERS | Informational | Solved - 04/04/2022 |
// Medium
None
function _safeTransfer(address _token, address _to, uint256 _amount) internal {
if (_token != address(0)) {
IERC20 _rewardToken = IERC20(_token);
uint256 _balance = _rewardToken.balanceOf(address(this));
if (_amount > _balance) {
_rewardToken.transfer(_to, _balance);
} else {
_rewardToken.transfer(_to, _amount);
}
} else {
uint256 _balance = address(this).balance;
if (_amount > _balance) {
payable(_to).transfer(_balance);
} else {
payable(_to).transfer(_amount);
}
}
}
function addTokenStaking(uint _sessionId, address stakeAddress, uint rewardPool, address rewardToken, uint _burn) external {
bytes32 key = keccak256(abi.encodePacked(_sessionId, stakeAddress, rewardToken));
require(!addedStakings[key], "DUPLICATE_STAKING");
addedStakings[key] = true;
bool burn = true;
if (_burn == 1) {
burn = true;
} else {
burn = false;
}
tokenStakings[++stakeId] = TokenStaking(_sessionId, stakeAddress, rewardPool, rewardToken, burn);
bytes32 stakeKey = stakeKeyOf(sessionId, stakeId);
keyToId[stakeKey] = stakeId;
Session storage session = sessions[_sessionId];
newStakePeriod(
stakeKey,
session.startTime,
session.endTime,
rewardPool
);
emit AddStaking(_sessionId, stakeId);
}
SOLVED: The code does check the post balance and compares it to the sum of the pre-balance and the requested amount.
// Medium
None
function stakeToken(uint _stakeId, uint _cityId, uint _buildingId, uint _amount, uint8 v, bytes32[2] calldata sig) external {
TokenStaking storage tokenStaking = tokenStakings[_stakeId];
// todo
// validate the session id
bytes32 stakeKey = stakeKeyOf(tokenStaking.sessionId, _stakeId);
require(isActive(stakeKey), "session not active");
//validate stake id
require(_stakeId <= stakeId,"do not have this stakeId");
{
bytes memory prefix = "\x19Ethereum Signed Message:\n32";
bytes32 message = keccak256(abi.encodePacked(_stakeId, tokenStaking.sessionId, _cityId, _buildingId));
bytes32 hash = keccak256(abi.encodePacked(prefix, message));
address recover = ecrecover(hash, v, sig[0], sig[1]);
require(recover == owner(), "Verification failed about stakeToken");
}
deposit(stakeKey, msg.sender, _amount);
IERC20 token = IERC20(tokenStaking.stakeToken);
require(token.balanceOf(msg.sender) >= _amount, "Not enough token to stake");
// uint preBalance = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), _amount);
// _amount = token.balanceOf(address(this)) - preBalance;
emit StakeToken(msg.sender, tokenStaking.sessionId, _stakeId, _cityId, _buildingId, _amount);
}
function stakeNft(uint _stakeId, uint _cityId, uint _buildingId, uint _scapeNftId, uint _power, uint8 _v, bytes32[2] calldata sig) external {
TokenStaking storage tokenStaking = tokenStakings[_stakeId];
// validate the session id
bytes32 stakeKey = stakeKeyOf(tokenStaking.sessionId, _stakeId);
Player storage player = playerParams[stakeKey][msg.sender];
require(player.nftId <= 0 && player.power <= 0, "already stake nft");
require(isActive(stakeKey), "session not active");
//validate stake id
require(_stakeId <= stakeId,"do not have this stakeId");
IERC721 nft = IERC721(tokenStaking.stakeToken);
require(nft.ownerOf(_scapeNftId) == msg.sender, "not owned");
{
bytes memory prefix = "\x19Ethereum Signed Message:\n32";
bytes32 message = keccak256(abi.encodePacked(tokenStaking.sessionId, _stakeId, _cityId, _buildingId, _scapeNftId, _power));
bytes32 hash = keccak256(abi.encodePacked(prefix, message));
address recover = ecrecover(hash, _v, sig[0], sig[1]);
require(recover == owner(), "Verification failed about stakeNft");
}
nft.safeTransferFrom(msg.sender, address(this), _scapeNftId);
deposit(stakeKey, msg.sender, _power);
player.nftId = _scapeNftId;
player.power = _power;
emit StakeNft(msg.sender, tokenStaking.sessionId, _stakeId, _cityId, _buildingId, _scapeNftId, _power);
}
function getBonus(uint _stakeId, uint _cityId, uint _buildingId, uint _bonusPercent, uint8 _v, bytes32[2] calldata sig) external {
TokenStaking storage tokenStaking = tokenStakings[_stakeId];
Session storage session = sessions[tokenStaking.sessionId];
bytes32 stakeKey = stakeKeyOf(tokenStaking.sessionId, _stakeId);
require(block.timestamp > session.endTime, "it has to be after the session");
Player storage player = playerParams[stakeKey][msg.sender];
require(player.receiveBonus == true, "already rewarded");
{
bytes memory prefix = "\x19Ethereum Signed Message:\n32";
bytes32 message = keccak256(abi.encodePacked(_stakeId, tokenStaking.sessionId, _cityId, _buildingId, _bonusPercent));
bytes32 hash = keccak256(abi.encodePacked(prefix, message));
address recover = ecrecover(hash, _v, sig[0], sig[1]);
require(recover == owner(), "Verification failed about getBonus");
}
uint256 _totalreward = claimable(stakeKey, msg.sender) + player.claimedAmound;
uint256 _totalBonus = _totalreward.mul(scaler).mul(_bonusPercent).div(100).div(scaler);
require(_totalBonus > 0, "totalBonus must > 0");
_safeTransfer(tokenStaking.rewardToken, msg.sender, _totalBonus);
player.receiveBonus = true;
}
SOLVED: Code is not checking for msg.sender
in signatures.
// Low
None
function startSession(uint _startTime, uint _endTime) external {
require(validSessionTime(_startTime, _endTime), "INVALID_SESSION_TIME");
sessionId++;
sessions[sessionId] = Session(_startTime, _endTime, true);
emit StartSession(sessionId, _startTime, _endTime);
}
function validSessionTime(uint _startTime, uint _endTime) public view returns(bool) {
Session storage session = sessions[sessionId];
if (_startTime > session.endTime && _startTime >= block.timestamp && _startTime < _endTime) {
return true;
}
return false;
}
RISK ACCEPTED: The code is protected with the onlyOwner
modifier, so only the owner can add new sessions. Plus, the client states that: "This is done on purpose because if we by any chance make a mistake, we would need to redeploy everything. But with multiple active sessions, we can just open another session and change the active session in the client"
// Low
None
constructor(
address _mscpToken,
address _cityNft,
address _roverNft,
address _scapeNft,
address _verifier,
address _feeTo
) public {
MSCP = _mscpToken;
cityNft = _cityNft;
roverNft = _roverNft;
scapeNft = _scapeNft;
verifier = _verifier;
feeTo = _feeTo;
}
SOLVED: The code is now checking if the parameters are not zero.
// Low
None
function mint(uint _tokenId, uint8 _category, address _to) external returns(bool) {
if (!minters[msg.sender] || minted[_tokenId] || _to == address(0) || _category > 8) {
return false;
}
categoryOf[_tokenId] = _category;
_safeMint(_to, _tokenId);
emit Minted(_to, _tokenId, _category, block.timestamp);
return true;
}
function mint(uint _tokenId, uint8 _type, address _to) external returns(bool) {
if (!minters[msg.sender] || minted[_tokenId] || _to == address(0) || _type > 8) {
return false;
}
typeOf[_tokenId] = _type;
_safeMint(_to, _tokenId);
emit Minted(_to, _tokenId, _type, block.timestamp);
return true;
}
SOLVED: Variables were removed from the parameters and the contract storage.
// Informational
None
function addTokenStaking(uint _sessionId, address stakeAddress, uint rewardPool, address rewardToken, uint _burn) external {
bytes32 key = keccak256(abi.encodePacked(_sessionId, stakeAddress, rewardToken));
require(!addedStakings[key], "DUPLICATE_STAKING");
addedStakings[key] = true;
bool burn = true;
if (_burn == 1) {
burn = true;
} else {
burn = false;
}
tokenStakings[++stakeId] = TokenStaking(_sessionId, stakeAddress, rewardPool, rewardToken, burn);
bytes32 stakeKey = stakeKeyOf(sessionId, stakeId);
keyToId[stakeKey] = stakeId;
Session storage session = sessions[_sessionId];
newStakePeriod(
stakeKey,
session.startTime,
session.endTime,
rewardPool
);
emit AddStaking(_sessionId, stakeId);
}
function validSessionTime(uint _startTime, uint _endTime) public view returns(bool) {
Session storage session = sessions[sessionId];
if (_startTime > session.endTime && _startTime >= block.timestamp && _startTime < _endTime) {
return true;
}
return false;
}
SOLVED: The memory
keyword is now used instead of storage
.
// Informational
None
function mintRover(uint _id, uint8 _type, uint _amount, uint8 _v, bytes32 _r, bytes32 _s) external {
{ // avoid stack too deep
// investor, project verification
bytes memory prefix = "\x19Ethereum Signed Message:\n32";
bytes32 message = keccak256(abi.encodePacked(msg.sender, address(this), roverNft, _id, _amount, _type));
bytes32 hash = keccak256(abi.encodePacked(prefix, message));
address recover = ecrecover(hash, _v, _r, _s);
require(recover == verifier, "sig");
}
CityNft nft = CityNft(roverNft);
require(nft.mint(_id, _type, msg.sender), "Failed to mint rover");
emit MintRover(msg.sender, _amount, _id, _type);
}
function mintCity(uint _id, uint8 _category, uint _amount, uint8 _v, bytes32 _r, bytes32 _s) external {
{ // avoid stack too deep
// investor, project verification
bytes memory prefix = "\x19Ethereum Signed Message:\n32";
bytes32 message = keccak256(abi.encodePacked(msg.sender, address(this), cityNft, _id, _amount, _category));
bytes32 hash = keccak256(abi.encodePacked(prefix, message));
address recover = ecrecover(hash, _v, _r, _s);
require(recover == verifier, "sig");
}
CityNft nft = CityNft(cityNft);
require(nft.mint(_id, _category, msg.sender), "Failed to mint city");
emit MintCity(msg.sender, _amount, _id, _category);
}
SOLVED: Removed _amount
variable from function arguments.
digraph G {
graph [ ratio = "auto", page = "100", compound =true, bgcolor = "#2e3e56" ];
node [ style = "filled", fillcolor = "#edad56", color = "#edad56", penwidth =3 ];
edge [ color = "#fcfcfc", penwidth =2, fontname = "helvetica Neue Ultra Light" ];
subgraph "clusterMoonscapeGame" {
graph [ label = "MoonscapeGame", color = "#445773", fontcolor = "#f0f0f0", style = "rounded", bgcolor = "#445773" ];
"MoonscapeGame.<Constructor>" [ label = "<Constructor>", color = "#FF9797", fillcolor = "#FF9797" ];
"MoonscapeGame.purchaseMoondust" [ label = "purchaseMoondust", color = "#ffbdb9", fillcolor = "#ffbdb9" ];
"MoonscapeGame.stakeForMoondust" [ label = "stakeForMoondust", color = "#ffbdb9", fillcolor = "#ffbdb9" ];
"MoonscapeGame.unstakeForMoondust" [ label = "unstakeForMoondust", color = "#ffbdb9", fillcolor = "#ffbdb9" ];
"MoonscapeGame.importCity" [ label = "importCity", color = "#ffbdb9", fillcolor = "#ffbdb9" ];
"MoonscapeGame.exportCity" [ label = "exportCity", color = "#ffbdb9", fillcolor = "#ffbdb9" ];
"MoonscapeGame.mintCity" [ label = "mintCity", color = "#ffbdb9", fillcolor = "#ffbdb9" ];
"MoonscapeGame.burnScapeForBuilding" [ label = "burnScapeForBuilding", color = "#ffbdb9", fillcolor = "#ffbdb9" ];
"MoonscapeGame.burnScapeForConnection" [ label = "burnScapeForConnection", color = "#ffbdb9", fillcolor = "#ffbdb9" ];
"MoonscapeGame.importRover" [ label = "importRover", color = "#ffbdb9", fillcolor = "#ffbdb9" ];
"MoonscapeGame.exportRover" [ label = "exportRover", color = "#ffbdb9", fillcolor = "#ffbdb9" ];
"MoonscapeGame.mintRover" [ label = "mintRover", color = "#ffbdb9", fillcolor = "#ffbdb9" ];
"MoonscapeGame.onERC721Received" [ label = "onERC721Received", color = "#ffbdb9", fillcolor = "#ffbdb9" ];
"MoonscapeGame.IERC20" [ label = "IERC20" ];
"MoonscapeGame.CityNft" [ label = "CityNft" ];
}
subgraph "clusterIERC20" {
graph [ label = "IERC20", color = "#e8726d", fontcolor = "#f0f0f0", style = "rounded,dashed", bgcolor = "#3b4b63" ];
"IERC20.balanceOf" [ label = "balanceOf" ];
"IERC20.transferFrom" [ label = "transferFrom" ];
"IERC20.transfer" [ label = "transfer" ];
}
subgraph "cluster_amount" {
graph [ label = "_amount", color = "#e8726d", fontcolor = "#f0f0f0", style = "rounded,dashed", bgcolor = "#3b4b63" ];
"_amount.add" [ label = "add" ];
}
subgraph "clusterCityNft" {
graph [ label = "CityNft", color = "#e8726d", fontcolor = "#f0f0f0", style = "rounded,dashed", bgcolor = "#3b4b63" ];
"CityNft.ownerOf" [ label = "ownerOf" ];
"CityNft.safeTransferFrom" [ label = "safeTransferFrom" ];
"CityNft.mint" [ label = "mint" ];
}
"MoonscapeGame.purchaseMoondust";
"MoonscapeGame.IERC20";
"IERC20.balanceOf";
"IERC20.transferFrom";
"_amount.add";
"MoonscapeGame.stakeForMoondust";
"MoonscapeGame.unstakeForMoondust";
"IERC20.transfer";
"MoonscapeGame.importCity";
"MoonscapeGame.CityNft";
"CityNft.ownerOf";
"CityNft.safeTransferFrom";
"MoonscapeGame.exportCity";
"MoonscapeGame.mintCity";
"CityNft.mint";
"MoonscapeGame.burnScapeForBuilding";
"MoonscapeGame.burnScapeForConnection";
"MoonscapeGame.importRover";
"MoonscapeGame.exportRover";
"MoonscapeGame.mintRover";
"MoonscapeGame.purchaseMoondust" -> "MoonscapeGame.IERC20" [ color = "#1bc6a6" ];
"MoonscapeGame.purchaseMoondust" -> "IERC20.balanceOf" [ color = "white" ];
"MoonscapeGame.purchaseMoondust" -> "IERC20.transferFrom" [ color = "white" ];
"MoonscapeGame.purchaseMoondust" -> "_amount.add" [ color = "white" ];
"MoonscapeGame.stakeForMoondust" -> "MoonscapeGame.IERC20" [ color = "#1bc6a6" ];
"MoonscapeGame.stakeForMoondust" -> "IERC20.balanceOf" [ color = "white" ];
"MoonscapeGame.stakeForMoondust" -> "IERC20.transferFrom" [ color = "white" ];
"MoonscapeGame.stakeForMoondust" -> "_amount.add" [ color = "white" ];
"MoonscapeGame.unstakeForMoondust" -> "MoonscapeGame.IERC20" [ color = "#1bc6a6" ];
"MoonscapeGame.unstakeForMoondust" -> "IERC20.balanceOf" [ color = "white" ];
"MoonscapeGame.unstakeForMoondust" -> "IERC20.transfer" [ color = "white" ];
"MoonscapeGame.importCity" -> "MoonscapeGame.CityNft" [ color = "#1bc6a6" ];
"MoonscapeGame.importCity" -> "CityNft.ownerOf" [ color = "white" ];
"MoonscapeGame.importCity" -> "CityNft.safeTransferFrom" [ color = "white" ];
"MoonscapeGame.exportCity" -> "MoonscapeGame.CityNft" [ color = "#1bc6a6" ];
"MoonscapeGame.exportCity" -> "CityNft.safeTransferFrom" [ color = "white" ];
"MoonscapeGame.mintCity" -> "MoonscapeGame.CityNft" [ color = "#1bc6a6" ];
"MoonscapeGame.mintCity" -> "CityNft.mint" [ color = "white" ];
"MoonscapeGame.burnScapeForBuilding" -> "MoonscapeGame.CityNft" [ color = "#1bc6a6" ];
"MoonscapeGame.burnScapeForBuilding" -> "CityNft.ownerOf" [ color = "white" ];
"MoonscapeGame.burnScapeForBuilding" -> "CityNft.safeTransferFrom" [ color = "white" ];
"MoonscapeGame.burnScapeForConnection" -> "MoonscapeGame.CityNft" [ color = "#1bc6a6" ];
"MoonscapeGame.burnScapeForConnection" -> "CityNft.safeTransferFrom" [ color = "white" ];
"MoonscapeGame.importRover" -> "MoonscapeGame.CityNft" [ color = "#1bc6a6" ];
"MoonscapeGame.importRover" -> "CityNft.ownerOf" [ color = "white" ];
"MoonscapeGame.importRover" -> "CityNft.safeTransferFrom" [ color = "white" ];
"MoonscapeGame.exportRover" -> "MoonscapeGame.CityNft" [ color = "#1bc6a6" ];
"MoonscapeGame.exportRover" -> "CityNft.safeTransferFrom" [ color = "white" ];
"MoonscapeGame.mintRover" -> "MoonscapeGame.CityNft" [ color = "#1bc6a6" ];
"MoonscapeGame.mintRover" -> "CityNft.mint" [ color = "white" ];
rankdir=LR
node [shape=plaintext]
subgraph cluster_01 {
label = "Legend";
key [label=<<table border="0" cellpadding="2" cellspacing="0" cellborder="0">
<tr><td align="right" port="i1">Internal Call</td></tr>
<tr><td align="right" port="i2">External Call</td></tr>
<tr><td align="right" port="i3">Defined Contract</td></tr>
<tr><td align="right" port="i4">Undefined Contract</td></tr>
</table>>]
key2 [label=<<table border="0" cellpadding="2" cellspacing="0" cellborder="0">
<tr><td port="i1"> </td></tr>
<tr><td port="i2"> </td></tr>
<tr><td port="i3" bgcolor="#445773"> </td></tr>
<tr><td port="i4">
<table border="1" cellborder="0" cellspacing="0" cellpadding="7" color="#e8726d">
<tr>
<td></td>
</tr>
</table>
</td></tr>
</table>>]
key:i1:e -> key2:i1:w [color="#1bc6a6"]
key:i2:e -> key2:i2:w [color="white"]
}
}
digraph G {
graph [ ratio = "auto", page = "40" ];
"CityNft";
"ERC721";
"ERC721Burnable";
"Ownable";
"MoonscapeGame";
"IERC721Receiver";
"IERC20";
"SafeERC20";
"SafeMath";
"Address";
"Context";
"ERC165";
"IERC721";
"IERC721Metadata";
"IERC721Enumerable";
"IERC165";
"EnumerableSet";
"EnumerableMap";
"Strings";
"ERC20";
"CityNft" -> "ERC721";
"CityNft" -> "ERC721Burnable";
"CityNft" -> "Ownable";
"MoonscapeGame" -> "Ownable";
"MoonscapeGame" -> "IERC721Receiver";
"Ownable" -> "Context";
"ERC721" -> "Context";
"ERC721" -> "ERC165";
"ERC721" -> "IERC721";
"ERC721" -> "IERC721Metadata";
"ERC721" -> "IERC721Enumerable";
"IERC721" -> "IERC165";
"IERC721Metadata" -> "IERC721";
"IERC721Enumerable" -> "IERC721";
"ERC165" -> "IERC165";
"ERC721Burnable" -> "Context";
"ERC721Burnable" -> "ERC721";
"ERC20" -> "Context";
"ERC20" -> "IERC20";
}
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