The Picaroons is a new NFT collection by Alex Lucas, with support from Pranksy. Holders of certain NFTs by Alex were able to mint up to 2 tokens, then during public mint anyone could mint 2 tokens (and previous minters could mint 2 more). The 10k collection quickly sold out during public mint, and is now only available on secondary marketplaces.
An interesting thing about this contract is the extra effort into preventing bots from minting directly from the contract. They maintained an allow list of addresses in their web app instead of putting the snapshot into the contract, and minting required a signature generated by their web app. Let’s dig into the contract code…
Mint
function mint(bytes32 hash, bytes memory signature, uint256 tokenQuantity, uint256 maxAmountAllowed) external payable {
require(saleLive, "SALE_CLOSED");
// Is the signature valid
require(matchAddresSigner(hash, signature), "DIRECT_MINT_DISALLOWED");
// Verify if the keccak256 hash matches the hash generated by the hashTransaction function
require(hashTransaction(msg.sender, tokenQuantity, maxAmountAllowed) == hash, "HASH_FAIL");
// Out of stock check
require(totalSupply() + tokenQuantity <= TOTAL_SUPPLY, "OUT_OF_STOCK");
// Is the address allow more mint more tokens?
require(amountMintedList[msg.sender] + tokenQuantity <= maxAmountAllowed, "EXCEED_ALLOC");
require(PRICE * tokenQuantity <= msg.value, "INSUFFICIENT_ETH");
for(uint256 i = 0; i < tokenQuantity; i++) {
amountMintedList[msg.sender]++;
_safeMint(msg.sender, totalSupply() + 1);
}
}
The mint function requires the following:
Sale isn’t closed
The hash argument was signed (signature) by a known signing address
The hash argument can be verified with the other function arguments
There are tokens available to mint
You have not minted too much
You sent enough ETH
Once those checks pass, it’s a simple mint loop, incrementing a counter for how many you have minted. The interesting parts are requirements 2 & 3.
The _signerAddress is defined as a static variable. In order to generate a verifiable signature for the hash, the private key associated with this signing address must exist in their web app. Because this address is static, with no way to change it, they hopefully had some good app and server security to prevent any leaking of the private key. Assuming no one else has access to the private key, the signature can only be generated by their web app, and therefore no one, including bots, could mint directly from the contract. All minting actions had to go through their web app, which can be protected from bots using more traditional methods.
How did they secure their web app from bots?
They required a captcha before a signature was generated, which prevents most web bots.
Before the public mint, only addresses on an allow list based on NFT ownership were able to mint.
This allow list was generated from a snapshot ~2 weeks before minting was enabled. But unlike many allow list snapshots, it wasn’t put into the contract. Instead they must have made a database for their web app to check against, which was only used during the pre-sale.
How did they make a signature in a web app that could be verified within the contract?
Libraries like web3.js provide a way to sign data in javascript using a private key. The library also provides message hashing. So by using a library like web3.js in a server-side web app, you can do the following:
Encode function arguments into a message
Hash that message
Sign the hash
The hash and signature can then be returned from the web app to the browser, then passed to the contract, thereby preventing direct contract minting.
Hash Transaction
This function reconstructs the hashed message that was signed. It does this by encoding the arguments, then hashing them with solidity’s standard hashing algorithm, keccak256.
function hashTransaction(address sender, uint256 qty, uint256 maxAmountAllowed) private pure returns(bytes32) {
bytes32 hash = keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32",
keccak256(abi.encodePacked(sender, qty, maxAmountAllowed)))
);
return hash;
}
That’s about it for the minting. Let’s see what Slither says…
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.
No significant issues found. Nearly all the issues are in the dependency contracts, which are pretty standard, and the only issues Slither finds for ThePicaroons contract are cosmetic.
Gas Efficiency
The contract uses ERC721Enumerable. While it’s a somewhat common extension to ERC721, and it adds some useful functionality, it definitely increases gas costs for minting and transfers. Azuki did a deep analysis of this and created ERC721A to solve this gas issue without losing key functionality. Using ERC721A would have been a nice improvement to the Picaroons contract, but the contract is otherwise very simple and efficient.
Provenance Hash
One final detail in the contract is the use of a NFT Provenance Hash. This can be used to verify that the reveal after mint was defined before minting began, and not manipulated after mint but before reveal. The fourth transaction on the contract, right after public mint was enabled, was to set the provenance hash to e4b58ca66f8bf42902acff422cb634ff8c6d012627a71f195071c725558670b9. While they don’t give a nice breakdown on their website for verifying the metadata of the tokens, like BAYC does, it’s still a good thing to do and provides that verification capability to anyone that wants to do the work.
/// Set the smart-contract proof as an hash of all NFT metadata hashes
/// @param hash of all NFTs metadata hashes
function setProvenanceHash(string calldata hash) external onlyOwner notLocked {
proof = hash;
}
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.
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:
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: