3DMutantMfers is a new NFT collection by @scott_visuals. He is also the artist and creator of 3DMfers and Cosmo Creatures. For this mint, the team wanted to reward holders of 3DMfers and Cosmo Creatures with free mints, while still keeping public mints very affordable at 0.029 ETH. The idea with the free mint was that you would be able to get 1 3DMutantMfer for each 3DMfer or Cosmo Creature you held. Unfortunately, there was a bug in the contract function that caused the free claim to fail for most people. Let’s dig in to the contract code.
Public Mint
Let’s start with the public mint
function, which works great and is very simple.
function mint( uint numberOfTokens ) external payable mintCompliance( numberOfTokens ) {
require( msg.value >= price * numberOfTokens, "Ether value sent is not correct" );
_mintLoop( msg.sender, numberOfTokens );
}
The mint
function takes in the number of tokens you want to mint, ensures that you’ve sent the correct amount of ETH (0.029 * numberOfTokens
), then calls _mintLoop
with msg.sender
(your wallet address).
Mint Compliance
Before you can mint, there’s a number of conditions that are checked by mintCompliance
.
modifier mintCompliance( uint numberOfTokens ) {
require( isActive, "Sale must be active to mint 3DMutantMfers" );
require( numberOfTokens <= maxOrder, "Can only mint 20 tokens at a time" );
require( numberOfTokens > 0, "Token Mint Count must be > 0" );
uint256 supply = _owners.length;
require( supply + numberOfTokens <= MAX_SUPPLY, "Purchase would exceed max supply of 3DMutantMfers" );
uint256 mintedCount = addressMintedBalance[msg.sender];
require(mintedCount + numberOfTokens <= MAX_PER_WALLET, "Max NFT per address exceeded");
_;
}
The mintCompliance
modifier function is also used by the freeClaimMint
function covered below. It checks for the following conditions:
- Minting is active
- You are minting at least one token but not more than 20
- You can’t exceed the total supply of 4444 tokens
- You can’t mint more than 1000 tokens into 1 wallet
All of these numbers can be changed by the contract owner, with the restriction that total supply cannot be set lower than the number of tokens. The functions that allow these modifications are below:
function setActive(bool isActive_) external onlyOwner {
if( isActive != isActive_ )
isActive = isActive_;
}
function setMaxOrder(uint maxOrder_) external onlyOwner {
if( maxOrder != maxOrder_ )
maxOrder = maxOrder_;
}
function setPrice(uint price_ ) external onlyOwner {
if( price != price_ )
price = price_;
}
function setMaxSupply(uint maxSupply_ ) external onlyOwner {
if( MAX_SUPPLY != maxSupply_ ){
require(maxSupply_ >= _owners.length, "Specified supply is lower than current balance" );
MAX_SUPPLY = maxSupply_;
}
}
// Update Max Tokens A Wallet can mint
function setMaxPerWallet(uint maxPerWallet_ ) external onlyOwner {
if( MAX_PER_WALLET != maxPerWallet_ ){
MAX_PER_WALLET = maxPerWallet_;
}
}
Mint Loop
function _mintLoop(address _receiver, uint256 numberOfTokens) internal {
uint256 supply = _owners.length;
for (uint256 i = 0; i < numberOfTokens; i++) {
addressMintedBalance[_receiver]++;
_safeMint( _receiver, supply++, "" );
}
}
This function simply iterates through the number of tokens to mint, increments that number of tokens for your wallet (_receiver
) then mints each token. This contract uses ERC721B, which is an implementation of ERC721 optimized to reduce gas when minting multiple tokens. Because this is relatively new code, and not from OpenZeppelin (the defacto standard library), it’s possible there’s some issues that haven’t been identified yet. However, the code in each function is relatively simple and very similar to standard ERC721 code.
Free Claim Mint
Let’s look at the freeClaimMint
, which had an issue where most transactions attempts failed with “Warning! Error encountered during contract execution [Out of gas]
“.
function freeClaimMint( uint numberOfTokens, bytes memory signature, string[] memory contract1TokenIds, string[] memory contract2TokenIds ) external mintCompliance( numberOfTokens ) {
require(verifySender(signature, contract1TokenIds, contract2TokenIds), "Invalid Access");
// Check to make sure there are token ids
require(contract1TokenIds.length > 0 || contract2TokenIds.length > 0, "Empty Token IDs");
uint totalTokenIds = contract1TokenIds.length + contract2TokenIds.length;
require(totalTokenIds == numberOfTokens, "Token IDs and Mint Count mismatch");
// Lets make sure we are not claiming for already claimed tokens of contract 1
bool isValidTokenIds = true;
for (uint i = 0; isValidTokenIds && i < contract1TokenIds.length; i++) {
for (uint j = 0; isValidTokenIds && j < contract1ClaimedTokensCount; j++) {
string memory contractClaimedToken = contract1ClaimedTokens[j];
string memory tokenToClaim = contract1TokenIds[i];
if (keccak256(bytes(tokenToClaim)) == keccak256(bytes(contractClaimedToken))) {
isValidTokenIds = false;
}
}
}
require(isValidTokenIds, "Cosmo Creatures Token ID passed is already claimed");
// Lets make sure we are not claiming for already claimed tokens of contract 2
for (uint i = 0; isValidTokenIds && i < contract2TokenIds.length; i++) {
for (uint j = 0; isValidTokenIds && j < contract2ClaimedTokensCount; j++) {
string memory contractClaimedToken = contract2ClaimedTokens[j];
string memory tokenToClaim = contract2TokenIds[i];
if (keccak256(bytes(tokenToClaim)) == keccak256(bytes(contractClaimedToken))) {
isValidTokenIds = false;
}
}
}
require(isValidTokenIds, "3D Mfrs Token ID passed is already claimed");
for (uint i = 0; i < contract1TokenIds.length; i++) {
contract1ClaimedTokensCount++;
contract1ClaimedTokens.push(contract1TokenIds[i]);
}
for (uint i = 0; i < contract2TokenIds.length; i++) {
contract2ClaimedTokensCount++;
contract2ClaimedTokens.push(contract2TokenIds[i]);
}
_mintLoop( msg.sender, numberOfTokens );
}
As you can see, this is quite a complicated function. You can compare it to the BackgroundMfers minting functions to see how much simpler their implementation is for each free claim function. In this case, the intention was to provide a single function that would mint 1 or more tokens depending on how many you owned from 3DMfers and Cosmo Creatures. But for most people that tried this, the transaction failed, losing them gas. The team is very supportive of their community, though, and they very quickly responded by:
- announcing the problem
- removing the free claim button from their website
- refunding gas fees to everyone that had a failed transaction
- creating a claim form and then airdropping 3DMutantMfers, at their own expense
Over 1.3 ETH is a somewhat expensive bug to pay for, but it builds a lot of good will, and makes it clear this is a quality team that’s not out for a quick cash grab. So what went wrong? I’m reminded of the Zen of Python: explicit is better than implicit. In the BackgroundMfers minting functions, they made everything very explicit:
- each contract has its own minting function (mfers, dadmfers1, dadmfers2)
- you have to select precisely which tokens from the external contract to mint with
For 3DMutantMfers, the team tried to make a very simple user experience, requiring a lot of implicit/hidden complexity that didn’t quite work. Let’s look at the code more.
Verify Signature
The very first line, after mintCompliance
, is verifySignature
, which checks that the function arguments have been signed by a known signerAddress
.
function verifySender(bytes memory signature, string[] memory contract1TokenIds, string[] memory contract2TokenIds) internal view returns (bool) {
string memory contract1TokensString = "";
string memory contract2TokensString = "";
for (uint i = 0; i < contract1TokenIds.length; i++) {
contract1TokensString = string(abi.encodePacked(contract1TokensString, contract1TokenIds[i], i < contract1TokenIds.length - 1 ? "," : ""));
}
for (uint i = 0; i < contract2TokenIds.length; i++) {
contract2TokensString = string(abi.encodePacked(contract2TokensString, contract2TokenIds[i], i < contract2TokenIds.length - 1 ? "," : ""));
}
bytes32 hash = ECDSA.toEthSignedMessageHash(keccak256(abi.encodePacked(msg.sender, contract1TokensString, contract2TokensString)));
return ECDSA.recover(hash, signature) == signerAddress;
}
This means the website must be generating these signatures, hopefully on a backend server where the private key for the signerAddress
is kept secure. There is a function to change the signer address, which could be used in case the key is compromised, or just as good practice key rotation.
function setSignerAddress(address _newSignerAddress) external onlyOwner {
signerAddress = _newSignerAddress;
}
Token Validation
Next the function tries to validate the following:
- There is at least 1 external contract token to check (3DMfers or Cosmo Creatures)
- The number of tokens to mint matches the number of tokens that you own
- The tokens to mint haven’t been claimed already
For the last point, this is done for each contract, and I’ve copied the first block below.
// Lets make sure we are not claiming for already claimed tokens of contract 1
bool isValidTokenIds = true;
for (uint i = 0; isValidTokenIds && i < contract1TokenIds.length; i++) {
for (uint j = 0; isValidTokenIds && j < contract1ClaimedTokensCount; j++) {
string memory contractClaimedToken = contract1ClaimedTokens[j];
string memory tokenToClaim = contract1TokenIds[i];
if (keccak256(bytes(tokenToClaim)) == keccak256(bytes(contractClaimedToken))) {
isValidTokenIds = false;
}
}
}
require(isValidTokenIds, "Cosmo Creatures Token ID passed is already claimed");
Minting
Finally, there’s 2 loops, 1 for each contract, to increment a counter and record which contract tokens have been claimed. I only included 1 loop below for brevity. Once the loops are completed, there’s a call to _mintLoop
to finally mint the tokens.
for (uint i = 0; i < contract2TokenIds.length; i++) {
contract2ClaimedTokensCount++;
contract2ClaimedTokens.push(contract2TokenIds[i]);
}
_mintLoop( msg.sender, numberOfTokens );
This final part seems simple enough, but it turns out this where the bug is. Let’s see what we can learn from slither.
Slither Analysis
Slither is a python tool for static analysis of Solidity contracts. You can use it to get a quick summary of the contract code, and then look for any deeper issues.
$ slither 0x09f589e03381b767939ce118a209d474cc6d52fc --print human-summary

As we’ve seen above, there’s definitely complex code. One less important issue identified is that there’s no check for a zero address when setting the signerAddress
. However, signerAddress
is only used for the free claim, which shouldn’t be used anyway due to the bug. Almost all other issues are for the 11 dependency contracts, and none are significant. Except when you look at all the analysis, slither does identify the likely cause for the out of gas error:
$ slither 0x09f589e03381b767939ce118a209d474cc6d52fc
MutantMfers.freeClaimMint(uint256,bytes,string[],string[]) (MutantMfers.sol#1401-1450) has costly operations inside a loop:
- contract1ClaimedTokensCount ++ (MutantMfers.sol#1440)
MutantMfers.freeClaimMint(uint256,bytes,string[],string[]) (MutantMfers.sol#1401-1450) has costly operations inside a loop:
- contract2ClaimedTokensCount ++ (MutantMfers.sol#1445)
Magmar, the creator of the BackgroundMfers contract, explained the issue in the Discord:
The issue here is out of gas. The problem is the free mint contract has two variable-gas for loops and is screwing with Metamask’s infura gas estimation fees. These functions were likely tested with low quantities but the gas usage will increase as minting increases, costing more and more gas, with a higher likelihood of failing. They will likely work if you gas up your transaction heavily, but you will spend 0.02+ for a free mint.
In other words, for users that had many 3DMfers and/or Cosmo Creatures, their wallet underestimated the gas fee required, causing the transaction to fail. Some people in the discord said they successfully got free claims by increasing the gas fee, which is a more advanced user behavior. Perhaps if a Counter was used instead, everything would have worked great – lower gas fees and better wallet gas estimation. This is an excellent example of how different Solidity and the EVM are different from most other programming languages and environments, where incrementing a counter is simple and very cheap.
Conclusion
So what could have been done differently? As stated above, if contract1ClaimedTokensCount
and contract2ClaimedTokensCount
were Counters, then maybe everything would have worked as intended. Also, it’s possible the counters were not even necessary. In all the instances that one of the counters is used, the corresponding array length (such as contract2ClaimedTokens.length
) could have been used instead. Alternatively (or in addition to) they could have done the following:
- separate claiming functions for 3DMfers and Cosmo Creatures
- let users choose how many free tokens to mint, and which tokens to claim with
- address any user experience changes on the website, such as an option to mint with all tokens from a contract (some people own quite a few Cosmo Creatures, so a “claim with all” button could be helpful)
The user experience would be a little different, but I doubt free minters would complain. And two more things that every contract creator should do:
- Run slither on your contract
- Test every public function in many ways, with many inputs
Given the situation, what went right?
- the team quickly notified everyone and removed the free claim button from the website
- they refunded all the failed transactions
- they provided a simple form for claiming free tokens, then airdropped them, paying the gas fee themselves
- the public mint function worked great at a very affordable price
- art was revealed 24 hours later with ~25% of the collection minted, and many happy owners