Platform: Code4rena
Start Date: 17/03/2023
Pot Size: $36,500 USDC
Total HM: 10
Participants: 98
Period: 3 days
Judge: leastwood
Total Solo HM: 5
Id: 223
League: ETH
Rank: 8/98
Findings: 3
Award: $435.83
🌟 Selected for report: 0
🚀 Solo Findings: 0
401.0269 USDC - $401.03
https://github.com/code-423n4/2023-03-canto-identity/blob/main/canto-namespace-protocol/src/Namespace.sol#L144 https://github.com/code-423n4/2023-03-canto-identity/blob/main/canto-namespace-protocol/src/Utils.sol#L73-L217
The tokenName and image of Namespace should always be the same. The image is correct but the tokenName only consists of emojis.
Duplicate names are checked on the tokenName and not on the image. Because of this a tile of font class 1 with character index 12 will have the same name as a tile of class 2 with index 12. Both names will be an emoji with index 12. The second NameSpace won't be created because the name is already registered even though the correct image is different.
This is because the conversion characterToUnicodeBytes
has a wrong input parameter. It always has 0 as fontClass so the function characterToUnicodeBytes()
in Utils.sol sees this as an emoji.
NameSpace.sol L144: bytes memory charAsBytes = Utils.characterToUnicodeBytes(0, tileData.characterIndex, characterModifier);
When you log the metadata in tokenURI()
:
You can see the emojis are correct but the other ones are all converted to an emoji
Foundry
This can easily be fixed by changing the input parameter to tileData.fontClass
.
NameSpace.sol L144: - bytes memory charAsBytes = Utils.characterToUnicodeBytes(0, tileData.characterIndex, characterModifier); + bytes memory charAsBytes = Utils.characterToUnicodeBytes(tileData.fontClass, tileData.characterIndex, characterModifier);
#0 - OpenCoreCH
2023-03-29T20:56:23Z
Dup #117
#1 - c4-judge
2023-04-10T14:59:07Z
0xleastwood marked the issue as duplicate of #117
#2 - c4-judge
2023-04-11T20:47:03Z
0xleastwood marked the issue as satisfactory
#3 - c4-judge
2023-04-11T20:50:02Z
0xleastwood changed the severity to 3 (High Risk)
🌟 Selected for report: Sathish9098
Also found by: 0xAgro, 0xSmartContract, 0xdaydream, 0xnev, Awesome, Aymen0909, BRONZEDISC, Bauchibred, Deathstore, Diana, IceBear, Jerry0x, Kresh, Matin, Rolezn, Stryder, T1MOH, Udsen, adriro, alejandrocovrr, atharvasama, codeslide, cryptonue, descharre, igingu, jack, joestakey, libratus, lukris02, luxartvinsec, nadin, nasri136, reassor, scokaf, shark, slvDev, tnevler
22.7749 USDC - $22.77
ID | Finding | Instances |
---|---|---|
L-01 | Missing 0 address checks in constructors | 3 |
L-02 | Buying over 180 trays exceeds block gas limit | 1 |
L-03 | Transaction shouldn't revert when emoji does not support skin modifier | 1 |
L-04 | TokenURI of ProfilePicture will revert when it's connected to the CIDNFT | 1 |
ID | Finding | Instances |
---|---|---|
N-01 | Unspecific compiler version pragma | 1 |
N-02 | Attach SafeTransferLib to ERC20 | 2 |
N-03 | Unnecesarry variable assignments | 3 |
N-04 | mapping nftCharacters does not get deleted | 1 |
N-05 | Be consistent with how you generate metadata | 2 |
Allowing zero-addresses will lead to contract reverts and force redeployments if there are no setters for such address variables.
buy()
is a very expensive function. Around 180 trays can be bought at once before exceeding the block gas limit of 30 million. This also means the owner can't mint all 1000 of them during prelaunch in one transaction. One mitigation is to have a limit on _amount
and add a require statement.
When an emoji does not support a skintonemodifier but one is given, the transaction will revert. There is no reason to revert, it's better to just return the emoji with skintonemodifier 0 rather than reverting.
if (_characterModifier != 0) { - if (!supportsSkinToneModifier) revert EmojiDoesNotSupportSkinToneModifier(_characterIndex); + if (!supportsSkinToneModifier) return character; if (_characterModifier == 1) { character = abi.encodePacked(character, hex"F09F8FBB"); } else if (_characterModifier == 2) { character = abi.encodePacked(character, hex"F09F8FBC"); } else if (_characterModifier == 3) { character = abi.encodePacked(character, hex"F09F8FBD"); } else if (_characterModifier == 4) { character = abi.encodePacked(character, hex"F09F8FBE"); } else if (_characterModifier == 5) { character = abi.encodePacked(character, hex"F09F8FBF"); } else { revert InvalidSkinToneModifierProvided(_characterModifier); } } return character;
When a user mints a ProfilePicture but doesn't immediately add it to his CIDNFT, the function tokenURI()
will revert with the errorCode PFPNoLongerOwnedByOriginalOwner
. Even though the owner probably still has the NFT. This is because the function getPFP()
will have a cidNFTID of 0. This might lead to users reminting their NFT because they think they have the wrong picture.
A better way do this would be to update a mapping everytime the pfp nft gets minted and check if that matches the owner of the nft.
L34: + mapping(uint256 => address) private pfpMinter; L79: function mint(address _nftContract, uint256 _nftID) external { uint256 tokenId = ++numMinted; if (ERC721(_nftContract).ownerOf(_nftID) != msg.sender) revert PFPNotOwnedByCaller(msg.sender, _nftContract, _nftID); ProfilePictureData storage pictureData = pfp[tokenId]; pictureData.nftContract = _nftContract; pictureData.nftID = _nftID; + pfpMinter[tokenId] = msg.sender; _mint(msg.sender, tokenId); emit PfpAdded(msg.sender, tokenId, _nftContract, _nftID); } L94: function getPFP(uint256 _pfpID) public view returns (address nftContract, uint256 nftID) { if (_ownerOf[_pfpID] == address(0)) revert TokenNotMinted(_pfpID); ProfilePictureData storage pictureData = pfp[_pfpID]; nftContract = pictureData.nftContract; nftID = pictureData.nftID; uint256 cidNFTID = cidNFT.getPrimaryCIDNFT(subprotocolName, _pfpID); IAddressRegistry addressRegistry = cidNFT.addressRegistry(); - if (cidNFTID == 0 || addressRegistry.getAddress(cidNFTID) != ERC721(nftContract).ownerOf(nftID)) { + if (pfpMinter[_pfpID] != ERC721(nftContract).ownerOf(nftID)) { nftContract = address(0); nftID = 0; // Strictly not needed because nftContract has to be always checked, but reset nevertheless to 0 } }
Avoid floating pragmas for non-library contracts. Contracts should be deployed with the same compiler version and flags that they have been tested the most with. Locking the pragma helps ensure that contracts do not accidentally get deployed using, for example, the latest compiler which may have higher risks of undiscovered bugs. Contracts may also be deployed by others and the pragma indicates the compiler version intended by the original authors.
You can attach library function to any type with the for statement. This is used in most protocols and is the most readable.
L14: + using SafeTransferLib for ERC20; L157: - SafeTransferLib.safeTransferFrom(note, msg.sender, revenueAddress, _amount * trayPrice); + note.safeTransferFrom(msg.sender, revenueAddress, _amount * trayPrice);
The same can be done in Namespace.
It's useless to write a memory variable to another memory variable. Even when it's a struct, it doesn't save any more gas. The extra assignments can be removed
Tray.TileData memory tileData = tray.getTile(trayID, tileOffset); // Will revert if tileOffset is too high - uint8 characterModifier = tileData.characterModifier; if (tileData.fontClass != 0 && _characterList[i].skinToneModifier != 0) { revert CannotFuseCharacterWithSkinTone(); } if (tileData.fontClass == 0) { // Emoji - characterModifier = _characterList[i].skinToneModifier; + tileData.characterModifier = _characterList[i].skinToneModifier; } - bytes memory charAsBytes = Utils.characterToUnicodeBytes(0, tileData.characterIndex, characterModifier); + bytes memory charAsBytes = Utils.characterToUnicodeBytes(0, tileData.characterIndex, tileData.characterModifier); - tileData.characterModifier = characterModifier;
nftCharacters
does not get deletedWhen you burn()
a namespace, the mappings tokenToName
and [nameToToken
] gets deleted. But the mapping nftCharacters
is also associated to that nameSpace so it should also be deleted.
The function tokenURI() in Namespace and Tray both use abi.encodePacked to generate the json metadata. The function tokenURI() in Bio uses string.concat. It's best to make this consistent accross the whole protocol.
#0 - c4-judge
2023-04-11T06:00:55Z
0xleastwood marked the issue as grade-b
🌟 Selected for report: 0xSmartContract
Also found by: 0xdaydream, 0xnev, Aymen0909, Deekshith99, Diana, EvanW, Fanz, JCN, Jerry0x, K42, Kresh, Madalad, MiniGlome, Polaris_tow, Rageur, ReyAdmirado, Rolezn, SAAJ, SaeedAlipoor01988, Sathish9098, Shubham, Udsen, Viktor_Cortess, Walter, anodaram, arialblack14, atharvasama, caspersolangii, codeslide, descharre, fatherOfBlocks, felipe, ginlee, igingu, lukris02, nadin, slvDev, tnevler, turvy_fuzz, viking71
12.034 USDC - $12.03
ID | Finding | Gas saved | Instances |
---|---|---|---|
G-01 | Require or revert statements that check input arguments should be at the top of the function | - | 1 |
G-02 | Burn all prelaunch trays and remove every check | - | 1 |
G-03 | Don't create a seperate loop for burning trays | 2269 | 1 |
G-04 | Add unchecked block for _drawing() | 150 | 1 |
In this case no gas is wasted when a function is destined to fail.
The functions tokenURI()
burn()
and when you mint/transfer a token _beforeTokenTransfers()
all check whether the tray is a prelaunch tray or not. The prelaunch trays are useless after the prelaunch period so it's better to burn them all so you don't have to perform all those checks.
Burning the trays and removing the checks will save gas for every mint, burn and transfer of a tray.
_beforeTokenTransfers()
can then just be empty.
function burn(uint256 _id) external { address trayOwner = ownerOf(_id); if ( namespaceNFT != msg.sender && trayOwner != msg.sender && getApproved(_id) != msg.sender && !isApprovedForAll(trayOwner, msg.sender) ) revert CallerNotAllowedToBurn(); - if (msg.sender == namespaceNFT) { - // Disallow fusing for prelaunch trays after phase has ended - uint256 numPrelaunchMinted = prelaunchMinted; - if (numPrelaunchMinted != type(uint256).max && _id <= numPrelaunchMinted) - revert PrelaunchTrayCannotBeUsedAfterPrelaunch(_id); - } delete tiles[_id]; _burn(_id); }
Removing the check on tokenURI()
doesn't save any gas because it's a view function but removes a lot of code then for better readability.
When you fuse trays, the function fuse()
holds an array of unique trays so it can burn them at the end of the function. This is not necessary and the trays can be instantly burned instead of adding them to the uniqueTrays
array. This is not a problem because if function revert all the changes to the state will also revert.
2269 gas can be saved with the following optimization:
L120: - uint256 numUniqueTrays; - uint256[] memory uniqueTrays = new uint256[](_characterList.length); L153: if (isLastTrayEntry) { - uniqueTrays[numUniqueTrays++] = trayID; // Verify address is allowed to fuse address trayOwner = tray.ownerOf(trayID); if ( trayOwner != msg.sender && tray.getApproved(trayID) != msg.sender && !tray.isApprovedForAll(trayOwner, msg.sender) ) revert CallerNotAllowedToFuse(); + tray.burn(trayID); } L174: - for (uint256 i; i < numUniqueTrays; ++i) { - tray.burn(uniqueTrays[i]); - }
_drawing()
The function _drawing()
does a lot of calculations. There is no risk of over/underflow because there is no input parameter that the user can choose. The default "checked" behavior costs more gas when calculating, because under-the-hood the EVM does extra checks for over/underflow. Adding unchecked for the whole _drawing()
function saves around 150 gas.
#0 - c4-judge
2023-04-11T00:16:38Z
0xleastwood marked the issue as grade-b