Platform: Code4rena
Start Date: 08/03/2023
Pot Size: $60,500 USDC
Total HM: 2
Participants: 123
Period: 7 days
Judge: hansfriese
Id: 220
League: ETH
Rank: 20/123
Findings: 1
Award: $235.24
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xSmartContract
Also found by: 0x1f8b, 0x6980, 0xAgro, 0xSolus, 0xhacksmithh, 0xkazim, ABA, BPZ, BowTiedOriole, ChainReview, DadeKuma, DeFiHackLabs, Deathstore, DevABDee, Diana, Dravee, Dug, Englave, Go-Langer, Haipls, IceBear, Inspex, Jeiwan, Kek, Kresh, Madalad, MatricksDeCoder, MyFDsYours, RaymondFam, Rolezn, SAAJ, Sathish9098, Taloner, Udsen, Viktor_Cortess, atharvasama, ayden, brgltd, btk, carlitox477, catellatech, chaduke, codeislight, deadrxsezzz, descharre, erictee, fatherOfBlocks, favelanky, glcanvas, handsomegiraffe, jasonxiale, jekapi, joestakey, lemonr, luxartvinsec, martin, matrix_0wl, minhquanym, mrpathfindr, nadin, oyc_109, parsely, peanuts, pfedprog, rbserver, rokso, saian, santipu_, scokaf, slvDev, tsvetanovv, ubl4nk, ulqiorra, yamapyblack, zaskoh
235.2398 USDC - $235.24
Overview
Risk Rating | Number of issues |
---|---|
Low Risk | 7 |
Non-Critical Risk | 9 |
Table of Contents
uint8(_assetType) > 4
check is wrong and unnecessary due to a vacuous truthNeoTokyoStaker._stakeLP
: Check that stakes will earn pointstransferFrom
on ERC721 tokens_assetTransferFrom
is used both for ERC721 and ERC20@openzeppelin/contracts
versionNeoTokyoStaker.getStakerPosition()
is clunkyBYTES2.getReward()
's naming is confusing"@openzeppelin/contracts"
should be a dependency, not a dev-dependencyunchecked
statements are undocumenteddelete
instead of setting every fields to their default valueuint8(_assetType) > 4
check is wrong and unnecessary due to a vacuous truthThe following uint8(_assetType) > 4
checks never be verified due to a vacuous truth:
// Validate that the asset being staked is of a valid type. if (uint8(_assetType) > 4) { revert InvalidAssetType(uint256(_assetType)); }
if (uint8(_assetType) == 2 || uint8(_assetType) > 4) { revert InvalidAssetType(uint256(_assetType)); }
Indeed, the enum AssetType
is limited to an index of 3:
/** This enum tracks each type of asset that may be operated on with this staker. @param S1_CITIZEN A staked Neo Tokyo S1 Citizen. @param S2_CITIZEN A staked Neo Tokyo S2 Citizen. @param BYTES A set of staked BYTES ERC-20 token. @param LP Staked BYTES-ETH LP ERC-20 token. */ enum AssetType { S1_CITIZEN, //@audit index 0 S2_CITIZEN, //@audit index 1 BYTES, //@audit index 2 LP //@audit index 3 }
And the following will always revert if using anything that is >= 4
:
function stake ( AssetType _assetType, //@audit-ok will revert with anything >= 4 function withdraw ( AssetType _assetType, //@audit-ok will revert with anything >= 4
Therefore, as uint8(_assetType)
is at most equal to 3
in a transaction, it's impossible to ever verify uint8(_assetType) > 4
Furthermore, uint8(_assetType) > 4
is a wrong check as it doesn't exclude 4
which isn't even a reachable value for AssetType
. If the input parameter in the method wouldn't have reverted for some reason with the input 4
, both uint8(_assetType) > 4
conditions would have let a wrong index of 4
slip through.
I suggest deleting those as they can never happen. But if you insist on them existing just to be reassured, consider either replacing those by uint8(_assetType) >= 4
or uint8(_assetType) > 3
, as these would be the right checks.
NeoTokyoStaker._stakeLP
: Check that stakes will earn pointsIn the following LP stake scenario, Bob is staking 10e18
LP tokens and earning 4000 points as a result.
If we tweak the test so that Bob stakes 1/1000 of that amount (0.01e18
), the result would be 4 points.
However, if we tweak further the test so that Bob stakes 0.0099999e18
, the result is 0 point. It would've been legitimate to believe that the minimum would've been 0.0025e18
for 1 point but it instead directly drops to 0 under 0.01e18
.
While this is an edge case that may seem negligible, it shouldn't be possible for the user to stake his LP tokens and not earn anything in return. This is especially true as, here, points
aren't called bonusPoints
like in _stakeBytes
.
Additionally, each point is actually worth 200 * 1e18
BYTES, so not being able to earn below 4 points here might be unexpected.
Consider adding an assert
in _stakeLP
checking points > 0
.
transferFrom
on ERC721 tokensThe transferFrom
function is used instead of safeTransferFrom
here, and it's discouraged by OpenZeppelin. We can even find this warning at multiple places in the contest repo here:
contracts/s1/beckLoot.sol: 90: * WARNING: Usage of this method is discouraged, use {safeTransferFrom} whenever possible. contracts/s1/NTCitizenDeploy.sol: 113: * WARNING: Usage of this method is discouraged, use {safeTransferFrom} whenever possible. contracts/s1/NTItems.sol: 93: * WARNING: Usage of this method is discouraged, use {safeTransferFrom} whenever possible. contracts/s1/NTLandDeploy.sol: 93: * WARNING: Usage of this method is discouraged, use {safeTransferFrom} whenever possible. contracts/s1/vaultBox.sol: 90: * WARNING: Usage of this method is discouraged, use {safeTransferFrom} whenever possible. contracts/s2/NTOuterCitizenDeploy.sol: 109: * WARNING: Usage of this method is discouraged, use {safeTransferFrom} whenever possible. contracts/s2/NTOuterIdentity.sol: 94: * WARNING: Usage of this method is discouraged, use {safeTransferFrom} whenever possible. contracts/s2/NTS2Items.sol: 93: * WARNING: Usage of this method is discouraged, use {safeTransferFrom} whenever possible. contracts/s2/NTS2LandDeploy.sol: 93: * WARNING: Usage of this method is discouraged, use {safeTransferFrom} whenever possible.
If the arbitrary address is a contract and is not aware of the incoming ERC721 token, the sent token could be locked.
Consider transitioning from transferFrom
to safeTransferFrom
here:
NeoTokyoStaker.sol:1498: _assetTransferFrom(S1_CITIZEN, address(this), msg.sender, citizenId); NeoTokyoStaker.sol:1563: _assetTransferFrom(S2_CITIZEN, address(this), msg.sender, citizenId); NeoTokyoStaker.sol: 1489: _assetTransferFrom( 1490 VAULT, 1491 address(this), 1492 msg.sender, 1493 stakedCitizen.stakedVaultId 1494 ); 1495 }
Notice that the following will need to be replaced in the new method by safeTransferFrom
's signature. :
/// The `transferFrom` selector for ERC-20 and ERC-721 tokens. bytes4 constant private _TRANSFER_FROM_SELECTOR = 0x23b872dd; + /// The `safeTransferFrom` selector for ERC-721 tokens. + bytes4 constant private _SAFE_TRANSFER_FROM_SELECTOR = 0x42842e0e;
_assetTransferFrom
is used both for ERC721 and ERC20Just like mentioned in the comment here, the transferFrom
selector is used both for ERC-20 and ERC-721 tokens:
/// The `transferFrom` selector for ERC-20 and ERC-721 tokens. bytes4 constant private _TRANSFER_FROM_SELECTOR = 0x23b872dd;
While the signatures match by chance (one uses tokenId
, one uses amount
), this is actually confusing and it'd be better to separate concerns and follow the previous finding's suggestion of using safeTransferFrom
to transfer ERC721
tokens. However, this time, replacing those occurrences will require that NeoTokyoStaker
inherits from ERC721TokenReceiver
:
- contract NeoTokyoStaker is PermitControl, ReentrancyGuard { + contract NeoTokyoStaker is PermitControl, ReentrancyGuard, ERC721TokenReceiver {
Affected code:
contracts/staking/NeoTokyoStaker.sol: 898: _assetTransferFrom(S1_CITIZEN, msg.sender, address(this), citizenId); 928: _assetTransferFrom(VAULT, msg.sender, address(this), vaultId); 1011: _assetTransferFrom(S2_CITIZEN, msg.sender, address(this), citizenId);
Hence, _TRANSFER_FROM_SELECTOR
will only be used for ERC20 tokens and the previously suggested _SAFE_TRANSFER_FROM_SELECTOR
will be used only for ERC721 tokens.
@openzeppelin/contracts
versionAs some known vulnerabilities exist in the current @openzeppelin/contracts
version, consider updating package.json
with at least @openzeppelin/contracts@4.7.3
here: https://github.com/code-423n4/2023-03-neotokyo/blob/main/package.json#L23
The only vulnerability that seems to affect this project is the following:
The configuration methods in NeoTokyoStaker
don't emit events:
The change methods in BYTES2
don't emit events:
Not emitting events when changing state variables is a bad practice and can be seen as a trust issue
NeoTokyoStaker.getStakerPosition()
is clunkyIt's only possible to fetch the S1 Citizen or S2 Citizen types through getStakerPosition(), otherwise the documentation asks to use the public variables for LP assets and the full output of getStakerPositions() for BYTES.
However, this doesn't facilitate integration with external contracts at all. I suggest adding 2 else if
clauses for the remaining asset types to read the publicly available stakerLPPosition
mapping and to read BYTES through getStakerPositions
.
BYTES2.getReward()
's naming is confusingUsually, the get
prefix is used for view-only functions. However, here, this function is actually more of a claimReward
function, as it calls an external claimReward
and then mints tokens.
The following is using a hardcoded 2
instead of the clearer, more readable and more maintainable _assetType == AssetType.BYTES
enum value.
Consider the following:
- 1668: if (uint8(_assetType) == 2 || uint8(_assetType) > 4) { + 1668: if (_assetType == AssetType.BYTES || uint8(_assetType) > 4) {
"@openzeppelin/contracts"
should be a dependency, not a dev-dependencyFile: package.json 07: "dependencies": { 08: "@nomiclabs/hardhat-waffle": "^2.0.3", 09: "@openzeppelin/contracts-upgradeable": "^4.4.2", + 10: "@openzeppelin/contracts": "^4.3.1", 10: "chai": "^4.3.6", 11: "dotenv": "^16.0.1", 12: "solc": "^0.8.17" 13: }, 14: "devDependencies": { 15: "@babel/core": "^7.11.6", 16: "@babel/plugin-transform-runtime": "^7.11.5", 17: "@babel/preset-env": "^7.11.5", 18: "@babel/register": "^7.11.5", 19: "@babel/runtime": "^7.11.2", 20: "@nomicfoundation/hardhat-foundry": "^1.0.0", 21: "@nomiclabs/hardhat-ethers": "^2.0.0", 22: "@nomiclabs/hardhat-etherscan": "^2.1.1", 23: "@nomiclabs/hardhat-ganache": "^2.0.0", - 24: "@openzeppelin/contracts": "^4.3.1",
The following code is a copy-paste of the getConfiguredVaultMultiplier() method. Consider calling the existing function instead of having a duplicated piece of code.
unchecked
statements are undocumentedWhile there doesn't seem to be an issue with them, there should never exist any doubt in the reason to use an unchecked
statement in the code.
This will also be beneficial for your future bug bounty program.
Therefore, they should be thorougly documented, but this is never the case:
contracts/staking/BYTES2.sol: 151 /* 152 We are aware that this math does not round perfectly for all values of 153 `_amount`. We don't care. 154 */ 155 uint256 treasuryShare; 156: unchecked { 157 treasuryShare = _amount * 2 / 3; 158 } 159 _mint(TREASURY, treasuryShare); 160 } contracts/staking/NeoTokyoStaker.sol: 966 // Update caller staking information and asset data. 967 PoolData storage pool = _pools[AssetType.S1_CITIZEN]; 968: unchecked { 969 citizenStatus.points = 970 identityPoints * vaultMultiplier * timelockMultiplier / 971 _DIVISOR / _DIVISOR; 972 citizenStatus.timelockEndTime = block.timestamp + timelockDuration; 973 974 // Record the caller's staked S1 Citizen. 975 _stakerS1Position[msg.sender].push(citizenId); 976 977 // Update the pool point weights for rewards 978 pool.totalPoints += citizenStatus.points; 1020 // Update caller staking information and asset data. 1021 PoolData storage pool = _pools[AssetType.S2_CITIZEN]; 1022: unchecked { 1023 citizenStatus.points = 100 * timelockMultiplier / _DIVISOR; 1024 citizenStatus.timelockEndTime = block.timestamp + timelockDuration; 1025 1026 // Record the caller's staked S2 Citizen. 1027 _stakerS2Position[msg.sender].push(citizenId); 1028 1029 // Update the pool point weights for rewards 1030 pool.totalPoints += citizenStatus.points; 1031 } 1071 // Validate that the caller actually staked the Citizen. 1072 if (citizenStatus.timelockEndTime == 0) { 1073 revert CannotStakeIntoUnownedCitizen(citizenId, seasonId); 1074 } 1075 1076 PoolData storage pool = _pools[AssetType.S1_CITIZEN]; 1077: unchecked { 1078 uint256 bonusPoints = (amount * 100 / _BYTES_PER_POINT); 1079 citizenStatus.stakedBytes += amount; 1080 citizenStatus.points += bonusPoints; 1081 pool.totalPoints += bonusPoints; 1082 } 1083 1092 // Validate that the caller actually staked the Citizen. 1093 if (citizenStatus.timelockEndTime == 0) { 1094 revert CannotStakeIntoUnownedCitizen(citizenId, seasonId); 1095 } 1096 1097 PoolData storage pool = _pools[AssetType.S2_CITIZEN]; 1098: unchecked { 1099 uint256 bonusPoints = (amount * 100 / _BYTES_PER_POINT); 1100 citizenStatus.stakedBytes += amount; 1101 citizenStatus.points += bonusPoints; 1102 pool.totalPoints += bonusPoints; 1103 } 1104 1153 // Update caller staking information and asset data. 1154 PoolData storage pool = _pools[AssetType.LP]; 1155: unchecked { 1156 uint256 points = amount * 100 / 1e18 * timelockMultiplier / _DIVISOR; 1157 1158 // Update the caller's LP token stake. 1159 stakerLPPosition[msg.sender].timelockEndTime = 1160 block.timestamp + timelockDuration; 1161 stakerLPPosition[msg.sender].amount += amount; 1162 stakerLPPosition[msg.sender].points += points; 1163 1164 // Update the pool point weights for rewards. 1165 pool.totalPoints += points; 1283: unchecked { 1284 points += s1Citizen.points; 1292: unchecked { 1293 points += s2Citizen.points; 1298: unchecked { 1299 points += stakerLPPosition[_recipient].points; 1331: unchecked { 1332 uint256 timeSinceReward = block.timestamp - lastPoolRewardTime; 1333 totalReward += currentRewardRate * timeSinceReward; 1334 } 1342: unchecked { 1343 uint256 timeSinceReward = window.startTime - lastPoolRewardTime; 1344 totalReward += currentRewardRate * timeSinceReward; 1345 } 1355: unchecked { 1356 uint256 timeSinceReward = 1357 block.timestamp - lastPoolRewardTime; 1358 totalReward += currentRewardRate * timeSinceReward; 1359 } 1378: unchecked { 1379 uint256 timeSinceReward = block.timestamp - lastPoolRewardTime; 1380 totalReward = window.reward * timeSinceReward; 1381 } 1387 // Return final shares. 1388: unchecked { 1389 uint256 share = points * _PRECISION / pool.totalPoints * totalReward; 1390 uint256 daoShare = share * pool.daoTax / (100 * _DIVISOR); 1391 share /= _PRECISION; 1392 daoShare /= _PRECISION; 1393 return ((share - daoShare), daoShare); 1394 } 1443 // Calculate total reward and tax. 1444 uint256 totalReward; 1445 uint256 totalTax; 1446: unchecked { 1447 totalReward = (s1Reward + s2Reward + lpReward); 1448 totalTax = (s1Tax + s2Tax + lpTax); 1449 } 1520: unchecked { 1521 pool.totalPoints -= stakedCitizen.points; 1522 } 1583 // Update caller staking information and asset data. 1584 PoolData storage pool = _pools[AssetType.S2_CITIZEN]; 1585: unchecked { 1586 pool.totalPoints -= stakedCitizen.points; 1587 } 1626 // Update caller staking information and asset data. 1627 PoolData storage pool = _pools[AssetType.LP]; 1628: unchecked { 1629 uint256 points = amount * 100 / 1e18 * lpPosition.multiplier / _DIVISOR; 1630 1631 // Update the caller's LP token stake. 1632 lpPosition.amount -= amount; 1633 lpPosition.points -= points; 1634 1635 // Update the pool point weights for rewards. 1636 pool.totalPoints -= points; 1637 } 1638
delete
instead of setting every fields to their default value- stakedCitizen.stakedBytes = 0; //@audit-issue confusing as devs need to check element by element, just use delete `stakedS1[msg.sender][citizenId]`; - stakedCitizen.timelockEndTime = 0; - stakedCitizen.points = 0; - stakedCitizen.hasVault = false; - stakedCitizen.stakedVaultId = 0; + delete stakedS1[msg.sender][citizenId];
- stakedCitizen.stakedBytes = 0; //@audit-issue confusing as devs need to check element by element, just use delete `stakedS2[msg.sender][citizenId];`; - stakedCitizen.timelockEndTime = 0; - stakedCitizen.points = 0; + delete stakedS2[msg.sender][citizenId];
When the return statement is documented but unnamed, consider adding a little comment with the name as such: Type Location /* name */
.
Affected code:
contracts/staking/NeoTokyoStaker.sol: 623: @return The "Credit Yield" trait value of the component Identity item of 624 the S1 Citizen with the token ID of `_citizenId`. 625 */ 626 function getCreditYield ( 627 uint256 _citizenId, 628 uint256 _vaultId - 629 ) public view returns (string memory) { + 629 ) public view returns (string memory /* Credit Yield */) { 657: @return The configured point multiplier for the Vault with token ID of 658 `_vaultId`. 659 */ 660 function getConfiguredVaultMultiplier ( 661 uint256 _vaultId - 662 ) public view returns (uint256) { + 662 ) public view returns (uint256 /* Configured Vault Multiplier */) { 687: @return The list of token IDs of a particular Citizen type that have been 688 staked by `_staker`. 689 */ 690 function getStakerPosition ( 691 address _staker, 692 AssetType _assetType - 693 ) external view returns (uint256[] memory) { + 693 ) external view returns (uint256[] memory /* TokenId list */) { 709: @return The position of the `_staker` across all asset types. 710 */ 711 function getStakerPositions ( 712 address _staker - 713 ) external view returns (StakerPosition memory) { + 713 ) external view returns (StakerPosition memory /* Staker Positions */) { 823: @return Whether or not `_a` and `_b` are equal. 824 */ 825 function _stringEquals ( 826 string memory _a, 827 string memory _b - 828 ) private pure returns (bool) { + 828 ) private pure returns (bool /* `_a` equals `_b` */) { 1406: @return A tuple containing (the number of tokens due to be minted to 1407 `_recipient` as a reward, and the number of tokens that should be minted 1408 to the DAO treasury as a DAO tax). 1409 */ 1410 function claimReward ( 1411 address _recipient - 1412 ) external returns (uint256, uint256) { + 1412 ) external returns (uint256 /* Reward amount */, uint256 /* DAO Tax amount */) {
Using import declarations of the form import {<identifier_name>} from "some/file.sol"
avoids polluting the symbol namespace making flattened files smaller, and speeds up compilation
BYTES2.sol:4:import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; BYTES2.sol:5:import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; BYTES2.sol:8:import "../access/PermitControl.sol"; BYTES2.sol:9:import "../interfaces/IByteContract.sol"; BYTES2.sol:10:import "../interfaces/IStaker.sol"; NeoTokyoStaker.sol:4:import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; NeoTokyoStaker.sol:6:import "../access/PermitControl.sol"; NeoTokyoStaker.sol:7:import "../interfaces/IByteContract.sol"; NeoTokyoStaker.sol:8:import "../interfaces/IGenericGetter.sol";
According to the Solidity style guide, functions should be laid out in the following order: constructor()
, receive()
, fallback()
, external
, public
, internal
, private
, but NeoTokyoStaker
doesn't follow this pattern
#0 - c4-judge
2023-03-17T03:03:02Z
hansfriese marked the issue as grade-a