We have been excited about NFTs since we started building them into galactic.camera. We have been studying solidity and watching drops to see how you make a good NFT happen. What we learned was that most NFT drops are pretty simple: ERC721, IPFS, a healthy dose of community and some luck.

We have a lot of luck, we have a growing community and we have a lot of programming chops. We set out to do something that we hadn’t yet seen. We had a few requirements:

  • Everything had to exist on chain - We loved what Avastars, Loot, On-Chain Monkeys, and the like have been doing - but we wanted to take it another level.
  • We wanted it to be fun with hidden “utility” - something for the hodler to discover.
  • We wanted to push our understanding of NFTs and solidity.

This writeup should explain all the though process that went into it. It is targeted towards people who have never programmed an NFT, but goes pretty deep. Send us some feedback!!.

We want to give props to the scaffold-eth community for pushing up some amazing examples of ERC721 contracts. Thanks for that!

TLDR

For those who are interested, this is the tldr:

  • We made an NFT that was 100% on chain, 100% SVG and reactive to what is in your wallet
  • Solidity has some gotchas
  • We spent some time bikeshedding cuz we are engineers
  • There are a few small bugs that would have been good to catch before we deployed to mainnet
  • We should have made the renderer be upgradable so that we could fix bugs in the rendering engine after mainnet deploy
  • Solidity is fun

There is a lot more - please read on:


The experiment

This NFT experiment was a free-to-mint, on-chain, SVG based ERC721 (NFT). We knew that we wanted our artwork to have several random traits such as colors, but our biggest requirement was that we wanted our NFT to react to the other NFTs our users owned.

Many of the challenges we faced were a direct consequence of our desire to keep the artwork completely “on-chain.” This means that all of the artwork, and the code to generate it was hard-coded into our program. This is challenging because there are strict space limitations on how much code can be contained in a single Ethereum contact.

This turned out to be a good decision as it meant that we were able to explore several approaches to being efficient. Had we decided to experiment with an NFT drop where the image assets were stored “off chain”, the project would have been much more simple and there would have been little to no challenge in the implementation.

Init

The project was originally conceptualized as a “pizza-based” NFT. We love pizza. Unfortunately. the desired parameters for the artwork were not well defined at the outset of the project. We knew we wanted to make each pizza unique in terms of its coloration (e.g different crust, sauce, and pepperoni colors) and that it could react to the owner’s other NFTs.

What those changes might be, and figuring out ways to make them compelling was less easy. Our first idea was to use the image assets from other NFTs as background patterns for components of the pizza like the pepperonis or the sauce.

For example, if the owner of our NFT also owned an Adam Bomb NFT, we would use that Adam Bomb’s image as the background of the pepperonis. Similar things could be applied if the owner also held a Bored Ape, CryptoPunk, et cetera.

This is absolutely possible to do with SVG, but it amounted to our first roadblock. The largest NFT marketplace, OpenSea was this roadblock. OpenSea determines how SVGs will render on their site and it appears they have had security issues in the past. As we discovered, they will sanitize SVG files and remove those external resources, rendering our approach unworkable.

With that avenue sealed off for the moment, our second idea was to add additional toppings linked to the other NFTs. Own a Bored Ape? You get mushrooms. Own an Adam Bomb? You get Pineapple. The idea was feasible, but not terribly compelling. Tying certain NFTs to toppings didn’t really make sense: Why pineapples? Why mushrooms?

PizzaOnChain happens

In the midst of this process, a new NFT project was launched called PizzaOnChain. As the name implied it was an NFT drop that also had on-chain SVG pizzas! Theirs was a more conventional project where 8-bit styled pizzas composed of various crusts, sauces, sizes and toppings would be generated based on encoded “rarity” values. We were obviously bummed, but we love pizza - so were stoked that someone was doing neat on-chain pizza NFTs.

This turned out to be a blessing in disguise for us because it allowed us to pivot our idea to something much more compelling and in line with our design goals.

The Floppy Disk

Our next idea was Floppy Disks. Floppy Disks have a high-nostalgia value and fit in quite well with the idea of Retro Computing, 8-bit graphics, and many of the ideas you see floating around in the web3 space.

They also afforded us more opportunities for tying the ownership of other NFTs to the composed artwork. We were also able to find a nice public domain floppy disk SVG.

Each floppy has a label with seven slots we could fill in with the names of software or files “stored” on the disk. If you own an Adam Bomb we could throw “The Anarchist’s Cookbook” on there. If you owned an OniForce, maybe you have some issues of Phrack (an old-school hacking magazine).

We were also afforded the ability to colorize the disk in interesting ways. The disk itself could be a solid color, or have interesting and rare patterns. The label could also have its own color.

Lets make a Floppy NFT!

With a plan in place for a compelling and interactive NFT we got to work building the contract to implement the project.

The first thing you do when creating an NFT is to import some standard definition of what that even means.

The mechanics of how such a token is minted, how the supply of tokens is managed, how ownership is queried, and more is all defined in a document written by a bunch of smart people called EIP721.

Fortunately for us, rather than re-implement all of that, we use a library created by even more smart people at a company named Open Zeppelin that does it all for us. Check out the code if you’re interested in seeing how it works.

Using this library is as easy as adding the following line to the top of your project.

1
import "@openzeppelin/contracts/token/ERC721/ERC721.sol"

This base contract–named ERC721–gives us several functions named things like _mint, _safeMint, balanceOf, ownerOf, tokenURI, and all the various “transfer” functions needed to satisfy the definition of an NFT.

We are actually using an “extension” of this interface called ERC721Enumerable which builds on top of the base implementation to add functions that keep track of the amount of tokens minted and adds some additional utility functions like tokenOfOwnerByIndex, totalSupply, and tokenByIndex.

Finally we bring in another OpenZeppelin interface called Ownable that adds the concept of an address “owning” a contract. This adds a very helpful onlyOwner modifier for our functions that makes it possible the restrict certain administrative functions to the owner (generalgalactic.eth) of the contract.

The constructor

With all this in place we can make a super simple NFT by using those two aforementioned base contracts and setting up our constructor. A constructor is just a special function that is called automatically once when a contract is deployed to the block chain.

It can have many arguments that configure how your contract works. Here’s the simplest possible implementation of our constructor.

1
2
3
4
5
6
7
8
contract GalacticFloppyDisk is Ownable, ERC72Enumerable {
    constructor(
      string memory _NFT_NAME,
      string memory _NFT_SYMBOL
    )
      ERC721(_NFT_NAME, _NFT_SYMBOL)
    {}
}

You’ll notice that our contract accepts two parameters which are both strings: the name of the NFT (_NFT_NAME) and the symbol of the NFT (_NFT_SYMBOL). These are the parameters required to initialize the ERC721 interface, so we pass those on to its constructor.

You may notice that we don’t perform a similar initialization for the Ownable interface we inherit from. In the case where you inherit from a contract with a constructor that has no parameters, it is called explicitly for us.

As mentioned before, constructors are executed automatically upon the deployment of a contract, therefore all the required arguments for your constructor must be supplied for deployment to succeed.

Variables

At this point you have a very basic, and very boring NFT.

For our project we wanted to limit the total number of mintable tokens and we wanted to also limit how many tokens each wallet could mint. For example’s sake, let’s say those values are 100 and 2 respectively.

One option is to hard-code those values directly into the contract, a totally valid approach. If we wanted to go that route we can implement it by adding those values to the top portion of our contract.

1
2
uint256 public constant AVAILABLE_SUPPLY = 100;
uint256 public constant MAX_PER_ADDRESS = 2;

This works fine, but it has a major downside in that it becomes more difficult to test these parameters. If your contract enforced values of 10,000 and 2,000 respectively, you’d need a large amount of test code to verify the correct behavior.

Instead, a more flexible approach is to provide those values to the constructor as arguments. This allows us to deploy the contract to a real net with the values we desire for the real drop, but during testing we can deploy to a testnet and use small values that make for easier testing.

Variable Types

Eschewing constants leaves us with the option of using either immutable or mutable values for these parameters. At first it may seem that immutable and constant variables offer the same benefits (unchangable values), but they differ in one very specific way.

  • Constants: Are locked in at build/initialization time and can never be changed.
  • Immutables: Start out uninitialized (0), and may only be initialized once, within the contract’s constructor.

Mutable variables are truly variable and are denoted by the lack of any modifier.

Once those immutable properties are defined we can update our constructor to accept them and then store them in our contract’s storage space.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
uint256 immutable public MAX_PER_ADDRESS;
uint256 immutable public AVAILABLE_SUPPLY;

constructor(
  string memory _NFT_NAME,
  string memory _NFT_SYMBOL,
  uint256 _MAX_PER_ADDRESS,
  uint256 _AVAILABLE_SUPPLY
)
  ERC721(_NFT_NAME, _NFT_SYMBOL)
  {
    MAX_PER_ADDRESS = _MAX_PER_ADDRESS;
    AVAILABLE_SUPPLY  _AVAILABLE_SUPPLY;
  }

Variable Visibility

You may have noticed that we’ve marked our first two variables as public. This means these values are accessible from all our internal functions and from external callers as well. This is nice, as it allows external observers see certain values of our contract.

In addition to public we have the option of marking variables as private. Private variables are only accessible from the functions in our contract.

Storage and Memory

This is a good spot to talk about the differences between storage and memory. The way data is allocated for use in your contract is one aspect that contributes to the cost of deploying and using your program (the other cost is derived from the amount of operations in your contract).

Whether or not your data lives in storage or memory impacts how and when your data is available to your functions (and gas-cost) of your code.

You can think of using storage much in the same way you might use a database or a files on a disk in traditional programming languages.

Variables that live in memory are those which are defined within the context of a function call, sometimes referred to as “local variables”. Space for these values is allocated at some point during a function’s execution, and once the function has completed running, that space is reclaimed. The only way to get those values out of your function is to include them in the return part of the function.

The two variables we just initialized in the top-level scope of our contract (MAX_PER_ADDRESS, and AVAILABLE_SUPPLY) are initialized outside of a function and as such are allocated in our contract’s storage space. This means that data lives on the Ethereum blockchain within the context of our contract. This means you can access that data within any of your functions.

Writing to these variables incurs a cost and reading from them incurs a dramatically lower cost. The size of the variable also comes into play. You’ll notice we are annotating those two variables as uint256 types meaning they should be interpreted in our program as an integer with a capacity of 256 bits. This variable can represent a positive integer anywhere from 0 to 2 ** 256 - 1 (a very big number).

Writing and reading memory variables also incurs a cost, but an order of magnitude less than those in storage.

It is therefore important to only use storage for data which absolutely must persist between function calls. It’s also very important to try and use the most compact representations of your data as possible.

Data Types

In addition to the data types I’ve already mentioned (uint256), there are other integer data types like bool, uint8, uint16 and so forth which can represent certain ranges of values.

There are other types that are more amenable to storing non-numerical data. There are dynamic length types like string and bytes that can be extended to hold any number of characters. Additionally there are fixed length byte array types with names like bytes1, bytes2, all the way up to bytes32.

In addition to these built-in types, there are also complex types called arrays, mappings, and structs which can be used to organize your data in useful ways.

Pausing the Sale

Another requirement we had was to be able to pause the minting of NFTs at any point post-launch in case of emergency. There are libraries that make this possible, but we chose an effective and simple approach.

We defined a mutable, private, boolean variable named saleActive and allocated it into storage. This variable was set to an initial value of true. This means the sale will be active from the moment the contract is deployed. If we had allocated this variable without an initial value, solidity would have set it to false, meaning the sale should begin deactivated.

Next a function was written to allow the owner of the contract (Galactic) to change its value. Remember the onlyOwner modifier we talked about earlier?

1
2
3
4
5
bool private saleActive = true;

function flipSaleState() public onlyOwner {
  saleActive = !saleActive;
}

Super simple. We use the logical-NOT operator on our boolean variable to flip a true value to false (or false to true) on each execution of the function. We reference the saleActive storage variable without any kind of namespace you might see in a dynamically typed language like JavaScript or Python (e.g. this.saleActive / self.saleActive).

You’ll notice we marked saleActive as private and the previous immutable variables as public. Private variables are only available in the contract they were defined in. Public are readable by internal or external means. Variables can also be internal which is similar to private, but they can be accessed by derivative contracts.

Acting on the saleActive value.

At this point we can flip the saleActive bit but it has no real effect until we use its value inside a function. In our case we are hoping to use it in the primary function of our contract: the minting function. The next step is to define how our minting process works.

The core functionality of “minting” an NFT is handled for us by the base implementation of the ERC721 contract, so we don’t even have to understand what that means. Our job is to define all the requirements that must be true for minting to take place.

We only want to allow a mint to happen if:

  • We haven’t minted more than the AVAILABLE_SUPPLY of tokens.
  • The address requesting a mint hasn’t obtained more than the MAX_PER_ADDRESS amount of tokens.
  • The value of saleActive must be true.

We check these requirements using a special function named require.

This function takes two arguments. The first is an expression that resolves to a value of either true or false. The second is a string that is surfaced if the requirement isn’t met.

If the resolved value is false, an error is raised with the string; the transaction fails; and is reverted. The error string will be exposed to the caller to help them understand why their transaction didn’t succeed. If the resolved value is true, we move on to the next line of code.

To implement these checks, we first defined our mint function. Some NFT contracts allow for the caller to mint many tokens per call, in which case we would allow the caller to specify that value as a parameter. Our function takes no parameters, so we are operating under the assumption that only 1 token may be minted per call.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function mint() external {
  uint256 _currentTokenCount = _tokenCounter.current();
  uint256 _walletOwns = balanceOf(_msgSender());

  uint256 _newAmountMinted = _currentTokenCount + 1;
  uint256 _newWalletOwns = _walletOwns + 1;

  // Are these all True?
  require(saleActive,                           "Sale is not active");
  require(_newAmountMinted <= AVAILABLE_SUPPLY, "No more tokens remaining");
  require(_newWalletOwns <= MAX_PER_ADDRESS,    "You already have the maximum number of tokens");

  // Then we can mint!
  _safeMint(_msgSender(), _currentTokenCount);
  _tokenCounter.increment();
}

And there you go! If all our requirements are met, we mint a token and increment our token counter for the next mint.

One note, our friend Shazow pointed out after we deployed that balanceOf can be expensive and there might be a more efficient way of handling line 3 in the above diagram.

Function Types

There are some interesting new things in this mint function. The first is that it is marked with the external modifier. This looks and operate a lot like the modifiers we set on our variables. There are four different modifier options for functions: public, private, external, and internal.

Functions marked as public can be called from internal functions or from other, external contracts.

Functions marked with external can ONLY be called from other contracts or entities; they cannot be called from internal functions.

Functions can also be marked as internal meaning they can only be called from other functions in the contract (and contracts that derive from the contract), or private which can only be accessed by the contract they are defined in (not contracts derived from your contract).

Payable Functions

One thing we aren’t doing in our mint function is accepting any kind of payment. This is because our NFT is “free-to-mint”. Many NFT mints do impose a cost to mint.

In order to facilitate accepting payment in a function, they can be marked as payable. This would signal to callers of the function that some amount of ETH must be sent along with the function call for it to be valid. The value sent by the caller is exposed to to our code on a special global variable named msg on its value property.

Helper Functions

You may also notice we used a function called _msgSender(), this is a convenient function that returns the address of the caller of our function. This value can also be accessed using that special global variable named msg, on its sender property. There are reasons to use the helper function, but they are esoteric and not super important to detail here.

Baking traits into each floppy

One of the requirements for our NFT is that they should have certain traits “baked-in” for all time. The first of these are the colors of the disk and its label.

To satisfy this requirement we need to be able to store this information in storage so that it can be accessed from a special function named tokenURI later on. We also need to figure out a way to make each generated token have unique colors.

In a typical programming language we would use a special function that provides us with random data to select the colors. However, and important aspect of the Solidity VM is that it’s something called “deterministic”. This means that regardless of when or where a function is executed, if it’s executed within the same context with the same parameters, it must return the same value. Incorporating random data would break this requirement.

Therefore a common, decidedly non-random approach is to using a concept called “hashing” to generate pseudo-random values that are good enough for our purposes. The downside to this approach is that these are not truly random values and could be guessed ahead of time and exploited for good or bad purposes. Since we’re just picking colors here and not performing any sensitive or important operations, it’s fine… lol

Unique Token Traits

We want to be able to generate five colors for each disk and we want every disk to have a completely unique set of five colors. We’ll want to generate other unique characteristics for our disks so we’ll need a mechanism to derive those characteristics from some amount of pseudo-random data.

To ensure we’ll have enough for all our needs, we’ll generate 32 bytes of data that we’ll call “genes” to associate with every token we mint. We’ll store this gene data in contract storage in a mapping that associates a token identifier (a uint256) with 32 bytes of pseudo-random data (bytes32).

To do this we’ll use a “hashing function”. It’s not super important to know exactly how this particular function works, beyond:

  1. It accepts any amount of bytes as an input.
  2. It produces exactly 32 bytes of data as an output.
  3. Any single bit change in the input will change the value of the output.
  4. For any given input value, the output value will always be the same.

This function is named keccak256 and we call it like so:

1
2
3
4
5
6
7
keccak256(abi.encodePacked(
  block.timestamp,
  block.difficulty,
  _currentTokenCount,
  _msgSender(),
  address(this)
));

You’ll notice another function at play here called abi.encodePacked which just takes a bunch of data and concatenates it into one big array of bytes, each one after the other.

What is important is that we’re including 5 different values to the hashing function which will be vastly different for each token we generate, and which should result in different gene data for every token minted.

The “gene”, represented as a hex string, will look something like this. Keep in mind every minted token will have a dramatically different value:

0xae64182c76b96741aa267c3fb5dd41c88bbba412699059dd2e9bfbe3847abb35

Once we generate this gene we’ll keep track of it in contract storage inside a mapping variable. A mapping is a complex data type much like a dictionary or address book. Each item in the mapping has a key and value. Every key corresponds to exactly one value, thus making it a very convenient way to lookup a value if we know the key.

In our case we’re going to use a token’s identifier (an uint256) as the key, and the gene data (a bytes32) as the value. Here is how that’s annotated:

1
mapping(uint256 => bytes32) private genes;

Finally we’ll make the association in our minting function like so:

1
2
3
4
5
6
7
genes[id] = keccak256(abi.encodePacked(
  block.timestamp,
  block.difficulty,
  id,
  _msgSender(),
  address(this)
));

Translating from “Genes” to “Traits”

Now that we’ve completed our minting function and generated our genes, we move on to the other half of an NFT, the tokenURI function. This function defines the attributes and the actual “image” of the artwork associated with the token. This URI should point to a JSON file with a standard set of attributes that define the characteristics of the NFT.

Just as valid as an HTTP URI would be an IPFS URI.

IPFS, or InterPlanetary File System is kind of like a way to serve websites like you’d use a torrent to serve pirated episodes of Succession.

However using either of those URI types means that the artwork is stored “off-chain” on a server or servers somewhere.

That is, the resources defining the characteristics and artwork of the token can be changed by whoever owns those resources and servers. More desirable is a token that lives completely “on-chain”.

Currently the only reasonable way to achieve this is to use a special type of URI called a Data URI.

A data URI is a special type of URI that encodes the content of the resource directly into the URI itself. Commonly this data is encoded using a special process called Base64 encoding.

Here is an example taken from Wikipedia of a data URI that encodes a small, red dot in PNG format. You can paste this string into your browser and see for yourself →

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==

Using base64 is crucial to being able to encode this file into a data URI because it is binary file format that cannot easily be represented over the web. The Base64 algorithm shifts the binary representation of the data into characters which will not be mis-interpreted by your browser.

The real power comes when you realize you can use this approach for literally any type of file! We’ll use this twice in our contract to ensure that both the JSON metadata and the image are both generated by our contract, and as such, live entirely and forever on the Ethereum blockchain.

Our tokenURI function will ultimately return something that looks like data:application/json;base64,AAAA....==.

The JSON File

The decoded JSON file resource returned by our tokenURI function could look a lot like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "name": "Galactic Floppy #832",
  "description": "This floppy is radical!",
  "attributes": [
    {"trait_type": "Disk Color",    "value": "#B58C67"},
    {"trait_type": "Label Color",   "value": "#11994D"},
    {"trait_type": "Wacky Pattern", "value": "Polka Dots"},
    {"trait_type": "Insecure Disk", "value": "true"},
    {"trait_type": "Has Virus",     "value": "Michaelangelo"},
    {"trait_type": "BoringFile0",   "value": "Procomm Plus"},
    {"trait_type": "CoolFile0",     "value": "Anarchist's Cookbook"}
  ],
  "owner": "0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7",
  "image": "data:image/svg+xml;base64,AAAA........=="
}

The name, description, and owner fields should be fairly self-explanatory.

The attributes array describes all the factors that combine to make this token unique. We include the colors of the disk and it’s label, the software contained on the disk, and finally we add special traits if the disk is infected; insecure; or has an interesting pattern.

These attributes are consumed by NFT marketplaces like OpenSea and Rarible to determine the “rarity” of a given token as compared to all other tokens of the same type.

The final, and most interesting property is image, another URI. This field defines the “artwork” of the NFT and could again point to a traditional HTTP or IPFS URI, but we’ve again chosen to use a data URI to keep our floppy disks “on-chain.”

SVGs, yo: Scalable Vector Graphics

You may have noticed the image format we’ve selected is SVG, which stands for “Scalable Vector Graphics”. I’m not going to get into all the differences between raster and vector image formats, but the main difference is hinted at in the name. With a raster image format like PNG, JPEG, or GIF, there is a finite amount of information encoded directly into the file. If you scale the image beyond its native resolution, the image will appear blurry and generally “Not Cool.”

Vector graphics files contain what amount to instructions on how to draw the image. This means that no matter the scale, the image should look identical and crisp.

The other main advantage of SVG is that it is an XML file format. This makes it extremely easy to generate these files in a programming language like Solidity. You effectively just combine human-readable strings together until you have the final output.

The downside to SVG images is that they are really only suited for making “diagram-like” images without much photo-realistic detail. Lots of tricks and filters can be employed to create more realistic or interesting looking images, but at a certain level of complexity you’d likely be better off with a bitmap image format.

This is an example of an SVG that draws some circles, rectangles, and lines:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<svg width="391" height="391" viewBox="-70.5 -70.5 391 391"
    xmlns="http://www.w3.org/2000/svg"
 	  xmlns:xlink="http://www.w3.org/1999/xlink">
  <rect fill="#fff" stroke="#000" x="-70" y="-70" width="390" height="390"/>
  <g opacity="0.8">
	  <rect x="25" y="25" width="200" height="200" fill="lime" stroke-width="4" stroke="pink" />
	  <circle cx="125" cy="125" r="75" fill="orange" />
	  <polyline points="50,150 50,200 200,200 200,100" stroke="red" stroke-width="4" fill="none" />
	  <line x1="50" y1="50" x2="200" y2="200" stroke="blue" stroke-width="4" />
  </g>
</svg>

Generating Colors

Before we can even begin creating our SVG image, we need to use the genes we created during the minting process to generate all of the various features of the disk. First we’ll start off by how we obtain “colors” from the gene data.

This part starts to get a bit heavy into binary numbers, bitwise operations and such. They can be a little intimidating at first if you haven’t dealt with them before. Because of the level at which Solidity operates, these sorts of operations are often required to calculate and represent more “human-friendly” concepts like “strings representing colors.” I’ll try to ease you gently into how this process works!

First it’s important to know that colors on computers can be, and often are represented by specifying some mix of red, blue, and green components (otherwise known as the Additive Color Model.

Lots of red and blue and no green would make a kind of purple (magenta, actually). You can play around with this yourself on Google.

It’s also important to notice that color representations we generally use on the web consist of strings consisting of six hexadecimal characters. The first two characters can be thought of as an 8-bit number representing a value from 0 to 255 indicating how much “red” should be in the color. The second two are the same for green, and the final two are for the blue component of the color.

#[RR][BB]

We call these hexadecimal colors because they are represented using base 16 (0123456789ABCDEF) numbers, as opposed to base 10 us humans normally use (0123456789). A typical color might be represented as #1b1c1d. We’ll break each color down, and show what they represent in hex, decimal, and in the “bits” a computer works with:

  • Red Component - Hex: 1b / Binary: 0001 1011 / Decimal: 27
  • Blue Component - Hex: 1c / Binary: 0001 1100 / Decimal: 28
  • Green Component - Hex: 1d / Binary: 0001 1101 / Decimal: 29

#1b1c1d is a very dark gray color because the individual component values are very low and are all about the same value. Check it out on google.

The binary values like 0001 1011 are the 8 bits that represent the red component, in this case our red component is 27 out of the maximum value of 255 (not very red).

Therefore to represent a single color we need to obtain 3 bytes, or 24 bits of data. We’re going to generate five different colors, so in total we’ll need 15 bytes or 120 bits of data. We’ll pull that data out of the “random” sequence of gene data we generated before. It’s 32 bytes (256 bits) long so we have plenty to pull from.

We can only access our gene in one byte chunks, so to produce a three byte value from our gene we need to obtain three one byte chunks and then composite them together into a single three byte value. Here’s what a three byte value looks like in its binary representation, (#000000).

0000 0000 0000 0000 0000 0000

It’s a little instructive to show what our desired end state would be. Refer back to our hex color #1b1c1d from above, we want to composite all those binary values together to produce:

.---R---. .---G---. .---B---.
0001 1011 0001 1100 0001 1101

So let’s say the first three bytes of our genes are: 0x67, 0x8c and 0x18 that would imply our final color should be #678c18. This color is kind of a pea-green color (you can kind of see why, as the green, middle value 0x8c is much larger than the red or blue values).

First we take a byte of data each for our red, blue and green colors from the genes. I’ve shown what the values for each of these variables looks like to the computer:

1
2
3
bytes3 redBytes = bytes3(genes[tokenId][0]);   // 0x67 00 00
bytes3 greenBytes = bytes3(genes[tokenId][1]); // 0x8c 00 00
bytes3 blueBytes = bytes3(genes[tokenId][2]);  // 0x18 00 00

The red portion of the final value is already in the right spot, but the green and blue parts need to be “shifted” into the right place. So we’ll do that next:

1
2
greenBytes = greenBytes >> 8 // move this one over 8 spaces  -> 0x00 8c 00
blueBytes = blueBytes >> 16  // move this one over 16 spaces -> 0x00 00 18

Now we have:

redBytes =   0x67 00 00
greenBytes = 0x00 8c 00
blueBytes =  0x00 00 18

The final step is to combine all these values into a single bytes3 variable that represents the final value of 0x67 7c 18. This is achieved using the “OR-operator”. The OR operation is a bitwise operation that takes two values as an input and produces a single output. If you had the two inputs: 0b101 and 0b010 the output would be 0b111. In any single place containing a 1 would result in a 1 in the output value. If your two inputs were 0b000 and 0b010 the output would be 0b010.

We can exploit this behavior to effectively “smush” our red, blue and green parts into a single value. We do this in two steps:

bytes3 color = (redBytes | greenBytes) | blueBytes;

First we combine the red and green bytes: 0x67 00 00 | 0x00 7c 00 => 0x67 7c 00.

Finally, we combine that intermediary value with the blue bytes: 0x67 7c 00 | 0x00 00 18 => 0x67 7c 18.

This whole process can be written in a compact way like so:

1
2
3
4
bytes32 gene = genes[id]
bytes3 color1 = bytes3(gene[0]) |
                bytes3(gene[1]) >> 8 |
                bytes3(gene[2]) >> 16;

It should be pointed out that this is just one approach to generating these values. Another option might have been to use a 32 byte bitmask and then shifting everything over a lot of places. This approach would have allocated a lot more memory space, however.

Structs

Now that we’ve seen how we generate a single color, we need to decide on a strategy to store all five token colors together in one place. The approach we chose was to use a “struct” called a ColorSet. The definition of this struct looks like this.

1
2
3
4
5
6
7
struct ColorSet {
  bytes3 color1;
  bytes3 color2;
  bytes3 color3;
  bytes3 color4;
  bytes3 color5;
}

Using the previously described color-generation procedure we can define a ColorSet for our disk like so.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
ColorSet diskColors = ColorSet({
  color1: (
    bytes3(gene[0]) |
    bytes3(gene[1]) >> 8 |
    bytes3(gene[2]) >> 16
  ),
  color2: (
    bytes3(gene[3]) |
    bytes3(gene[4]) >> 8 |
    bytes3(gene[5]) >> 16
  ),
  color3: (
    bytes3(gene[6]) |
    bytes3(gene[7]) >> 8 |
    bytes3(gene[8]) >> 16
  ),
  color4: (
    bytes3(gene[9]) |
    bytes3(gene[10]) >> 8 |
    bytes3(gene[11]) >> 16
  ),
  color5: (
    bytes3(gene[12]) |
    bytes3(gene[13]) >> 8 |
    bytes3(gene[14]) >> 16
  )
});

For each color we use a different set of 3 bytes from our genes, 0 through 15 in total.

Other Baked-In Traits

To make our disks more interesting, we decided to include more traits baked into our disks. These were:

  1. A couple of different initial software files.
  2. Whether or not the disk was infected with a virus.
  3. Whether or not the disk contained a ‘mnemonic.txt’, file that typically holds the keys to your Ethereum wallet.
  4. Whether or not the disk should get a “wacky pattern” as opposed to a plain, single color disk.

We utilized a similar approach as we did to determine a disk’s color set to bake-in these traits.

Virus Infections

We created a function called getVirus. This function accepts the token’s gene and within it is defined an array of strings in memory that hold a number of virus names:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
string[18] memory viruses = [
  "Michaelangelo", "Europe-92",
  "Leandro", "STONED",
  "DOS.Casino", "BRAiN",
  "DOS.Walker", "Natas",
  "911 Virus", "Hare",
  "Yankee Doodle", "OneHalf",
  "MkS_vir", "GhostBall",
  "Elvira", "Ambulence",
  "Ithaqua", "Cascade"
];

To decide if a disk has a virus we want to pluck a “pseudo-random” number from our gene then perform a comparison against a threshold to decide if the disk has a virus. To control the incidence of this trait, we must make a decision on how much data to examine. We decided to use 16 bits of data from our gene.

This data can representing a number anywhere from 0 to 65535. Next we must select a threshold that determines how often this trait is selected. We might say that “A disk has a virus if the value is larger than 32768”. This would mean that roughly 50% of all disks will have a virus.

If we increase that virus threshold to 65,000, then only roughly 0.8% of all disks will have a virus.

First let’s generate that number. We want to generate a 16 bit number so we’ll need two bytes of data from our gene that we’ll combine in similar fashion as before.

You’ll notice we shift left on our integers rather than right on our bytes, this is just a difference in how those two data types work.

Let’s say gene 15 is 0x67 or 103 or 0b0110 0111 and gene 16 is 0x8c or 140 or 0b1000 1100.

1
2
uint16 lowByte = uint16(gene[15]);
uint16 highByte = uint16(gene[16]);
  • lowByte is 0x0067 (103), stored as 0b0000 0000 0110 0111
  • highByte is 0x008c (140), stored as 0b0000 0000 1000 1100

Next we shift the high byte 8 bits to the left to get 0b1000 1100 0000 0000 or 0x8c00 or 35,840.

1
highByte = highByte << 8;

Finally we’ll OR these two values (0b1000 1100 0000 0000, 0b0000 0000 0110 0111) into a single value, resulting in 0b1000 1100 0110 0111, or 0x8c67 in hex, 35,943 in decimal.

1
uint16 infectedDisk = highByte | lowByte;

Assuming our infection threshold was 32,384, and since 35,943 is greater than that, this disk would be infected!

Finally we need to select the specific virus on the disk. We’ll do this by taking that infectedDisk value and and use the modulo operator (%) to pick an item from our list of viruses.

The modulo operator answers the question: If we divide one number by another number, what is the remainder of that division. If we perform 10 modulo 2 the remainder is 0 since 2 divides evenly into 10. If we perform, 10 modulo 3, the remainder is 1 since 3 fits in to 10 three times. What’s most important to realise is that when you perform A modulo B, the remainder will have a value from 0 to A, depending on the value of B.

In our case, we are going to take 35,943 divided by the number of viruses we have (18). 35,943 mod 18 comes out to 1996 with a remainder of 15.

We use the remainder value of 15 to look into our virusNames array and select the virus for this disk. According to the list I pasted above, the virus located at position 15 would be “Ambulance.”

1
return infectedDisk > threshold ? viruses[infectedDisk % viruses.length] : '';

Disk Software

We want each disk to be “pre-loaded” with some vintage software. We define a function named getInitialSoftware that against accepts our gene data. Inside of this function we define a list of some common software you might find on floppy disks back in the day.

1
2
3
4
5
string[45] memory boringSoftware = [
  "WordPerfect 5.1", "Lotus 123 3.1+", "Norton Cmmndr [CRK]",
  "Qmodem 4.5 (DOS)", "FreePascal GO32v2", "Wildcat BBS 4",
  "ACiDDraw v0.05", ... "Turbo Assembler", "XTree", "GALACT~1.EXE"
];

We wanted there to be either one or two of these softwares baked into the disk so first we use a single gene (8 bits, 0-255) as our determinant.

1
2
3
uint boringSoftwares = (uint8(
  genes[21]
) % 2) + 1; // How many initial softwares are ALWAYS on the disk.

We again use modulo, but with a divisor of 2 to get a value of either 0 or 1. Finally we add 1 to the result to obtain a value of either 1 or 2.

Back in the day, it was uncommon for large software packages to fit onto a single disk. I have vivid memories of writing DOOM 2 to a whole stack of disks to take to school. So it was common practice to label your disks “Doom2 Disk 1 of 8” or “Microsoft Word Disk 3/7”.

So, in addition to selecting a software package to put on the disk we also need to calculate the number of disks each software needs and also pick one of those disk numbers to annotate our label with.

We again turn to a structs bundle this information together. First a new struct named BoringSoftware was created.

1
2
3
4
5
struct BoringSoftware {
  string  softwareName;
  uint256 diskCount;
  uint256 diskNumber;
}

This struct has three components:

  1. string softwareName: Here we’ve elected to store the name of the software.
  2. uint256 diskCount: This is the number of disks the software requires.
  3. uint256 diskNumber: This is the specific disk number that our disk represents.

Next, since we will be defining up to two of these structs for our disk we initialize a fixed-sized array that can hold these structs:

1
BoringSoftware[2] memory initialSoftware;

Again we turn to our “gene and modulo” strategy for picking all these values. Since we know we’ll be including at least one software on each disk we’ll pick the first by selecting a position in our software array.

1
uint256 boringIdx = (uint8(gene[22])) % boringSoftware.length;

Next we’ll determine the number of disks this software requires

1
uint8 diskCount = (uint8(boringIdx) % 11) + 1;

Here we’ve used the boringIdx value, modulo’d with 11 and incremented by 1 to produce a value between 1 and 12. We’ve used the boringIdx here instead of another gene because we always want each software package to have the same number of disks, regardless of what NFT it shows up on.

Now to determine which disk of this software is stored on our NFT. We’ve gone back to the gene approach, this time using gene 23 and modulo’ing it with the diskCount and adding 1. Let’s assume QModem came out to having 7 disks, this operation would generate a number between 1 and 7.

1
uint8 diskNumber = (uint8(gene[23]) % diskCount)+ 1;

Now we can populate the first slot of our initialSoftware array by plucking the name of the selected software and storing the rest of our values in the struct.

1
2
3
4
5
initialSoftware[0].push(BoringSoftware({
  softwareName: boringSoftware[boringIdx],
  diskCount: diskCount,
  diskNumber: diskNumber
}));

In some cases, we’ll be including a second set of software on the disk. One thing we don’t want to do is include the same software twice. This is possible and rare, but we can easily defend against it.

First we’ll detect whether we should be adding a second software. If so we’ll copy the value we used on the first slot to a new variable named origBoringIdx and initialize a counter i to 0.

1
2
3
if (boringSoftwares == 2) {
  uint256 origBoringIdx = boringIdx;
  uint8 i = 0;

Next we’ll use a while-loop that terminates when we select an index that’s different from the original one:

1
2
3
4
while (boringIdx == origBoringIdx) {
  boringIdx = (uint8(gene[24 + i])) % boringSoftware.length;
  i++;
}

Thus, if for whatever reason we happen to land upon the same index twice, we’ll try again, moving forward to a new gene. Once we pick a different index, we’ll move on and perform the same process as before, inserting the struct this time into the second slot.

1
2
3
4
5
6
7
diskCount = (uint8(boringIdx) % 11) + 1;
diskNumber = (uint8(gene[25 + i]) % diskCount)+ 1;
initialSoftware[1] = BoringSoftware({
    softwareName: boringSoftware[boringIdx],
    diskCount: diskCount,
    diskNumber: diskNumber
});

Wacky Patterns and Insecure Disks

The final two instances of baked-in data are very similar: wackyPatterns and insecureDisks.

For insecureDisks we’re merely interested in a boolean value, yes or no. For wackyPatterns, we’ve defined 3 different patterns a disk can have.

For insecure disks we created a new function isDiskInsecure that accepts the gene, and within it, generate a 16 bit value from our genes and compare it to a rarity threshold:

1
2
3
4
5
6
7
function isDiskInsecure(bytes32 gene, uint256 threshold) internal pure returns (bool) {
  uint16 insecureDisk = uint16(
    uint16(gene[28]) |
    uint16(gene[29])) << 8
  );
  return insecureDisk > threshold ? true : false;
}

For selecting a pattern we do much the same. A function called getPattern was created that also accepts a gene, selects a 16-bit value, and compares it to threshold value.

1
2
3
4
5
6
7
function getPattern(bytes32 gene, uint256 threshold) internal pure returns (uint8 patternId) {
  uint16 wackyPattern = uint16(
    uint16(gene[30]) |
    uint16(uint8(gene[31])) << 8
  );
  return wackyPattern > threshold ? uint8(wackyPattern % 3) + 1 : 0;
}

In this case, rather than just return a boolean value, we’ll be returning a value between 1 and 3 if there is a pattern or a value 0 if there’s no pattern.

Interacting with External Contracts to add Special Software

One of our design requirements was that if the owner of a Galactic Floppy is also the owner of other, popular NFT’s, certain special software should appear on their disk.

One of the most exciting aspects of Ethereum contracts are that they are public and open-source, and you can call any of their public functions. All ERC721 compliant contracts have a public function named balanceOf that accepts a wallet address and returns a value representing the number of tokens that the wallet owns. We want to call this function and if it returns a value greater than 0, we can act upon it!

Contract Interfaces

The first step was to define what’s called an abstract contract interface that tells the Ethereum VM what the external contract looks like. We’re only interested in that single balanceOf function, so it looks like this:

1
2
3
abstract contract NFTContract {
  function balanceOf(address owner) external virtual view returns (uint256);
}

This essentially says, we’ll define an external contract at some address, and the compiler should assume it has a function attached that conforms to the parameters we specified above. In this case, that there is a balanceOf function that accepts an address and returns an uint256.

Next, in addition to the contract interface, we want to be able to store some metadata. In our case, a nice descriptive name, and a string representing the special software that should be added to the token holder’s disk. For this we again turn to a struct we named CoolSoftware.

1
2
3
4
5
struct CoolSoftware {
  NFTContract contractInterface;
  string contractName;
  string softwareName;
}

We want to be able to support a dynamic number of these, and don’t want to have to hard-code all the information into our contract, so we created a private storage array of CoolSoftware structs that keeps track of all the external contracts we want to interface with.

1
CoolSoftware[] private coolSoftware;

Now we can write a simple administrative function, named setNFTContract that accepts a contract address the should conform to the interface we defined, and the two presentation strings we mentioned before:

1
2
3
4
5
6
7
function setNFTContract(address contractAddress, string memory contractName, string memory softwareName) public onlyOwner {
  coolSoftware.push(CoolSoftware({
    contractInterface: NFTContract(contractAddress),
    contractName: contractName,
    softwareName: softwareName
  }));
}

This is the first place we’ve made use of the onlyOwner modifier, which restricts calls to this function to the address (Galactic) that owns the contract.

The meat of the function is simple, we initialize a new CoolSoftware struct with the NFTContract interface pointed to the given address plus the two strings, and push it onto our coolSoftware array.

Later on, we added two additional functions, getNFTContracts so we could inspect what was stored in the contract, and clearNFTContracts so we could reset this list in case of errors. The main pitfall we ran into later was adding contracts that did not exactly conform to the interface we defined above, leading to periods of time when our NFTs wouldn’t render correctly.

Time to Build the JSON

The two items we need to build our Token metadata JSON file are the array of token attributes and the image data itself. Let’s focus on the traits first.

Building an Array of Traits

The array of traits will be placed into a JSON file, so it’s important to keep in mind that we’ll be building up a list of JSON elements by concatenating strings together.

This part of the NFT process is probably the most frustrating, error-prone, and difficult to read, so bear with me.

Since our array of traits includes information about the various files on the disk, we first need to compile a list of all the software on the disk. We’ve done half of this when we called getInitialSoftware and now we need to query all of our NFT contracts to determine if the disk should hold any special software.

Since we also need to build a list of files to include in the image data as well, it behooves us to try and compile all of this data at the same time. To facilitate this, a function named getFiles was written.

First it builds up lists of both the names of all the software on the disk, and builds up all the SVG text elements needed to render out the file list (and the virus label) on the image.

Get Files

The getFiles function looks like this:

1
getFiles(address owner, BoringSoftware[2] memory initialSoftware, bool isInsecure, string memory infectedWith) internal view returns (FileListing memory)

It accepts a token owner, which is the owner of our token, but also could be the owner of any number of external NFTs. It also accepts the output from the getInitialSoftware function we detailed before. It also accepts the boolean value representing whether the disk is insecure or not, and finally it accepts the name, if any, of the virus infecting the disk.

The getFiles function returns a new struct named FileListing that looks like this:

1
2
3
4
5
struct FileListing {
  string textElements;
  string[10] boringSoftwares;
  string[10] specialSoftwares;
}

This struct will hold a dynamically sized string that contains all the various <text> elements for the filenames that will be rendered onto the disk’s label.

Additionally, for JSON purposes, we have two fixed-size string arrays that hold the raw names of the software stored on the disk.

Bug alert! These two fixed-sized arrays were one part of a bug that made it into our contract as you’ll see below.

At the beginning of our getFiles function we define three memory variables:

1
2
3
uint256 fileCount;
uint256 yPos;
FileListing memory fl;
  • fileCount - An integer that keeps track of how many “files” are on the disk. Since we could have any number of external NFTs, up to two initial softwares, and the “mnemonic.txt” it’s feasible that there could be more files to put on the disk than spaces are available on the label. We keep track of how many items we’ve added to the label, and refuse to add more if the label is full.
  • yPos - This value represents the vertical position of each item on the label. We need the text elements to line up with the label lines.
  • FileListing memory fl - This is the initialized struct we’ll be updating and ultimately returning from this function.

First we iterate over the initialSoftware array holding BoringSoftware structs, of which there could be one or two.

1
for (uint256 i = 0; i < initialSoftware.length; i++) {

Next we calculate the position of the text element

1
yPos = fileCount * 23 + 32;

Then we check to see if the current software “exists.” This really only comes into play on the second iteration, as there may have only been one initial software on the disk.

1
if (bytes(initialSoftware[i].softwareName).length > 0) {

If we should proceed, we use the abi.encodedPacked method to concatenate together all the required SVG string data to compose the text element.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fl.textElements = string(abi.encodePacked(
  fl.textElements,
  "<text class='t' x='49' y='",
  yPos.toString(),
  "'>* ",
  initialSoftware[i].softwareName,
  " disk ",
  initialSoftware[i].diskNumber.toString(),
  "/",
  initialSoftware[i].diskCount.toString(),
  "</text>"
));

Finally, we store the raw name of the included software into the FileListing.boringSoftware string array and increment the fileCount.

1
2
fl.boringSoftwares[i] = initialSoftware[i].softwareName;
fileCount++;

Next we’ll include the mnemonic.txt if the isInsecure bit was set to true. We’ll now calculate the y-position of this text element using the fileCount and then increment it once again.

1
2
3
4
5
6
7
8
if (isInsecure) {
  yPos = fileCount * 23 + 32;
  fl.textElements = string(abi.encodePacked(
    fl.textElements,
    "<text class='t' x='49' y='",yPos.toString(),"'>* mnemonic.txt</text>"
  ));
  fileCount++;
}

Next up we handle our “special” software. We’ll be querying each of the stored NFT contracts to determine if they should be included. This is also where a critical bug was introduced in concert with the FileListing struct described earlier.

for (uint256 i = 0; i < coolSoftware.length; i++) {
  uint256 ownerBalance = coolSoftware[i].contractInterface.balanceOf(owner);

The first bug

You’ll notice we just assume the contract we added has a balanceOf method and that it only accepts a single argument, owner. But what happens if someone added the wrong address, or the address to a contract that didn’t have this method, or the method accepted slightly different arguments?

Our function breaks and the tokenURI function reverts the call, resulting in no NFT for that token! This could have been avoided by wrapping this call in what’s called a try/catch block. This mechanism allows us to handle any of the errors described above and continue operating. This would’ve reduced a number of headaches we encountered during our roll-out.


If the owner has at least one of the external NFTs and we haven’t already filled the disk up, we calculate the y-position, create the appropriate text element, store the name in our FileListing.specialSoftwares array, and increment fileCount.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
if (ownerBalance > 0) {
  if (fileCount < 7) {
    yPos = fileCount * 23 + 32;
    fl.textElements = string(abi.encodePacked(
      fl.textElements,
      "<text class='t' x='49' y='",
      yPos.toString(),
      "'>* ",
      coolSoftware[i].softwareName,
      "</text>"
    ));
    fl.specialSoftwares[i] = coolSoftware[i].softwareName;
    fileCount++;
  }
}

The Second Bug

We designed the contract in such a way that we could add as many external contracts to react to as we cared for. Let’s imagine we add 20 of those.

You should also remember that our struct, FileListing, representing the variable fl above has two properties, specialSoftwares and boringSoftware which are fixed-size arrays of strings, fixed at 10 items in our case.

So what happens if we’re on our 14th iteration of this loop (i = 13), and the owner of our token, also owns a token in that 14th contract? We attempt to make the call fl.specialSoftware[13] = coolSoftware[13].softwareName.

Since fl.specialSoftware only has 10 slots this produces an error and causes the function to exit prematurely, breaking the individual’s token. This could again have been caught with a try/catch or by making our fixed-size array so large it would be unlikely we’d hit this error–with the downside of wasting some space.


For the last step, we determine if the disk should be infected with a virus and include a special text element for the virus. This text element is a different font, color. More importantly it is rotated and applied “on top” of the rest of the elements, as if written with a red marker:

1
2
3
4
5
6
7
8
if (bytes(infectedWith).length > 0) {
  fl.textElements = string(abi.encodePacked(
    fl.textElements,
    "<text x='110' y='100' style='fill: rgb(255, 100, 100); font-family: sanserif; font-size: 28px; white-space: pre; font-style: italic; font-weight: bold;' stroke-width='1' stroke='#0b000099' text-anchor='middle' dominant-baseline='central' transform='rotate(-25 100 0)'><tspan>Infected with</tspan><tspan x='100' y='130'>",
    infectedWith,
    "!!</tspan></text>"
  ));
}

Ok lets really build that traits array

So now we’ve collected together every bit of data we could possibly need to build up that traits array. And as such you’ll see our getTraits method accepts it all:

1
function getTraits(colorSet memory tokenColors, FileListing memory files, bool isInsecure, uint256 wackyPattern, string memory infectedWith) internal pure returns (string memory)

We’ll take in the disk’s colorSet, FileListing, isInsecure bit, wackyPattern, and potentially an infectedWith string. We’ll return back a new string that can be interpolated into our token’s metadata JSON.

Every disk will always have a Disk Color and Label Color, so we add those first:

1
2
3
4
string memory traits = string(abi.encodePacked(
  '{"trait_type": "Disk Color", "value": "',tokenColors.color1.toColor(),'"},',
  '{"trait_type": "Label Color", "value": "',tokenColors.color2.toColor(),'"}'
));

You’ll note we used some helper methods toColor on the byte3 values stored in our colorSet. This is implemented using Solidity’s “Using directive” that allows for attaching functions to certain types. It’s not super important how this works, but it takes the raw bit data stored in these variables and creates hexadecimal string representations like #1b1c1d.

Next we add traits for the various software that could be on the disk:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
for (uint256 i = 0; i < files.boringSoftwares.length; i++) {
  if (bytes(files.boringSoftwares[i]).length > 0) {
    traits = string(abi.encodePacked(
      traits,
      ",",
      '{"trait_type": "',
      string(abi.encodePacked("BoringFile", i.toString())),
      '", "value": "',
      files.boringSoftwares[i],
      '"}'
    ));
  }
}

for (uint256 i = 0; i < files.specialSoftwares.length; i++) {
  if (bytes(files.specialSoftwares[i]).length > 0) {
    traits = string(abi.encodePacked(
      traits,
      ",",
      '{"trait_type": "',
      string(abi.encodePacked("CoolFile", i.toString())),
      '", "value": "',
      files.specialSoftwares[i],
      '"}'
    ));
  }
}

If the disk is insecure or infected, we include a trait for each:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
if (isInsecure) {
  traits = string(abi.encodePacked(
    traits,
    ",",
    '{"trait_type": "Insecure Disk", "value": "true"}'
  ));
}
if (bytes(infectedWith).length > 0) {
  traits = string(abi.encodePacked(
    traits,
    ",",
    '{"trait_type": "Has Virus", "value": "',infectedWith,'"}'
  ));
}

And finally, if the disk has a pattern we include a trait for that as well:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
if (wackyPattern > 0) {
  string memory wackyPatternName;
  if (wackyPattern == 1) {
    wackyPatternName = "Crosses";
  } else if (wackyPattern == 2) {
    wackyPatternName = "Ombre";
  } else if (wackyPattern == 3) {
    wackyPatternName = "Polka Dot";
  }
  traits = string(abi.encodePacked(
    traits,
    ",",
    '{"trait_type": "Wacky Pattern", "value": "',wackyPatternName,'"}'
  ));
}

Creating the NFT Artwork Image

Now that we’ve created our traits array we’re finally ready to create the SVG that everyone will see and admire. This is accomplished using an external library we wrote called SVGBuilder.

Originally the SVGBuilder implementation was included inside our GalacticFloppyDisk contract, but we had reached the limit for solidity programs, which is 24kB. It would likely fit inside again since we’ve done some refactoring, but leaving it as an external contract library allowed us to explore what it was like to deploy and use a more complex program.

Libraries in Solidity are just special contracts, deployed to their own Ethereum address that you can call just like your own contract (assuming the library functions you want to call are annotated as public or external).

The functions also need to accept all the data they need in their arguments, and can’t access any of the storage data inside your main contract. This means the functions must be marked as pure.

Our SVGBuilder library only has one function named generateSVG and its signature looks like this:

1
generateSVGofToken(colorSet memory tokenColors, FileListing memory files, uint8 wackyPattern) public pure returns (string memory)

The first thing this function does is determine the diskFill color using the ColorSet’s color1:

1
string memory diskFill = tokenColors.color1.toColor();

Next we generate the beginning part of the SVG file, which never changes. It includes the SVG header, some style directives, and the beginning of some SVG definitions, the first of which is a linearGradient present on every disks’ metal shutter:

1
2
3
string memory svg = string(abi.encodePacked(
  "<svg viewBox='0 0 322.2 332.55' xmlns='http://www.w3.org/2000/svg'><style>.t { white-space: pre; fill: rgb(51, 51, 51); font: 11px monospace; }</style><defs><linearGradient id='a' x1='80.802' x2='254.2' y1='-28.879' y2='-28.879' gradientTransform='matrix(-.99682 0 0 -2.5057 319.72 205.97)' gradientUnits='userSpaceOnUse'><stop stop-color='#878787' offset='0'/><stop stop-color='#fff' offset='.5'/><stop stop-color='#878787' offset='1'/></linearGradient>"
));

Next, since our wacky patterns must be defined within the <defs> scope we must handle those.

Each of these patterns utilized different sets of colors, the first listed here is a set of repeating crosses. The second is a set of repeating polka-dots and the third is a linearGradient using all of the colors in the ColorSet.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
if (wackyPattern > 0) {
  diskFill = "url(#b)";
  if (wackyPattern == 1) {
    svg = string(abi.encodePacked(
      svg,
      string(abi.encodePacked(
        "<pattern id='b' patternUnits='userSpaceOnUse' width='20' height='20' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='",
        tokenColors.color1.toColor(),
        "'/><path d='M3.25 10h13.5M10 3.25v13.5'  stroke-linecap='square' stroke-width='1' stroke='",
        tokenColors.color3.toColor(),
        "' fill='none'/></pattern>"
      ))
    ));
  }

  if (wackyPattern == 2) {
    svg = string(abi.encodePacked(
      svg,
      string(abi.encodePacked(
        "<linearGradient id='b' x1='80.802' x2='254.2' y1='-28.879' y2='-28.879' gradientTransform='matrix(-1.667037, 1.746009, -1.812309, -1.730337, 383.987524, -171.581138)' gradientUnits='userSpaceOnUse'><stop stop-color='",
        tokenColors.color1.toColor(),
        "' offset='0'/><stop stop-color='",
        tokenColors.color4.toColor(),
        "' offset='.223'/><stop stop-color='",
        tokenColors.color5.toColor(),
        "' offset='.451'/><stop stop-color='",
        tokenColors.color2.toColor(),
        "' offset='.771'/><stop stop-color='",
        tokenColors.color3.toColor(),
        "' offset='1'/></linearGradient>"
      ))
    ));
  }

  if (wackyPattern == 3) {
    svg = string(abi.encodePacked(
      svg,
      string(abi.encodePacked(
        "<pattern id='b' patternUnits='userSpaceOnUse' width='40' height='40' patternTransform='scale(2) rotate(0)'><rect x='0' y='0' width='100%' height='100%' fill='",
        tokenColors.color1.toColor(),
        "'/><path d='M40 45a5 5 0 110-10 5 5 0 010 10zM0 45a5 5 0 110-10 5 5 0 010 10zM0 5A5 5 0 110-5 5 5 0 010 5zm40 0a5 5 0 110-10 5 5 0 010 10z'  stroke-width='2' stroke='",
        tokenColors.color4.toColor(),
        "' fill='none'/><path d='M20 25a5 5 0 110-10 5 5 0 010 10z'  stroke-width='2' stroke='",
        tokenColors.color5.toColor(),
        "' fill='none'/></pattern>"
      ))
    ));
  }
}

You should notice that if we are using a pattern, we overwrite the plain color value of diskFill to be url(#b). This means the disk will be filled with the selected pattern, all of which contain the id='b' in their definition.

Now that we’ve potentially included a pattern definition in our <def> block we can close it out and start the elements defining the look of the disk itself. The below block defines the body of the disk and ellipses for the hub and magnetic disc. We use the value of diskFill in the fill attribute of the disk body.

1
2
3
4
5
svg = string(abi.encodePacked(
  svg,
  "</defs><ellipse cx='160.95' cy='164.38' rx='158.43' ry='161.93' fill='#797474'/><ellipse cx='162.67' cy='163.34' rx='58.216' ry='59.966' fill='#897b7b' stroke='#070707' stroke-width='11.339'/><path d='m311.54 292.2h-4.2v-13.65h-8.7v13.65h-4.2l8.55 24.75z' stroke='#808080' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10' stroke-width='.9'/><path d='m311.39 23.4h-18.15v11.85h18.15z' stroke='#808080' stroke-linecap='round' stroke-miterlimit='8' stroke-width='.9'/><path d='m319.19 331.64 1.9375-1.7812 0.625-2.2501-0.3125-324.15-0.75-1.66-1.6562-1.06-315.43-0.28-2.1 1.18-1.06 1.97 0.44 289.19h2.25v13.188l-1.78-0.438v4.5l24.28 21.312 4.81 0.28175 1.5-1.3438h25.81l2.25-2.25 5.25-0.1562h173.38l-2e-3 1.5 42.624 0.3124-0.156 1.6563 38.094 0.28125zm-288.91-296.25h-18.15v-11.84h18.15z' fill='",
  diskFill
));

Next we start building the label on top of the disk. SVGs are written with the objects most in the background first, and the foreground objects last. At the bottom of this last block we draw the various other bits of the disk, mainly the lock tab, the label, and the shutter (note how it’s “filled” with url(#a) which is the linear gradient we defined at the top of our <def> block.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
svg = string(abi.encodePacked(
  svg,
  "' fill-rule='evenodd' opacity='.75' stroke='#020000'/><g transform='rotate(180 295.08 220.36)'><path d='m550.9 440.27-0.01874-184.65-1.8-2.1-240 0.3-2.1 1.5v184.95z' fill='#eaeaea' fill-rule='evenodd' stroke='#9b9b9b' stroke-width='.8'/><path d='m307.58 285.32h242.7' fill='none' stroke='",
  tokenColors.color2.toColor(),
  "' stroke-linejoin='round' stroke-miterlimit='10' stroke-width='1.2'/><path d='m307.58 308.42h242.7' fill='none' stroke='",
  tokenColors.color2.toColor(),
  "' stroke-linejoin='round' stroke-miterlimit='10' stroke-width='1.2'/><path d='m307.58 331.67h242.7' fill='none' stroke='",
  tokenColors.color2.toColor(),
    "' stroke-linejoin='round' stroke-miterlimit='10' stroke-width='1.2'/><path id='b' d='m307.58 355.07h242.7' fill='none' stroke='",
  tokenColors.color2.toColor()
));
svg = string(abi.encodePacked(
  svg,
  "' stroke-linejoin='round' stroke-miterlimit='10' stroke-width='1.2'/><path d='m307.58 378.32h242.7' fill='none' stroke='",
  tokenColors.color2.toColor(),
  "' stroke-linejoin='round' stroke-miterlimit='10' stroke-width='1.2'/><path d='m307.58 401.57h242.7' fill='none' stroke='",
  tokenColors.color2.toColor(),
  "' stroke-linejoin='round' stroke-miterlimit='10' stroke-width='1.2'/><path d='m307.36 439.87h243.14v-15h-243.14z' fill='",
  tokenColors.color2.toColor(),
  "' stroke-width='1.0009'/></g><path d='m280.79 330.3v-106.35l-0.75-1.35-1.05-0.45h-46.5' fill='none' stroke='#808080' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10' stroke-width='.6'/><path d='m238.53 332.02v-107.97l-3.125-2.4062h-165.97l-2.75 3.0312v107.34h171.84zm-99.031-17.781h-46.375v-87h46.375z' color='#000000' fill='url(#a)' stroke-width='.3'/>"
));

The final step is to include our file listing text elements that sit on top of the label, and close the top-level svg tag:

1
2
3
4
5
6
svg = string(abi.encodePacked(
  svg,
  files.textElements,
  "</svg>"
));
return svg;

That’s all folks

The last step in our tokenURI function is to Base64 encode our SVG image data:

1
string memory image = Base64.encode(bytes(SVGBuilder.generateSVGofToken(colors, files, pattern)));

And finally bring everything together into a giant, base64’d JSON file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
return
  string(
      abi.encodePacked(
        'data:application/json;base64,',
        Base64.encode(
            bytes(
                  abi.encodePacked(
                      '{"name":"',
                      string(abi.encodePacked('Galactic Floppy #', id.toString())),
                      '", "description":"',
                      'This Floppy Is Radical!',
                      '", "external_url":"https://floppy.galactic.io/?token=',
                      id.toString(),
                      '", "attributes": [',
                      traits,
                      '], "owner":"',
                      uint160(owner).toHexString(20),
                      '", "image": "',
                      'data:image/svg+xml;base64,',
                      image,
                      '"}'
                  )
                )
            )
      )
  );

Coda

This was an extremely deep dive into a single Solidity contract that implemented an on-chain, reactive SVG, but there were so many more things we learned along the way:

  • Writing formal tests for our contracts in Javascript and Mocha.
  • Deploying contracts with statically linked external libraries (SVGBuilder).
  • Deploying the contract to a locally running chain for ad-hoc testing.
  • Deploying to “staging” on testnets like Rinkeby.
  • Using etherscan to test interactions with our contract
  • Using Hardhat to fork the mainnet blockchain to deploy “debug” contracts to diagnose production errors we encountered.

At the beginning of this process I knew literally nothing about Solidity and very little about Ethereum. The entire process detailed here, from start to finish, took less than two weeks, and I feel like we learned two months of lessons in that time frame.

While the ultimate utility of things like NFT artwork and Ethereum contracts will likely be debated forever, our foray into implementing and deploying one taught us a lot and got us thinking about other interesting and interactive experiments we can do in the future.