Skip to main content

Command Palette

Search for a command to run...

Chain Runners: A new on chain paradigm?

Breaking down the latest, all on-chain NFT pixel art.

Published
8 min read
Chain Runners: A new on chain paradigm?

We are going to deep dive into the how the latest on chain NFT project built their contract. They derived some inspiration from previous projects and gave them credit in their code (Anonymice, blitmap).

Table of Contents:

  1. Project Overview
  2. Contracts
    1. ChainRunners.sol
    2. ChainRunnersBaseRenderer.sol
      1. Picking the Layers
      2. Generating the Pixels
  3. Security and Safety Considerations
    1. setRenderingContractAddress
    2. setLayers
  4. Notes

Project Overview

Before we being, here are the contracts that we will be looking at:
Main Contract: ChainRunners.sol - stores NFT data and contains controls for minting
Render Contract: ChainRunnersBaseRenderer.sol - calculates the pixels/art and creates an SVG

Project Name: Chain Runners
Website: https://www.chainrunners.xyz/
Description: 100% on chain generative pixel art. This means that all of the data making up the art is computed and stored on chain.
Total Supply: 10,000 (85 reserved for founders)
Mint Price: 0.05 ETH
Early Access: Yes
Mint Status: Finished
OpenSea: https://opensea.io/collection/chain-runners-nft (OpenSea is a marketplace for buying and selling NFTs)

Contracts

Now that we have an overview of the project lets dive in. We will start with the first contract

ChainRunners.sol

This contract is an ERC721 with some additional functionality to control minting access - founder, early access, and public minting - and creating the data that the NFTs will be built from.

The first function we want to look at is the internal mint function:

function mint(uint256 tokenId) internal {
    ChainRunnersTypes.ChainRunner memory runner;
    runner.dna = uint256(keccak256(abi.encodePacked(
        tokenId,
        msg.sender,
        block.difficulty,
        block.timestamp
    )));

    _safeMint(msg.sender, tokenId);
    runners[tokenId] = runner;
}

It is a simple function that does the following:

  1. Generates the dna of a runner based on the tokenId and some other parameters. We concatenate the parameters and then take the keccak256 (sha3) hash and cast it to a unsigned integer of 256 bits.
    1. It is possible to mint multiple runners (tokens) in a single transaction so using the tokenId as a parameter was necessary to ensure the dna would be different for every runner
  2. Mint the tokenId using the ERC721 _safeMint
  3. Save the runner (its dna) to a state variable

The runners (and their dna) are stored on this main contract. We dont have an image/svg yet though. For this we will need to start using the second contract, but before we do, the second contract address must be set. It is set by, and only by, the owner of the contract. Remember this, we will discuss the tradeoffs soon.

function setRenderingContractAddress(address _renderingContractAddress) public onlyOwner {
    renderingContractAddress = _renderingContractAddress;
}

So far we have a list of tokenIds mapped to their runners (which contain the dna) and a rendering contract. In order to get the svg/rendering we need to call the function tokenURI (NOTE - it is not the only way but it is the easiest):

function tokenURI(uint256 _tokenId) public view virtual override returns (string memory) {
    require(_exists(_tokenId), "ERC721Metadata: URI query for nonexistent token");

    if (renderingContractAddress == address(0)) {
       return '';
    }

    IChainRunnersRenderer renderer = IChainRunnersRenderer(renderingContractAddress);
    return renderer.tokenURI(_tokenId, runners[_tokenId]);
}

The tokenURI function calls the renderer contract's tokenURI function with the tokenId and the runner associated with the tokenId. What we get back is a base64 encoded string which when we decode it we see contains the SVG data which we can then render. image.png image.png image.png

Its basically magic at this point, but lets take a look at the second contract and see how this magic happens.

ChainRunnersBaseRenderer.sol

As we saw in the first contract, the main entry point on this contract is the tokenURI function.

 /*
    Generate base64 encoded tokenURI.

    All string constants are pre-base64 encoded to save gas.
    Input strings are padded with spacing/etc to ensure their length is a multiple of 3.
    This way the resulting base64 encoded string is a multiple of 4 and will not include any '=' padding characters,
    which allows these base64 string snippets to be concatenated with other snippets.
    */
    function tokenURI(uint256 tokenId, ChainRunnersTypes.ChainRunner memory runnerData) public view returns (string memory) {
        // first we obtain the layers, colors, and traits
        (Layer [NUM_LAYERS] memory tokenLayers, Color [NUM_COLORS][NUM_LAYERS] memory tokenPalettes, uint8 numTokenLayers, string[NUM_LAYERS] memory traitTypes) = getTokenData(runnerData.dna);
        string memory attributes;
        for (uint8 i = 0; i < numTokenLayers; i++) {
            attributes = string(abi.encodePacked(attributes,
                bytes(attributes).length == 0 ? 'eyAg' : 'LCB7',
                'InRyYWl0X3R5cGUiOiAi', traitTypes[i], 'IiwidmFsdWUiOiAi', tokenLayers[i].name, 'IiB9'
                ));
        }
        // then we construct the svg from the layers and colors
        string[4] memory svgBuffers = tokenSVGBuffer(tokenLayers, tokenPalettes, numTokenLayers);
        // finally, we construct the final base64 encoded string
        return string(abi.encodePacked(
                'data:application/json;base64,eyAgImltYWdlX2RhdGEiOiAiPHN2ZyB2ZXJzaW9uPScxLjEnIHZpZXdCb3g9JzAgMCAzMjAgMzIwJyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHNoYXBlLXJlbmRlcmluZz0nY3Jpc3BFZGdlcyc+',
                svgBuffers[0], svgBuffers[1], svgBuffers[2], svgBuffers[3],
                'PHN0eWxlPnJlY3R7d2lkdGg6MTBweDtoZWlnaHQ6MTBweDt9PC9zdHlsZT48L3N2Zz4gIiwgImF0dHJpYnV0ZXMiOiBb',
                attributes,
                'XSwgICAibmFtZSI6IlJ1bm5lciAj',
                Base64.encode(uintToByteString(tokenId, 6)),
                'IiwgImRlc2NyaXB0aW9uIjogIkNoYWluIFJ1bm5lcnMgYXJlIE1lZ2EgQ2l0eSByZW5lZ2FkZXMgMTAwJSBnZW5lcmF0ZWQgb24gY2hhaW4uIn0g'
            ));
    }

There are two functions that are called here that do the heavy lifting. First, the function getTokenData is called which picks the race, layers, and colors. Then the function tokenSVGBuffer creates the svg rects (pixels). Lastly, the remaining code in tokenURI bundles everything together in the final base64 string

Before jumping into the details, lets define some of the data we will see:

  • WEIGHTS - Rarity weights split by Race and Layer; they determine the frequency of that layer for that race
  • Race - There are 3 races in this contract. They are hard coded and created on contract creation
    1. Alien/Human
    2. Skull
    3. Bot
  • Layers - These are the traits that layer on top of each other to create the image. These are set by the owner using the setLayers function. There are 13 traits which are pre-encoded in base64:
    1. Background - QmFja2dyb3VuZCAg
    2. Race - UmFjZSAg
    3. Face - RmFjZSAg
    4. Mouth - TW91dGgg
    5. Nose - Tm9zZSAg
    6. Eyes - RXllcyAg
    7. Ear Accessory - RWFyIEFjY2Vzc29yeSAg
    8. Face Accessory - RmFjZSBBY2Nlc3Nvcnkg
    9. Mask - TWFzayAg
    10. Head Below - SGVhZCBCZWxvdyAg
    11. Eye Accessory - RXllIEFjY2Vzc29yeSAg
    12. Head Above - SGVhZCBBYm92ZSAg
    13. Mouth Accessory - TW91dGggQWNjZXNzb3J5

Picking the Layers

The function getTokenData splits the dna into 13 pieces. The second piece is used to obtain the Race by using the weights associated with the default race. Other pieces are used to obtain specific traits such as having a mask or a face accessory. This is done to make sure layers are compatible with each other visually (it wouldn't look good if the NFT had a mask with a mouth accessory).

Then we generate the layers that will make up the NFT. For each piece of dna, we generate the layer index using the weights of race and the current iteration. This gives us a layer which contains a name, an item index, a layer index, and a hexString. The hexString is used to generate RGBA colors that will be transformed into a 6 letter hex string later on. The iteration is also used to select the trait name from the 13 traits to be used in the final base64 string listed as attributes.

Generating the Pixels

Once we have the layers and the colors, we can start building the NFT pixel by pixel. The function tokenSVGBuffer creates 8 buffers of 4 pixels 32 times (8432=1024). It creates 4 pixels independently and then bundles those into a base64 encoded string. Each pixel combines the individual colors from the layer at a specific position in the hexString. Since most of the layers wont have overlapping pixels we only combine the first two non-transparent layers. The combination of the layers is then converted into a 6 letter hex string. Doing this 8 times in a single iteration creates an entire row of pixels. This happens 32 times generating all of the rows.

Security and Safety Considerations

Overall, generating and storing the data and logic that makes the NFTs/art on chain is a big step in the direction of complete ownership over the NFT and immutable art. Here we see two caveats (albeit small ones) that make it not 100% immutable.

setRenderingContractAddress

Going back to the first contract, we saw the function setRenderingContractAddress which can only be set by the owner. Since all of the logic for creating the art from the dna is in the rendering contract it is possible for the owner to change the way the dna is rendered. Is this a bad thing? Maybe, maybe not. It really all depends on trusting the owners. It might be an issue if the owner's account is hacked or the owner becomes malicious; they can change the rendering contract to something new making an entirely new project. On the other hand, the owner can upgrade the rendering contract to something new which contains additional functionality and extends the current project. At this point, there isn't a need to fear a malicious owner and the community seems to be growing. I'm definitely leaning to the side of extensible upgrades.

setLayers

In the second contract there is a function setLayers, which the owner uses to set the different layers of traits and pixel colors to use for generating the art. The only checks on this function are for the caller to be the owner. This means the owner can change the layers at any point and change the art by doing so since the art is recomputed on each call to tokenURI. Given that it costed the owners close to 7 ETH (you can add up all the fees from the 2nd-8th transactions on the contract) just to upload the original layers' data, its unlikely that they will use this to change the art. It might have been interesting to store the hash of the entire tokenURI to prove no manipulation has been done but then again maybe the team has bigger plans for expanding the narrative and needs the flexibility.

Notes

  1. If we look at the first contract again, we can see that there is a function mintRunnerZero. It is callable by anyone but in order to mint it, you need to know the seed used to configure runnerZero which is set by the owner using configureRunnerZero. These functions have not been called yet by the owners! This is one reason that I believe the above security considerations are meant to be used as upgrades in a second/additional narrative to the Chain Runners sagas.

  2. There isn't a single hard coded plain text string in the entire render contract. Anything that is meant to be a string was pre-encoded to base64. This saves on computation and can also save on gas if the functions are called as part of an external call (not an external view call). The only down side to this is readibility, though decoding base64 isn't difficult.

  3. The only pieces of data that are stored are:

    • WEIGHTS
    • Layers
    • Runner DNA
    • Pre-encoded base64 strings