ERC721 Development: Should you write metadata for tokens before or after minting?

If you're building an ERC721 collectible series, should you write metadata (to IPFS for off-chain metadata, and directly to the contract for on-chain metadata) before a user mints a token, or should you write the metadata after a user mints a token?

If the answer is before, then doesn't that effectively hurt the "randomness" of the minting process, since users can inspect IPFS or the on-chain metadata to see which tokens they would mint? If yes, does it matter?

If the answer is after, should you implement this by having a server listening for a "minted" event from the smart contract, that then writes then generates and writes the metadata?

2 Likes

Yes, in fact Meebits was exploited in this way. Whether it matters depends on the project. In this case, the attacker was able to mint a rarer NFT which presumably would be worth more, by abusing the sale.

In this space, the ideal is generally to minimize trust placed in other parties. So a centralized server, in my opinion, would generally be against the ethos.

I know I'm not giving concrete answers, sorry. I think it's possible to come up with a good design with nice properties, but it will depend on the specifics of each project.

1 Like

To be fair, the Meebits exploit happened due to the way in which randomness was "achieved". Had they used VRF, I don't believe that would have happened.

I think the larger question (outside of how one gets their randomness) is the sequencing of generating a random NFT.

Let's say I have an ERC721 with 100 tokens, and there are 3 different rarities - bronze, silver, and gold. Furthermore, let's say as part of the metadata I would like to associate a given contextual attribute with the metadata as well - let's say what device minted it - iPhone, Android, etc.

I cannot seem to find a workflow that supports this. You can't write all metadata before, because you are missing a fundamental attribute. However, you can't do any lazy minting, because then theoretically you could game the randomness.

What would you call best practice to avoid this dilemma?

Are you aware of NFT projects using VRFs? That sounds interesting.


One thing you can do is store that "contextual" data on-chain at the time of purchase. You can later use this stored information to generate the token URI. Some options:

  • Include a query parameter in the URL:
    tokenURI(id) = baseURI() + "/" + id + "?device=" + device(id)
    Though this will not be embedded in the metadata file itself if your baseURI is an IPFS directory.
  • Have separate baseURI per device:
    tokenURI(id) = baseURI(device(id)) + "/" + id
    Each can be a different IPFS directory.
  • Return a data URI, something like:
    tokenURI(id) = "data:application/json;base64," + Base64.encode(metadataJson(id, device(id))
    See UniswapV3 for a reference of this approach.

In my upcoming project we'll be using VRF to generate a random id to mint! I'll keep you posted :slight_smile:

On the three ideas:

  1. I like this idea but there is something so tangible about it being in the JSON that I would opt out for this
  2. This entails (through no fault of your own - it comes from my own faulty example) that we know a priori all device_ids, which unfortunately for my use case, we don't.
  3. I found this approach to be very interesting. It does entail holding all metadata on chain (besides, theoretically, the image/media itself, which could still be held on Arweave and just the id held on chain). I wonder about the price of this.

Let's say we had metadata of something like:

metadata = [
   { value: nftA, frequency: 2, arweaveLink: 0xA },
   { value: nftB, frequency: 3, arweaveLink: 0xB },
   { value: nftC, frequency: 5, arweaveLink: 0xC },
]

I'm thinking we then could do something (off chain) like:

distribution = []
for (int i = 0; i < metadata.length; i++) {
    metadata[i].id = i;
    for (int j = 0; j < metadata[i].frequency; j++) {
         distribution.push({ id: i, customValue : 0});
    }
}

We then can have a contract like:

contract My721 is ERC721 {
  /* STRUCTS */
  
  struct TokenStruct {
      uint256 id;
      uint256 customValue;
    }

  struct FamilyNFT {
    uint256 id;
    uint256 frequency;
    bytes32 value;
    bytes32 arweaveLink;
  }

  /* STATE VARIABLES */
  TokenStruct[] public tokens;

  mapping(uint256 => bool) public minted;

  // NOTE: I suppose this could be a mapping,
  // but I didn't want to loop through the `_differentNfts` on-chain.
  // The correct data structure I suppose depends on the size, i.e. number
  // of "uniqueNfts" we have?
  FamilyNFT[] public differentNfts;

  // these probably are memory/calldata or something :)
  constructor(TokenStruct[] _tokens, FamilyNFT[] _differentNfts) ERC721("NFT", "My721") {
    tokens = _tokens;
    differentNfts = _differentNfts;
  }

  // Of course we wouldn't use an unbounded loop or this naive of an implementation,
  // but just to get the point across...
  function mint(uint256 _customValue) public {
    // this represents something that theoretically is only known at mint time
    // but we want in the metadata
    require(_customValue != 0, "mint:: customValue must be non zero!");

    while (false) {
      uint256 randomNumber = getRandomNumber() % tokens.length;
      // indicates not yet minted
      if (minted[randomNumber].customValue != 0) {
        minted[randomNumber].customValue = _customValue;
        _mint(randomNumber, msg.sender);
        break;
      }
    }
  }

  function tokenURI(uint256 tokenId) public returns (string) {
    // First get this token
    TokenStruct token = tokens[tokenId];
    // Then get the family it belongs to
    FamilyNFT nftData = differentNfts[token.id];
    return
      string(
        abi.encodePacked(
          "data:application/json;base64,",
          Base64.encode(
            bytes(
              abi.encodePacked(
                '{"value":"',
                nftData.value,
                '", "tokenId":"',
                tokenId,
                '", "customValue":"',
                customValue,
                '", "imageLink": "',
                nftData.arweaveLink,
                '"}'
              )
            )
          )
        )
      );
  }
}

Thoughts?

2 Likes

Looks good, except that for numbers encodePacked is not going to work. It will include the bits that represent the number, and you want to concatenate its ASCII representation, so you should use Strings.toString(uint256).

1 Like

Thanks so much for the review!

Wondering at what point that amount on chain becomes so expensive as to not be feasible? Any ideas how to test this/pitfalls to look out for?

In any event, I do think it may be the only way to achieve what I'm looking for, so there will be tradeoffs :slight_smile:

Well this is a function that is really only ever invoked off-chain via eth_call. So the fact that it is expensive isn't so much of a concern. You could look at the gas cost of Uniswap V3's tokenURI to have as a reference value, because it looks expensive and it can serve as a safe upper bound.

Sorry, was referring to the idea of that amount of storage on chain. You would turn the tokenURI into a view function anyway so as not to take any gas.

It's basically the question of how much is too much when it comes to on-chain storage.

I don't know how much more data you plan to store, but from what you've shown so far it sounds very reasonable to me.

You can use smaller data types (like uint32) to fit more values into a single storage slot. It will be cheaper.

One concern is that we cannot copy calldata or memory to storage, which means we would have to iterate over _tokens and _differentNfts.

I worry this is prohibitively expensive gas wise. For example, I just ran the following with eth-gas-reporter and ids.length == 5000, and exceeded the gas limit by quite a bit:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

pragma experimental ABIEncoderV2;

contract Contract {
    struct Struct {
        uint id;
        bytes32 imageLink;
    }

    mapping(uint => Struct) structs;
    function initialize(uint[] calldata ids, bytes32[] calldata links) public {
        for (uint i; i < ids.length; i++) 
            structs[i] = Struct(ids[i], links[i]);
    }
}

InvalidInputError: Transaction gas limit is 13694632 and exceeds block gas limit of 12450000.

This drastically prohibits the feasibility of this approach. I hate to dip into the world of assembly, and I'm not sure if that will help or not.

Is there anything you can thing of to avoid this?

Using assembly will not help. Writing to storage is expensive in any way you do it.

This is not something you should do in a single transaction. I imagined you were going to store the data each time an NFT is minted. This should be ok in terms of gas.

If you know all of this data at the time of deployment, you should use IPFS!