ERC721R: A new ERC721 contract for random minting so people don’t snipe all the rares!
Or: how to snipe all the rares if ERC721R isn’t being used!
May 5, 2022 UPDATE: ERC721R has been EXPLOITED and PATCHED. Get the updated code from Github and read below for an explanation.
TLDR: Until today, an attacker could exploit ERC721R by minting from a contract. I fixed this by preventing contracts from minting.
If you are using ERC721R in a contract that allows other contracts to mint (the default behavior), you are vulnerable. If your contract prevents other contracts from minting, you are not vulnerable. Fashion Hat Punks, the first contract to use ERC721R, is not vulnerable
How does the exploit work?
As I wrote in the original ERC721R explanation (below), in order to game ERC721R, you need to be able to predict the value of a hash with these inputs:
I went on to say:
For a normal person, this is impossible because it requires knowing the timestamp of the block of your mint and you do not have access to this information.
This is unfortunately incorrect. A normal person can exploit this. But how?
The first step is to mint from a contract instead of from your wallet. For example:
- Attacker deploys Contract A.
- Contract A has a function called
mintRemote()that calls the mint function on an ERC721R contract with Attacker as
toAddress, minting Attacker one token.
- Attacker calls
This setup allows Attacker to game all of the block-specific global variables in the ERC721R randomization hash.
This is because
mintRemote() is executed in the same block as the ERC721R mint function, which means that the value of
mintRemote() is the same as the value of
block.timestamp in the ERC721R contract’s randomization function.
The same applies to
tx.gasprice, and the rest of the global variables. This gives Contract A enough information to predict the token it is about to mint, and to return from
mintRemote()without minting if the token is not to the Attacker’s liking.
Thus, ERC721R has been exploited. However, the exploit is inefficient as it allows us to test only one random outcome per
mintRemote()transaction. This is not ideal because it might take many transactions to find the token we want.
To fix this, we need to be able to “re-roll” the hash mid-transaction to find a favorable outcome. However as the global variables are per-block, we cannot change them mid-transaction. What can we change? We can change the
The randomization function depends on the mint target, changing the mint target address changes which token will be minted.
One way to accomplish this would be to have 1000s of addresses and cycle through them in
mintRandom(), trying each until we got the token we wanted.
A better way is to use Ethereum’s CREATE2 opcode. CREATE2 allows a contract to deploy another contract to an address the deploying contract knows ahead of time. Specifically, the address of a contract deployed in this fashion can be pre-computed like this:
Now we have the final exploit. Attacker deploys Contract A with these functions:
Contract A combines these functions to find a contract address that can be minted a favorable token, mints a token to that address, deploys a Child Contract to that address, and then the Child transfers the token to the attacker in its constructor and
This version of the exploit is orders of magnitude more efficient, but it is still not cheap. In my testing it cost about 10M in gas to try 2000 child contract addresses. If there are 10,000 remaining tokens it could cost around $7k USD to get the one you want, but this goes down if there are multiple favorable tokens and/or there are fewer tokens remaining.
Introduction: Blessed and Lucky
Specifically, I wanted an alien. They look the coolest and there are only 8 in the 6,969 collection. And I got one!
Though it might not have been clear from the Tweet, what I meant was that I was lucky to have figured out how to 100% guarantee I would get an alien without needing any additional luck.
Read on for how I did it, how you can do it too, and, if you’re a dev, how you can prevent it from happening!
How to mint rare NFTs without needing luck
The key to minting a rare NFT is knowing the id of every rare token in advance.
For example, once I knew my alien was #4002, all I had to do was refresh the mint page until I saw #3992 had been minted and then immediately mint 10 mphers.
How did I know #4002 was an alien? Let’s retrace my steps.
First, go to the Etherscan page for the mpher contract and look up the tokenURI of a token that has already been minted, token #1:
As you can see, mphers, like many contracts, constructs metadata URIs by combining the token id with an IPFS hash.
The benefit of this approach is that it gives you the provenance of the entire collection in every URI and, while that URI can be changed, doing so affects everyone and is public.
By contrast, imagine if the token URI contained no provenance hash, for example
https://mphers.art/api?tokenId=1. As a collector you could never be certain that the devs weren’t silently changing #1’s metadata whenever they wanted.
However, if you have an API, you can say “if #4002 has not been minted, do not show any information about it” and you can’t do this if you go the IPFS route.
Once the metadata has been revealed (which in mpfers case was instantly), you can look up the metadata of any token, whether or not it has been minted.
Just replace the trailing “1” in the URI above with the id you want.
These metadata files give us all the attributes of the mpher with the specified id. For example, in the case of my alien:
To find the aliens, we just need to search all the metadata files for the string “alien mpher.”
Next, download the 6,969 metadata files. Here I’m using OpenSea’s IPFS gateway but you can try
ipfs.io or something else if that works better.
This snippet uses
curl to download the files 10 at a time. Downloading thousands of files quickly can be finicky so you might end up with some duplicates or errors. But if you fiddle with it you should be able to get everything (and for our purposes dupes aren’t a problem).
Now that you have the files in one directory, just
grep for the aliens:
The numbers that appear are the names of the files that contain “alien mpher” and therefore the ids of the aliens themselves.
The whole process takes less than 10 minutes. And you can use this technique on many NFTs that are minting right now.
In practice, it is not completely trivial to manually mint at the exact right moment to get the alien, especially when tokens are minting quickly. If you really want to “go big” with this approach, you should write a bot to poll
totalSupply() every second and automatically submit the mint transaction at the exact right moment.
And if you want to go “absolutely huge,” you could look for the token you need to see in the mempool before it is even minted and get your mint into the same block!
However, in my experience, the “big” approach is enough to win 99% of the time—though, interestingly, not 100% of the time.
“Have I been getting played this entire time?”
Is one question you might be asking yourself if you’re just learning about this now.
The idea that you had zero chance at minting anything that someone using this technique also wanted is distressing.
But, did you have no chance? In a way, you had the same chance as everyone else!
Take me for example: I figured this out on my own using public information and I put it to work using free open-source tools. Anyone can do this, and in general, if you don’t investigate how a contract works before minting you are going to run into much worse issues than this.
The mpher mint was 100% fair.
Still, while it was a fair game, “snipe the alien” might not have been the game everyone wanted to play.
Instead, people might have had more fun overall playing the game of “mint lottery” where tokens were distributed by chance and it was impossible to gain an advantage over someone who was just clicking the “mint” button.
How might we do this?
Fair Random Minting
For Fashion Hat Punks, my goal was to create a random minting experience without sacrificing fairness. In my view, a predictable mint is far better than an unfair one. Above all else, participants must be kept on equal footing.
Unfortunately, the most common way to create a random experience—the so-called a post-mint “reveal”— is deeply unfair. It works like this:
- Token metadata is inaccessible during the mint. Instead, tokenURI() points to the same blank JSON file for all ids.
- Once all the tokens are minted, the contract owner updates the IPFS hash to the real metadata.
- There is no way to verify how the contract owner chose which token ids got which metadata, and the results appears to be random.
Here, the person setting the metadata obviously has a tremendous unfair advantage over the people who are minting because they alone determine who gets what! Unlike the mpher mint, here is a situation where you actually have no chance to compete.
But what if it is a well-known, trusted, doxxed dev team with a long track record. Are reveals okay in this case?
No! No one can be trusted with this kind of power. Even if someone isn’t consciously trying to cheat, they bring unconscious biases to the table just like everyone else. Beyond this, they might simply make a mistake and not realize it until it was too late.
You should not trust yourself either. Imagine, you do a reveal, you feel pretty good you did it correctly (nothing is 100%!), and somehow you end up with the rarest NFT. Would that not feel a tiny bit weird? Are you sure you deserve it? Personally, as an NFT developer, I would not want to be in this situation.
The bottom line is this: reveals are bad*
*UNLESS: they are done trustlessly, meaning everyone can verify their fairness without having to trust the devs (which you should never do).
To achieve a trustless reveal, you need a way of proving that the reveal was fair—typically by having the reveal happen on-chain and be powered by randomness that is verifiably outside of anyone’s control (e.g., through Chainlink).
Tubby Cats did a great job on a reveal like this and I recommend you check out their contract and launch reflections. Their reveal was also cool in that it was progressive—you didn’t have to wait for the end of the mint to learn what you got.
The downside to trustlessness in general is that it is extremely difficult to get right—@DefiLlama had this to say in his launch reflections:
When writing the contract I made it as trustless as possible, removing as much trust as possible into the team.
The reason for it is that I believe it’s important for every participant to know the rules of the game and know that they won’t be changed from under them (everyone should have complete information to make decisions, if processes are changed in the middle that creates groups of people with privileged information), while trust minimization is important because that’s the whole raison d’etre for smart contracts (and it makes it impossible to hack even if the team is compromised). However, this was a huge mistake, since it greatly reduced our flexbility and the actions we could take to correct things that happened.
And @DefiLlama is a top-tier dev. If maximizing trustlessness gave him this many headaches, imagine what it will do to you!
Therefore, my recommendation is to use a worse solution that still suffices in 99% of cases and that is much easier to implement: random token assignments.
Enter ERC721R: A fully-compliant implementation of IERC721 that selects token ids pseudo-randomly
ERC721R implements the converse of a reveal: instead of minting token ids deterministically and assigning metadata randomly, we mint token ids randomly and assign metadata deterministically.
This allows us to reveal all metadata before minting while still minimizing snipe opportunities.
To use it, copy the contract into your project directory (sorry, no NPM package yet),
import it, and use this code:
How does ERC721R work?
First, a disclaimer: unlike a trustless reveal, ERC721R is not truly random. In this sense it creates the same “game” we saw in the mpher situation where minters can use publicly-available information to compete to exploit the mint. However, in the case of ERC721R, the game is significantly more difficult.
In order to game ERC721R, you would need to be able to predict the value of a hash with these inputs:
For a normal person, this is impossible because it requires knowing the timestamp of the block of your mint and you do not have access to this information. (THIS HAS BEEN SHOWN TO BE INACCURATE. See the update at the top of this article).
A miner who has control over when blocks mine (and therefore can influence the timestamp) can theoretically do this, but even then they must set the timestamp to a value in the future and whatever they’re doing depends on the hash of the previous block which expires in about 10 seconds when the next block is mined.
I believe this pseudo-randomness is “good enough,” but if there is big money on the line, it will be gamed with 100% certainty, so be careful! Of course the system it is replacing—predictable minting—will also be gamed.
The token id selection itself happens in a very clever implementation of the modern version of the Fisher–Yates shuffle algorithm that I copied from CryptoPhunksV2.
To understand it, first consider the naive solution: (the below assumes a 10,000 item collection)
- Create an array containing the numbers 0–9999.
- When you want to mint a token, randomly select an item from the array and use that value as your token id.
- Remove that value from the array and reduce its length by 1 so that every index in the shortened array corresponds to an available token id.
This works, but it costs too much gas because changing the length of an array and storing a huge array filled with non-zero values are costly.
How can we avoid both? What if we instead started with an array containing 10,000 zeros, which is cheap to create. Now let’s use each index in that array to represent an id.
Suppose we choose index #6500 randomly—#6500 would be our token id and we would indicate index #6500 was already used by (for example) replacing the 0 in index #6500 with a 1.
But what would happen if we chose #6500 again? We would observe a 1, indicating #6500 was taken, but then what? We cannot simply “roll again” as this would make gas unpredictable and high, especially for later mints.
The genius of modern Fisher-Yates is that it gives us mechanism for selecting an available token id 100% of the time without the cost of maintaining a separate list. Here’s how it works:
- Create an array containing 10,000 zeros.
- Initialize a uint
numAvailableTokenswith the value 10,000.
- Pick a random number between 0 and
numAvailableTokens — 1
- Suppose you chose #6500—look at index #6500. If the value is 0, #6500 is your next token id. If the value is non-zero, the value at index #6500 is your next token id (starting to get weird!)
- Now, look at the last value in the array, which is the value at index
numAvailableTokens — 1. If that value is 0, update the value at index #6500 to the last index in the array (#9999 if it’s the first token). If the last value in the array is not zero, update index #6500 to store this final non-zero value.
- Repeat 3–6 to get the next available token id.
And there you have it! The array stays the same size and yet we are able to reliably choose an available id. Here is the Solidity code:
Unfortunately, this algorithm still uses significantly more gas than the leading sequential mint solution, ERC721A.
This is most pronounced when minting multiple tokens in one transaction—e.g., a 10 token mint costs about 5x more on ERC721R than ERC721A. That said, ERC721A has been optimized much further than ERC721R so there is probably room for improvement.
Here are your options:
- ERC721A: Minters pay lower gas but must spend time and energy devising and executing a competitive minting strategy or be comfortable with worse minting results.
- ERC721R: Higher gas, but the easy minting strategy of just clicking the button is optimal in all but the most extreme cases. If miners game ERC721R it’s the worst of both worlds: higher gas and a ton of work to compete.
- ERC721A + standard reveal: Low gas, but not verifiably fair. Please do not do this!
- ERC721A + trustless reveal: The best solution if done correctly, highly-challenging for dev, potential for difficult-to-correct errors.
If you want to learn more, head over to the GitHub repository and check out the code! Pull requests more than welcomed—I’m sure I’ve overlooked many opportunities for gas savings.