How To Prevent NFT Trait Sniping In Your PFP Project

With Solidity and Pinata

Justin Hunter
Pinata
12 min readOct 22, 2021

--

Thanks to @NFTHerder for inspiring this post and providing feedback: https://twitter.com/NFTherder/status/1448750784573751303

NFTs have hit their stride this year. One of the biggest drivers for the mainstream adoption has been PFP (profile picture) projects. By having a mechanism to show off your NFT to the world in the form of a social media avatar, NFTs suddenly gained both value and utility in the eyes of many people who otherwise would not have bought an NFT.

These profile pictures have ushered in new uses for NFTs including community participation, media distribution, and more. And while the NFT community has grown, and while the number of projects has grown, there have been challenges. One challenge comes with the fundamental nature of IPFS, the most popular storage solution for NFT metadata and assets. IPFS is a public network. This means anything stored on IPFS is available for anyone to find.

For PFP drops, having data public before all tokens have been minted or before the project is ready for the data to be public presents a major problem. Rarity is one of the driving forces behind the value of these NFTs. If the traits associated with each NFT are publicly available before the project is ready for that data to be available, it’s possible for people to search for the best traits and wait to mint a rarer NFT.

This is called trait sniping, trait sniffing, or trait leakage, and it’s a real problem.

Before we dive into solving this problem, let’s explore how it works. Let’s say a fictional project has 5000 NFTs they plan to release on a specified mint day. Leading up to that mint day, the project might upload all of their metadata to IPFS. The general structure for this is to upload a folder full of metadata files. Each file will be given a name corresponding with the eventual tokenID that will be associated when the NFT is minted. Now, let’s say the project has a public sale on one day and they mint 2500 of their 5000 NFTs. Those minted tokens will now have tokenURIs that will point to something like ipfs://METADATA_FOLDER_CID/TOKEN_ID.

This means someone could append a tokenID of 2501 or higher to see the traits for all tokens that have not yet been minted. That presents a problem for the project because people will wait until earlier available tokens are minted if they know something rarer is coming. If many people do this, it reduces the chances of the project’s full supply being minted. It also allows trait snipers to pay more in gas fees to mint their desired NFT.

This isn’t a hypothetical scenario, though. It’s happening every day. Recently, the high profile project, MekaVerse, struggled with this:

Now, with a basic understanding of the problem, let’s try to find a solution.

Getting Set Up

The solution to the problem outlined above is a technical one, so we’re going to reach into our technical toolbox and use:

  • Pinata for IPFS storage and distributing our files
  • Solidity to write our smart contract
  • OpenZeppelin for pre-built contract libraries
  • Hardhat to test and deploy our contract code

If you want to take full advantage of the capabilities Pinata layers into IPFS, you’ll want to sign up for a Pinata paid plan. Otherwise, a free plan will help get you started as long as your collection is a small one.

Once you’ve signed up, we can get set to write some code. Fire up your command line and make sure you have Node.js installed

Let’s start by creating our project directory:

mkdir no-more-trait-sniping && cd no-more-trait-sniping

Now, we need to initialize our project directory so that we can use things like Hardhat and OpenZeppelin. To do so, run the following:

npm init -y

Now we’re ready to set up our Hardhat project, which is what we’ll use to write our smart contract. Run this command:

npx hardhat

You’ll be prompted to either create a sample project or create an empty project. Choose the sample project because it gets us a lot of tools and scripts we can start with. You can also choose “y” to the questions that you’ll be prompted with during the project creation.

This will install dependencies and create the appropriate files. When it’s done, open up your project in your favorite text editor. You’ll notice we have a scripts folder, a contracts folder, and a test folder. This sets us up nicely. Let’s start with the contract.

Writing The Contract

Open the contracts folder, and you’ll see a Greeter.sol contract. This is the sample that comes with the Hardhat sample project generator. It’s a simple contract that we are going to rewrite entirely. Let’s start that by changing the file name. Let’s call our contract PFP.sol.

This is a good opportunity to grab a Solidity extension for your text editor if you don’t have one. Pretty much all good text editors have extensions, and Solidity is ubiquitous enough to be available for most of them. The extension should give you syntax highlighting and error helpers. For example, in VSCode, my Solidity extension is telling me there is a problem right at the top of my contract file:

This is great because it highlights the importance of the Solidity extension and reminds me that we need to make an update to our project. In your main project directory, find the hardhat.config.js file. You’ll see a Solidity version declared there. We should change this to 0.8.0. Then return to your PFP.sol file and change your pragma version declaration to this:

pragma solidity ^0.8.0;

The error should now be gone, and we can dive into our setup and code for the contract.

We’re going to be extending the OpenZeppelin ERC721 contract for our NFT project. So, let’s install the required dependency for OpenZeppelin:

npm install @openzeppelin/contracts

Now, before we rewrite the existing contract code and import our OpenZeppelin helpers, let’s talk about what we need to solve for. We know we don’t want to reveal metadata too early. So, we need a couple of things:

  1. An indicator as to whether or not we should be returning the folder with metadata URI or an initial placeholder.
  2. A way to update both the status of our indicator and the URI that should be returned for each token.

With that in mind, let’s get the structure of our contract set, and then we can dive into the changes we need to make. We are going to need to import some helpers from OpenZeppelin, so right beneath the hardhat import in your contract, add this:

import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

We are importing a counters library to help with counting total supply. We are importing the ERC721Enumerable library because it gives us enumerablility to the tokens minted. This just means it provides us some helper functions we can use:

The Ownable library is a helper that lets us define functions that can only be called by the owner of the contract. Finally, the Strings library is a nice helper to convert unsigned integers to strings.

Ok, now we’re ready to actually write this contract. Go ahead and delete everything below the imports we just set up. Let’s start by defining our new contract and setting up a basic constructor:

contract PFP is ERC721Enumerable, Ownable {
using Counters for Counters.Counter;
using Strings for uint256;
constructor(string memory name, string memory symbol) ERC721(name, symbol) {}}

We are defining our contract name as PFP and we are using ERC721Enumerable and Ownable. We then tell the contract that we’ll use the Counters library and the Strings library. Finally, we have a constructor. Think of this as the code that is executed when the contract is deployed. We’re going to add more to this, but as of right now, when the contract is deployed, we are providing a name and a symbol that will be used to identify our specific NFTs.

We are going to need to set up a few variables for our contract. Remember we need an indicator for whether or not we should be revealing the token metadata folder URI. Let’s call that REVEAL and we also need a variable to store our URI. We can just call that URI. We also need to tell the contract how many total tokens CAN be minted. We’ll call that MAX_TO_MINT. We also need to track total minted tokens. We can use the ERC721Enumerable extension, but I’d rather us define it ourselves. We’ll call totalMinted. Below the using Strings declaration, ad this:

uint256 MAX_TO_MINT = 5000;
uint256 totalMinted;
string public URI;
bool public REVEAL = false;

Now, we need to be sure to set the initial value for the URI variable when the contract is deployed. So, let’s add this to the constructor. Update your constructor to look like this:

constructor(string memory name, string memory symbol, string memory initialURI) ERC721(name, symbol) {
URI = initialURI;
}

Now, when we deploy our contract, we need to provide a name, a symbol, and a value that will act as the initial URI. Let’s talk about that URI for a minute before moving on.

We aren’t going to actually reveal any of the art or traits in our project until we’re ready. So, this URI will be a single metadata file that applies to all tokens minted until the REVEAL boolean is flipped to true.

Ok, back to the code.

Because the tokenURI is the thing we’re really trying to solve for, I think that’s the first function we should tackle. The ERC721 contract through OpenZeppelin already has the tokenURI function written, so we need to override it. We can do that by defining our function with an override property like this:

function tokenURI(uint256 tokenId)
public
view
override
returns (string memory)
{
}

This is a public function, overriding the existing function we would normally get out of the box thanks to OpenZeppelin. It takes in a tokenID and returns the tokenURI. Let’s actually override this function now.

Something important to remember here with PFP projects is that normally, the tokenURI is a concatenation of the base_uri and the tokenID. We don’t want that until we’ve flipped the reveal switch. So we have two different versions of the URI we might return.

Update your function to look like this:

function tokenURI(uint256 tokenId)
public
view
override
returns (string memory)
{
if (REVEAL) {
return string(abi.encodePacked(URI, tokenId.toString()));
}
return URI;
}

This function is now looking at our REVEAL variable. If we’ve flipped it to true, we can concatenate the URI with the tokenID. Otherwise, we’re going to return that initial URI.

I keep repeating this, but it’s important. The initial URI is a single metadata file that will apply for every tokenID. You can see this in the new tokenURI function. No matter what tokenID is passed into the function, if the REVEAL variable is false we will return the same tokenURI.

Now, we need to be able to update both the URI and the REVEAL variable. Let’s write a function to do that:

function toggleReveal(string memory updatedURI) public onlyOwner {
REVEAL = !REVEAL;
URI = updatedURI;
}

This is our first (and only function) that uses onlyOwner. This modifier comes from the Ownable library and allows us to restrict the function to only the contract owner. If the contract owner calls this function, they will need to supply a new URI. When that happens, the REVEAL boolean is flipped and the new URI is set.

This works great for both flipping REVEAL to true but it also lets the contract correct the URI if they made a mistake or revealed too early.

The last function we’re going to write is our minting function. Most projects charge a fee for minting new NFTs, so we’re going to make that assumption in our mint function. This means we need to set a price for each token. To keep things flexible, let’s create a function to do that before we create the mint function, and let’s call that function from our constructor.

function setPrice(uint256 price) public onlyOwner {
tokenPrice = price;
}

This function expected a price value to be supplied. Solidity contracts handle currency in GWEI. We need to define a new variable called tokenPrice. We also need to make sure our constructor calls this setPrice function when the contract is deployed.

Your variables at the top of your contract should look like this now:

using Counters for Counters.Counter;
using Strings for uint256;
uint256 totalMinted;
uint256 MAX_TO_MINT = 5000;
string public URI;
bool public REVEAL = false;
uint256 tokenPrice;

And now, the updated constructor should look like this:

constructor(
string memory name,
string memory symbol,
string memory initialURI,
uint256 initialPrice
) ERC721(name, symbol) {
URI = initialURI;
setPrice(initialPrice);
}

When we deploy the contract, we need to also include the price per token. But why do we call the setPrice function in the constructor rather than just set the variable directly? We could do that, but we also had to create the setPrice function in case the owner of the contract ever needed to adjust it. So, it makes sense to define the function as we did previously and then just call it.

Ok, now we can write our mint function. Let’s start by building its skeleton:

function mint(address addr) public payable returns (uint256) {}

This function will take in the wallet address into which the NFT should be minted. It will also return the tokenID after it’s minted. Note the payable modifier? This just tells Solidity that this function expects a payment.

We need to do a few things before we can actually mint, though. We need to make sure that the person trying to mint has provided enough ETH to cover the cost of the NFT. We also need to check if there are any tokens available to mint. Let’s set that up:

function mint(address addr) public payable returns (uint256) {
require(msg.value >= tokenPrice, "insufficient funds");
require(totalMinted < MAX_TO_MINT, "Would exceed max supply");
uint256 tokenId = totalMinted + 1;
totalMinted += 1;
_safeMint(addr, tokenId);

return tokenId;
}

The two require statements at the beginning of the function perform the necessary checks. Then, we create a new tokenId, we update the total number of minted tokens, then we use the _safeMint function to mint a new NFT into the wallet specified.

And that’s it! Here’s the full contract:

The contract controls the visibility of metadata, so the last step is to actually get your metadata and image files onto IPFS so that you have the right URIs to provide the contract.

Uploading Metadata and Files

We’ve already established that we want to start with a placeholder image and metadata for our project. For this, we just need to pick an image and upload it through Pinata’s upload tool.

Once you’ve uploaded the file, you can copy the CID in the Pin Table. Then, you need to create a metadata file in JSON format. Since we already have our text editor fired up with our contract code, let’s create a new file at the root of the project called metadata.json. In that file add the following:

{
"name": "PFP",
"description": "Placeholder for the upcoming PFP drop",
"image": "QmXwECqnS7eEeg8tvwL7w963MhqV5vvQwD5UZGkxZM21E2"
}

Save that file, make sure you can find it in your computer’s file system, then go back to Pinata. Upload that file. You’ll now have a CID for the metadata. And that CID is what you’ll use as your initial URI. Save it somewhere.

Presumably, you have a directory of images for your project. You would also have a directory of metadata files. These should each be named according to the token IDs (1, 2, 3, etc). Those metadata files would point to each of the image files. Now, we don’t want to expose these yet, but we will upload them ahead of the reveal.

On reveal day, you can now upload your folder of images. You will then make sure your metadata files are updated to point to the correct image CIDs. For an in-depth tutorial on creating these files, check out our PFP project tutorial. When everything is ready, upload the metadata folder. Save the CID you get back from this upload as you’ll use it to update your smart contract and officially “reveal” the NFTs.

On your smart contract, call the toggleReveal function. Pass in ipfs://METADATA_CID. And your reveal will be complete. You may have to manually refresh your metadata on OpenSea, but outside of that, you’re done! And you’ve prevented trait sniping of your project.

Wrapping Up

Trait sniping is a problem with NFT PFP projects, but the same problems can apply to other NFT projects. The ability to update data on a smart contract is core to Solidity, so making use of this functionality in a smart way will help prevent trait sniping.

You can combine a simple smart contract function with Pinata to make your life easier when it’s time to reveal the final product.

Happy pinning!

--

--

Justin Hunter
Pinata

Writer. Lead Product Manager, ClickUp. Tinkerer.