/ Blog /thorhead-tech-part-1


Thorhead Tech Part 1: How Not to Build an NFT Project

This is Part 1 of a Technical Series on Thorhead, my VeChain-based NFT project. In this series, we'll be diving into the more technical aspects of the project, including the smart contract, the front-end & back-end, and hilighting key learnings along the way. If you're interested in the project's non-technical backstory, check out the Thorhead: Origins post.


I have not failed. I've just found 10,000 ways that won't work.

— Thomas Edison

The above quote is a good summary of the process of building Thorhead. My road to learning how to build on a blockchain was a long and winding one. Coming from a web2 development background, I made a lot of rookie mistakes along the way, but I learned a lot. Hopefully, I can save you some time and headache by sharing what I learned.

The Dumb Contract

I started learning about smart contracts and blockchain development as I would learning any other technology: diving in face-first, with 1000 tabs of the docs open, building the most simple thing I can imagine. In this case, it was the most stripped-down version of a "smart" contract. I put smart in quotes because it was just a contract that incremented a number every time you called a function.

contract DumbContract {
  uint256 public number;

  function increment() public {
    number++;
  }
}

Really groundbreaking stuff, I know.

But the point of this contract is not to innovate but to solidify my understanding of how to deploy a smart contract to VeChain. Thanks to an amazing tutorial by favo, I was able to set up a hardhat project, deploy DumbContract to the testnet, call the function, and see the number increment. Success!

Now that I can successfully deploy something, it was time to move on adding tools to my toolbelt. I started by going through Solidity-by-example. I learned about data types, structs, mappings, and events and felt like I had a good enough grasp to start building.

Contract Complexity

As I started putting together the Thorhead contract, I knew that I wanted to have a few key features:

  • Storing as much data as possible on-chain
  • Procedurally generating images
  • Randomly generating traits on a per-mint basis

Having read about structs and enums, I started putting together data structures that I thought I'd need.

struct Nft {
  uint256 id;
  address owner;
  bool holo;
  HeadHeight headHeight;
  HeadStyle headStyle;
  bool burned;
}

enum HeadHeight {
  Tier1,
  Tier2,
  Tier3,
  Tier4,
  . . .
}

enum HeadStyle {
  Style1,
  Style2,
  Style3,
  Style4,
  . . .
}

Now those of you who are more experienced with Solidity are already cringing at this point. And you're right to be. There are much more simple ways to handle things like this in Solidity (namely mappings). But I didn't know that at the time so I moved forward with this structure.

Now I started building basic SVG images for the different traits that I could store directly on chain. This is just a small example of what I was working with:

function getHeadHeightPaths(HeadHeight _headHeight) public pure returns (string memory) {
  if (_headHeight == HeadHeight.Tier1) {
    return '<path>...</path>';
  } else if (_headHeight == HeadHeight.Tier2) {
    return '<path>...</path>';
  } else if (_headHeight == HeadHeight.Tier3) {
    return '<path>...</path>';
  } else if (_headHeight == HeadHeight.Tier4) {
    return '<path>...</path>';
  }
}

From here contract complexity ballooned. I added more and more traits, more and more functions, to compile whole SVGs on-chain. But, the concept was proven. And it worked for the time being. I was able to mint a Thorhead, see the record in the contract, and see the (very stripped down) SVG image returned from the contract. With that I was able to check the first two items off my list. Now it was time to tackle randomness.

The Randomness Problem

I quickly learned that the concept of randomness is a bit of a misnomer in blockchain. There is no true randomness on-chain. Everything is deterministic. So how do you generate randomness in a deterministic system? What is pseudo-random in a deterministic environment. I naively landed on gasleft() and a nonce.

function _getRandomNumber(address _forAddress) internal returns (uint256) {
  return uint256(
    keccak256(
      abi.encode(
        block.timestamp,
        block.number,
        _forAddress,
        _useNonce(_forAddress),
        gasleft()
      )
    )
  );
}

mapping(address account => uint256) private _nonces;

function _useNonce(address owner) internal virtual returns (uint256) {
  return _nonces[owner]++;
}

Looking at gasleft() in the solidity docs, we see that it is a function that returns the amount of gas left in the current block. gasleft() would vary widely based on the contents of the rest of the block, which was unlikely to be controlled by a singular user. A nonce (number used once), would be hidden from the user and incremented every time a random number was requested.

Based on the gasleft and nonce, the _getRandomNumber function would compute a massive "random" number that could be sliced and diced to build up a struct of random values to determine traits.

struct Randomness {
  uint256 holoRand;
  uint256 headStyleRand;
  uint256 hairColorRand;
  uint256 hatColorRand;
}

function _getRandomness(uint256 value) private pure returns (Randomness memory) {
  uint256 section1 = uint256(value & ((1 << 64) - 1));            // Extract lower 64 bits
  uint256 section2 = uint256((value >> 64) & ((1 << 64) - 1));    // Extract next 64 bits
  uint256 section3 = uint256((value >> 128) & ((1 << 64) - 1));   // Extract next 64 bits
  uint256 section4 = uint256((value >> 192) & ((1 << 64) - 1));   // Extract next 64 bits

  return (Randomness(section1, section2, section3, section4));
}

Ultimately, this is still susceptible to manipulation via a coordinated attack. But I figured it was good enought for the time being and crossed it off my list. Thankfully, I didn't even get to see whether or not this worked because I ran into a much bigger problem: contract size limits.

The Monolithic Contract

Before I could successfully make another deploy to the testnet, I ran into a new error:

Error: gas limit exceeded

My contract was too big.

As it turns out, the amount of raw text data I was attempting to store in a single contract on-chain was too large. Thankfully it was on the testnet because each deploy was creeping closer and closer to the gas limit of 400 VeThor per deploy. And this was before I had even fully fleshed out the SVGs. It was this that made me take a step back and question my approach.

People are storing tons of data on-chain, right? So what is wrong with my approach that is causing every feature to feel like a new mountain to climb? I started looking at other standards and source code to see how they were handling things. That's when I learned, I was doing it all wrong.

Ethereum Improvement Proposals

As luck would have it, some very smart people had already thought deeply about these problems. The Ethereum Improvement Proposals (EIPs) are a series of standards that have been proposed and accepted by the Ethereum community to help guide the development of the Ethereum ecosystem. These standards cover everything from token standards to contract standards to interfaces and more. You'll sometimes also hear them called Ethereum Requests for Comment (ERCs). ERC encompasses the broader discussion and adoption processes as well as the formal proposal process. In the context of this, and future posts, I'll refer to these standards as ERCs, but if someone references an EIP, know that it refers to the same standard.

ERC-721

The ERC-721 standard is a non-fungible token (NFT) standard that defines the minimum interface a smart contract must implement to allow unique tokens to be managed, owned, and traded. This standard is what powers the vast majority of NFT projects on VeChain and other blockchains. The standard is simple, extensible, and it is what I should have been using from the start. These standards are so ubiquitous that there are even easily importable libraries that let you extend the internals of the ERC-721 standard to fit your needs.

Using this off-the-shelf standard unlocked a whole new perspective of understanding what I wanted my contract to do. I didn't need to store the SVGs on-chain. I didn't need to store the randomness on-chain. I didn't need to store the traits on-chain. I just needed to store a unique identifier and a reference to the metadata that would be stored off-chain. As I dug even deeper, I learned that there are even standards around the formatting of the metadata that is stored off-chain. This enabled interfacing with platforms like World of V dead simple.

Further Improvements

With the ERC-721 standard in hand, I was able to refactor the contract to be much more concise and focused. I was able to remove the randomness generation, trait storage, and SVGs from the contract entirely and focus on the core functionality. However, I was still left with a few lingering thoughts:

  • Is there a sensible way to store large-form data on-chain? (i.e. SVGs, JSON, or image data)
  • Can the metadata standards be improved to include more complex data structures?

I have written some of the more nuanced thoughts on these questions in the VeChain discourse forum. You can find the full post here. I would greatly appreciate any feedback or thoughts on those concepts as it will hopefully help improve the future of the VeChain ecosystem.

Key Takeaways

Start with the Standards

Don't reinvent the wheel. Odds are, if you are running into friction, you're trying to do something incorrectly. Look at the existing ERCs and see if there is a standard that fits your use case.

Avoid On-Chain RNG

There are a million simple ways to handle randomness off-chain. Unless something like this VRF VeChain Improvement Proposal is adopted, it is best to handle randomness off-chain. While this does sacrifice some of the transparency that blockchain provides, it is a necessary trade-off when working in a deterministic environment.

Compose Small Contracts

There is nothing that says you are required to do everything in a singular contract. In fact, it is often better to compose small units of functionality by calling other contracts from contracts (see an example here). This touches on a more complex concept of upgradability, but that is a topic for another post. A practical example of using small contracts with a clear scope of responsibility is in the VeBetterDAO. There is a list of several contracts that are used in concert to orchestrate the complex operations of the organization. If this was all done in a single contract, it would likely exceed the gas limit as well.

Up Next: The math behind the procedurally generated images, odds, and what that means for the circulating VeThor supply.


Support Kyle by sending a tip:

by Kyle